Add Elsa Script DSL (#7076)

* Update packages.yml

* Update elsa-server-and-studio.yml

* Update elsa-server.yml

* Update elsa-studio.yml (#6715)

* Update ListWorkflowDefinitionsRequest.cs (#6761)

Remove unnecessary line breaks

* Correct namespace and import for `ConfigureEngineWithVariableTypes`.

* Resolves build issues, update package versions and restructure project references

- Updated multiple package versions in `Directory.Packages.props` for better dependency management, including `BenchmarkDotNet`, `FastEndpoints`, and `Microsoft.Extensions.Http.Resilience`.
- Minor version upgrade for `System.Formats.Asn1` in `_build.csproj`.
- Replaced project reference to `Elsa.csproj` with `Elsa.IO.Http.csproj` in `Elsa.ServerAndStudio.Web.csproj`, enhancing modularity.
- Added new using directive for `Elsa.IO.Http.Features` in `Program.cs` to support new HTTP functionalities.

* Remove unused project references from Elsa.sln

These changes indicate that the associated projects or dependencies are no longer needed or have been replaced by other components in the solution.

* Rename copilot-setup-steps.yml.yml to copilot-setup-steps.yml

* Update RawStringContent encoding in JsonContentFactory (#6786)

* Update RawStringContent encoding in JsonContentFactory

Modified the instantiation of `RawStringContent` to use a
new `UTF8Encoding` instance with `encoderShouldEmitUTF8Identifier`
set to `false`, affecting the handling of the UTF-8 byte order
mark (BOM) in serialized JSON content. Fixes a bug with content length being different than expected.

* Refactor JsonContentFactory to reuse UTF8Encoding

Introduced a private static readonly field `_utf8Encoding` in the `JsonContentFactory` class to improve code readability and performance. This change replaces the instantiation of `UTF8Encoding` in the `CreateHttpContent` method, allowing for the reuse of the same encoding instance.

---------

Co-authored-by: Max Brooks <Max@compyl.com>

* Enhance thread safety with ConcurrentDictionary usage (#6760)

* Enhance thread safety with ConcurrentDictionary usage

Replaced `IDictionary` with `ConcurrentDictionary` for
both `_scheduledTasks` and `_scheduledTaskKeys` to
improve thread safety in a multi-threaded environment.

Updated methods `RegisterScheduledTask`,
`RemoveScheduledTask`, and `RemoveScheduledTasks` to
utilize the `Remove` method of `ConcurrentDictionary`,
ensuring safe and efficient removal of scheduled tasks.

* Refactor task registration and removal logic

Updated `RegisterScheduledTask` to use `AddOrUpdate` for streamlined task management. This change simplifies the addition and updating of scheduled tasks by consolidating logic into a single operation. Introduced `RemoveScheduledTask` method to handle task removal by name, improving code organization and clarity.

* Improve task removal handling in LocalScheduler

Modified the `LocalScheduler` class to enhance the removal process of scheduled tasks from the `_scheduledTaskKeys` collection. The removal operation now captures the result in a variable and includes a conditional check to log a warning if the task was not found, improving error handling and debugging capabilities.

* Refactor task removal in LocalScheduler

Updated the removal process for scheduled tasks in `_scheduledTasks`.
The new implementation collects all corresponding keys and attempts to remove them individually, logging warnings for any failures. This enhances error handling and provides better debugging information.

---------

Co-authored-by: Max Brooks <Max@compyl.com>

* Add IAsyncEnumerable check to ItemSourceActivityExecutionContextExtensions.GetItemSource (#6897)

* Use FullName in WorkflowDictionary (#6923)

* Fixed ParentWorkflowInstanceId not being set (#7029)

Co-authored-by: Peter Klooster <peter.klooster@autotaalglas.nl>

* Remove unused solution projects and update package references

- Deleted several project references from `Elsa.sln` to clean up the solution.
- Updated `Directory.Packages.props` for consistency and alignment with the latest package versions.

* Simplify CI pipeline by removing `Test` step from `Compile+Test+Pack` process.

* Initial plan

* Add ElsaScript DSL module with parser and compiler

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Add integration tests for ElsaScript DSL

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Add comprehensive documentation for ElsaScript DSL

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Refactor workflow activity instantiation logic

- Removed `ActivityFactory` and its related interfaces and extensions.
- Introduced `ActivityActivator` for handling activity creation.
- Extended AST with support for comprehensive workflow structures:
  - Added nodes for flowcharts, if/else, loops, and variable declarations.
- Updated `IElsaScriptCompiler` to use asynchronous methods.
- Expanded `ElsaScriptParser` to simplify syntax for `UseNode` and argument parsing.
- Adjusted compiler and parser for compatibility with new workflow AST model.

* Refactor test method names for clarity and add new compiler and parser tests

- Updated method names in `CompilerTests` and `ParserTests` for better readability and description of test intent.
- Added tests for compiler and parser:
  - Support for workflows without the `workflow` keyword.

* Refactor `ElsaScriptParser` to improve statement parsing and introduce a tokenizer

- Added `TokenizeStatements` method to split source into statements for enhanced parsing accuracy.
- Updated logic to process statements instead of raw lines, reducing parsing complexity and improving reliability.
- Improved handling of workflow and statement parsing, including edge cases with braces, parentheses, and string literals.

* Introduce ElsaScript support for BlobStorage workflow provider

- Added the `Elsa.WorkflowProviders.BlobStorage.ElsaScript` module to enable ElsaScript-based workflow definitions for BlobStorage.
- Implemented `ElsaScriptBlobWorkflowFormatHandler` for parsing ElsaScript workflows stored in BlobStorage.
- Extended `ElsaScriptParser` to leverage Parlot for improved DSL parsing.
- Introduced `IBlobWorkflowFormatHandler` to centralize workflow format handling and parsing.
- Updated `Elsa.Server.Web` to reference the new module and include an ElsaScript "Hello World" example workflow.

* Refactor ElsaScript services, update logging, and improve workflow handling

- Changed `ElsaScriptCompiler` service registration from `Singleton` to `Scoped` for better dependency management.
- Enhanced the "Hello World" example workflow and added `CopyToOutputDirectory` configuration.
- Removed unused namespaces and adjusted references in multiple projects to improve maintainability.
- Updated logging levels in `appsettings.json` to reduce unnecessary debug output.
- Improved `PolymorphicObjectConverter` by removing redundant dependencies.
- Added missing references to enhance feature support and ensure compatibility.

* Refactor activity instantiation and improve argument handling in `ElsaScriptCompiler`

- Added support for positional arguments with constructor matching logic.
- Refactored `InstantiateActivityUsingConstructor` to enhance activity creation.
- Updated `ActivityDescriptor` and related types to include `ClrType` for streamlined activity resolution.
- Simplified `TypedActivityProvider` by annotating it with `[UsedImplicitly]`.
- Adjusted `ElsaScriptParser` to remove unnecessary options from string literal definitions.

* Add HTTP-enabled "Hello World" workflow and support for additional HTTP activity constructors

- Introduced a new ElsaScript example workflow `hello-world-http.elsa` with an HTTP endpoint and response.
- Enhanced `HttpEndpoint` and `WriteHttpResponse` activities with additional constructors for improved flexibility.
- Updated project to include the new workflow in the output directory.

* Enhance `ElsaScriptParser` with a custom parser to handle nested raw expressions for ElsaScript workflows

- Introduced `RawExpressionParser` to parse raw text after `=>` up to a matching closing parenthesis.
- Updated `elsaExpressionWithLang` and `elsaExpressionWithoutLang` to use `RawExpressionParser`.
- Trimmed whitespace in parsed expressions.
- Added integration and parser tests for complex workflows with variables and expressions.
- Updated example workflow `hello-world-http.elsa` to demonstrate expression usage.
- Added `Elsa.Http` module reference to enable HTTP-based activities.

* Update "Hello World" workflow to simplify naming and enhance response logic

- Renamed workflow from `HelloWorldHttpDsl2` to `HelloWorldHttpDsl`.
- Updated HTTP endpoint path to `/hello-world-dsl` for consistency.
- Improved response logic by utilizing `getMessage()` JavaScript function.

* Add support for `OriginalSource` in workflow materialization and enhance ElsaScript materializer

- Introduced `OriginalSource` property in `WorkflowDefinition` and `MaterializedWorkflow` for preserving original source representation (e.g., ElsaScript, JSON, YAML).
- Added `ElsaScriptWorkflowMaterializer` implementation to materialize workflows directly from ElsaScript source.
- Updated `DefaultWorkflowDefinitionStorePopulator` to determine `StringData` or `OriginalSource` based on materialized workflow format.
- Enhanced `WorkflowDefinitionMapper` to support symmetric round-tripping with `OriginalSource`.
- Registered `ElsaScriptWorkflowMaterializer` in `ElsaScriptFeature` for dependency injection.
- Updated `JsonBlobWorkflowFormatHandler` and added `OriginalSource` support for round-trip preservation.
- Simplified `ElsaScriptParser` by aligning variable and parser naming.

* Update V3_6 migrations for PostgreSQL, MySQL, and Oracle databases and associated designer files.

* Handle disposal and race conditions in `ScheduledCronTask`

- Added `_disposed` flag to prevent accessing disposed resources.
- Updated `_executionSemaphore` and `_scopeFactory` logic to safely handle `ObjectDisposedException`.
- Enhanced task scheduling and timer disposal with additional safeguards against race conditions.
- Modified tests to ensure proper disposal and logging behavior when handling edge cases.

* Add support for metadata in ElsaScript workflows and enhance parser and compiler functionality

- Introduced metadata syntax in ElsaScript workflows (e.g., `DisplayName`, `Description`, `Version`) to enable metadata-driven behavior.
- Enhanced `ElsaScriptCompiler` to process metadata and properly integrate it into `Workflow` objects.
- Updated `ElsaScriptParser` to parse program-level AST with support for multiple workflows and global use statements.
- Refactored tests to validate metadata parsing and ensure backward compatibility with existing workflows.
- Added new test cases to cover scenarios like metadata parsing, compilation, and multi-workflow programs.

* Add support for `foreach` loops in ElsaScript and remove `let` keyword

- Introduced `foreach` loop syntax in `ElsaScriptParser` and `ElsaScriptCompiler`, enabling iteration over collections with optional variable declaration.
- Updated `ForNode` and `ForEachNode` to include a `DeclaresVariable` flag for improved variable handling.
- Removed support for the `let` keyword in variable declarations, streamlining syntax to use `var` and `const` only.
- Enhanced `for` loop syntax to support optional `var` declaration and block or single-statement bodies.
- Refactored test cases to validate `foreach` and `for` loop enhancements and ensure backward compatibility.

* Simplify ElsaScript workflow syntax by removing redundant quotes in workflow identifiers and updating `for` loop syntax for clarity and consistency.

* Remove redundant quotes from workflow identifiers in integration tests

* Simplify Elsa scripts and improve error handling

- Removed redundant braces in workflow declarations for streamlined syntax.
- Enhanced logging in `JsonBlobWorkflowFormatHandler` and `ElsaScriptBlobWorkflowFormatHandler` to warn on parsing errors and provide context.
- Updated configuration to log errors for `Elsa.Workflows.ActivityRegistry`.
- Refined "Hello World" and "For Loop" workflows for clarity and added improved loop handling.

* Refine Elsa workflows and update compiler logic

- Simplified "Hello World" workflow by adding braces and improving consistency.
- Adjusted "For Loop" workflow to rename and clarify logic, including expression updates and variable handling.
- Fixed compiler mapping of `"cs"` to `"CSharp"` for better clarity.
- Enhanced "Hello World HTTP" workflow to correctly reference `variables.message` in expressions.

* Add flowchart support in ElsaScript parser, compiler, and integration tests

- Introduced `flowchart` syntax in `ElsaScriptParser` to support flowchart-based workflows.
- Updated `ElsaScriptCompiler` to compile `flowchart` nodes with labeled activities, connections, entry points, and variables.
- Added integration tests for parsing and compiling empty and simple flowcharts.
- Enhanced `FlowchartNode` and `LabeledActivityNode` for better representation of flowchart structures.
- Improved error handling and logging for invalid flowchart configurations.

* Add tests for compiling and parsing flowcharts with nodes, connections, and block nodes in ElsaScript

- Added integration tests for compiling and validating flowchart structures, including activities, connections, and entry points.
- Implemented parser tests for parsing flowcharts with node connections and block nodes.
- Updated project files to include new workflow examples for testing.

* Add Parlot package and update project file in integration tests

- Added `Parlot` package version `0.0.27` to `Directory.Packages.props`.
- Updated integration test project file to include a new `Include` directive for better targeting.

* Update Parlot package to version 1.5.2 in Directory.Packages.props

* Remove `elsa-server-and-studio.yml` workflow and update solution file

- Deleted `elsa-server-and-studio.yml` workflow as it's no longer needed.
- Updated `Elsa.sln` to remove reference to the deleted workflow.

* Remove `elsa-studio.yml` workflow and update solution and packages

- Deleted `elsa-studio.yml` workflow as it's no longer used.
- Updated `Elsa.sln` to remove reference to the deleted workflow.
- Changed `base_version` in `packages.yml` from `3.7.0` to `3.6.0`.

* Downgrade Docker image in `elsa-server.yml` workflow from `v3.7.0-preview` to `v3.6.0-preview`

* Update Docker image tag in `elsa-server.yml` workflow from `v3.6.0-preview` to `v3.6-preview`

* Add logging support to `LocalScheduler` and replace `Debug.WriteLine` with `ILogger`

* Remove unused `System.Collections.Generic` and `Elsa.Extensions` imports in `LocalScheduler`

- Cleaned up unnecessary using directives to improve code readability and maintainability.
- Minor whitespace adjustment for consistent formatting.

* Remove unnecessary whitespace in `LocalScheduler` for consistent formatting

* Improve exception handling in blob workflow format handlers

- Updated exception handling in `ElsaScriptBlobWorkflowFormatHandler` and `JsonBlobWorkflowFormatHandler` to gracefully catch and log all exceptions during workflow parsing.
- Adjusted comments to clarify behavior for invalid user-provided files, ensuring the workflow loading process is not disrupted.

* Refactor blob workflow format handlers to use `SupportedExtensions` for improved file filtering

- Added `SupportedExtensions` property to all blob format handlers to optimize blob storage browsing.
- Simplified `CanHandle` logic by removing extension checks, leveraging `SupportedExtensions` for initial filtering.
- Updated comments for clarity and consistency across handlers.

* Refactor `DefaultWorkflowDefinitionStorePopulator` to simplify `stringData` assignment logic and improve readability

* Remove outdated comment in `CompilerTests` about skipped tests

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor `ElsaScriptCompiler` to streamline type conversion logic, improve language mapping, and enhance asynchronous flowchart compilation

* [WIP] Update ParseError printing based on feedback (#7082)

* Initial plan

* Fix ParseError formatting to use Message and Position properties

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Replace `as` casts with direct casts in ParserTests for null safety (#7083)

* Initial plan

* Replace 'as' casts with direct casts in ParserTests for better null safety

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Fix Oracle column types for OriginalSource and other large text fields (#7079)

* Initial plan

* Fix Oracle OriginalSource and StringData column types to handle large data

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Refactor tests to replace type checks with `Assert.IsType` for improved clarity and type safety

* Initial plan (#7080)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* Add `Parlot` package reference and update solution structure by removing and reorganizing projects and workflows.

* Set default expression language to "JavaScript" in `ElsaScriptCompiler`.

* Add integration test to verify default expression language resets between ElsaScript compilations

* Simplify UTF-8 encoding in JsonContentFactory (#7081)

* Initial plan

* Remove explicit UTF8Encoding in JsonContentFactory and use Encoding.UTF8

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

* Fix test to use Encoding.UTF8.GetByteCount for multi-byte character support

Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>

---------

Co-authored-by: Ender <37611092+zengande@users.noreply.github.com>
Co-authored-by: Matt <knibbsy10@live.com>
Co-authored-by: Max Brooks <45081361+MaxBrooks114@users.noreply.github.com>
Co-authored-by: Max Brooks <Max@compyl.com>
Co-authored-by: FuJa0815 <30809803+FuJa0815@users.noreply.github.com>
Co-authored-by: Peter Klooster <crashkonijn@gmail.com>
Co-authored-by: Peter Klooster <peter.klooster@autotaalglas.nl>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-11-25 19:57:50 +01:00 committed by GitHub
parent 3340e14b74
commit a1d4e541fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 4477 additions and 500 deletions

View file

@ -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 }}

View file

@ -133,6 +133,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.14.0-beta.1"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.14.0-beta.1"/>
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="9.23.26000"/>
<PackageVersion Include="Parlot" Version="1.5.6"/>
<PackageVersion Include="Polly" Version="8.6.5"/>
<PackageVersion Include="Polly.Extensions" Version="8.6.5"/>
<PackageVersion Include="Polly.Extensions.Http" Version="3.0.0"/>

View file

@ -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}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>

3
run-dsl-tests.sh Normal file
View file

@ -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

View file

@ -3,9 +3,12 @@
<ItemGroup>
<ProjectReference Include="..\..\modules\Elsa.Caching\Elsa.Caching.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Common\Elsa.Common.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Dsl.ElsaScript\Elsa.Dsl.ElsaScript.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Expressions\Elsa.Expressions.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Persistence.EFCore.Sqlite\Elsa.Persistence.EFCore.Sqlite.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Scheduling\Elsa.Scheduling.csproj" />
<ProjectReference Include="..\..\modules\Elsa.WorkflowProviders.BlobStorage.ElsaScript\Elsa.WorkflowProviders.BlobStorage.ElsaScript.csproj" />
<ProjectReference Include="..\..\modules\Elsa.WorkflowProviders.BlobStorage\Elsa.WorkflowProviders.BlobStorage.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Workflows.Runtime.Distributed\Elsa.Workflows.Runtime.Distributed.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
@ -24,4 +27,19 @@
<Folder Include="App_Data\" />
</ItemGroup>
<ItemGroup>
<None Update="Workflows\hello-world.elsa">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Workflows\hello-world-http.elsa">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Workflows\for-loop.elsa">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Workflows\flowchart-test.elsa">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -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 =>
{

View file

@ -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;
}
}

View file

@ -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!")
}

View file

@ -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}`);
}

View file

@ -0,0 +1,6 @@
use expressions js;
workflow HelloWorldDsl
{
WriteLine("Hello World from Elsa DSL!");
}

View file

@ -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()}`);
}

View file

@ -3,7 +3,8 @@
"LogLevel": {
"Default": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Elsa": "Debug"
"Elsa": "Warning",
"Elsa.Workflows.ActivityRegistry": "Error"
}
},
"HostBuilder": {

View file

@ -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;
/// <summary>
/// Represents a request to list workflow definitions.
@ -63,4 +62,4 @@ public class ListWorkflowDefinitionsRequest
/// The order direction.
/// </summary>
public OrderDirection? OrderDirection { get; set; }
}
}

View file

@ -153,7 +153,6 @@ public class ActivityTestFixture
services.AddSingleton<IWellKnownTypeRegistry, WellKnownTypeRegistry>();
services.AddSingleton<IActivityDescriber, ActivityDescriber>();
services.AddSingleton<IPropertyDefaultValueResolver, PropertyDefaultValueResolver>();
services.AddSingleton<IActivityFactory, ActivityFactory>();
services.AddSingleton<IPropertyUIHandlerResolver, PropertyUIHandlerResolver>();
services.AddSingleton<IActivityRegistry, ActivityRegistry>();
services.AddScoped<IActivityRegistryLookupService, ActivityRegistryLookupService>();

View file

@ -0,0 +1,22 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents an activity invocation.
/// </summary>
public class ActivityInvocationNode : StatementNode
{
/// <summary>
/// The activity name (e.g., "WriteLine", "SendEmail").
/// </summary>
public string ActivityName { get; set; } = string.Empty;
/// <summary>
/// The arguments passed to the activity.
/// </summary>
public List<ArgumentNode> Arguments { get; set; } = [];
/// <summary>
/// Optional variable to capture the result.
/// </summary>
public string? ResultVariable { get; set; }
}

View file

@ -0,0 +1,17 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents an argument passed to an activity.
/// </summary>
public class ArgumentNode : AstNode
{
/// <summary>
/// The parameter name (null for positional arguments).
/// </summary>
public string? Name { get; set; }
/// <summary>
/// The argument value expression.
/// </summary>
public ExpressionNode Value { get; set; } = null!;
}

View file

@ -0,0 +1,17 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Base class for all AST nodes in ElsaScript.
/// </summary>
public abstract class AstNode
{
/// <summary>
/// The line number in the source where this node starts.
/// </summary>
public int Line { get; set; }
/// <summary>
/// The column number in the source where this node starts.
/// </summary>
public int Column { get; set; }
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a block of statements (maps to Sequence).
/// </summary>
public class BlockNode : StatementNode
{
/// <summary>
/// The statements in the block.
/// </summary>
public List<StatementNode> Statements { get; set; } = [];
}

View file

@ -0,0 +1,22 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a connection in a flowchart.
/// </summary>
public class ConnectionNode : AstNode
{
/// <summary>
/// The source label.
/// </summary>
public string Source { get; set; } = string.Empty;
/// <summary>
/// The optional outcome.
/// </summary>
public string? Outcome { get; set; }
/// <summary>
/// The target label.
/// </summary>
public string Target { get; set; } = string.Empty;
}

View file

@ -0,0 +1,127 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Base class for expression nodes.
/// </summary>
public abstract class ExpressionNode : AstNode
{
}
/// <summary>
/// Represents a literal expression.
/// </summary>
public class LiteralNode : ExpressionNode
{
/// <summary>
/// The literal value.
/// </summary>
public object? Value { get; set; }
}
/// <summary>
/// Represents an Elsa expression with a specific language.
/// </summary>
public class ElsaExpressionNode : ExpressionNode
{
/// <summary>
/// The expression language (e.g., "js", "cs", "py", "liquid").
/// If null, uses the default language from use statements.
/// </summary>
public string? Language { get; set; }
/// <summary>
/// The expression code.
/// </summary>
public string Expression { get; set; } = string.Empty;
}
/// <summary>
/// Represents a template string expression.
/// </summary>
public class TemplateStringNode : ExpressionNode
{
/// <summary>
/// The parts of the template string (alternating between literals and expressions).
/// </summary>
public List<TemplatePartNode> Parts { get; set; } = [];
}
/// <summary>
/// Represents a part of a template string.
/// </summary>
public class TemplatePartNode : AstNode
{
/// <summary>
/// Whether this is a literal or expression part.
/// </summary>
public bool IsExpression { get; set; }
/// <summary>
/// The content (literal text or expression code).
/// </summary>
public string Content { get; set; } = string.Empty;
}
/// <summary>
/// Represents an identifier (variable reference).
/// </summary>
public class IdentifierNode : ExpressionNode
{
/// <summary>
/// The identifier name.
/// </summary>
public string Name { get; set; } = string.Empty;
}
/// <summary>
/// Represents an array literal.
/// </summary>
public class ArrayLiteralNode : ExpressionNode
{
/// <summary>
/// The elements of the array.
/// </summary>
public List<ExpressionNode> Elements { get; set; } = [];
}
/// <summary>
/// Represents a binary expression (e.g., a + b).
/// </summary>
public class BinaryExpressionNode : ExpressionNode
{
/// <summary>
/// The left operand.
/// </summary>
public ExpressionNode Left { get; set; } = null!;
/// <summary>
/// The operator.
/// </summary>
public string Operator { get; set; } = string.Empty;
/// <summary>
/// The right operand.
/// </summary>
public ExpressionNode Right { get; set; } = null!;
}
/// <summary>
/// Represents a unary expression (e.g., ++i, i++).
/// </summary>
public class UnaryExpressionNode : ExpressionNode
{
/// <summary>
/// The operator.
/// </summary>
public string Operator { get; set; } = string.Empty;
/// <summary>
/// The operand.
/// </summary>
public ExpressionNode Operand { get; set; } = null!;
/// <summary>
/// Whether the operator is prefix (true) or postfix (false).
/// </summary>
public bool IsPrefix { get; set; }
}

View file

@ -0,0 +1,27 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a flowchart definition.
/// </summary>
public class FlowchartNode : StatementNode
{
/// <summary>
/// Variable declarations scoped to the flowchart.
/// </summary>
public List<VariableDeclarationNode> Variables { get; set; } = [];
/// <summary>
/// The labeled activities (nodes) in the flowchart.
/// </summary>
public List<LabeledActivityNode> Activities { get; set; } = [];
/// <summary>
/// The connections (edges) between activities.
/// </summary>
public List<ConnectionNode> Connections { get; set; } = [];
/// <summary>
/// The optional entry point label.
/// </summary>
public string? EntryPoint { get; set; }
}

View file

@ -0,0 +1,27 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a foreach statement (e.g., foreach (var item in collection)).
/// </summary>
public class ForEachNode : StatementNode
{
/// <summary>
/// Whether this foreach loop declares a new variable (var present in header).
/// </summary>
public bool DeclaresVariable { get; set; }
/// <summary>
/// The loop variable name.
/// </summary>
public string VariableName { get; set; } = null!;
/// <summary>
/// The collection expression to iterate over.
/// </summary>
public ExpressionNode Collection { get; set; } = null!;
/// <summary>
/// The body statement.
/// </summary>
public StatementNode Body { get; set; } = null!;
}

View file

@ -0,0 +1,42 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a range-based for statement (e.g., for (var i = 0 to 10 step 1)).
/// </summary>
public class ForNode : StatementNode
{
/// <summary>
/// Whether this for loop declares a new variable (var present in header).
/// </summary>
public bool DeclaresVariable { get; set; }
/// <summary>
/// The loop variable name.
/// </summary>
public string VariableName { get; set; } = null!;
/// <summary>
/// The start value expression.
/// </summary>
public ExpressionNode Start { get; set; } = null!;
/// <summary>
/// The end value expression.
/// </summary>
public ExpressionNode End { get; set; } = null!;
/// <summary>
/// The step value expression.
/// </summary>
public ExpressionNode Step { get; set; } = null!;
/// <summary>
/// Whether the end value is inclusive (through) or exclusive (to).
/// </summary>
public bool IsInclusive { get; set; }
/// <summary>
/// The body statement.
/// </summary>
public StatementNode Body { get; set; } = null!;
}

View file

@ -0,0 +1,22 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents an if statement.
/// </summary>
public class IfNode : StatementNode
{
/// <summary>
/// The condition expression.
/// </summary>
public ExpressionNode Condition { get; set; } = null!;
/// <summary>
/// The then branch.
/// </summary>
public StatementNode Then { get; set; } = null!;
/// <summary>
/// The optional else branch.
/// </summary>
public StatementNode? Else { get; set; }
}

View file

@ -0,0 +1,20 @@
using JetBrains.Annotations;
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a labeled activity in a flowchart (node declaration).
/// </summary>
[UsedImplicitly]
public class LabeledActivityNode : AstNode
{
/// <summary>
/// The label (node name).
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// The activity statement (can be ActivityInvocationNode, BlockNode, etc.).
/// </summary>
public StatementNode Activity { get; set; } = null!;
}

View file

@ -0,0 +1,12 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a listen statement (for triggers).
/// </summary>
public class ListenNode : StatementNode
{
/// <summary>
/// The activity invocation for the trigger.
/// </summary>
public ActivityInvocationNode Activity { get; set; } = null!;
}

View file

@ -0,0 +1,19 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents the root of an ElsaScript program, which can contain:
/// - Global use statements
/// - One or more workflow declarations
/// </summary>
public class ProgramNode : AstNode
{
/// <summary>
/// Global use statements (imports and expression language settings) that apply to all workflows.
/// </summary>
public List<UseNode> GlobalUseStatements { get; set; } = [];
/// <summary>
/// Workflow declarations in this program.
/// </summary>
public List<WorkflowNode> Workflows { get; set; } = [];
}

View file

@ -0,0 +1,8 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Base class for statement nodes.
/// </summary>
public abstract class StatementNode : AstNode
{
}

View file

@ -0,0 +1,17 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a switch case.
/// </summary>
public class SwitchCaseNode : AstNode
{
/// <summary>
/// The case value.
/// </summary>
public ExpressionNode Value { get; set; } = null!;
/// <summary>
/// The case body.
/// </summary>
public StatementNode Body { get; set; } = null!;
}

View file

@ -0,0 +1,22 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a switch statement.
/// </summary>
public class SwitchNode : StatementNode
{
/// <summary>
/// The switch expression.
/// </summary>
public ExpressionNode Expression { get; set; } = null!;
/// <summary>
/// The cases.
/// </summary>
public List<SwitchCaseNode> Cases { get; set; } = [];
/// <summary>
/// The optional default case.
/// </summary>
public StatementNode? Default { get; set; }
}

View file

@ -0,0 +1,33 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a use statement (import or expression language configuration).
/// </summary>
public class UseNode : AstNode
{
/// <summary>
/// The type of use statement (e.g., "expressions", namespace import).
/// </summary>
public UseType Type { get; set; }
/// <summary>
/// The value (e.g., "js", "cs", or a namespace).
/// </summary>
public string Value { get; set; } = string.Empty;
}
/// <summary>
/// The type of use statement.
/// </summary>
public enum UseType
{
/// <summary>
/// Expression language configuration (e.g., "use expressions js;").
/// </summary>
Expressions,
/// <summary>
/// Namespace import (e.g., "use Elsa.Activities.Console;").
/// </summary>
Namespace
}

View file

@ -0,0 +1,22 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a variable declaration.
/// </summary>
public class VariableDeclarationNode : StatementNode
{
/// <summary>
/// The variable kind (var, let, const).
/// </summary>
public VariableKind Kind { get; set; }
/// <summary>
/// The variable name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// The initial value expression.
/// </summary>
public ExpressionNode? Value { get; set; }
}

View file

@ -0,0 +1,10 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// The kind of variable declaration.
/// </summary>
public enum VariableKind
{
Var,
Const
}

View file

@ -0,0 +1,17 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a while statement.
/// </summary>
public class WhileNode : StatementNode
{
/// <summary>
/// The condition expression.
/// </summary>
public ExpressionNode Condition { get; set; } = null!;
/// <summary>
/// The body statement.
/// </summary>
public StatementNode Body { get; set; } = null!;
}

View file

@ -0,0 +1,29 @@
namespace Elsa.Dsl.ElsaScript.Ast;
/// <summary>
/// Represents a workflow definition in ElsaScript.
/// </summary>
public class WorkflowNode : AstNode
{
/// <summary>
/// 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.
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Workflow metadata properties (DisplayName, Description, DefinitionId, Version, etc.)
/// These are specified in the optional parenthesized argument list after the workflow id.
/// </summary>
public Dictionary<string, object> Metadata { get; set; } = new();
/// <summary>
/// The use statements (imports and expression language settings) at workflow scope.
/// </summary>
public List<UseNode> UseStatements { get; set; } = [];
/// <summary>
/// The body of the workflow (a sequence of statements).
/// </summary>
public List<StatementNode> Body { get; set; } = [];
}

View file

@ -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;
/// <summary>
/// Compiles an ElsaScript AST into Elsa workflows.
/// </summary>
public class ElsaScriptCompiler(IActivityRegistryLookupService activityRegistryLookupService, IElsaScriptParser parser) : IElsaScriptCompiler
{
private static readonly Dictionary<string, string> LanguageMappings = new(StringComparer.OrdinalIgnoreCase)
{
["js"] = "JavaScript",
["cs"] = "CSharp",
["py"] = "Python",
["liquid"] = "Liquid"
};
private string _defaultExpressionLanguage = "JavaScript";
private readonly Dictionary<string, Variable> _variables = new();
/// <inheritdoc />
public async Task<Workflow> CompileAsync(string source, CancellationToken cancellationToken = default)
{
var programNode = parser.Parse(source);
return await CompileAsync(programNode, cancellationToken);
}
/// <inheritdoc />
public async Task<Workflow> 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<Workflow> 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<IActivity>();
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<string>(workflowNode.Metadata, "DefinitionId") ?? workflowNode.Id;
var displayName = GetMetadataValue<string>(workflowNode.Metadata, "DisplayName") ?? workflowNode.Id;
var description = GetMetadataValue<string>(workflowNode.Metadata, "Description") ?? string.Empty;
var definitionVersionId = GetMetadataValue<string>(workflowNode.Metadata, "DefinitionVersionId") ?? $"{definitionId}-v1";
var version = GetMetadataValueOrDefault(workflowNode.Metadata, "Version", 1);
var usableAsActivity = GetMetadataValue<bool?>(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<T>(Dictionary<string, object> metadata, string key) =>
metadata.TryGetValue(key, out var value) ? ConvertValue<T>(value, default) : default;
private T GetMetadataValueOrDefault<T>(Dictionary<string, object> metadata, string key, T defaultValue) =>
metadata.TryGetValue(key, out var value) ? ConvertValue(value, defaultValue) : defaultValue;
private static T? ConvertValue<T>(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<IActivity?> 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<IActivity> 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<IActivity> CompileBlockAsync(BlockNode block, CancellationToken cancellationToken = default)
{
var activities = new List<IActivity>();
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<IActivity> CompileIfAsync(IfNode ifNode, CancellationToken cancellationToken = default)
{
var condition = CompileExpressionAsInput<bool>(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<IActivity> CompileForEachAsync(ForEachNode forEach, CancellationToken cancellationToken = default)
{
Variable loopVariable;
if (forEach.DeclaresVariable)
{
// Create a new loop variable
loopVariable = new Variable<object>(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<ICollection<object>>(forEach.Collection);
var body = await CompileStatementAsync(forEach.Body, cancellationToken);
var forEachActivity = new ForEach<object>(items)
{
CurrentValue = new(loopVariable),
Body = body
};
return forEachActivity;
}
private async Task<IActivity> CompileForAsync(ForNode forNode, CancellationToken cancellationToken = default)
{
Variable loopVariable;
if (forNode.DeclaresVariable)
{
// Create a new loop variable
loopVariable = new Variable<int>(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<int>(forNode.Start);
var end = CompileExpressionAsInput<int>(forNode.End);
var step = CompileExpressionAsInput<int>(forNode.Step);
var body = await CompileStatementAsync(forNode.Body, cancellationToken);
var forActivity = new For
{
Start = start,
End = end,
Step = step,
OuterBoundInclusive = new Input<bool>(forNode.IsInclusive),
CurrentValue = new Output<object?>(loopVariable),
Body = body
};
return forActivity;
}
private async Task<IActivity> CompileWhileAsync(WhileNode whileNode, CancellationToken cancellationToken = default)
{
var condition = CompileExpressionAsInput<bool>(whileNode.Condition);
var body = await CompileStatementAsync(whileNode.Body, cancellationToken);
return new While(condition)
{
Body = body
};
}
private async Task<IActivity> CompileSwitchAsync(SwitchNode switchNode, CancellationToken cancellationToken = default)
{
var cases = new List<SwitchCase>();
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<IActivity> 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<string, IActivity>();
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<Connection>();
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<IActivity> 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<T> CompileExpressionAsInput<T>(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<T>(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<T>
Type innerType;
if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Input<>))
{
// Extract the T from Input<T>
innerType = targetType.GetGenericArguments()[0];
}
else
{
innerType = targetType;
}
// Use reflection to call CompileExpressionAsInput<T>
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<ArgumentNode> 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<T> parameters as positional arguments (excluding optional params)
// 2. All non-optional parameters are Input<T> 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<T> 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<T> 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<T> 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<object?>();
var allParams = selectedCtor.GetParameters();
foreach (var param in allParams)
{
// Check if this is one of our Input<T> parameters
var inputParamIndex = Array.IndexOf(selectedInputParams, param);
if (inputParamIndex >= 0)
{
// This is an Input<T> 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<T> 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;
}

View file

@ -0,0 +1,26 @@
using Elsa.Dsl.ElsaScript.Ast;
using Elsa.Workflows.Activities;
namespace Elsa.Dsl.ElsaScript.Contracts;
/// <summary>
/// Compiles an ElsaScript AST into Elsa workflows.
/// </summary>
public interface IElsaScriptCompiler
{
/// <summary>
/// Parses and compiles the given ElsaScript source code into a workflow.
/// </summary>
/// <param name="source">The ElsaScript source code.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The compiled workflow.</returns>
Task<Workflow> CompileAsync(string source, CancellationToken cancellationToken = default);
/// <summary>
/// Compiles the given program AST into a workflow.
/// </summary>
/// <param name="programNode">The program AST.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The compiled workflow.</returns>
Task<Workflow> CompileAsync(ProgramNode programNode, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,16 @@
using Elsa.Dsl.ElsaScript.Ast;
namespace Elsa.Dsl.ElsaScript.Contracts;
/// <summary>
/// Parses ElsaScript source code into an AST.
/// </summary>
public interface IElsaScriptParser
{
/// <summary>
/// Parses the given source code into a program AST that can contain multiple workflows.
/// </summary>
/// <param name="source">The ElsaScript source code.</param>
/// <returns>The parsed program AST containing all workflows.</returns>
ProgramNode Parse(string source);
}

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Elsa.Dsl.ElsaScript</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<Description>
Provides a JavaScript-inspired textual DSL (ElsaScript) for authoring Elsa 3 workflows.
</Description>
<PackageTags>elsa module dsl elsascript workflows</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Parlot" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
<ProjectReference Include="..\Elsa.Expressions\Elsa.Expressions.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,19 @@
using Elsa.Dsl.ElsaScript.Features;
using Elsa.Features.Services;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Extension methods for <see cref="IModule"/>.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Adds the ElsaScript DSL feature.
/// </summary>
public static IModule UseElsaScript(this IModule module, Action<ElsaScriptFeature>? configure = null)
{
return module.Use(configure);
}
}

View file

@ -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;
/// <summary>
/// Feature for ElsaScript DSL support.
/// </summary>
public class ElsaScriptFeature : FeatureBase
{
/// <inheritdoc />
public ElsaScriptFeature(IModule module) : base(module)
{
}
/// <inheritdoc />
public override void Configure()
{
// No additional configuration needed
}
/// <inheritdoc />
public override void Apply()
{
Services.AddSingleton<IElsaScriptParser, ElsaScriptParser>();
Services.AddScoped<IElsaScriptCompiler, ElsaScriptCompiler>();
Services.AddScoped<IWorkflowMaterializer, ElsaScriptWorkflowMaterializer>();
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

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

View file

@ -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;
/// <summary>
/// Materializes a <see cref="Workflow"/> from a <see cref="WorkflowDefinition"/>'s ElsaScript source code.
/// </summary>
public class ElsaScriptWorkflowMaterializer(IElsaScriptCompiler compiler) : IWorkflowMaterializer
{
/// <summary>
/// The name of the materializer.
/// </summary>
public const string MaterializerName = "ElsaScript";
/// <inheritdoc />
public string Name => MaterializerName;
/// <inheritdoc />
public async ValueTask<Workflow> 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;
}
}

View file

@ -0,0 +1,630 @@
using Elsa.Dsl.ElsaScript.Ast;
using Elsa.Dsl.ElsaScript.Contracts;
using Parlot;
using Parlot.Fluent;
namespace Elsa.Dsl.ElsaScript.Parser;
/// <summary>
/// ElsaScript parser using Parlot for robust parsing.
/// </summary>
public class ElsaScriptParser : IElsaScriptParser
{
private static readonly Parser<ProgramNode> 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<ExpressionNode>();
var statement = Deferred<StatementNode>();
// Expression parsers
var booleanLiteral = Terms.Text("true").Or(Terms.Text("false"))
.Then<ExpressionNode>(x => new LiteralNode { Value = x.ToString() == "true" });
var numberLiteral = decimalLiteral
.Then<ExpressionNode>(x => new LiteralNode { Value = x });
var intLiteral = integerLiteral
.Then<ExpressionNode>(x => new LiteralNode { Value = (long)x });
var stringExpr = stringLiteral
.Then<ExpressionNode>(x => new LiteralNode { Value = x.ToString() });
var identifierExpr = identifier
.Then<ExpressionNode>(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<ExpressionNode>(elements => new ArrayLiteralNode { Elements = elements.ToList() });
// Elsa expression: lang => <raw text until matching )>
// 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<ExpressionNode>(x => new ElsaExpressionNode
{
Language = x.Item1.ToString(),
Expression = x.Item3.ToString().Trim()
});
var elsaExpressionWithoutLang = arrow
.And(rawExpressionText)
.Then<ExpressionNode>(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<StatementNode>(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<StatementNode>(x => new ListenNode { Activity = x.Item2 });
// Statement: variable declaration, listen, or activity invocation
var activityStatement = activityInvocation
.Then<StatementNode>(x => x);
// Declare deferred for loop, foreach, and flowchart parsers
var forStatement = Deferred<StatementNode>();
var foreachStatement = Deferred<StatementNode>();
var flowchartStatement = Deferred<StatementNode>();
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<StatementNode>(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<StatementNode>(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<StatementNode>(statements => statements.Count == 1
? statements.First()
: new BlockNode { Statements = statements.ToList() });
var nodeActivityStatement = activityInvocation.Then<StatementNode>(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<object>(v => v)
.Or(entryDeclaration.Then<object>(e => e))
.Or(nodeDeclaration.Then<object>(n => n))
.Or(connectionDeclaration.Then<object>(c => c));
var flowchartBody = Between(leftBrace, ZeroOrMany(flowchartBodyElement), rightBrace);
var flowchartStatementParser = flowchartKeyword
.And(flowchartBody)
.Then<StatementNode>(result =>
{
var bodyElements = result.Item2;
var variables = new List<VariableDeclarationNode>();
var nodes = new List<LabeledActivityNode>();
var connections = new List<ConnectionNode>();
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<object>();
workflowBodyElement.Parser = workflowUseStatement
.Then<object>(u => u)
.Or(statementWithSemicolon.Then<object>(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<string, object>();
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<UseNode>();
var statements = new List<StatementNode>();
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<WorkflowNode> { 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<WorkflowNode>
{
new WorkflowNode
{
Id = "DefaultWorkflow",
UseStatements = new List<UseNode>(),
Body = statements
}
}
};
});
var programParser = programWithWorkflow.Or(programWithStatements);
ProgramParser = programParser;
}
/// <inheritdoc />
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;
}
/// <summary>
/// Static helper to evaluate constant expressions during parsing.
/// </summary>
private static object EvaluateConstantExpressionStatic(ExpressionNode exprNode)
{
return exprNode switch
{
LiteralNode literal => literal.Value ?? string.Empty,
IdentifierNode identifier => identifier.Name,
_ => string.Empty
};
}
}
/// <summary>
/// Exception thrown when parsing fails.
/// </summary>
public class ParseException : Exception
{
public ParseException(string message) : base(message)
{
}
}
/// <summary>
/// Custom parser that captures raw text after => until the matching closing parenthesis.
/// Supports nested parentheses.
/// </summary>
internal sealed class RawExpressionParser : Parser<TextSpan>
{
public override bool Parse(ParseContext context, ref ParseResult<TextSpan> 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;
}
}

View file

@ -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.

View file

@ -29,6 +29,19 @@ public class HttpEndpoint : Trigger<HttpRequest>
public HttpEndpoint([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
}
/// <inheritdoc />
public HttpEndpoint(Input<string> path, Input<string> method, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
Path = path;
SupportedMethods = new(ObjectLiteral.From(new[] { method }));
}
/// <inheritdoc />
public HttpEndpoint(Input<string> path, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
Path = path;
}
/// <summary>
/// The path to associate with the workflow.

View file

@ -24,6 +24,19 @@ public class WriteHttpResponse : Activity
public WriteHttpResponse([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
}
/// <inheritdoc />
public WriteHttpResponse(Input<object?> content, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
Content = content;
}
/// <inheritdoc />
public WriteHttpResponse(Input<object?> content, Input<string?> contentType, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
{
Content = content;
ContentType = contentType;
}
/// <summary>
/// The status code to return.

View file

@ -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
{
/// <inheritdoc />
@ -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<string>("Name")
.HasColumnType("varchar(255)");
b.Property<string>("OriginalSource")
.HasColumnType("longtext");
b.Property<string>("ProviderName")
.HasColumnType("longtext");

View file

@ -18,6 +18,14 @@ namespace Elsa.Persistence.EFCore.MySql.Migrations.Management
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalSource",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<string>(
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");
}
}
}

View file

@ -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<string>("Name")
.HasColumnType("varchar(255)");
b.Property<string>("OriginalSource")
.HasColumnType("longtext");
b.Property<string>("ProviderName")
.HasColumnType("longtext");

View file

@ -14,6 +14,7 @@ internal class Management : IEntityTypeConfiguration<WorkflowDefinition>, IEntit
builder.Property<string>("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");
}

View file

@ -1,146 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
{
/// <inheritdoc />
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Data",
schema: _schema.Schema,
table: "WorkflowInstances",
type: "NVARCHAR2(2000)",
nullable: true,
oldClrType: typeof(string),
oldType: "NCLOB",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "StringData",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NVARCHAR2(2000)",
nullable: true,
oldClrType: typeof(string),
oldType: "NCLOB",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "MaterializerContext",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NVARCHAR2(2000)",
nullable: true,
oldClrType: typeof(string),
oldType: "NCLOB",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NVARCHAR2(2000)",
nullable: true,
oldClrType: typeof(string),
oldType: "NCLOB",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Data",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NVARCHAR2(2000)",
nullable: true,
oldClrType: typeof(string),
oldType: "NCLOB",
oldNullable: true);
migrationBuilder.AlterColumn<byte[]>(
name: "BinaryData",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "RAW(2000)",
nullable: true,
oldClrType: typeof(byte[]),
oldType: "BLOB",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Data",
schema: _schema.Schema,
table: "WorkflowInstances",
type: "NCLOB",
nullable: true,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "StringData",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NCLOB",
nullable: true,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "MaterializerContext",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NCLOB",
nullable: true,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NCLOB",
nullable: true,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Data",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NCLOB",
nullable: true,
oldClrType: typeof(string),
oldType: "NVARCHAR2(2000)",
oldNullable: true);
migrationBuilder.AlterColumn<byte[]>(
name: "BinaryData",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "BLOB",
nullable: true,
oldClrType: typeof(byte[]),
oldType: "RAW(2000)",
oldNullable: true);
}
}
}

View file

@ -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
{
/// <inheritdoc />
@ -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<string>("Name")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("OriginalSource")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("ProviderName")
.HasColumnType("NVARCHAR2(2000)");

View file

@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
{
/// <inheritdoc />
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalSource",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "NCLOB",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "StringData",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "JSON",
nullable: true,
oldClrType: typeof(string),
oldType: "NCLOB",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
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");
}
}
}

View file

@ -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<byte[]>("BinaryData")
.HasColumnType("RAW(2000)");
.HasColumnType("BLOB");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TIMESTAMP(7) WITH TIME ZONE");
b.Property<string>("Data")
.HasColumnType("NVARCHAR2(2000)");
.HasColumnType("NCLOB");
b.Property<string>("DefinitionId")
.IsRequired()
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("Description")
.HasColumnType("NVARCHAR2(2000)");
.HasColumnType("NCLOB");
b.Property<bool>("IsLatest")
.HasColumnType("BOOLEAN");
@ -57,7 +57,7 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
.HasColumnType("BOOLEAN");
b.Property<string>("MaterializerContext")
.HasColumnType("NVARCHAR2(2000)");
.HasColumnType("NCLOB");
b.Property<string>("MaterializerName")
.IsRequired()
@ -66,11 +66,14 @@ namespace Elsa.Persistence.EFCore.Oracle.Migrations.Management
b.Property<string>("Name")
.HasColumnType("NVARCHAR2(450)");
b.Property<string>("OriginalSource")
.HasColumnType("NCLOB");
b.Property<string>("ProviderName")
.HasColumnType("NVARCHAR2(2000)");
b.Property<string>("StringData")
.HasColumnType("NVARCHAR2(2000)");
.HasColumnType("JSON");
b.Property<string>("TenantId")
.HasColumnType("NVARCHAR2(450)");

View file

@ -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
{
/// <inheritdoc />
@ -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<string>("Name")
.HasColumnType("text");
b.Property<string>("OriginalSource")
.HasColumnType("text");
b.Property<string>("ProviderName")
.HasColumnType("text");

View file

@ -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
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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");
}
}
}
}

View file

@ -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<string>("Name")
.HasColumnType("text");
b.Property<string>("OriginalSource")
.HasColumnType("text");
b.Property<string>("ProviderName")
.HasColumnType("text");

View file

@ -0,0 +1,235 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<byte[]>("BinaryData")
.HasColumnType("varbinary(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Data")
.HasColumnType("nvarchar(max)");
b.Property<string>("DefinitionId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsLatest")
.HasColumnType("bit");
b.Property<bool>("IsPublished")
.HasColumnType("bit");
b.Property<bool>("IsReadonly")
.HasColumnType("bit");
b.Property<bool>("IsSystem")
.HasColumnType("bit");
b.Property<string>("MaterializerContext")
.HasColumnType("nvarchar(max)");
b.Property<string>("MaterializerName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("OriginalSource")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderName")
.HasColumnType("nvarchar(max)");
b.Property<string>("StringData")
.HasColumnType("nvarchar(max)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.Property<string>("ToolVersion")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("UsableAsActivity")
.HasColumnType("bit");
b.Property<int>("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<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("CorrelationId")
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Data")
.HasColumnType("nvarchar(max)");
b.Property<string>("DataCompressionAlgorithm")
.HasColumnType("nvarchar(max)");
b.Property<string>("DefinitionId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("DefinitionVersionId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("datetimeoffset");
b.Property<int>("IncidentCount")
.HasColumnType("int");
b.Property<bool>("IsExecuting")
.HasColumnType("bit");
b.Property<bool>("IsSystem")
.HasColumnType("bit");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("ParentWorkflowInstanceId")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("SubStatus")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("TenantId")
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<int>("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
}
}
}

View file

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.SqlServer.Migrations.Management
{
/// <inheritdoc />
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalSource",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OriginalSource",
schema: _schema.Schema,
table: "WorkflowDefinitions");
}
}
}

View file

@ -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<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("OriginalSource")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderName")
.HasColumnType("nvarchar(max)");

View file

@ -0,0 +1,231 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<byte[]>("BinaryData")
.HasColumnType("BLOB");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Data")
.HasColumnType("TEXT");
b.Property<string>("DefinitionId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsLatest")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<bool>("IsReadonly")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("MaterializerContext")
.HasColumnType("TEXT");
b.Property<string>("MaterializerName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OriginalSource")
.HasColumnType("TEXT");
b.Property<string>("ProviderName")
.HasColumnType("TEXT");
b.Property<string>("StringData")
.HasColumnType("TEXT");
b.Property<string>("TenantId")
.HasColumnType("TEXT");
b.Property<string>("ToolVersion")
.HasColumnType("TEXT");
b.Property<bool?>("UsableAsActivity")
.HasColumnType("INTEGER");
b.Property<int>("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<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Data")
.HasColumnType("TEXT");
b.Property<string>("DataCompressionAlgorithm")
.HasColumnType("TEXT");
b.Property<string>("DefinitionId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DefinitionVersionId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("TEXT");
b.Property<int>("IncidentCount")
.HasColumnType("INTEGER");
b.Property<bool>("IsExecuting")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("ParentWorkflowInstanceId")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubStatus")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TenantId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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
}
}
}

View file

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Elsa.Persistence.EFCore.Sqlite.Migrations.Management
{
/// <inheritdoc />
public partial class V3_6 : Migration
{
private readonly Elsa.Persistence.EFCore.IElsaDbContextSchema _schema;
/// <inheritdoc />
public V3_6(Elsa.Persistence.EFCore.IElsaDbContextSchema schema)
{
_schema = schema;
}
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalSource",
schema: _schema.Schema,
table: "WorkflowDefinitions",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OriginalSource",
schema: _schema.Schema,
table: "WorkflowDefinitions");
}
}
}

View file

@ -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<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("OriginalSource")
.HasColumnType("TEXT");
b.Property<string>("ProviderName")
.HasColumnType("TEXT");

View file

@ -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

View file

@ -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<ICommandSender>();
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<ICommandSender>();
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();

View file

@ -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<string, IScheduledTask> _scheduledTasks = new Dictionary<string, IScheduledTask>();
private readonly IDictionary<IScheduledTask, ICollection<string>> _scheduledTaskKeys = new Dictionary<IScheduledTask, ICollection<string>>();
private readonly ILogger<LocalScheduler> _logger;
private readonly ConcurrentDictionary<string, IScheduledTask> _scheduledTasks = new();
private readonly ConcurrentDictionary<IScheduledTask, ICollection<string>> _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
/// <summary>
/// Initializes a new instance of the <see cref="LocalScheduler"/> class.
/// </summary>
public LocalScheduler(IServiceProvider serviceProvider)
public LocalScheduler(IServiceProvider serviceProvider, ILogger<LocalScheduler> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
/// <inheritdoc />
@ -70,13 +73,17 @@ public class LocalScheduler : IScheduler
private void RegisterScheduledTask(string name, IScheduledTask scheduledTask, IEnumerable<string>? 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();
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides ElsaScript support for BlobStorage workflows provider.
</Description>
<PackageTags>elsa extension module elsascript blob storage</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.WorkflowProviders.BlobStorage\Elsa.WorkflowProviders.BlobStorage.csproj" />
<ProjectReference Include="..\Elsa.Dsl.ElsaScript\Elsa.Dsl.ElsaScript.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,19 @@
using Elsa.Features.Services;
using Elsa.WorkflowProviders.BlobStorage.ElsaScript.Features;
namespace Elsa.WorkflowProviders.BlobStorage.ElsaScript.Extensions;
/// <summary>
/// Extensions for <see cref="IModule"/>.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Enables ElsaScript support for BlobStorage workflows.
/// </summary>
public static IModule UseElsaScriptBlobStorage(this IModule module, Action<ElsaScriptBlobStorageFeature>? configure = null)
{
module.Configure(configure);
return module;
}
}

View file

@ -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;
/// <summary>
/// A feature that enables ElsaScript support for the BlobStorage workflow provider.
/// </summary>
[DependsOn(typeof(BlobStorageFeature))]
[DependsOn(typeof(ElsaScriptFeature))]
public class ElsaScriptBlobStorageFeature(IModule module) : FeatureBase(module)
{
/// <inheritdoc />
public override void Apply()
{
// Register the ElsaScript format handler
Services.AddScoped<IBlobWorkflowFormatHandler, ElsaScriptBlobWorkflowFormatHandler>();
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -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;
/// <summary>
/// Handles ElsaScript-formatted workflow definitions from blob storage.
/// </summary>
public class ElsaScriptBlobWorkflowFormatHandler(IElsaScriptCompiler compiler, ILogger<ElsaScriptBlobWorkflowFormatHandler> logger) : IBlobWorkflowFormatHandler
{
/// <inheritdoc />
public string Name => "ElsaScript";
/// <inheritdoc />
public IEnumerable<string> SupportedExtensions => ["elsa"];
/// <inheritdoc />
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;
}
/// <inheritdoc />
public async ValueTask<MaterializedWorkflow?> 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;
}
}
}

View file

@ -0,0 +1,43 @@
using Elsa.Workflows.Runtime;
using FluentStorage.Blobs;
namespace Elsa.WorkflowProviders.BlobStorage.Contracts;
/// <summary>
/// Handles parsing of workflow definitions from blob storage in a specific format.
/// </summary>
public interface IBlobWorkflowFormatHandler
{
/// <summary>
/// A short name or ID of the handler, for diagnostics/logging.
/// </summary>
string Name { get; }
/// <summary>
/// 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.
/// </summary>
IEnumerable<string> SupportedExtensions { get; }
/// <summary>
/// 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.
/// </summary>
/// <param name="blob">The blob to check.</param>
/// <param name="contentType">The content type from blob metadata, if available.</param>
bool CanHandle(Blob blob, string? contentType);
/// <summary>
/// Attempts to parse the blob content into a MaterializedWorkflow.
/// Returns null if the content is not valid for this handler.
/// </summary>
/// <param name="blob">The blob being parsed.</param>
/// <param name="content">The text content of the blob.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask<MaterializedWorkflow?> TryParseAsync(
Blob blob,
string content,
CancellationToken cancellationToken = default);
}

View file

@ -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<IBlobStorageProvider>(sp => new BlobStorageProvider(BlobStorage(sp)));
// Register the JSON format handler (built-in support)
Services.AddScoped<IBlobWorkflowFormatHandler, JsonBlobWorkflowFormatHandler>();
Services.AddWorkflowsProvider<BlobStorageWorkflowsProvider>();
}

View file

@ -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;
/// <summary>
/// Handles JSON-formatted workflow definitions from blob storage.
/// </summary>
public class JsonBlobWorkflowFormatHandler(
IActivitySerializer activitySerializer,
WorkflowDefinitionMapper workflowDefinitionMapper,
ILogger<JsonBlobWorkflowFormatHandler> logger) : IBlobWorkflowFormatHandler
{
/// <inheritdoc />
public string Name => "Json";
/// <inheritdoc />
public IEnumerable<string> SupportedExtensions => ["json"];
/// <inheritdoc />
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;
}
/// <inheritdoc />
public ValueTask<MaterializedWorkflow?> TryParseAsync(Blob blob, string content, CancellationToken cancellationToken = default)
{
try
{
var workflowDefinitionModel = activitySerializer.Deserialize<WorkflowDefinitionModel>(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);
}
}
}

View file

@ -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<IBlobWorkflowFormatHandler> _handlers;
/// <summary>
/// Initializes a new instance of the <see cref="BlobStorageWorkflowsProvider"/> class.
/// </summary>
public BlobStorageWorkflowsProvider(
IBlobStorageProvider blobStorageProvider,
IActivitySerializer activitySerializer,
ISystemClock systemClock,
IHasher hasher,
WorkflowDefinitionMapper workflowDefinitionMapper,
VariableDefinitionMapper variableDefinitionMapper)
IEnumerable<IBlobWorkflowFormatHandler> handlers)
{
_blobStorageProvider = blobStorageProvider;
_activitySerializer = activitySerializer;
_systemClock = systemClock;
_hasher = hasher;
_workflowDefinitionMapper = workflowDefinitionMapper;
_variableDefinitionMapper = variableDefinitionMapper;
_handlers = handlers;
}
/// <inheritdoc />
@ -48,10 +31,26 @@ public class BlobStorageWorkflowsProvider : IWorkflowsProvider
/// <inheritdoc />
public async ValueTask<IEnumerable<MaterializedWorkflow>> 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<MaterializedWorkflow> ReadWorkflowAsync(Blob blob, CancellationToken cancellationToken)
private async Task<MaterializedWorkflow?> 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<WorkflowDefinitionModel>(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;
}
}

View file

@ -1,8 +0,0 @@
using Elsa.Workflows.Models;
namespace Elsa.Workflows;
public interface IActivityFactory
{
IActivity Create(Type type, ActivityConstructorContext context);
}

View file

@ -36,8 +36,12 @@ public static class ItemSourceActivityExecutionContextExtensions
yield return item;
}
}
if (items is IEnumerable<T> enumerable)
else if (items is IAsyncEnumerable<T> asyncEnumerable)
{
await foreach (T item in asyncEnumerable)
yield return item;
}
else if (items is IEnumerable<T> enumerable)
{
foreach (T item in enumerable)
yield return item;

View file

@ -182,7 +182,6 @@ public class WorkflowsFeature : FeatureBase
.AddScoped<IActivityRegistryLookupService, ActivityRegistryLookupService>()
.AddSingleton<IPropertyDefaultValueResolver, PropertyDefaultValueResolver>()
.AddSingleton<IPropertyUIHandlerResolver, PropertyUIHandlerResolver>()
.AddSingleton<IActivityFactory, ActivityFactory>()
.AddTransient<WorkflowBuilder>()
.AddScoped(typeof(Func<IWorkflowBuilder>), sp => () => sp.GetRequiredService<WorkflowBuilder>())
.AddScoped<IWorkflowBuilderFactory, WorkflowBuilderFactory>()

View file

@ -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;
/// <inheritdoc />
public class ActivityFactory : IActivityFactory
public record ActivityConstructorContext(ActivityDescriptor ActivityDescriptor, Func<Type, IActivity> ActivityFactory)
{
public IActivity Create(Type type, ActivityConstructorContext context)
public T CreateActivity<T>() 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<T>(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<TaskActivityAttribute>().FirstOrDefault() is { } taskActivityAttribute)
if (activityDescriptor.Attributes.OfType<TaskActivityAttribute>().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
/// </summary>
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;
}
}
}

View file

@ -15,6 +15,11 @@ public class ActivityDescriptor
/// </summary>
public string TypeName { get; set; } = null!;
/// <summary>
/// The .NET type of the activity type.
/// </summary>
public Type ClrType { get; set; } = null!;
/// <summary>
/// The namespace of the activity type.
/// </summary>
@ -123,8 +128,4 @@ public class ActivityDescriptor
/// </remarks>
[JsonIgnore]
public Func<JsonSerializerOptions, JsonSerializerOptions>? 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);
}

View file

@ -15,7 +15,6 @@ namespace Elsa.Workflows.Serialization.Converters;
/// </summary>
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<NotFoundActivity>()!;
var notFoundContext = new ActivityConstructorContext(notFoundActivityDescriptor, activityRoot, clonedOptions);
var notFoundActivity = (NotFoundActivity)activityFactory.Create(typeof(NotFoundActivity), notFoundContext);
var notFoundActivity = JsonActivityConstructorContextHelper.CreateActivity<NotFoundActivity>(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;
}

View file

@ -15,7 +15,7 @@ using Container = Elsa.Workflows.Activities.Container;
namespace Elsa.Workflows;
/// <inheritdoc />
public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IActivityFactory activityFactory, IPropertyUIHandlerResolver propertyUIHandlerResolver) : IActivityDescriber
public class ActivityDescriber(IPropertyDefaultValueResolver defaultValueResolver, IPropertyUIHandlerResolver propertyUIHandlerResolver) : IActivityDescriber
{
/// <inheritdoc />
public async Task<ActivityDescriptor> 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;
},

View file

@ -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<WorkflowDefinitionActivity>();
activity.Type = typeName;
activity.WorkflowDefinitionId = definition.DefinitionId;
activity.WorkflowDefinitionVersionId = definition.Id;

View file

@ -78,7 +78,14 @@ public class WorkflowDefinition : VersionedEntity
/// A textual representation of the workflow. The data is to be interpreted by the configured materializer.
/// </summary>
public string? StringData { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? OriginalSource { get; set; }
/// <summary>
/// A binary representation of the workflow. The data is to be interpreted by the configured materializer.
/// </summary>

View file

@ -11,33 +11,35 @@ namespace Elsa.Workflows.Management;
[PublicAPI]
public static class ActivityDescriberExtensions
{
/// <summary>
/// Describes an output property.
/// </summary>
/// <param name="activityDescriber">The activity describer.</param>
/// <param name="expression">The property expression.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="TActivity">The type of the activity.</typeparam>
/// <typeparam name="TProperty">The type of the property.</typeparam>
/// <returns>The output descriptor.</returns>
public static async Task<OutputDescriptor> DescribeOutputProperty<TActivity, TProperty>(this IActivityDescriber activityDescriber, Expression<Func<TActivity, TProperty>> expression, CancellationToken cancellationToken = default)
extension(IActivityDescriber activityDescriber)
{
var propertyInfo = expression.GetProperty()!;
return await activityDescriber.DescribeOutputPropertyAsync(propertyInfo, cancellationToken);
}
/// <summary>
/// Describes an output property.
/// </summary>
/// <param name="expression">The property expression.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="TActivity">The type of the activity.</typeparam>
/// <typeparam name="TProperty">The type of the property.</typeparam>
/// <returns>The output descriptor.</returns>
public async Task<OutputDescriptor> DescribeOutputProperty<TActivity, TProperty>(Expression<Func<TActivity, TProperty>> expression, CancellationToken cancellationToken = default)
{
var propertyInfo = expression.GetProperty()!;
return await activityDescriber.DescribeOutputPropertyAsync(propertyInfo, cancellationToken);
}
/// <summary>
/// Describes an input property.
/// </summary>
/// <param name="activityDescriber">The activity describer.</param>
/// <param name="expression">The property expression.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="TActivity">The type of the activity.</typeparam>
/// <typeparam name="TProperty">The type of the property.</typeparam>
/// <returns>The input descriptor.</returns>
public static async Task<InputDescriptor> DescribeInputPropertyAsync<TActivity, TProperty>(this IActivityDescriber activityDescriber, Expression<Func<TActivity, TProperty>> expression, CancellationToken cancellationToken = default)
{
var propertyInfo = expression.GetProperty()!;
return await activityDescriber.DescribeInputPropertyAsync(propertyInfo, cancellationToken);
/// <summary>
/// Describes an input property.
/// </summary>
/// <param name="expression">The property expression.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="TActivity">The type of the activity.</typeparam>
/// <typeparam name="TProperty">The type of the property.</typeparam>
/// <returns>The input descriptor.</returns>
public async Task<InputDescriptor> DescribeInputPropertyAsync<TActivity, TProperty>(Expression<Func<TActivity, TProperty>> expression, CancellationToken cancellationToken = default)
{
var propertyInfo = expression.GetProperty()!;
return await activityDescriber.DescribeInputPropertyAsync(propertyInfo, cancellationToken);
}
}
}

View file

@ -1,20 +0,0 @@
using Elsa.Workflows;
using Elsa.Workflows.Models;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides a set of extension methods for <see cref="IActivityFactory"/>.
/// </summary>
public static class ActivityFactoryExtensions
{
/// <summary>
/// Creates an activity of the specified type.
/// </summary>
/// <param name="activityFactory">The activity factory.</param>
/// <param name="context">The activity constructor context.</param>
/// <typeparam name="T">The type of the activity to create.</typeparam>
/// <returns>The created activity.</returns>
public static T Create<T>(this IActivityFactory activityFactory, ActivityConstructorContext context) => (T)activityFactory.Create(typeof(T), context);
}

View file

@ -32,6 +32,15 @@ public class WorkflowDefinitionMapper
/// <returns>The mapped <see cref="Workflow"/>.</returns>
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<WorkflowDefinitionModel>(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);
}
/// <summary>
/// Maps a <see cref="WorkflowDefinitionModel"/> to a <see cref="Workflow"/>, using identity from stored definition.
/// </summary>
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<InputDefinition>(),
model.Outputs ?? new List<OutputDefinition>(),
model.Outcomes ?? new List<string>(),
model.CustomProperties ?? new Dictionary<string, object>(),
storedDefinition.IsReadonly,
storedDefinition.IsSystem);
}
/// <summary>
/// Maps a <see cref="WorkflowDefinitionModel"/> to a <see cref="Workflow"/>.
/// </summary>

View file

@ -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;
/// <summary>
/// Provides activity descriptors based on a list of activity types registered in the <see cref="ManagementOptions"/>.
/// </summary>
[UsedImplicitly]
public class TypedActivityProvider : IActivityProvider
{
private readonly IActivityDescriber _activityDescriber;

View file

@ -14,7 +14,8 @@ public static class WorkflowDictionaryExtensions
/// </summary>
public static void Add<TWorkflow>(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> 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<TWorkflow>(sp);
return new ValueTask<IWorkflow>(workflow);
@ -26,7 +27,8 @@ public static class WorkflowDictionaryExtensions
/// </summary>
public static void Add(this IDictionary<string, Func<IServiceProvider, ValueTask<IWorkflow>>> 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<IWorkflow>(workflow);

View file

@ -9,4 +9,5 @@ namespace Elsa.Workflows.Runtime;
/// <param name="ProviderName">The name of the provider that provided the workflow definition.</param>
/// <param name="MaterializerName">The name of the materializer that materialized the workflow.</param>
/// <param name="MaterializerContext">The context of the materializer that materialized the workflow.</param>
public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null);
/// <param name="OriginalSource">The original source representation (JSON, ElsaScript, YAML, etc.). When present, enables symmetric round-trip preservation without re-serialization.</param>
public record MaterializedWorkflow(Workflow Workflow, string ProviderName, string MaterializerName, object? MaterializerContext = null, string? OriginalSource = null);

View file

@ -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);

View file

@ -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;

16
test-flowchart.txt Normal file
View file

@ -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;
}
}

