Merge remote-tracking branch 'origin/develop/3.6.0'
This commit is contained in:
commit
a9154e412f
85
.github/copilot-instructions.md
vendored
85
.github/copilot-instructions.md
vendored
|
|
@ -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.
|
||||
4
.github/workflows/copilot-setup-steps.yml
vendored
4
.github/workflows/copilot-setup-steps.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/docker-ca.yml
vendored
2
.github/workflows/docker-ca.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
61
.github/workflows/elsa-server-and-studio.yml
vendored
61
.github/workflows/elsa-server-and-studio.yml
vendored
|
|
@ -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 }}
|
||||
61
.github/workflows/elsa-studio.yml
vendored
61
.github/workflows/elsa-studio.yml
vendored
|
|
@ -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 }}
|
||||
45
.github/workflows/packages.yml
vendored
45
.github/workflows/packages.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
10
.github/workflows/pr.yml
vendored
10
.github/workflows/pr.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -80,3 +80,7 @@ unlist.sh
|
|||
/docker/data/
|
||||
/docker/azurite-data/
|
||||
docker/docker-compose-datadog.yml
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
artifacts/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
71
Elsa.sln
71
Elsa.sln
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>`)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
3
run-dsl-tests.sh
Normal 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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
16
src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa
Normal file
16
src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/apps/Elsa.Server.Web/Workflows/for-loop.elsa
Normal file
18
src/apps/Elsa.Server.Web/Workflows/for-loop.elsa
Normal 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!")
|
||||
}
|
||||
8
src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa
Normal file
8
src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa
Normal 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}`);
|
||||
}
|
||||
6
src/apps/Elsa.Server.Web/Workflows/hello-world.elsa
Normal file
6
src/apps/Elsa.Server.Web/Workflows/hello-world.elsa
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
use expressions js;
|
||||
|
||||
workflow HelloWorldDsl
|
||||
{
|
||||
WriteLine("Hello World from Elsa DSL!");
|
||||
}
|
||||
|
|
@ -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()}`);
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Elsa": "Debug"
|
||||
"Elsa": "Warning",
|
||||
"Elsa.Workflows.ActivityRegistry": "Error"
|
||||
}
|
||||
},
|
||||
"HostBuilder": {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ public static class JsonObjectExtensions
|
|||
{
|
||||
return model.GetProperty<T>(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (Exception)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
38
src/common/Elsa.Testing.Shared/Activities/TestContainer.cs
Normal file
38
src/common/Elsa.Testing.Shared/Activities/TestContainer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
17
src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs
Normal file
17
src/modules/Elsa.Dsl.ElsaScript/Ast/ArgumentNode.cs
Normal 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!;
|
||||
}
|
||||
17
src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs
Normal file
17
src/modules/Elsa.Dsl.ElsaScript/Ast/AstNode.cs
Normal 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; }
|
||||
}
|
||||
12
src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs
Normal file
12
src/modules/Elsa.Dsl.ElsaScript/Ast/BlockNode.cs
Normal 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; } = [];
|
||||
}
|
||||
22
src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs
Normal file
22
src/modules/Elsa.Dsl.ElsaScript/Ast/ConnectionNode.cs
Normal 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;
|
||||
}
|
||||
127
src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs
Normal file
127
src/modules/Elsa.Dsl.ElsaScript/Ast/ExpressionNode.cs
Normal 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; }
|
||||
}
|
||||
27
src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs
Normal file
27
src/modules/Elsa.Dsl.ElsaScript/Ast/FlowchartNode.cs
Normal 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; }
|
||||
}
|
||||
27
src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs
Normal file
27
src/modules/Elsa.Dsl.ElsaScript/Ast/ForEachNode.cs
Normal 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!;
|
||||
}
|
||||
42
src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs
Normal file
42
src/modules/Elsa.Dsl.ElsaScript/Ast/ForNode.cs
Normal 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!;
|
||||
}
|
||||
22
src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs
Normal file
22
src/modules/Elsa.Dsl.ElsaScript/Ast/IfNode.cs
Normal 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; }
|
||||
}
|
||||
20
src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs
Normal file
20
src/modules/Elsa.Dsl.ElsaScript/Ast/LabeledActivityNode.cs
Normal 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!;
|
||||
}
|
||||
12
src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs
Normal file
12
src/modules/Elsa.Dsl.ElsaScript/Ast/ListenNode.cs
Normal 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!;
|
||||
}
|
||||
19
src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs
Normal file
19
src/modules/Elsa.Dsl.ElsaScript/Ast/ProgramNode.cs
Normal 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; } = [];
|
||||
}
|
||||
8
src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs
Normal file
8
src/modules/Elsa.Dsl.ElsaScript/Ast/StatementNode.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Dsl.ElsaScript.Ast;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for statement nodes.
|
||||
/// </summary>
|
||||
public abstract class StatementNode : AstNode
|
||||
{
|
||||
}
|
||||
17
src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs
Normal file
17
src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchCaseNode.cs
Normal 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!;
|
||||
}
|
||||
22
src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs
Normal file
22
src/modules/Elsa.Dsl.ElsaScript/Ast/SwitchNode.cs
Normal 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; }
|
||||
}
|
||||
33
src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs
Normal file
33
src/modules/Elsa.Dsl.ElsaScript/Ast/UseNode.cs
Normal 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
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
10
src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs
Normal file
10
src/modules/Elsa.Dsl.ElsaScript/Ast/VariableKind.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace Elsa.Dsl.ElsaScript.Ast;
|
||||
|
||||
/// <summary>
|
||||
/// The kind of variable declaration.
|
||||
/// </summary>
|
||||
public enum VariableKind
|
||||
{
|
||||
Var,
|
||||
Const
|
||||
}
|
||||
17
src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs
Normal file
17
src/modules/Elsa.Dsl.ElsaScript/Ast/WhileNode.cs
Normal 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!;
|
||||
}
|
||||
29
src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs
Normal file
29
src/modules/Elsa.Dsl.ElsaScript/Ast/WorkflowNode.cs
Normal 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; } = [];
|
||||
}
|
||||
600
src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs
Normal file
600
src/modules/Elsa.Dsl.ElsaScript/Compiler/ElsaScriptCompiler.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
26
src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj
Normal file
26
src/modules/Elsa.Dsl.ElsaScript/Elsa.Dsl.ElsaScript.csproj
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
3
src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml
Normal file
3
src/modules/Elsa.Dsl.ElsaScript/FodyWeavers.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait />
|
||||
</Weavers>
|
||||
43
src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs
Normal file
43
src/modules/Elsa.Dsl.ElsaScript/Helpers/ActivityActivator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
630
src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs
Normal file
630
src/modules/Elsa.Dsl.ElsaScript/Parser/ElsaScriptParser.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
185
src/modules/Elsa.Dsl.ElsaScript/README.md
Normal file
185
src/modules/Elsa.Dsl.ElsaScript/README.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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<>);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue