Merge remote-tracking branch 'origin/develop/3.6.0'

This commit is contained in:
Sipke Schoorstra 2025-11-27 21:41:59 +01:00
commit a9154e412f
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
370 changed files with 14426 additions and 3633 deletions

View file

@ -51,7 +51,7 @@ docker/ # Docker configurations
## Build Instructions
### Prerequisites
- **.NET 9.0 SDK** (verified working version: 9.0.305)
- **.NET 10.0 SDK**
- **Build time**: Initial restore ~1-2 minutes, full compile ~5-10 minutes
### Critical Build Information
@ -111,11 +111,10 @@ find test/unit -name "*.csproj" | head -5 | xargs -I {} dotnet build {}
### Expected Build Warnings
- `NU1900`: Unable to load service index for external feeds (safe to ignore)
- `NU1801`: Service index warnings for feedz.io sources (safe to ignore)
- `NU1101`: Missing Elsa.Studio packages (blocks studio app builds)
### Successful Build Indicators
- Core modules (Elsa.Workflows.Core, etc.) compile successfully
- Server applications (Elsa.Server.Web) build without the studio UI
- Server applications (Elsa.Server.Web) build
- Most modules show "succeeded with X warning(s)" (warnings are acceptable)
## Testing
@ -142,7 +141,7 @@ dotnet test --no-build --no-restore [project-path]
### Code Standards
- **Language version**: C# latest
- **Target framework**: .NET 9.0
- **Target framework**: .NET 10.0
- **Nullable reference types**: Enabled
- **Implicit usings**: Enabled
- **EditorConfig**: Configured (4-space indentation, CRLF line endings)
@ -154,23 +153,22 @@ dotnet test --no-build --no-restore [project-path]
- **Async/await**: Extensive use throughout for scalability
### Common Gotchas
1. **External Dependencies**: Studio-related projects require external packages
2. **NuGet Source Mapping**: Configured in NuGet.Config, restricts where packages can be sourced
3. **Multiple Target Frameworks**: Some projects conditionally target different frameworks
4. **Build Warnings**: Many NU1900/NU1801 warnings are expected and safe
1. **NuGet Source Mapping**: Configured in NuGet.Config, restricts where packages can be sourced
2**Multiple Target Frameworks**: Some projects conditionally target different frameworks
3**Build Warnings**: Many NU1900/NU1801 warnings are expected and safe
## Continuous Integration
### GitHub Actions Workflow
- **Trigger**: Pull requests to `main` branch
- **Runner**: ubuntu-latest
- **.NET Version**: 9.x (latest)
- **.NET Version**: 10.x (latest)
- **Commands**: `./build.cmd Compile Test Pack`
- **File**: `.github/workflows/pr.yml` (auto-generated by NUKE)
### CI Pipeline Steps
1. Checkout code
2. Setup .NET 9.x SDK
2. Setup .NET 10.x SDK
3. Execute: Compile → Test → Pack
4. Expected warnings for external feed access
5. Studio apps may be excluded from CI builds
@ -216,15 +214,68 @@ Multiple Docker configurations available:
dotnet build --no-restore
```
## Running the Applications
### Development Workflow Server
To run the workflow server for development:
```bash
cd src/apps/Elsa.Server.Web
dotnet restore --ignore-failed-sources
dotnet run
```
The server will start on the configured ports (check `appsettings.json` or environment variables).
## Troubleshooting
### Common Issues
1. **Missing External Packages**: If you encounter `NU1101` errors for Elsa.Studio packages:
- This is expected for studio-related apps
- Focus development on core workflow modules instead
- Or use Docker images that have pre-built studio components
2. **Build Fails on Server Apps**: If `Elsa.Server.Web` fails due to WebhooksCore:
- This is a known issue with external package feeds
- Try building individual core modules instead
- Use `--ignore-failed-sources` flag consistently
3. **Test Failures**: If many tests fail to run:
- External package dependencies may be unavailable
- Run tests for specific core modules individually
- Focus on tests that don't require studio packages
4. **Slow Initial Build**: First restore and compile can take 5-10+ minutes:
- This is normal for a large solution with 100+ projects
- Subsequent builds are much faster (incremental)
- Consider building specific projects/modules when iterating
## Additional Resources
### Documentation
- **Official Documentation**: [https://docs.elsaworkflows.io/](https://docs.elsaworkflows.io/)
- **README**: See [README.md](../README.md) for quick start and features overview
- **Contributing Guide**: See [CONTRIBUTING.md](../CONTRIBUTING.md) for contribution guidelines
### Community Support
- **GitHub Issues**: [Report bugs and request features](https://github.com/elsa-workflows/elsa-core/issues)
- **GitHub Discussions**: [Ask questions and discuss](https://github.com/elsa-workflows/elsa-core/discussions)
- **Discord**: [Join the community chat](https://discord.gg/hhChk5H472)
- **Stack Overflow**: [Tag: elsa-workflows](http://stackoverflow.com/questions/tagged/elsa-workflows)
### Enterprise Support
- **ELSA-X**: [Professional support and enterprise solutions](https://elsa-x.io)
## Important Notes for Coding Agents
1. **Always use `--ignore-failed-sources`** when restoring packages
2. **Focus on core workflow functionality** rather than studio UI components
3. **Studio apps require external packages** that may not be accessible
4. **Build warnings are normal** - don't try to fix NU1900/NU1801 warnings
5. **Test individual modules** rather than solution-wide tests when external deps fail
6. **Use direct dotnet commands** for building specific components when NUKE fails
7. **Check project references** before attempting builds - some projects have conditional references
8. **Start with core modules** like `Elsa.Workflows.Core`, `Elsa.Workflows.Runtime` which are more likely to build successfully
2. **Focus on core workflow functionality**
3. **Build warnings are normal** - don't try to fix NU1900/NU1801 warnings
4. **Test individual modules** rather than solution-wide tests when external deps fail
5. **Use direct dotnet commands** for building specific components when NUKE fails
6. **Check project references** before attempting builds - some projects have conditional references
7. **Start with core modules** like `Elsa.Workflows.Core`, `Elsa.Workflows.Runtime` which are more likely to build successfully
Trust these instructions for build and development workflows. Only search for additional information if these instructions are incomplete or found to be incorrect.

View file

@ -13,10 +13,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up .NET 9 SDK
- name: Set up .NET 10 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
dotnet-version: "10.0.x"
- name: Build, test, and pack
run: ./build.cmd Compile Test Pack

View file

@ -48,7 +48,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Publish TLS smoke test app
run: dotnet publish test/TlsSmoke/TlsSmoke.csproj -c Release -o artifacts/tls-smoke

View file

@ -1,61 +0,0 @@
name: Elsa Server + 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-server-and-studio-v3-7-0-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/ElsaServerAndStudio.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

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

@ -13,17 +13,6 @@ on:
- 'rc/*'
- 'develop/*'
- 'codex/*'
pull_request:
branches:
- 'main'
- 'bug/*'
- 'perf/*'
- 'patch/*'
- 'feat/*'
- 'enh/*'
- 'rc/*'
- 'develop/*'
- 'codex/*'
release:
types: [prereleased, published]
@ -47,6 +36,7 @@ jobs:
dotnet-version: |
8.0.x
9.0.x
10.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
@ -80,6 +70,17 @@ jobs:
dotnet test "$project" --configuration Release --no-build --logger "GitHubActions;report-warnings=false" /p:CollectCoverage=true
done
- name: Install ReportGenerator
run: dotnet tool install -g dotnet-reportgenerator-globaltool
- name: Generate HTML coverage report
run: |
reportgenerator \
"-reports:./artifacts/coverage/**/coverage.cobertura.xml" \
"-targetdir:./artifacts/coverage-report" \
"-reporttypes:Html;Cobertura;TextSummary" \
"-verbosity:Info"
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
@ -87,6 +88,12 @@ jobs:
name: coverage-reports
path: artifacts/coverage
- name: Upload Pages artifact
if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.0')
uses: actions/upload-pages-artifact@v3
with:
path: './artifacts/coverage-report'
build:
name: Build packages
needs: test
@ -178,3 +185,19 @@ jobs:
- name: Publish to nuget.org
run: dotnet nuget push *.nupkg -k ${{ secrets.NUGET_API_KEY }} -s ${{ env.nuget_feed_source }} --skip-duplicate
deploy_coverage:
name: Deploy coverage to GitHub Pages
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop/3.6.0'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -14,12 +14,14 @@
# </auto-generated>
# ------------------------------------------------------------------------------
name: PR
name: pr
on:
pull_request:
branches:
- main
- 'patch/*'
- 'develop/*'
paths:
- '**/*'
@ -35,7 +37,7 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: |
9.x
10.x
- uses: actions/checkout@v4
- name: 'Run: Compile, Test, Pack'
run: ./build.cmd Compile Test Pack
- name: 'Run: Compile, Test'
run: ./build.cmd Compile Test

4
.gitignore vendored
View file

@ -80,3 +80,7 @@ unlist.sh
/docker/data/
/docker/azurite-data/
docker/docker-compose-datadog.yml
# Test results
TestResults/
artifacts/

View file

@ -33,7 +33,9 @@
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CS0162;CS1591</NoWarn>
<!-- Obsolete API warnings - Suppressed for backward compatibility during migration period -->
<NoWarn>$(NoWarn);CS0618</NoWarn>
<!-- IL trimming warnings -->
<NoWarn>$(NoWarn);IL2026;IL2046;IL2057;IL2067;IL2070;IL2072;IL2075;IL2087;IL2091</NoWarn>
<NoWarn>$(NoWarn);IL2026;IL2046;IL2057;IL2067;IL2070;IL2072;IL2075;IL2087;IL2091;IL2111</NoWarn>
</PropertyGroup>
</Project>

View file

@ -3,10 +3,26 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<PropertyGroup>
<MicrosoftVersion>9.0.10</MicrosoftVersion>
<ResilienceVersion>9.10.0</ResilienceVersion>
</PropertyGroup>
<!-- Conditional package versions for .NET 8/9 compatibility -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0' or '$(TargetFramework)' == 'net9.0'">
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.11"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.11"/>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageVersion Include="Npgsql" Version="9.0.4"/>
</ItemGroup>
<!-- .NET 10 specific package versions -->
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
<PackageVersion Include="Npgsql" Version="10.0.0"/>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Antlr4.Runtime.Standard" Version="4.13.1" PrivateAssets="All"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations" Version="0.5.1"/>
@ -15,168 +31,145 @@
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.SqlServer" Version="0.5.1"/>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.SQLite" Version="0.5.1"/>
<PackageVersion Include="AspNetCore.Authentication.ApiKey" Version="9.0.0"/>
<PackageVersion Include="AutoMapper" Version="14.0.0" PrivateAssets="All"/>
<PackageVersion Include="Azure.Identity" Version="1.14.2"/>
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.19.0"/>
<PackageVersion Include="Azure.ResourceManager" Version="1.13.1"/>
<PackageVersion Include="Azure.ResourceManager.AppContainers" Version="1.3.0"/>
<PackageVersion Include="Azure.ResourceManager.Resources" Version="1.9.1"/>
<PackageVersion Include="AutoMapper" Version="15.1.0" PrivateAssets="All"/>
<PackageVersion Include="Azure.Identity" Version="1.17.1"/>
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.20.1"/>
<PackageVersion Include="Azure.ResourceManager" Version="1.13.2"/>
<PackageVersion Include="Azure.ResourceManager.AppContainers" Version="1.5.0"/>
<PackageVersion Include="Azure.ResourceManager.Resources" Version="1.11.1"/>
<PackageVersion Include="Azure.ResourceManager.ServiceBus" Version="1.1.0"/>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.2"/>
<PackageVersion Include="Bogus" Version="35.6.3"/>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.6"/>
<PackageVersion Include="Bogus" Version="35.6.5"/>
<PackageVersion Include="ConfigureAwait.Fody" Version="3.3.2" PrivateAssets="All"/>
<PackageVersion Include="Confluent.Kafka" Version="2.10.0"/>
<PackageVersion Include="Confluent.SchemaRegistry.Serdes.Avro" Version="2.10.0"/>
<PackageVersion Include="coverlet.collector" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All"/>
<PackageVersion Include="Cronos" Version="0.11.0"/>
<PackageVersion Include="Cronos" Version="0.11.1"/>
<PackageVersion Include="Dapper" Version="2.1.66"/>
<PackageVersion Include="Datadog.Trace.Bundle" Version="3.16.0"/>
<PackageVersion Include="DistributedLock" Version="2.6.0"/>
<PackageVersion Include="Datadog.Trace.Bundle" Version="3.31.0"/>
<PackageVersion Include="DistributedLock" Version="2.7.1"/>
<PackageVersion Include="DistributedLock.Core" Version="1.0.8"/>
<PackageVersion Include="DistributedLock.FileSystem" Version="1.0.3"/>
<PackageVersion Include="DistributedLock.Postgres" Version="1.3.0"/>
<PackageVersion Include="DistributedLock.Redis" Version="1.0.3"/>
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.0.6"/>
<PackageVersion Include="Elsa.Studio" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Agents" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Core.BlazorWasm" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Login.BlazorWasm" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="FastEndpoints" Version="7.0.1"/>
<PackageVersion Include="FastEndpoints.Security" Version="7.0.1"/>
<PackageVersion Include="FastEndpoints.Swagger" Version="7.0.1"/>
<PackageVersion Include="DistributedLock.Redis" Version="1.1.1"/>
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.2.1"/>
<PackageVersion Include="FastEndpoints" Version="7.1.1"/>
<PackageVersion Include="FastEndpoints.Security" Version="7.1.1"/>
<PackageVersion Include="FastEndpoints.Swagger" Version="7.1.1"/>
<PackageVersion Include="FluentMigrator" Version="7.1.0"/>
<PackageVersion Include="FluentMigrator.Runner" Version="7.1.0"/>
<PackageVersion Include="FluentStorage" Version="5.6.0"/>
<PackageVersion Include="FluentStorage.Azure.Blobs" Version="5.3.0"/>
<PackageVersion Include="Fluid.Core" Version="2.25.0"/>
<PackageVersion Include="Fody" Version="6.9.2" PrivateAssets="All"/>
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All"/>
<PackageVersion Include="FluentStorage" Version="6.0.0"/>
<PackageVersion Include="FluentStorage.Azure.Blobs" Version="6.0.0"/>
<PackageVersion Include="Fluid.Core" Version="2.31.0"/>
<PackageVersion Include="Fody" Version="6.9.3" PrivateAssets="All"/>
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.0" PrivateAssets="All"/>
<PackageVersion Include="Google.Protobuf" Version="3.31.1"/>
<PackageVersion Include="Grpc.Net.Client" Version="2.71.0"/>
<PackageVersion Include="Grpc.Tools" Version="2.72.0" PrivateAssets="All"/>
<PackageVersion Include="Hangfire" Version="1.8.20"/>
<PackageVersion Include="Hangfire.MemoryStorage" Version="1.8.1.1"/>
<PackageVersion Include="Grpc.Tools" Version="2.76.0" PrivateAssets="All"/>
<PackageVersion Include="Hangfire" Version="1.8.22"/>
<PackageVersion Include="Hangfire.MemoryStorage" Version="1.8.1.2"/>
<PackageVersion Include="Hangfire.PostgreSql" Version="1.20.12"/>
<PackageVersion Include="Hangfire.Storage.SQLite" Version="0.4.2"/>
<PackageVersion Include="Humanizer.Core" Version="2.14.1"/>
<PackageVersion Include="IronCompress" Version="1.6.3"/>
<PackageVersion Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageVersion Include="Jint" Version="4.4.1"/>
<PackageVersion Include="LinqKit.Core" Version="1.2.8"/>
<PackageVersion Include="MailKit" Version="4.12.1"/>
<PackageVersion Include="MassTransit" Version="8.4.1"/>
<PackageVersion Include="MassTransit.Azure.ServiceBus.Core" Version="8.4.1"/>
<PackageVersion Include="Humanizer.Core" Version="3.0.1"/>
<PackageVersion Include="IronCompress" Version="1.7.0"/>
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.2"/>
<PackageVersion Include="Jint" Version="4.4.2"/>
<PackageVersion Include="LinqKit.Core" Version="1.2.9"/>
<PackageVersion Include="MailKit" Version="4.14.1"/>
<PackageVersion Include="MassTransit" Version="8.5.5"/>
<PackageVersion Include="MassTransit.Azure.ServiceBus.Core" Version="8.5.5"/>
<PackageVersion Include="MassTransit.Extensions.DependencyInjection" Version="7.3.1"/>
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.4.1"/>
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.14.0"/>
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2"/>
<PackageVersion Include="Microsoft.Data.Sqlite" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.5"/>
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.0"/>
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="10.0.0"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.0"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.0"/>
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="10.0.0"/>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite.Design" Version="1.1.6"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Http" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="$(ResilienceVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Options" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="$(ResilienceVersion)"/>
<PackageVersion Include="Microsoft.Identity.Client" Version="4.72.1"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0"/>
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.3"/>
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Resilience" Version="10.0.0"/>
<PackageVersion Include="Microsoft.Identity.Client" Version="4.79.2"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>
<PackageVersion Include="Moq" Version="4.20.72"/>
<PackageVersion Include="MongoDB.Driver" Version="3.4.0"/>
<PackageVersion Include="MongoDB.Driver" Version="3.5.1"/>
<PackageVersion Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0"/>
<PackageVersion Include="MongoDB.Driver.Extensions" Version="2.0.2"/>
<PackageVersion Include="MySql.Data" Version="9.3.0"/>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageVersion Include="Npgsql" Version="9.0.3"/>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageVersion Include="MySql.Data" Version="9.5.0"/>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4"/>
<PackageVersion Include="NSubstitute" Version="5.3.0"/>
<PackageVersion Include="NuGet.Packaging" Version="6.14.0"/>
<PackageVersion Include="NuGet.Protocol" Version="6.14.0"/>
<PackageVersion Include="Nuke.Components" Version="9.0.4"/>
<PackageVersion Include="NuGet.Packaging" Version="7.0.0"/>
<PackageVersion Include="NuGet.Protocol" Version="7.0.0"/>
<PackageVersion Include="Nuke.Components" Version="10.0.0"/>
<PackageVersion Include="Open.Linq.AsyncExtensions" Version="1.2.0"/>
<PackageVersion Include="OpenTelemetry" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.AutoInstrumentation" Version="1.11.0"/>
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.12.0-beta.1"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.12.0-beta.1"/>
<PackageVersion Include="Oracle.EntityFrameworkCore" Version="9.23.80"/>
<PackageVersion Include="Polly" Version="8.6.2"/>
<PackageVersion Include="Polly.Extensions" Version="8.6.2"/>
<PackageVersion Include="OpenTelemetry" Version="1.14.0"/>
<PackageVersion Include="OpenTelemetry.AutoInstrumentation" Version="1.12.0"/>
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.14.0"/>
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0"/>
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0"/>
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0"/>
<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"/>
<PackageVersion Include="PolySharp" Version="1.15.0" PrivateAssets="All"/>
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0"/>
<PackageVersion Include="Proto.Actor" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.CodeGen" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.AzureContainerApps" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.Kubernetes" Version="1.7.0"/>
<PackageVersion Include="Proto.Cluster.TestProvider" Version="1.7.0"/>
<PackageVersion Include="Proto.OpenTelemetry" Version="1.7.0"/>
<PackageVersion Include="Proto.Persistence" Version="1.7.0"/>
<PackageVersion Include="Proto.Persistence.Sqlite" Version="1.7.0"/>
<PackageVersion Include="Proto.Persistence.SqlServer" Version="1.7.0"/>
<PackageVersion Include="Proto.Remote" Version="1.7.0"/>
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0"/>
<PackageVersion Include="pythonnet" Version="3.1.0-preview2024-09-06"/>
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
<PackageVersion Include="Quartz.Serialization.Json" Version="3.14.0"/>
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.15.1"/>
<PackageVersion Include="Quartz.Serialization.Json" Version="3.15.1"/>
<PackageVersion Include="Refit" Version="8.0.0"/>
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0"/>
<PackageVersion Include="Scrutor" Version="6.1.0"/>
<PackageVersion Include="Serilog" Version="4.3.0"/>
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.2"/>
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0"/>
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0"/>
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0"/>
<PackageVersion Include="ShortGuid" Version="2.0.1"/>
<PackageVersion Include="StackExchange.Redis" Version="2.8.37"/>
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1"/>
<PackageVersion Include="StackExchange.Redis" Version="2.10.1"/>
<PackageVersion Include="System.CommandLine" Version="2.0.0"/>
<PackageVersion Include="System.Data.SqlClient" Version="4.9.0"/>
<PackageVersion Include="System.Linq.Async" Version="6.0.3"/>
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.6.7"/>
<PackageVersion Include="System.Linq.Async" Version="7.0.0"/>
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.7.0"/>
<PackageVersion Include="System.Net.Http" Version="4.3.4"/>
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageVersion Include="System.Formats.Asn1" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="System.Text.Json" Version="$(MicrosoftVersion)"/>
<PackageVersion Include="Testcontainers" Version="4.6.0"/>
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0"/>
<PackageVersion Include="Testcontainers.RabbitMq" Version="4.6.0"/>
<PackageVersion Include="Testcontainers.Redis" Version="4.6.0"/>
<PackageVersion Include="System.Formats.Asn1" Version="10.0.0"/>
<PackageVersion Include="System.Text.Json" Version="10.0.0"/>
<PackageVersion Include="Testcontainers" Version="4.9.0"/>
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.9.0"/>
<PackageVersion Include="Testcontainers.RabbitMq" Version="4.9.0"/>
<PackageVersion Include="Testcontainers.Redis" Version="4.9.0"/>
<PackageVersion Include="ThrottleDebounce" Version="2.0.1"/>
<PackageVersion Include="WebhooksCore" Version="0.0.1"/>
<PackageVersion Include="xunit" Version="2.9.3"/>
<PackageVersion Include="xunit.abstractions" Version="2.0.3"/>
<PackageVersion Include="xunit.assert" Version="2.9.3"/>
<PackageVersion Include="xunit.extensibility.core" Version="2.9.3"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" PrivateAssets="All"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="All"/>
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0"/>
</ItemGroup>
</Project>

View file

@ -94,17 +94,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.Core.UnitTes
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Tenants", "src\modules\Elsa.Tenants\Elsa.Tenants.csproj", "{29638A67-E79F-44FE-AC05-DA499EBA929E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{B789B743-0FBE-4BBC-9F45-84C20C8D4814}"
ProjectSection(SolutionItems) = preProject
.github\workflows\bounty.yml = .github\workflows\bounty.yml
.github\workflows\elsa-server-and-studio.yml = .github\workflows\elsa-server-and-studio.yml
.github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml
.github\workflows\elsa-studio.yml = .github\workflows\elsa-studio.yml
.github\workflows\packages.yml = .github\workflows\packages.yml
.github\workflows\pr.yml = .github\workflows\pr.yml
.github\workflows\stale.yml = .github\workflows\stale.yml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{99F2B1DA-2F69-4D70-A2A3-AC985AD91EC4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C80C8231-D35C-4ACC-9ED6-9F3DB221535E}"
@ -311,6 +300,43 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.UnitTests"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Scheduling.UnitTests", "test\unit\Elsa.Scheduling.UnitTests\Elsa.Scheduling.UnitTests.csproj", "{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}"
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
.github\dependabot.yml = .github\dependabot.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{2158FC82-6253-47F2-9216-B266710D50F1}"
ProjectSection(SolutionItems) = preProject
.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-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
.github\workflows\copilot-setup-steps.yml = .github\workflows\copilot-setup-steps.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "issue_templates", "issue_templates", "{36556068-64BF-4D4B-827E-EC8CDC23D516}"
ProjectSection(SolutionItems) = preProject
.github\ISSUE_TEMPLATE\bug_report.md = .github\ISSUE_TEMPLATE\bug_report.md
.github\ISSUE_TEMPLATE\feature_request.md = .github\ISSUE_TEMPLATE\feature_request.md
.github\ISSUE_TEMPLATE\improvement.md = .github\ISSUE_TEMPLATE\improvement.md
.github\ISSUE_TEMPLATE\performance.md = .github\ISSUE_TEMPLATE\performance.md
.github\ISSUE_TEMPLATE\task.md = .github\ISSUE_TEMPLATE\task.md
.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
@ -551,6 +577,22 @@ Global
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Release|Any CPU.Build.0 = Release|Any CPU
{6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
@ -646,6 +688,13 @@ Global
{20C75446-CAE0-48D1-85D9-459541C4D8B6} = {18453B51-25EB-4317-A4B3-B10518252E92}
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92}
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D} = {18453B51-25EB-4317-A4B3-B10518252E92}
{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

@ -0,0 +1,153 @@
# Configuration Binding Issue: SigningKey Not Bound from appsettings.json
**Date**: 2025-11-20
**Issue**: IdentityTokenOptions.SigningKey property remaining null despite being present in appsettings.json
**Related GitHub Issue**: #7019 (Array serialization)
---
## Symptoms
- `IdentityTokenOptions.SigningKey` property was not being bound from configuration
- Other properties in the same section (`AccessTokenLifetime`, `RefreshTokenLifetime`) were binding correctly
- Manual debugging showed:
- ✅ `identityTokenSection["SigningKey"]` returned the correct value
- ✅ `configuration.GetSection("Identity:Tokens:SigningKey").Value` returned the correct value
- ❌ `identityTokenSection.GetValue<string>("SigningKey")` returned `null`
- ❌ `identityTokenSection.Bind(options)` did not populate `SigningKey`
- Creating a fresh instance worked: `new IdentityTokenOptions()` could be bound successfully
## Initial False Leads
During troubleshooting, several potential causes were investigated but ruled out:
1. **Property initializers** - Initially suspected that default values like `= "http://elsa.api"` prevented binding, but this was disproven when fresh instances with the same defaults worked
2. **Configuration section retrieval** - The section was correctly retrieved and contained the value
3. **Nullable vs non-nullable** - Making `SigningKey` nullable didn't resolve the issue
4. **JSON encoding issues** - No BOM or hidden characters found in appsettings.json
5. **Configuration overrides** - No environment variables or other sources overriding the value
6. **Lambda capture issues** - The configuration section was correctly captured and accessible
## Root Cause
The actual root cause was **global TypeConverter registration interfering with the configuration binder**.
In `DefaultFormattersFeature.cs`, the following registration was made:
```csharp
TypeDescriptor.AddAttributes(typeof(IEnumerable),
new TypeConverterAttribute(typeof(EnumerableTypeConverter)));
```
This caused a cascade of issues:
1. **String binding failure**: Since `string` implements `IEnumerable<char>`, the configuration binder attempted to use `EnumerableTypeConverter` for string properties
2. **Silent failure**: Even though `EnumerableTypeConverter.CanConvertTo()` correctly returned `false` for strings, the global registration interfered with the fallback mechanism
3. **Partial success**: TimeSpan properties worked because they have built-in conversion support that bypassed the string converter issue
## Solution
### Phase 1: Remove Global TypeConverter Registration
**File**: `src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs`
**Change**: Removed the global `TypeDescriptor.AddAttributes` call for `IEnumerable`
**Result**:
- ✅ Configuration binding now works for all string properties
- ❌ Arrays started serializing as `"Int32[] Array"` instead of JSON (exposed GitHub issue #7019)
### Phase 2: Fix Array Serialization
**File**: `src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs`
**Change**: Added explicit JSON serialization for arrays and collections:
```csharp
// Byte arrays are base64-encoded
if (value is byte[] byteArray)
return Convert.ToBase64String(byteArray);
// Memory-like types preserved via TypeDescriptor
if (IsMemoryLike(sourceType))
{
var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType);
if (sourceTypeConverter.CanConvertTo(typeof(string)))
return (string?)sourceTypeConverter.ConvertTo(value, typeof(string));
return value.ToString();
}
// Serialize arrays and collections to JSON
if (value is IEnumerable)
{
return JsonSerializer.Serialize(value);
}
```
**Result**:
- ✅ Arrays serialize to JSON: `[1,2,3]` instead of `"Int32[] Array"`
- ✅ Configuration binding still works
- ✅ No TypeConverter interference with .NET type system
### Phase 3: Consolidate Logic (Cleanup)
**File Deleted**: `src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs`
**Rationale**: The `EnumerableTypeConverter` was only used by `ObjectFormatter.Format()`, so consolidating the logic directly into `ObjectFormatter` simplified the codebase and eliminated the need for TypeConverter registration entirely.
## Key Learnings
1. **Global TypeConverter registration is dangerous**: It can interfere with .NET's type system, configuration binding, and other framework features
2. **String implements IEnumerable**: This often-forgotten fact can cause unexpected behavior with type converters
3. **Configuration binder behavior is opaque**: When `.GetValue<T>()` fails but indexer `["key"]` works, it indicates type conversion issues
4. **Explicit is better than implicit**: Direct serialization logic in `ObjectFormatter` is clearer and safer than relying on TypeConverter infrastructure
5. **Test fresh instances vs captured instances**: Configuration binding through the Options pattern behaves differently than direct `.Bind()` calls
## Verification
### Tests Added/Consolidated
**File**: `test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs`
All tests consolidated into a single test class with 9 tests:
1. String is preserved as-is
2. Byte array is serialized as base64 string
3. Integer array is serialized as JSON array
4. String array is serialized as JSON array
5. String array with multiple elements is serialized as JSON array
6. Custom class array is serialized as JSON array
7. List of integers is serialized as JSON array
8. List with different values is serialized as JSON array
9. Null returns null
### Runtime Verification
- ✅ Server starts successfully
- ✅ `IdentityTokenOptions.SigningKey` binds correctly from configuration
- ✅ No `MissingConfigurationException` thrown
- ✅ JWT authentication works
- ✅ Workflow variable arrays serialize to JSON
- ✅ All 9 integration tests pass
## Files Modified
1. `src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs` - Removed global TypeConverter registration
2. `src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs` - Added explicit JSON serialization for collections
3. `src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs` - Cleaned up property initializers
4. `test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs` - Consolidated all tests (9 total)
## Files Deleted
1. `src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs` - Consolidated into ObjectFormatter
2. `test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs` - Consolidated into ObjectFormatterTests
---
## Future Reference
**Filename Pattern**: `YYYY-MM-DD_issue-description_root-cause.md`
When encountering similar configuration binding issues:
1. Check if the value exists using the indexer: `section["key"]`
2. Compare with `GetValue<T>("key")` - if they differ, suspect type conversion issues
3. Look for global TypeConverter registrations that might interfere
4. Test with a fresh instance to isolate Options pattern behavior
5. Check if the property type implements unexpected interfaces (like `string` implementing `IEnumerable<char>`)

View file

@ -0,0 +1,294 @@
# Exception Handling Improvements for Trigger Deletion and Scheduled Workflows
**Date**: November 20, 2025
**Issue**: #7077
**Branch**: bug/7077
**Type**: Bug Fix + Integration Tests
## Problem
When deleting triggers, the `TriggerIndexer.DeleteTriggersAsync` method would fail completely if any workflow failed to load due to a missing materializer or other exceptions. This resulted in the error:
```
System.Exception: Provider not found
at Elsa.Workflows.Management.Services.WorkflowDefinitionService.MaterializeWorkflowAsync
```
The method would stop processing remaining workflows, leaving triggers in an inconsistent state.
## Solution
### 1. Exception Handling (`src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs`)
Added a try-catch block in the `DeleteTriggersAsync` method (lines 67-79):
```csharp
foreach (var workflowDefinitionVersionId in workflowDefinitionVersionIds)
{
try
{
var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(
workflowDefinitionVersionId, cancellationToken);
if (workflowGraph == null)
continue;
await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to load workflow graph for workflow definition version {WorkflowDefinitionVersionId}. " +
"Skipping trigger deletion for this workflow.",
workflowDefinitionVersionId);
}
}
```
**Benefits**:
- Logs warning with full exception details for debugging
- Continues processing remaining workflows instead of failing completely
- Maintains system stability when individual workflows have issues
### 2. Integration Tests (`test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/`)
Created comprehensive integration tests in a new `TriggerIndexing` scenario folder with `Tests.cs`:
#### Test 1: All Workflows Fail to Load
```csharp
[Fact(DisplayName = "DeleteTriggersAsync should continue deleting triggers even when workflow fails to load")]
public async Task DeleteTriggersAsync_WorkflowFailsToLoad_ContinuesWithOtherTriggers()
```
- Verifies the method completes without throwing when all workflows fail
- Tests multiple failure types (JSON serialization errors, provider not found)
- Confirms all triggers remain when workflows can't be loaded
#### Test 2: Mixed Success and Failure
```csharp
[Fact(DisplayName = "DeleteTriggersAsync processes all workflows even when one fails")]
public async Task DeleteTriggersAsync_OneWorkflowFails_ProcessesAllWorkflows()
```
- Tests scenario where one workflow fails (workflow2 with "Provider not found")
- Two other workflows load successfully (workflow1 and workflow3)
- Verifies triggers for successful workflows are deleted
- Confirms failed workflow's trigger remains
- **This is the key test** - proves exception handling allows processing to continue
#### Test Infrastructure
**Custom Materializers**:
- `FailingMaterializer`: Throws "Provider not found" exception to simulate the reported issue
- `WorkingMaterializer`: Successfully materializes workflows from a registry for valid test cases
**Helper Methods** (DRY principle):
- `CreateWorkflowDefinition()`: Factory method for workflow definitions
- `CreateTrigger()`: Factory method for triggers
- `SaveWorkflowsAndTriggersAsync()`: Common save and verification logic
**Modern C# Features**:
- `file` scoped classes for helper materializers (limits visibility)
- Primary constructors for `WorkingMaterializer`
- Expression-bodied members for simple methods
## Code Quality Improvements
### Test Refactoring
- Renamed from `TriggerIndexerTests.cs` to `Tests.cs` (project convention)
- Reduced test methods from ~90 lines to ~30 lines each
- Eliminated duplicate workflow/trigger creation code
- Improved readability with array-based test data
- Clear Arrange-Act-Assert structure
### Benefits
- **More maintainable**: Changes only need to be made in one place
- **More readable**: Test intent is clearer with less boilerplate
- **More consistent**: Follows project naming conventions
- **Equally functional**: All tests pass with same behavior
## Test Results
Both tests pass successfully:
```
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 304 ms
✓ DeleteTriggersAsync should continue deleting triggers even when workflow fails to load [153 ms]
✓ DeleteTriggersAsync processes all workflows even when one fails [49 ms]
```
## 3. Resume Workflow Task Exception Handling (`src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs`)
### Problem
When a scheduled workflow instance is deleted before its scheduled resume time, the `ResumeWorkflowTask` would throw an unhandled exception:
```
Elsa.Workflows.Runtime.Exceptions.WorkflowInstanceNotFoundException: Workflow instance not found.
at Elsa.Workflows.Runtime.LocalWorkflowClient.GetWorkflowInstanceAsync
```
This would cause the scheduled task to fail, even though the workflow instance no longer exists and should simply be skipped.
### Solution
Added exception handling to gracefully handle missing workflow instances:
```csharp
public async ValueTask ExecuteAsync(TaskExecutionContext context)
{
var cancellationToken = context.CancellationToken;
var workflowRuntime = context.ServiceProvider.GetRequiredService<IWorkflowRuntime>();
var logger = context.ServiceProvider.GetRequiredService<ILogger<ResumeWorkflowTask>>();
try
{
var workflowClient = await workflowRuntime.CreateClientAsync(_request.WorkflowInstanceId, cancellationToken);
var request = new RunWorkflowInstanceRequest
{
Input = _request.Input,
Properties = _request.Properties,
ActivityHandle = _request.ActivityHandle,
BookmarkId = _request.BookmarkId
};
await workflowClient.RunInstanceAsync(request, cancellationToken);
}
catch (WorkflowInstanceNotFoundException ex)
{
logger.LogWarning(
"Scheduled workflow instance {WorkflowInstanceId} no longer exists and was likely deleted. Skipping execution.",
ex.InstanceId);
}
}
```
**Benefits**:
- Scheduled tasks no longer fail when workflow instances are deleted
- Clear logging explains why the task was skipped
- System continues operating normally
- No unnecessary error noise in logs
## 4. LocalScheduler Thread Safety (`src/modules/Elsa.Scheduling/Services/LocalScheduler.cs`)
### Problem
The `LocalScheduler` was experiencing race conditions during concurrent access, particularly during application startup when multiple workflows are being scheduled simultaneously:
```
System.IndexOutOfRangeException: Index was outside the bounds of the array.
at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
at System.Collections.Generic.Dictionary`2.set_Item(TKey key, TValue value)
at Elsa.Scheduling.Services.LocalScheduler.RegisterScheduledTask
```
This exception occurs when multiple threads modify the internal `Dictionary` instances concurrently without synchronization.
### Root Cause
Two dictionaries were being accessed concurrently without any thread safety:
- `_scheduledTasks`: Maps task names to scheduled tasks
- `_scheduledTaskKeys`: Maps scheduled tasks to their keys
Methods like `ScheduleAsync`, `ClearScheduleAsync`, and internal helper methods all modified these dictionaries without locks.
### Solution
Added thread synchronization using a lock object:
```csharp
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 object _lock = new(); // Added lock object
public ValueTask ScheduleAsync(string name, ITask task, ISchedule schedule, IEnumerable<string>? keys = null, CancellationToken cancellationToken = default)
{
var scheduleContext = new ScheduleContext(_serviceProvider, task);
var scheduledTask = schedule.Schedule(scheduleContext);
lock (_lock) // Protected dictionary access
{
RegisterScheduledTask(name, scheduledTask, keys);
}
return ValueTask.CompletedTask;
}
public ValueTask ClearScheduleAsync(string name, CancellationToken cancellationToken = default)
{
lock (_lock) // Protected dictionary access
{
RemoveScheduledTask(name);
}
return ValueTask.CompletedTask;
}
public ValueTask ClearScheduleAsync(IEnumerable<string> keys, CancellationToken cancellationToken = default)
{
lock (_lock) // Protected dictionary access
{
RemoveScheduledTasks(keys);
}
return ValueTask.CompletedTask;
}
}
```
**Benefits**:
- Eliminates race conditions during concurrent scheduling
- Prevents `IndexOutOfRangeException` during dictionary modifications
- Ensures thread-safe access to internal collections
- Simple and efficient locking strategy
**Implementation Notes**:
- Lock is only held during dictionary operations (minimal lock duration)
- Schedule creation happens outside the lock to minimize contention
- Private methods (`RegisterScheduledTask`, `RemoveScheduledTask`, `RemoveScheduledTasks`) are called within locks, so they don't need additional synchronization
**Why `lock` instead of `SemaphoreSlim`?**
The implementation uses a simple `lock` rather than `SemaphoreSlim` for optimal performance:
| Aspect | lock | SemaphoreSlim |
|--------|------|---------------|
| **Overhead** | Zero allocation | Heap allocations |
| **Complexity** | Automatic release | Manual try/finally |
| **Use Case** | Perfect for synchronous ops | Better for async ops |
| **Performance** | Compiler optimized | Additional overhead |
| **Async Support** | Cannot cross await | Can use WaitAsync |
**Decision Rationale**:
1. ✅ **All critical sections are synchronous** - Only dictionary operations (add/remove/lookup)
2. ✅ **Methods return `ValueTask.CompletedTask`** - Not truly async, just implementing interface signature
3. ✅ **No await inside critical sections** - Schedule creation happens outside the lock
4. ✅ **Zero allocation overhead** - `lock` is ideal for fast synchronous operations
`SemaphoreSlim` would be appropriate if:
- Critical sections contained `await` statements
- Methods were truly asynchronous with I/O operations
- Cancellation support was needed during lock acquisition
For this use case, `lock` is the optimal choice for simplicity, performance, and correctness.
## Files Changed
- `src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs` - Added exception handling for workflow loading failures
- `test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs` - New integration tests
- `src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs` - Added exception handling for missing workflow instances
- `src/modules/Elsa.Scheduling/Services/LocalScheduler.cs` - Added thread synchronization for concurrent access
## Impact
- **Stability**: System no longer fails when workflows or instances have issues
- **Thread Safety**: LocalScheduler now handles concurrent scheduling operations safely
- **Observability**: Warnings are logged with clear explanations for workflow and instance issues
- **Reliability**: Other workflows and scheduled tasks continue to be processed correctly
- **Coverage**: Comprehensive tests ensure trigger deletion behavior is maintained
- **Resilience**:
- Scheduled tasks handle deleted workflow instances gracefully
- Scheduler handles concurrent access during startup and runtime

View file

@ -0,0 +1,393 @@
# Workflow Instance Deletion: Runtime Coordination and Separation of Concerns
**Date**: 2025-11-20
**Branch**: bug/7077
**Commit**: 1f326295a38eee236b84d60e69afdf9e3a1eda1a
---
## Problem Statement
Workflow instances were not being properly deleted when using the Delete and BulkDelete API endpoints. The deletion operations were failing silently or inconsistently, particularly for running workflow instances.
## Root Cause
The deletion endpoints were directly using `IWorkflowInstanceManager` to delete workflow instances from the database without proper coordination with the workflow runtime. This caused several issues:
1. **No coordination for running workflows**: Running workflows need to be properly stopped/cancelled before deletion
2. **Race conditions**: In distributed scenarios, concurrent operations could cause database inconsistencies
3. **Missing cleanup**: Runtime state, bookmarks, and other related data weren't being cleaned up properly
4. **Violation of separation of concerns**: API layer was directly manipulating persistence layer without going through the runtime
## Solution Architecture
The solution refactors the deletion logic to properly use the workflow runtime infrastructure, ensuring proper coordination and cleanup.
### Key Design Principles
1. **Runtime coordination**: All workflow instance operations should go through `IWorkflowRuntime` and `IWorkflowClient`
2. **Proper cancellation**: Running workflows must be cancelled before deletion
3. **Distributed locking**: Use the same distributed locks as execution to prevent concurrent modifications
4. **Separation by status**: Handle running and finished instances differently
### Implementation Details
#### 1. Added Delete Method to IWorkflowClient
**File**: `src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs`
```csharp
/// <summary>
/// Deletes the workflow instance. This method ensures that the instance is properly stopped before deletion.
/// </summary>
Task<bool> DeleteAsync(CancellationToken cancellationToken = default);
```
**Rationale**: The `IWorkflowClient` is the proper abstraction for workflow instance operations. Adding deletion here ensures:
- Consistent API surface
- Proper coordination with other operations
- Support for both local and distributed scenarios
#### 2. Implemented LocalWorkflowClient.DeleteAsync
**File**: `src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs`
**Key logic**:
```csharp
public async Task<bool> DeleteAsync(CancellationToken cancellationToken = default)
{
// Load the workflow instance (single DB call)
var workflowInstance = await TryGetWorkflowInstanceAsync(cancellationToken);
if (workflowInstance == null)
return false;
// Cancel if running (ensures proper cleanup)
await CancelAsync(workflowInstance, cancellationToken);
// Delete the workflow instance
var filter = new WorkflowInstanceFilter { Id = workflowInstanceId };
await workflowInstanceManager.DeleteAsync(filter, cancellationToken);
return true;
}
```
**Key features**:
- **Single DB read**: Loads the instance once and reuses it
- **Proper cancellation**: Ensures running workflows are stopped first
- **Graceful handling**: Returns `false` if instance doesn't exist
- **Clean deletion**: Uses the manager's delete method for proper cleanup
**Supporting refactoring**:
- Extracted `CancelAsync(WorkflowInstance)` private method to avoid loading the instance twice
- Added `TryGetWorkflowInstanceAsync()` helper for non-throwing retrieval
#### 3. Implemented DistributedWorkflowClient.DeleteAsync
**File**: `src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs`
```csharp
public async Task<bool> DeleteAsync(CancellationToken cancellationToken = default)
{
// Use the same distributed lock as for execution to prevent concurrent DB writes
return await WithLockAsync(async () => await _localWorkflowClient.DeleteAsync(cancellationToken));
}
```
**Key feature**: Uses the same distributed lock mechanism (`workflow-instance:{id}`) as execution operations, preventing:
- Concurrent modifications during deletion
- Race conditions between delete and execute operations
- Inconsistent state in distributed scenarios
#### 4. Refactored Single Instance Delete Endpoint
**File**: `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs`
**Before**:
```csharp
internal class Delete(IWorkflowInstanceManager store) : ElsaEndpoint<Request>
{
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
var filter = new WorkflowInstanceFilter { Id = request.Id };
var deleted = await store.DeleteAsync(filter, cancellationToken);
// ...
}
}
```
**After**:
```csharp
internal class Delete(IWorkflowRuntime workflowRuntime) : ElsaEndpoint<Request>
{
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
var client = await workflowRuntime.CreateClientAsync(request.Id, cancellationToken);
var deleted = await client.DeleteAsync(cancellationToken);
// ...
}
}
```
**Changes**:
- ✅ Uses `IWorkflowRuntime` instead of `IWorkflowInstanceManager`
- ✅ Creates a workflow client for proper coordination
- ✅ Relies on runtime infrastructure for proper deletion
#### 5. Refactored Bulk Delete Endpoint
**File**: `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs`
**Strategy**: Split deletion into two phases based on workflow status:
**Phase 1: Delete Running Instances Individually**
```csharp
// Step 1: Delete running instances individually (requires coordination by the workflow runtime).
var runningFilter = new WorkflowInstanceFilter
{
Ids = baseFilter.Ids,
DefinitionId = baseFilter.DefinitionId,
DefinitionIds = baseFilter.DefinitionIds,
WorkflowStatus = WorkflowStatus.Running
};
var runningInstanceIds = await workflowInstanceStore.FindManyIdsAsync(runningFilter, cancellationToken);
var count = 0L;
foreach (var instanceId in runningInstanceIds)
{
var client = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken);
var deleted = await client.DeleteAsync(cancellationToken);
if (deleted)
count++;
}
```
**Phase 2: Bulk Delete Finished Instances**
```csharp
// Step 2: Bulk delete finished instances (no coordination needed).
// Use IWorkflowInstanceManager to ensure related records are also deleted.
var finishedFilter = new WorkflowInstanceFilter
{
Ids = baseFilter.Ids,
DefinitionId = baseFilter.DefinitionId,
DefinitionIds = baseFilter.DefinitionIds,
WorkflowStatus = WorkflowStatus.Finished
};
var finishedDeletedCount = await workflowInstanceManager.BulkDeleteAsync(finishedFilter, cancellationToken);
count += finishedDeletedCount;
```
**Rationale**:
- **Running instances**: Need individual coordination through the runtime (cancellation, locks, cleanup)
- **Finished instances**: Can be safely bulk-deleted as they have no active runtime state
- **IWorkflowInstanceManager vs IWorkflowInstanceStore**: Using `IWorkflowInstanceManager.BulkDeleteAsync()` ensures that related records (execution logs, bookmarks, activity execution records, etc.) are also deleted, not just the workflow instance record
- **Performance**: Avoids unnecessary overhead for finished workflows while ensuring safety for running ones
- **Correctness**: Each running workflow gets proper cancellation and distributed locking
**Trade-offs**:
- ✅ **Correctness**: Ensures proper cleanup and coordination
- ✅ **Safety**: Prevents race conditions and inconsistencies
- ⚠️ **Performance**: Running instances are deleted one-by-one instead of bulk
- This is acceptable because:
- Running instances are typically a small percentage of total instances
- Proper coordination is more important than bulk performance
- Finished instances still use efficient bulk deletion
## Benefits
### 1. Proper Coordination
- Running workflows are properly cancelled before deletion
- Runtime state is cleaned up correctly
- Bookmarks and related data are removed
### 2. Distributed Safety
- Uses the same distributed locks as execution
- Prevents race conditions in multi-node deployments
- Ensures consistency across the cluster
### 3. Separation of Concerns
- API layer uses runtime abstractions
- No direct persistence manipulation
- Easier to maintain and extend
### 4. Consistent Behavior
- Same deletion logic for single and bulk operations
- Predictable behavior across all scenarios
- Proper error handling and return values
## Testing Verification
### Automated Component Tests
Created comprehensive component tests in `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/`:
**Test Infrastructure:**
1. **Custom Activity**: `LongRunningActivity`
- Simulates a long-running workflow operation (3 seconds)
- Uses `SignalManager` to notify tests when execution starts
- Keeps workflow in memory without hitting a bookmark (crucial for testing the race condition)
2. **Test Workflows**:
- `SuspendingWorkflow`: Uses `LongRunningActivity` to create running workflow instances
- `CompletingWorkflow`: Completes immediately for testing finished workflow deletion
3. **Helper Methods** (following DRY principle):
- `StartRunningWorkflowAsync()`: Starts a workflow and waits for it to begin executing
- `AssertWorkflowInstanceDeletedAsync()`: Verifies a workflow instance is deleted
- Property accessors for common services (`WorkflowRuntime`, `SignalManager`, `WorkflowInstanceStore`)
**Test Cases:**
1. ✅ `DeleteRunningWorkflowInstance_ShouldCancelAndRemove`
- Tests deletion of a workflow actively executing in memory
- Verifies the instance is deleted and not found afterward
2. ✅ `DeleteFinishedWorkflowInstance_ShouldRemove`
- Tests deletion of a completed workflow instance
- Verifies proper cleanup of finished workflows
3. ✅ `DeleteNonExistentWorkflowInstance_ShouldThrowException`
- Tests error handling for non-existent instances
- Expects `Refit.ApiException` (404 Not Found)
4. ✅ `BulkDeleteMixedInstances_ShouldDeleteAll`
- Tests bulk deletion of 2 running + 2 finished instances
- Verifies all instances are properly deleted
5. ✅ `DeleteWorkflowInstance_ShouldNotBeResurrectedByPersistence` **(Core #7077 test)**
- **Critical test** that validates the distributed locking fix
- Starts a workflow with `LongRunningActivity` (stays in memory)
- Attempts deletion while workflow is still executing
- **Without the fix**: Workflow would be deleted but then resurrected when it completes
- **With the fix**: Deletion blocks until workflow completes (distributed lock), preventing resurrection
- Verifies the instance remains deleted after workflow completion
**Key Testing Patterns:**
- **SignalManager pattern**: Elegant coordination between workflow execution and test assertions
- Handles race conditions gracefully (signal can fire before or after wait)
- Uses `ConcurrentDictionary` with `TaskCompletionSource` for thread-safe signaling
- No static state to clean up between tests
- **WorkflowEvents pattern**: Used to wait for workflow state commits when needed
- **DRY principle**: Extracted common patterns into reusable helper methods
- **Readable tests**: Focus on "what" not "how" - test intent is clear and concise
All tests pass successfully, validating the fix works correctly.
### Manual Testing
- ✅ Delete single running workflow instance
- ✅ Delete single finished workflow instance
- ✅ Delete non-existent workflow instance (returns false)
- ✅ Bulk delete mixed running and finished instances
- ✅ Bulk delete by definition ID
- ✅ Verify proper cancellation before deletion
- ✅ Verify distributed locking in multi-node scenario
### Edge Cases Handled
- **Non-existent instance**: Returns `false` gracefully
- **Already cancelled/finished**: No-op on cancellation, proceeds to delete
- **Concurrent operations**: Distributed lock prevents conflicts
- **Partial bulk delete**: Counts successful deletions accurately
## Performance Considerations
### Single Delete
- **Before**: 1 query (direct delete)
- **After**: 2 queries (load + cancel if needed + delete)
- **Impact**: Minimal - proper coordination is worth the extra query
### Bulk Delete
- **Running instances**: N queries (where N = number of running instances)
- Acceptable because running instances are typically few
- Proper coordination requires individual handling
- **Finished instances**: 1 query (bulk delete)
- Maintains performance for the common case
- Most instances are finished, not running
### Optimization Opportunities
- Could add a `BulkCancelAsync` method for running instances
- Could batch running instance deletions with parallel processing
- Current approach prioritizes correctness over optimization
## Migration Notes
### Breaking Changes
None - this is a bug fix that doesn't change the API surface.
### Behavioral Changes
1. **Running workflows are now cancelled before deletion**
- Previous behavior: Direct delete (potentially inconsistent state)
- New behavior: Cancel then delete (proper cleanup)
2. **Bulk delete now handles running vs finished instances differently**
- Previous behavior: Attempted bulk delete of all instances
- New behavior: Individual delete for running, bulk for finished
### Upgrade Path
No action required - changes are fully backward compatible.
## Files Modified
### Core Implementation
1. `src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs`
- Added `DeleteAsync()` method
2. `src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs`
- Implemented `DeleteAsync()` with proper cancellation
- Refactored `CancelAsync()` to avoid duplicate loading
- Added `TryGetWorkflowInstanceAsync()` helper
3. `src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs`
- Implemented `DeleteAsync()` with distributed locking
4. `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs`
- Changed to use `IWorkflowRuntime` instead of `IWorkflowInstanceManager`
- Uses workflow client for deletion
5. `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs`
- Refactored to handle running and finished instances separately
- Changed to use `IWorkflowRuntime` for running instances
- Changed to use `IWorkflowInstanceManager.BulkDeleteAsync()` for finished instances (ensures related records are deleted)
### Test Files
6. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs` **(NEW)**
- Custom activity that simulates long-running operation with 3-second delay
- Uses `SignalManager` to notify tests when execution starts
- Keeps workflow in memory for testing race conditions
7. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs` **(NEW)**
- Test workflow using `LongRunningActivity`
- Used to create running workflow instances for testing
8. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs` **(NEW)**
- Test workflow that completes immediately
- Used for testing finished workflow deletion
9. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs` **(NEW)**
- 5 comprehensive component tests covering all deletion scenarios
- Tests the core #7077 fix (workflow resurrection prevention)
- Uses DRY principles with helper methods for maintainability
## Related Issues
- **Bug #7077**: Workflow instance not being deleted
- Related to proper runtime coordination and distributed locking
## Future Enhancements
1. **Batch cancellation**: Add `BulkCancelAsync` for running instances
2. **Parallel deletion**: Process running instances in parallel with configurable concurrency
3. **Soft delete**: Add option for soft delete with retention period
4. **Delete callbacks**: Add hooks for cleanup of related resources
5. **Metrics**: Track deletion performance and success rates
---
## Key Takeaways
1. **Always use runtime abstractions**: Don't bypass `IWorkflowClient` to directly manipulate persistence
2. **Coordinate running workflows**: Active workflows need proper cancellation before deletion
3. **Use distributed locks**: Prevent race conditions in multi-node deployments
4. **Separate concerns by status**: Running and finished workflows have different coordination needs
5. **Performance vs correctness**: Prioritize correctness, optimize performance within those constraints

View file

@ -8,10 +8,10 @@ using Nuke.Components;
[CustomGitHubActions(
"pr",
GitHubActionsImage.UbuntuLatest,
OnPullRequestBranches = ["main"],
OnPullRequestBranches = ["main", "patch/*", "develop/*"],
OnPullRequestIncludePaths = ["**/*"],
PublishArtifacts = false,
InvokedTargets = [nameof(ICompile.Compile), nameof(ITest.Test), nameof(IPack.Pack)],
InvokedTargets = [nameof(ICompile.Compile), nameof(ITest.Test)],
CacheKeyFiles = [],
ConcurrencyCancelInProgress = true
)

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace />
<NoWarn>CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006</NoWarn>
<NukeRootDirectory>..</NukeRootDirectory>
@ -19,12 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nuke.Components" Version="9.0.4" />
</ItemGroup>
<!--Overridden for vulnerability reasons with dependencies referencing older versions.-->
<ItemGroup>
<PackageReference Include="System.Formats.Asn1" Version="9.0.8" />
<PackageReference Include="Nuke.Components" Version="10.0.0" />
</ItemGroup>
</Project>

View file

@ -24,27 +24,38 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas
```csharp
namespace Elsa.Workflows.Activities.Flowchart.Models;
/// <summary>
/// Specifies the strategy for handling multiple inbound execution paths in a workflow.
/// Uses flow-based terminology to describe merge behavior.
/// </summary>
public enum MergeMode
{
/// <summary>
/// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
/// Suitable for flexible, unstructured merges where optional branches shouldn't block.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Schedule on each arriving token, allowing multiple executions if supported.
/// Flows freely when possible, ignoring dead/untaken paths.
/// Opportunistic execution based on upstream completion.
/// </summary>
Stream,
/// <summary>
/// Schedule on the first arriving token, block or cancel others.
/// Merges only the activated/flowing inbound branches.
/// Waits for all branches that received tokens, ignoring unactivated ones.
/// </summary>
Merge,
/// <summary>
/// Converges all inbound paths, requiring every connection to execute.
/// Strictest mode - will block on dead/untaken paths.
/// </summary>
Converge,
/// <summary>
/// Cascades execution for each arriving token independently.
/// Allows multiple concurrent executions (one per arriving token).
/// </summary>
Cascade,
/// <summary>
/// Races inbound branches, executing on first arrival and blocking others.
/// </summary>
Race
}
@ -62,14 +73,14 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas
```csharp
switch (mergeMode)
{
case MergeMode.Stream:
case MergeMode.Cascade:
case MergeMode.Race:
// Existing logic: Schedule on arrival, block others for Race.
// Schedule on arrival; for Race, block/cancel others.
// ...
break;
case MergeMode.Converge:
// Strict check: Wait for all forward inbounds if >1; else schedule immediately.
case MergeMode.Merge:
// Wait for tokens from all forward inbound connections (activated branches only).
var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
if (inboundConnections.Count > 1)
{
@ -82,9 +93,23 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas
}
break;
case MergeMode.None:
case MergeMode.Converge:
// Strictest mode: Wait for tokens from ALL inbound connections (forward + backward).
var allInboundConnections = flowGraph.GetInboundConnections(targetActivity);
if (allInboundConnections.Count > 1)
{
var hasAllTokens = allInboundConnections.All(inbound => /* token check */);
if (hasAllTokens) await flowContext.ScheduleActivityAsync(...);
}
else
{
await flowContext.ScheduleActivityAsync(...);
}
break;
case MergeMode.Stream:
default:
// Approximation: Schedule if no unconsumed tokens to inbound sources.
// Flows freely - approximation that proceeds when upstream completes.
var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
var hasUnconsumed = inboundConnections.Any(inbound => /* source token check */);
if (!hasUnconsumed) await flowContext.ScheduleActivityAsync(...);
@ -104,10 +129,10 @@ Flowchart execution starts with scheduling the root/start activity. As activitie
This ensures acyclic forward flow with support for backward loops, using tokens to track control without global state beyond the list.
### Merge Modes Explained
Each mode defines how tokens from multiple inbounds are synchronized:
Each mode defines how tokens from multiple inbounds are synchronized using flow-based terminology:
- **None (Default/Flexible Merge)**:
- **Behavior**: Schedules if there are no unconsumed tokens *to the sources* of inbounds (i.e., all upstream activities have completed, treating dead paths as "done").
- **Stream (Flexible/Opportunistic Flow)**:
- **Behavior**: Flows freely when possible, ignoring dead/untaken paths. Schedules if there are no unconsumed tokens *to the sources* of inbounds (i.e., all upstream activities have completed, treating dead paths as "done").
- **When to Use**: Flexible merges in unstructured flows; optional/exclusive branches (e.g., switch defaults) shouldn't block.
- **Scenarios**:
- **Forks with Untaken Paths**: Proceeds after active branches (e.g., in complex switch with dangling default).
@ -115,18 +140,26 @@ Each mode defines how tokens from multiple inbounds are synchronized:
- **Dead Paths**: Ignores untaken outcomes; no blocking.
- **Example**: In a switch with MatchAny, untaken default doesn't hang the merge.
- **Converge (Strict Synchronization)**:
- **Behavior**: Requires unconsumed, non-blocked tokens from *all* forward inbounds. For <=1 forward, schedules immediately (loop/sequential friendly).
- **When to Use**: Required "all must happen" joins; block if any branch untaken.
- **Merge (Activated Branches Synchronization)**:
- **Behavior**: Merges only activated/flowing inbound branches. Requires unconsumed, non-blocked tokens from *all* forward inbounds that received tokens. For <=1 forward, schedules immediately (loop/sequential friendly).
- **When to Use**: Synchronization points where only activated paths matter; block if any activated branch hasn't completed.
- **Scenarios**:
- **Conditional Forks**: Blocks downstream if e.g., decision returns false and subsequent activities are connected to the true branch.
- **Conditional Forks**: Blocks downstream if decision returns false and subsequent activities are connected to the true branch, but only waits for activated branches.
- **Loops**: Works if forward inbounds <=1; reschedules on backward tokens.
- **Dead Paths**: Blocks (desired for safety).
- **Example**: Converge after parallel approvals—only proceed if all complete.
- **Dead Paths**: Ignores untaken branches; waits only for activated ones.
- **Example**: Merge after parallel approvals—only proceed if all activated approval branches complete.
- **Stream (Per-Token Execution)**:
- **Behavior**: Schedules on each arriving token; may allow multiple concurrent executions of the target.
- **When to Use**: Streaming merges where each branch triggers independently (e.g., event streams).
- **Converge (Strictest - All Paths Required)**:
- **Behavior**: Converges ALL inbound paths, requiring every connection to execute (forward AND backward). Most strict mode.
- **When to Use**: When every single inbound path must execute before proceeding, regardless of activation status.
- **Scenarios**:
- **Strict Barriers**: Forces all possible paths to complete before proceeding.
- **Dead Paths**: Blocks on dead/untaken paths (desired for maximum safety).
- **Example**: Critical synchronization point requiring absolute completion of all defined paths.
- **Cascade (Per-Token Execution)**:
- **Behavior**: Cascades execution for each arriving token independently; may allow multiple concurrent executions of the target.
- **When to Use**: Streaming scenarios where each branch should trigger separate processing (e.g., event streams).
- **Scenarios**:
- **Forks**: Executes target per branch.
- **Loops**: Executes per iteration.
@ -134,8 +167,8 @@ Each mode defines how tokens from multiple inbounds are synchronized:
- **Example**: Logging each branch outcome separately.
- **Race (First-Wins)**:
- **Behavior**: Schedules on first token; blocks/cancels others (e.g., via blocked tokens and ancestor cancellation).
- **When to Use**: Racing conditions (e.g., first response wins).
- **Behavior**: Races inbound branches; schedules on first token, blocks/cancels others (e.g., via blocked tokens and ancestor cancellation).
- **When to Use**: Racing conditions where first result wins (e.g., first response).
- **Scenarios**:
- **Forks**: Only first branch proceeds.
- **Loops**: May race iterations if concurrent.
@ -143,12 +176,12 @@ Each mode defines how tokens from multiple inbounds are synchronized:
- **Example**: Waiting for fastest API response; cancel slower ones.
### Handling Common Scenarios
- **Simple Sequential**: Any mode schedules on token arrival (single inbound).
- **Fork-Join with Condition**: Converge blocks on false; None proceeds.
- **Looping Construct**: All modes work; Converge uses count check to avoid strictness.
- **Switch with Dangling Branches**: None ignores untaken; Converge blocks if required.
- **BPMN Alignment**: None ≈ XOR/OR-join (flexible); Converge ≈ AND-join (strict); Race ≈ Event-based; Stream ≈ partial OR.
- **Fork-Join with Condition**: Merge waits for activated branches; Converge blocks on all paths; Stream proceeds opportunistically.
- **Looping Construct**: All modes work; Merge and Converge use count check to avoid strictness on single inbound.
- **Switch with Dangling Branches**: Stream ignores untaken; Merge waits for activated; Converge blocks on all.
- **BPMN Alignment**: Stream ≈ XOR/OR-join (flexible); Merge ≈ AND-join for active paths; Converge ≈ strict AND-join; Race ≈ Event-based; Cascade ≈ parallel multi-instance.
## Consequences

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,7 +3,7 @@
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup Label="TargetFrameworks">
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup Label="Files">

View file

@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

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

@ -4,13 +4,13 @@ using Elsa.Common.RecurringTasks;
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
using Elsa.Features.Services;
using Elsa.Identity.Multitenancy;
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;
@ -46,7 +46,7 @@ services
.AddWorkflowsFrom<Program>()
.UseIdentity(identity =>
{
identity.TokenOptions = options => identityTokenSection.Bind(options);
identity.TokenOptions += options => identityTokenSection.Bind(options);
identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options));
@ -74,6 +74,8 @@ services
runtime.UseDistributedRuntime();
})
.UseWorkflowsApi()
.UseFluentStorageProvider()
.UseElsaScriptBlobStorage()
.UseScheduling()
.UseCSharp(options =>
{
@ -136,6 +138,7 @@ services.AddCors(cors => cors.AddDefaultPolicy(policy => policy.AllowAnyHeader()
// Build the web application.
var app = builder.Build();
// Configure the pipeline.
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();

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

@ -121,11 +121,16 @@ public static class ActivityExtensions
public static MergeMode GetMergeMode(this JsonObject activity)
{
return activity.GetProperty<MergeMode?>("customProperties", "mergeMode") ?? MergeMode.None;
var value = activity.GetProperty<MergeMode?>("customProperties", "mergeMode");
// Treat MergeMode.None as equivalent to null (no merge mode set), defaulting to Stream
return value == null || value == MergeMode.None ? MergeMode.Stream : value.Value;
}
public static void SetMergeMode(this JsonObject activity, MergeMode? value)
{
// Treat MergeMode.None as equivalent to null (no merge mode set)
if (value == MergeMode.None)
value = null;
activity.SetProperty(JsonValue.Create(value), "customProperties", "mergeMode");
}

View file

@ -160,7 +160,7 @@ public static class JsonObjectExtensions
{
return model.GetProperty<T>(path);
}
catch (Exception e)
catch (Exception)
{
return default;
}

View file

@ -9,5 +9,5 @@ public class RetryAttemptRecord : Entity
public string WorkflowInstanceId { get; set; } = null!;
public int AttemptNumber { get; set; }
public TimeSpan RetryDelay { get; set; }
public IDictionary<string, string?> Details { get; set; }
public IDictionary<string, string?> Details { get; set; } = null!;
}

View file

@ -5,4 +5,6 @@ namespace Elsa.Api.Client.Resources.StorageDrivers.Models;
/// </summary>
/// <param name="TypeName">The type name of the storage driver.</param>
/// <param name="DisplayName">The display name of the storage driver.</param>
/// <param name="Priority">The priority of the storage driver.</param>
/// <param name="Deprecated">Indicates whether the storage driver is deprecated.</param>
public record StorageDriverDescriptor(string TypeName, string DisplayName, double Priority = 0, bool Deprecated = false);

View file

@ -2,28 +2,51 @@ namespace Elsa.Api.Client.Shared.Enums;
/// <summary>
/// Specifies the strategy for handling multiple inbound execution paths in a workflow.
/// Uses flow-based terminology to describe merge behavior.
/// </summary>
public enum MergeMode
{
/// <summary>
/// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
/// Suitable for flexible, unstructured merges where optional branches shouldn't block.
/// No merge mode set. Treated as if merge mode was not specified (null).
/// Provides backward compatibility for existing workflows.
/// Defaults to Stream behavior at runtime.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Schedule on each arriving token, allowing multiple executions if supported.
/// Flows freely when possible, ignoring dead/untaken paths.
/// Opportunistic execution based on upstream completion.
/// Uses approximation that proceeds after all upstream sources complete.
/// Suitable for flexible, unstructured merges where optional branches shouldn't block.
/// </summary>
Stream,
/// <summary>
/// Merges only the activated/flowing inbound branches.
/// Waits for all branches that received tokens, ignoring unactivated ones.
/// Use for synchronization points where only taken paths matter (e.g., fork-joins with conditions).
/// </summary>
Merge,
/// <summary>
/// Converges all inbound paths, requiring every connection to execute.
/// Blocks until all branches complete, including unactivated ones.
/// Strictest mode - will block on dead/untaken paths.
/// Use when every single inbound path must execute before proceeding.
/// </summary>
Converge,
/// <summary>
/// Cascades execution for each arriving token independently.
/// Allows multiple concurrent executions (one per arriving token).
/// Use for streaming scenarios where each branch should trigger separate processing.
/// </summary>
Cascade,
/// <summary>
/// Races inbound branches, executing on first arrival and blocking others.
/// Schedule on the first arriving token, block or cancel others.
/// Use for competitive scenarios where only the first result matters.
/// </summary>
Race
}

View file

@ -28,7 +28,7 @@ public interface ICommandSender
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the result.</typeparam>
/// <returns>The result.</returns>
Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy strategy, CancellationToken cancellationToken = default);
Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy? strategy, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the specified strategy.
@ -39,7 +39,7 @@ public interface ICommandSender
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the result.</typeparam>
/// <returns>The result.</returns>
Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default);
Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy? strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the default strategy.
@ -54,7 +54,7 @@ public interface ICommandSender
/// <param name="command">The command to send.</param>
/// <param name="strategy">The command strategy to use.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default);
Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a command using the specified strategy.
@ -63,5 +63,5 @@ public interface ICommandSender
/// <param name="strategy">The command strategy to use.</param>
/// <param name="headers">Any headers to pass along.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default);
Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default);
}

View file

@ -42,8 +42,7 @@ public static class HandlerExtensions
/// </summary>
/// <param name="handler">The handler to invoke.</param>
/// <param name="handleMethod">The handle method.</param>
/// <param name="notificationContext">The notification to handle.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <param name="notificationContext">The notification context containing the notification and cancellation token.</param>
public static Task InvokeAsync(this INotificationHandler handler, MethodBase handleMethod, NotificationContext notificationContext)
{
var notification = notificationContext.Notification;

View file

@ -39,7 +39,7 @@ public static class MediatorExtensions
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task SendAsync(this IMediator mediator, ICommand command, CancellationToken cancellationToken = default)
{
await mediator.SendAsync(command, default, cancellationToken);
await mediator.SendAsync(command, null, cancellationToken);
}
/// <summary>
@ -50,6 +50,6 @@ public static class MediatorExtensions
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task SendAsync(this ICommandSender commandSender, ICommand command, CancellationToken cancellationToken = default)
{
await commandSender.SendAsync(command, default, cancellationToken);
await commandSender.SendAsync(command, null, cancellationToken);
}
}

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Elsa.Mediator.Contexts;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Command.Contracts;
@ -13,6 +14,7 @@ namespace Elsa.Mediator.Middleware.Command.Components;
public class CommandHandlerInvokerMiddleware(CommandMiddlewareDelegate next) : ICommandMiddleware
{
/// <inheritdoc />
[UnconditionalSuppressMessage("Trimming", "IL2060:Call to MakeGenericMethod can not be statically analyzed", Justification = "The result type is determined at runtime from command types and handlers are registered in DI.")]
public async ValueTask InvokeAsync(CommandContext context)
{
// Find all handlers for the specified command.

View file

@ -28,6 +28,7 @@ public class DefaultMediator : IMediator
/// <param name="commandPipeline">The command pipeline.</param>
/// <param name="notificationPipeline">The notification pipeline.</param>
/// <param name="options">The mediator options.</param>
/// <param name="serviceProvider">The service provider.</param>
public DefaultMediator(
IRequestPipeline requestPipeline,
ICommandPipeline commandPipeline,
@ -53,9 +54,10 @@ public class DefaultMediator : IMediator
return (T)context.Response;
}
public async Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default)
public async Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy? strategy, IDictionary<object, object> headers, CancellationToken cancellationToken = default)
{
var resultType = typeof(T);
strategy ??= _defaultCommandStrategy;
var context = new CommandContext(command, strategy, resultType, headers, _serviceProvider, cancellationToken);
await _commandPipeline.InvokeAsync(context);
@ -91,7 +93,7 @@ public class DefaultMediator : IMediator
}
/// <inheritdoc />
public Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy strategy, CancellationToken cancellationToken = default)
public Task<T> SendAsync<T>(ICommand<T> command, ICommandStrategy? strategy, CancellationToken cancellationToken = default)
{
return SendAsync(command, strategy, new Dictionary<object, object>(), cancellationToken);
}

View file

@ -63,7 +63,7 @@ public static class DispatchWorkflowExtensions
var workflowDispatcher = serviceProvider.GetRequiredService<IWorkflowDispatcher>();
var dispatchWorkflowResponse = await workflowDispatcher.DispatchAsync(new DispatchWorkflowDefinitionRequest
{
DefinitionVersionId = workflow.DefinitionHandle.DefinitionVersionId,
DefinitionVersionId = workflow.DefinitionHandle.DefinitionVersionId!,
InstanceId = instanceId ?? Guid.NewGuid().ToString(),
});
dispatchWorkflowResponse.ThrowIfFailed();

View file

@ -26,11 +26,13 @@ public static class RunWorkflowExtensions
/// <param name="services">The services.</param>
/// <param name="workflowDefinitionId">The ID of the workflow definition.</param>
/// <param name="input">An optional dictionary of input values.</param>
/// <param name="correlationId">An optional correlation id of the workflow.</param>
/// <param name="versionOptions">An optional set of options to specify the version of the workflow definition to retrieve.</param>
/// <returns>The workflow state.</returns>
public static async Task<WorkflowState> RunWorkflowUntilEndAsync(this IServiceProvider services,
string workflowDefinitionId,
IDictionary<string, object>? input = null,
string? correlationId = null,
VersionOptions? versionOptions = null)
{
var workflowDefinitionService = services.GetRequiredService<IWorkflowDefinitionService>();
@ -44,7 +46,8 @@ public static class RunWorkflowExtensions
var response = await workflowClient.CreateAndRunInstanceAsync(new()
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph.Workflow.Identity.Id),
Input = input
Input = input,
CorrelationId = correlationId
});
var bookmarkStore = services.GetRequiredService<IBookmarkStore>();
@ -64,7 +67,8 @@ public static class RunWorkflowExtensions
{
var runRequest = new RunWorkflowInstanceRequest
{
BookmarkId = bookmark.Id
BookmarkId = bookmark.Id,
Input = input
};
response = await workflowClient.RunInstanceAsync(runRequest);
}

View file

@ -0,0 +1,38 @@
using Elsa.Workflows;
using Elsa.Workflows.Activities;
namespace Elsa.Testing.Shared.Activities;
/// <summary>
/// Concrete implementation of Container for testing that schedules children sequentially.
/// </summary>
public class TestContainer : Container
{
private const string CurrentIndexProperty = "CurrentIndex";
protected override async ValueTask ScheduleChildrenAsync(ActivityExecutionContext context)
{
await HandleItemAsync(context);
}
private async ValueTask HandleItemAsync(ActivityExecutionContext context)
{
var currentIndex = context.GetProperty<int>(CurrentIndexProperty);
var childActivities = Activities.ToList();
if (currentIndex >= childActivities.Count)
{
await context.CompleteActivityAsync();
return;
}
var nextActivity = childActivities.ElementAt(currentIndex);
await context.ScheduleActivityAsync(nextActivity, OnChildCompleted);
context.UpdateProperty<int>(CurrentIndexProperty, x => x + 1);
}
private async ValueTask OnChildCompleted(ActivityCompletedContext context)
{
await HandleItemAsync(context.TargetContext);
}
}

View file

@ -7,35 +7,36 @@ namespace Elsa.Testing.Shared;
/// </summary>
public static class ActivityExecutionContextExtensions
{
/// <summary>
/// Determines whether the specified activity is scheduled in the workflow execution context.
/// </summary>
/// <param name="activityExecutionContext">The activity execution context instance used for checking the scheduled activity.</param>
/// <param name="activity">The activity to check for scheduling.</param>
/// <returns>True if the specified activity is scheduled; otherwise, false.</returns>
public static bool HasScheduledActivity(this ActivityExecutionContext activityExecutionContext, IActivity activity)
extension(ActivityExecutionContext activityExecutionContext)
{
return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
}
/// <summary>
/// Determines whether the specified activity is scheduled in the workflow execution context.
/// </summary>
/// <param name="activity">The activity to check for scheduling.</param>
/// <returns>True if the specified activity is scheduled; otherwise, false.</returns>
public bool HasScheduledActivity(IActivity activity)
{
return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
}
/// <summary>
/// Retrieves the collection of outcomes recorded in the execution context's journal data.
/// </summary>
/// <param name="activityExecutionContext">The activity execution context from which to retrieve the outcomes.</param>
/// <returns>A collection of outcome strings, or an empty collection if no outcomes are present.</returns>
public static IEnumerable<string> GetOutcomes(this ActivityExecutionContext activityExecutionContext)
{
return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : [];
}
/// <summary>
/// Retrieves the collection of outcomes recorded in the execution context's journal data.
/// </summary>
/// <returns>A collection of outcome strings, or an empty collection if no outcomes are present.</returns>
public IEnumerable<string> GetOutcomes()
{
return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : [];
}
/// <summary>
/// Determines whether the specified outcome exists in the current activity execution context.
/// </summary>
/// <param name="activityExecutionContext">The activity execution context to check for the outcome.</param>
/// <param name="outcome">The outcome to verify within the execution context.</param>
/// <returns>True if the specified outcome exists; otherwise, false.</returns>
public static bool HasOutcome(this ActivityExecutionContext activityExecutionContext, string outcome)
{
return activityExecutionContext.GetOutcomes().Contains(outcome);
/// <summary>
/// Determines whether the specified outcome exists in the current activity execution context.
/// </summary>
/// <param name="outcome">The outcome to verify within the execution context.</param>
/// <returns>True if the specified outcome exists; otherwise, false.</returns>
public bool HasOutcome(string outcome)
{
return activityExecutionContext.GetOutcomes().Contains(outcome);
}
}
}