View file

@ -0,0 +1,10 @@
use Elsa.Activities.Console;
workflow SimpleFlowchart {
flowchart {
entry A;
A: WriteLine("Test");
A -> B;
B: WriteLine("Done");
}
}

View file

@ -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;
/// <summary>
/// Integration tests for the ElsaScript compiler.
/// </summary>
public class CompilerTests
{
private readonly IElsaScriptCompiler _compiler;
public CompilerTests(ITestOutputHelper testOutputHelper)
{
var services = new TestApplicationBuilder(testOutputHelper)
.ConfigureElsa(elsa =>
{
elsa.UseElsaScript();
elsa.AddActivitiesFrom<HttpEndpoint>();
})
.Build();
_compiler = services.GetRequiredService<IElsaScriptCompiler>();
}
[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<Sequence>(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<Input<string>>(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<Input<object>>(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<For>(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<For>(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<Workflows.Activities.Flowchart.Activities.Flowchart>(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<Workflows.Activities.Flowchart.Activities.Flowchart>(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<Workflows.Activities.Flowchart.Activities.Flowchart>(workflow.Root);
// Should have one activity
Assert.Single(flowchart.Activities);
// The activity should be a Sequence (from the block)
var sequenceActivity = Assert.IsType<Sequence>(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<Input<string>>(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<Input<string>>(secondTextProp!.GetValue(secondWriteLine));
Assert.Equal("JavaScript", secondTextInput.Expression?.Type);
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Dsl.ElsaScript]*</Include>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared.Integration\Elsa.Testing.Shared.Integration.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Dsl.ElsaScript\Elsa.Dsl.ElsaScript.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Expressions.JavaScript\Elsa.Expressions.JavaScript.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Http\Elsa.Http.csproj" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Integration tests for the ElsaScript parser.
/// </summary>
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<IElsaScriptParser>();
}
[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<Ast.VariableDeclarationNode>(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<Ast.ActivityInvocationNode>(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<Ast.ListenNode>(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<Ast.VariableDeclarationNode>(workflow.Body[0]);
Assert.Equal("message", varDecl.Name);
Assert.Equal(Ast.VariableKind.Var, varDecl.Kind);
// Verify listen statement
var listenNode = Assert.IsType<Ast.ListenNode>(workflow.Body[1]);
Assert.Equal("HttpEndpoint", listenNode.Activity.ActivityName);
Assert.Single(listenNode.Activity.Arguments);
// Verify WriteLine with ElsaScript expression
var writeLineNode = Assert.IsType<Ast.ActivityInvocationNode>(workflow.Body[2]);
Assert.Equal("WriteLine", writeLineNode.ActivityName);
Assert.Single(writeLineNode.Arguments);
var writeLineExpr = Assert.IsType<Ast.ElsaExpressionNode>(writeLineNode.Arguments[0].Value);
Assert.Equal("js", writeLineExpr.Language);
// Verify WriteHttpResponse with variable reference
var writeHttpNode = Assert.IsType<Ast.ActivityInvocationNode>(workflow.Body[3]);
Assert.Equal("WriteHttpResponse", writeHttpNode.ActivityName);
Assert.Single(writeHttpNode.Arguments);
var writeHttpArg = Assert.IsType<Ast.IdentifierNode>(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<Ast.ForNode>(workflow.Body[0]);
Assert.True(forNode.DeclaresVariable); // var i
Assert.Equal("i", forNode.VariableName);
Assert.False(forNode.IsInclusive);
var startLiteral = Assert.IsType<Ast.LiteralNode>(forNode.Start);
// Numbers are parsed as decimals by the parser
Assert.Equal(0m, Convert.ToDecimal(startLiteral.Value!));
var endLiteral = Assert.IsType<Ast.LiteralNode>(forNode.End);
Assert.Equal(10m, Convert.ToDecimal(endLiteral.Value!));
var stepLiteral = Assert.IsType<Ast.LiteralNode>(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<Ast.ForNode>(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<Ast.FlowchartNode>(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<Ast.FlowchartNode>(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<Ast.FlowchartNode>(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<Ast.BlockNode>(flowchart.Activities[0].Activity);
Assert.Equal(2, blockNode.Statements.Count);
}
}

Some files were not shown because too many files have changed in this diff Show more