View file

@ -79,7 +79,17 @@ public class ActivityTestFixture
public async Task<ActivityExecutionContext> ExecuteAsync()
{
var context = await BuildAsync();
return await ExecuteAsync(context);
}
/// <summary>
/// Executes the activity using a pre-built <see cref="ActivityExecutionContext"/>.
/// Useful when you need to customize the context before execution, such as setting initial workflow state or overriding correlation IDs.
/// </summary>
/// <param name="context">The pre-built context to execute</param>
/// <returns>The <see cref="ActivityExecutionContext"/> after execution</returns>
public async Task<ActivityExecutionContext> ExecuteAsync(ActivityExecutionContext context)
{
// Set up variables and inputs, then execute the activity
await SetupExistingVariablesAsync(Activity, context);
await context.EvaluateInputPropertiesAsync();
@ -153,7 +163,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

@ -3,33 +3,24 @@ using Xunit.Abstractions;
namespace Elsa.Testing.Shared;
public class XunitLogger : ILogger
public class XunitLogger(ITestOutputHelper testOutputHelper, string categoryName) : ILogger
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly string _categoryName;
public XunitLogger(ITestOutputHelper testOutputHelper, string categoryName)
{
_testOutputHelper = testOutputHelper;
_categoryName = categoryName;
}
public IDisposable BeginScope<TState>(TState state) => NoopDisposable.Instance;
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NoopDisposable.Instance;
public bool IsEnabled(LogLevel logLevel)
=> true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
_testOutputHelper.WriteLine($"{_categoryName} [{eventId}] {formatter(state, exception)}");
testOutputHelper.WriteLine($"{categoryName} [{eventId}] {formatter(state, exception)}");
if (exception != null)
_testOutputHelper.WriteLine(exception.ToString());
testOutputHelper.WriteLine(exception.ToString());
}
private class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new NoopDisposable();
public static readonly NoopDisposable Instance = new();
public void Dispose()
{

View file

@ -2,6 +2,6 @@ namespace Elsa.Alterations.Core.Models;
public class NewAlterationJob
{
public string PlanId { get; set; }
public string WorkflowInstanceId { get; set; }
public string PlanId { get; set; } = null!;
public string WorkflowInstanceId { get; set; } = null!;
}

View file

@ -1,6 +1,6 @@
namespace Elsa.Alterations.Endpoints.Alterations.Submit;
/// <summary>
/// The response from the <see cref="Execute"/> endpoint.
/// The response from the Submit endpoint.
/// </summary>
public record Response(string PlanId);

View file

@ -3,7 +3,7 @@ using JetBrains.Annotations;
namespace Elsa.Common.DistributedHosting;
/// <summary>
/// Provides options related to distributed locking, which is used by <see cref="DefaultWorkflowRuntime"/>.
/// Provides options related to distributed locking, which is used by the workflow runtime.
/// </summary>
[UsedImplicitly]
public class DistributedLockingOptions

View file

@ -6,4 +6,5 @@ public static class StringExtensions
public static string WithDefault(this string? value, string defaultValue) => !string.IsNullOrWhiteSpace(value) ? value : defaultValue;
public static string EmptyIfNull(this string? value) => value ?? "";
public static string? NullIfEmpty(this string? value) => value == "" ? null : value;
public static string? NullIfWhiteSpace(this string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}

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

@ -9,6 +9,10 @@
<ItemGroup>
<PackageReference Include="Jint" />
</ItemGroup>
<!-- System.Linq.Async only for .NET 8 and 9 - .NET 10 has native async LINQ support -->
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<PackageReference Include="System.Linq.Async" />
</ItemGroup>

View file

@ -9,8 +9,9 @@ namespace Elsa.Expressions.JavaScript.Extensions;
public static class TypeAliasRegistryExtensions
{
/// <summary>
/// Register type <see cref="T"/> with the specified alias.
/// Register type <typeparamref name="T"/> with the specified alias.
/// </summary>
/// <typeparam name="T">The type to register.</typeparam>
public static void RegisterType<T>(this ITypeAliasRegistry registry, string alias) => registry.RegisterType(typeof(T), alias);
/// <summary>

View file

@ -2,16 +2,9 @@
namespace Elsa.Expressions.Liquid.Helpers;
public class ConfigurationSectionWrapper
public class ConfigurationSectionWrapper(IConfigurationSection section)
{
private readonly IConfigurationSection _section;
public override string ToString() => section.Value!;
public ConfigurationSectionWrapper(IConfigurationSection section)
{
_section = section;
}
public override string ToString() => _section.Value;
public ConfigurationSectionWrapper GetSection(string name) => new(_section.GetSection(name));
public ConfigurationSectionWrapper GetSection(string name) => new(section.GetSection(name));
}

View file

@ -1,4 +1,6 @@
using System.Collections;
using System.ComponentModel;
using System.Text.Json;
namespace Elsa.Expressions.Helpers;
@ -26,11 +28,41 @@ public static class ObjectFormatter
if (underlyingSourceType == typeof(string))
return value as string;
var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType);
// Byte arrays are base64-encoded for serialization
if (value is byte[] byteArray)
return Convert.ToBase64String(byteArray);
if (sourceTypeConverter.CanConvertTo(typeof(string)))
return (string?)sourceTypeConverter.ConvertTo(value, typeof(string));
// Memory-like types are preserved via TypeDescriptor
if (IsMemoryLike(sourceType))
{
var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType);
if (sourceTypeConverter.CanConvertTo(typeof(string)))
return (string?)sourceTypeConverter.ConvertTo(value, typeof(string));
return value.ToString();
}
// Serialize arrays and collections to JSON instead of "T[] Array" or "(Collection)".
// Fixes GitHub issue #7019.
if (value is IEnumerable)
{
return JsonSerializer.Serialize(value);
}
var converter = TypeDescriptor.GetConverter(underlyingSourceType);
if (converter.CanConvertTo(typeof(string)))
return (string?)converter.ConvertTo(value, typeof(string));
return value.ToString();
}
private static bool IsMemoryLike(Type type)
{
if (!type.IsGenericType)
return false;
var genericTypeDef = type.GetGenericTypeDefinition();
return genericTypeDef == typeof(Memory<>) || genericTypeDef == typeof(ReadOnlyMemory<>);
}
}

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.
@ -461,7 +474,7 @@ public class HttpEndpoint : Trigger<HttpRequest>
var contentType = httpRequest.ContentType!;
var headers = httpRequest.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray());
return await context.ParseContentAsync(contentStream, contentType, targetType, headers, cancellationToken);
return await context.ParseContentAsync(contentStream, contentType, targetType, headers!, cancellationToken);
}
private static bool HasContent(HttpRequest httpRequest) => httpRequest.Headers.ContentLength > 0;

View file

@ -240,8 +240,8 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
_ => typeof(string)
};
var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.Cast<string?>().ToArray(), StringComparer.OrdinalIgnoreCase);
var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.Cast<string?>().ToArray(), StringComparer.OrdinalIgnoreCase);
var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase);
var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase);
var headersDictionary = contentHeadersDictionary.Concat(responseHeadersDictionary).ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase);
return await context.ParseContentAsync(contentStream, contentType, targetType, headersDictionary, cancellationToken);
}

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.
@ -101,7 +114,7 @@ public class WriteHttpResponse : Activity
// Add headers.
var headers = context.GetHeaders(ResponseHeaders);
foreach (var header in headers)
response.Headers.Add(header.Key, header.Value);
response.Headers[header.Key] = header.Value;
// Get content and content type.
var content = context.Get(Content);

View file

@ -20,7 +20,7 @@ public class FormUrlEncodedHttpContentFactory : IHttpContentFactory
return dictionary.ToDictionary(x => x.Key, x => x.Value.ToString() ?? string.Empty);
if (content is string or JsonObject)
return JsonSerializer.Deserialize<Dictionary<string, string>>(JsonSerializer.Serialize(content));
return JsonSerializer.Deserialize<Dictionary<string, string>>(JsonSerializer.Serialize(content))!;
var jsonElement = JsonSerializer.SerializeToElement(content);
return jsonElement.EnumerateObject().ToDictionary(x => x.Name, x => x.Value.ToString());

View file

@ -7,10 +7,10 @@ namespace Elsa.Http.Contexts;
/// </summary>
/// <param name="Manager">The manager.</param>
/// <param name="Content">The content to get downloadables from.</param>
/// <param name="ETag">An optional ETag.</param>
/// <param name="Options">The downloadable options.</param>
/// <param name="CancellationToken">The cancellation token.</param>
public record DownloadableContext(
IDownloadableManager Manager,
object Content,
IDownloadableManager Manager,
object Content,
DownloadableOptions Options,
CancellationToken CancellationToken);

View file

@ -8,7 +8,7 @@ namespace Elsa.Extensions;
internal static class HttpActivityExecutionContextExtensions
{
public static async Task<object?> ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary<string, string?[]> headers, CancellationToken cancellationToken)
public static async Task<object?> ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary<string, string[]> headers, CancellationToken cancellationToken)
{
var parsers = context.GetServices<IHttpContentParser>().OrderByDescending(x => x.Priority).ToList();
var httpResponseParserContext = new HttpResponseParserContext(content, contentType, returnType, headers, cancellationToken);

View file

@ -1,8 +1,6 @@
using Elsa.Http.Options;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Contracts;
using Elsa.Workflows.Runtime.Notifications;
using JetBrains.Annotations;
using Microsoft.Extensions.Options;
namespace Elsa.Http.Handlers;
@ -10,7 +8,7 @@ namespace Elsa.Http.Handlers;
/// A handler that updates the route table when workflow triggers and bookmarks are indexed.
/// </summary>
[UsedImplicitly]
public class UpdateRouteTable(IRouteTableUpdater routeTableUpdater, IOptions<HttpActivityOptions> options) :
public class UpdateRouteTable(IRouteTableUpdater routeTableUpdater) :
INotificationHandler<WorkflowTriggersIndexed>,
INotificationHandler<WorkflowBookmarksIndexed>
{

View file

@ -5,7 +5,7 @@ using Elsa.Workflows.UIHints.Dropdown;
namespace Elsa.Http.UIHints;
/// <summary>
/// Provides options for the <see cref="SendHttpRequest"/> activity's <see cref="SendHttpRequest.ContentType"/> property.
/// Provides options for the <see cref="SendHttpRequest"/> activity's <see cref="SendHttpRequestBase.ContentType"/> property.
/// </summary>
public class HttpContentTypeOptionsProvider : DropDownOptionsProviderBase
{

View file

@ -10,7 +10,7 @@ namespace Elsa.Extensions;
public static class ModuleExtensions
{
/// <summary>
/// Installs & configures the <see cref="IdentityFeature"/> feature.
/// Installs and configures the <see cref="IdentityFeature"/> feature.
/// </summary>
public static IModule UseIdentity(this IModule module, Action<IdentityFeature>? configure = null)
{
@ -19,7 +19,7 @@ public static class ModuleExtensions
}
/// <summary>
/// Installs & configures the <see cref="IdentityFeature"/> feature.
/// Installs and configures the <see cref="IdentityFeature"/> feature.
/// </summary>
public static IModule UseIdentity(this IModule module, string signingKey, string issuer = "http://elsa.api", string audience = "http://elsa.api", TimeSpan? tokenLifetime = null)
{
@ -34,7 +34,7 @@ public static class ModuleExtensions
}
/// <summary>
/// Installs & configures the <see cref="IdentityFeature"/> feature.
/// Installs and configures the <see cref="DefaultAuthenticationFeature"/> feature.
/// </summary>
public static IModule UseDefaultAuthentication(this IModule module, Action<DefaultAuthenticationFeature>? configure = null)
{

View file

@ -1,5 +1,4 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
@ -16,13 +15,13 @@ public abstract class DesignTimeDbContextFactoryBase<TDbContext> : IDesignTimeDb
{
var builder = new DbContextOptionsBuilder<TDbContext>();
var connectionStringOption = new Option<string>("--connectionString", "Specifies the connection string.");
var command = new RootCommand();
var command = new RootCommand
{
connectionStringOption
};
command.AddOption(connectionStringOption);
var parser = new Parser(command);
var parseResult = parser.Parse(args);
var connectionString = parseResult.GetValueForOption(connectionStringOption) ?? "Data Source=local";
var parseResult = command.Parse(args);
var connectionString = parseResult.GetValue(connectionStringOption) ?? "Data Source=local";
var services = new ServiceCollection();
ConfigureServices(services);

View file

@ -1,4 +1,5 @@
using System.Reflection;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
@ -10,6 +11,7 @@ namespace Elsa.Persistence.EFCore;
/// <summary>
/// Class That enable Schema change for Migration
/// </summary>
[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to customize migration assembly behavior for schema-aware migrations.")]
public class DbSchemaAwareMigrationAssembly(
ICurrentDbContext currentContext,
IDbContextOptions options,

View file

@ -5,6 +5,7 @@
Provides common Entity Framework Core types for implementing EF Core persistence of varipus ELsa modules.
</Description>
<PackageTags>elsa extension module persistence efcore</PackageTags>
<RootNamespace>Elsa.Persistence.EFCore</RootNamespace>
</PropertyGroup>
<ItemGroup>

View file

@ -113,7 +113,7 @@ public static class BulkUpsertExtensions
var paramName = $"{{{parameterCount++}}}";
// If it's a shadow property, retrieve value via Entry(..).Property(..)
object? value = property.IsShadowProperty()
var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@ -176,7 +176,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
object? value = property.IsShadowProperty()
var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@ -185,7 +185,7 @@ public static class BulkUpsertExtensions
value = converter.ConvertToProvider(value);
placeholders.Add(paramName);
parameters.Add(value);
parameters.Add(value!);
}
sb.Append($"({string.Join(", ", placeholders)})");
@ -238,7 +238,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
object? value = property.IsShadowProperty()
var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@ -255,7 +255,7 @@ public static class BulkUpsertExtensions
else
placeholders.Add(paramName);
parameters.Add(value);
parameters.Add(value!);
}
sb.Append($"({string.Join(", ", placeholders)})");
@ -308,7 +308,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
object? value = property.IsShadowProperty()
var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@ -317,7 +317,7 @@ public static class BulkUpsertExtensions
value = converter.ConvertToProvider(value);
placeholders.Add(paramName);
parameters.Add(value);
parameters.Add(value!);
}
sb.Append($"({string.Join(", ", placeholders)})");
@ -374,7 +374,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
object? value = property.IsShadowProperty()
var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@ -382,7 +382,7 @@ public static class BulkUpsertExtensions
if (converter != null)
value = converter.ConvertToProvider(value);
parameters.Add(value);
parameters.Add(value!);
// Oracle aliases must match the column name
var alias = property.GetColumnName(storeObject);

View file

@ -13,7 +13,10 @@ namespace Elsa.Persistence.EFCore;
/// For plain objects, fall back to deep equality comparison using JSON serialization
/// (safe, but inefficient).
/// </remarks>
public class JsonValueComparer<T> : ValueComparer<T> {
public class JsonValueComparer<T>() : ValueComparer<T>((t1, t2) => DoEquals(t1!, t2!),
t => DoGetHashCode(t),
t => DoGetSnapshot(t))
{
private static string Json(T instance) {
return JsonSerializer.Serialize(instance);
@ -24,9 +27,7 @@ public class JsonValueComparer<T> : ValueComparer<T> {
if (instance is ICloneable cloneable)
return (T)cloneable.Clone();
var result = (T)JsonSerializer.Deserialize<T>(Json(instance));
return result;
return JsonSerializer.Deserialize<T>(Json(instance))!;
}
private static int DoGetHashCode(T instance) {
@ -43,15 +44,6 @@ public class JsonValueComparer<T> : ValueComparer<T> {
if (left is IEquatable<T> equatable)
return equatable.Equals(right);
var result = Json(left).Equals(Json(right));
return result;
return Json(left).Equals(Json(right));
}
public JsonValueComparer() : base(
(t1, t2) => DoEquals(t1, t2),
t => DoGetHashCode(t),
t => DoGetSnapshot(t)) {
}
}

View file

@ -15,7 +15,7 @@ internal static class JsonValueConverterHelper
public static string Serialize<T>(T obj) where T : class
{
return (obj == null ? null : JsonSerializer.Serialize(obj, JsonSerializerOptions))!;
return (obj == null! ? null : JsonSerializer.Serialize(obj, JsonSerializerOptions))!;
}
private static JsonSerializerOptions CreateJsonSerializerOptions()

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

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