diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index cb262164f..41f39a82b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -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.
\ No newline at end of file
diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
index e5e0f9205..8d96b036c 100644
--- a/.github/workflows/copilot-setup-steps.yml
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -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
diff --git a/.github/workflows/docker-ca.yml b/.github/workflows/docker-ca.yml
index e8a737d48..0a35f9c30 100644
--- a/.github/workflows/docker-ca.yml
+++ b/.github/workflows/docker-ca.yml
@@ -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
diff --git a/.github/workflows/elsa-server-and-studio.yml b/.github/workflows/elsa-server-and-studio.yml
deleted file mode 100644
index d3fef0f61..000000000
--- a/.github/workflows/elsa-server-and-studio.yml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml
deleted file mode 100644
index e102ad122..000000000
--- a/.github/workflows/elsa-studio.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: Elsa Studio Docker image
-on:
- workflow_dispatch:
- push:
- branches:
- - develop/3.6.0
-
-jobs:
- push_to_registry:
- name: Push Docker image to Docker Hub
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v2
- with:
- platforms: all
-
- - name: Set up Docker Buildx
- id: buildx
- uses: docker/setup-buildx-action@v2
-
- - name: Docker meta
- id: meta
- uses: docker/metadata-action@v3
- with:
- # list of Docker images to use as base name for tags
- images: |
- elsaworkflows/elsa-studio-v3-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 }}
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
index fa93c6e21..673cb5778 100644
--- a/.github/workflows/packages.yml
+++ b/.github/workflows/packages.yml
@@ -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
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 0b038eb07..c3c434b40 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -14,12 +14,14 @@
#
# ------------------------------------------------------------------------------
-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
diff --git a/.gitignore b/.gitignore
index 68a650b2a..34040f1bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -80,3 +80,7 @@ unlist.sh
/docker/data/
/docker/azurite-data/
docker/docker-compose-datadog.yml
+
+# Test results
+TestResults/
+artifacts/
diff --git a/Directory.Build.props b/Directory.Build.props
index 171bc74c7..969fcc3f5 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -33,7 +33,9 @@
$(NoWarn);CS0162;CS1591
+
+ $(NoWarn);CS0618
- $(NoWarn);IL2026;IL2046;IL2057;IL2067;IL2070;IL2072;IL2075;IL2087;IL2091
+ $(NoWarn);IL2026;IL2046;IL2057;IL2067;IL2070;IL2072;IL2075;IL2087;IL2091;IL2111
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 94364485b..baf28d767 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,10 +3,26 @@
true
false
-
- 9.0.10
- 9.10.0
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -15,168 +31,145 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/Elsa.sln b/Elsa.sln
index 3ef9f27c8..f8c579aff 100644
--- a/Elsa.sln
+++ b/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}
diff --git a/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md b/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md
new file mode 100644
index 000000000..550dcbee4
--- /dev/null
+++ b/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md
@@ -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("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`, 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()` 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("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`)
diff --git a/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md b/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md
new file mode 100644
index 000000000..4abe30641
--- /dev/null
+++ b/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md
@@ -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();
+ var logger = context.ServiceProvider.GetRequiredService>();
+
+ 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 _scheduledTasks = new Dictionary();
+ private readonly IDictionary> _scheduledTaskKeys = new Dictionary>();
+ private readonly object _lock = new(); // Added lock object
+
+ public ValueTask ScheduleAsync(string name, ITask task, ISchedule schedule, IEnumerable? 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 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
diff --git a/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md b/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md
new file mode 100644
index 000000000..485c6b8c0
--- /dev/null
+++ b/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md
@@ -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
+///
+/// Deletes the workflow instance. This method ensures that the instance is properly stopped before deletion.
+///
+Task 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 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 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
+{
+ 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
+{
+ 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
diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs
index 1d2847d54..62d8d1ba2 100644
--- a/build/Build.CI.GitHubActions.cs
+++ b/build/Build.CI.GitHubActions.cs
@@ -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
)
diff --git a/build/_build.csproj b/build/_build.csproj
index be8285ad2..dac12855f 100644
--- a/build/_build.csproj
+++ b/build/_build.csproj
@@ -2,7 +2,7 @@
Exe
- net9.0
+ net10.0
CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006
..
@@ -19,12 +19,7 @@
-
-
-
-
-
-
+
diff --git a/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md b/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
index b1143eca0..f6f279a50 100644
--- a/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
+++ b/doc/adr/0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
@@ -24,27 +24,38 @@ We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBas
```csharp
namespace Elsa.Workflows.Activities.Flowchart.Models;
+ ///
+ /// Specifies the strategy for handling multiple inbound execution paths in a workflow.
+ /// Uses flow-based terminology to describe merge behavior.
+ ///
public enum MergeMode
{
///
- /// 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.
- ///
- None,
-
- ///
- /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
- /// Use for required synchronization points.
- ///
- Converge,
-
- ///
- /// Schedule on each arriving token, allowing multiple executions if supported.
+ /// Flows freely when possible, ignoring dead/untaken paths.
+ /// Opportunistic execution based on upstream completion.
///
Stream,
///
- /// 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.
+ ///
+ Merge,
+
+ ///
+ /// Converges all inbound paths, requiring every connection to execute.
+ /// Strictest mode - will block on dead/untaken paths.
+ ///
+ Converge,
+
+ ///
+ /// Cascades execution for each arriving token independently.
+ /// Allows multiple concurrent executions (one per arriving token).
+ ///
+ Cascade,
+
+ ///
+ /// Races inbound branches, executing on first arrival and blocking others.
///
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
diff --git a/run-dsl-tests.sh b/run-dsl-tests.sh
new file mode 100644
index 000000000..99e4b3b6d
--- /dev/null
+++ b/run-dsl-tests.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+# Run from the repository root, or adjust the path below as needed.
+dotnet test test/integration/Elsa.Dsl.ElsaScript.IntegrationTests/Elsa.Dsl.ElsaScript.IntegrationTests.csproj --verbosity normal
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 84212213e..6330b1711 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
- net8.0;net9.0
+ net8.0;net9.0;net10.0
diff --git a/src/apps/Directory.Build.props b/src/apps/Directory.Build.props
index 50f33a49d..0d8817302 100644
--- a/src/apps/Directory.Build.props
+++ b/src/apps/Directory.Build.props
@@ -1,6 +1,6 @@
- net9.0
+ net10.0
latest
enable
enable
diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj
index 6fa3939e8..206866f3d 100644
--- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj
+++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj
@@ -3,9 +3,12 @@
+
+
+
@@ -24,4 +27,19 @@
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+
diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs
index 742a8820a..b056f5010 100644
--- a/src/apps/Elsa.Server.Web/Program.cs
+++ b/src/apps/Elsa.Server.Web/Program.cs
@@ -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()
.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();
diff --git a/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa b/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa
new file mode 100644
index 000000000..39439a8da
--- /dev/null
+++ b/src/apps/Elsa.Server.Web/Workflows/flowchart-test.elsa
@@ -0,0 +1,16 @@
+use Elsa.Activities.Console;
+
+workflow FlowchartTest {
+ flowchart {
+ var counter = 0;
+
+ entry Start;
+
+ Start: WriteLine("Starting flowchart");
+ CheckCondition: WriteLine("Checking condition");
+ EndNode: WriteLine("Done!");
+
+ Start -> CheckCondition;
+ CheckCondition.Done -> EndNode;
+ }
+}
diff --git a/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa b/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa
new file mode 100644
index 000000000..cad2bb7df
--- /dev/null
+++ b/src/apps/Elsa.Server.Web/Workflows/for-loop.elsa
@@ -0,0 +1,18 @@
+use expressions js;
+
+workflow ForLoopTest
+{
+ WriteLine("First loop")
+
+ for (var i = 0 through 10 step 1)
+ {
+ WriteLine(js => `Step: ${variables.i}`)
+ }
+
+ WriteLine("Second loop")
+
+ for (var i = 0 to 10 step 2)
+ WriteLine(cs => return $"Step: {Variables.Get("i")}";)
+
+ WriteLine("Done!")
+}
\ No newline at end of file
diff --git a/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa b/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa
new file mode 100644
index 000000000..f4bb0db7d
--- /dev/null
+++ b/src/apps/Elsa.Server.Web/Workflows/hello-world-http.elsa
@@ -0,0 +1,8 @@
+use expressions js;
+
+workflow HelloWorldHttpDsl
+{
+ var message = "Hello World from DSL via Expressions!";
+ listen HttpEndpoint("/hello-world-dsl");
+ WriteHttpResponse(js => `Message: ${variables.message}`);
+}
\ No newline at end of file
diff --git a/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa b/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa
new file mode 100644
index 000000000..37a88a681
--- /dev/null
+++ b/src/apps/Elsa.Server.Web/Workflows/hello-world.elsa
@@ -0,0 +1,6 @@
+use expressions js;
+
+workflow HelloWorldDsl
+{
+ WriteLine("Hello World from Elsa DSL!");
+}
\ No newline at end of file
diff --git a/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa b/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa
new file mode 100644
index 000000000..05998558e
--- /dev/null
+++ b/src/apps/Elsa.Server.Web/Workflows/multi-workflow-example.elsa
@@ -0,0 +1,29 @@
+use Elsa.Activities.Console;
+
+workflow HelloWorldDsl(
+ DisplayName: "Hello World DSL",
+ Description: "Demonstrates a simple ElsaScript workflow with metadata.",
+ DefinitionId: "hello-world-dsl",
+ DefinitionVersionId: "hello-world-dsl-v1",
+ Version: 1,
+ UsableAsActivity: true
+)
+{
+ use expressions js;
+
+ WriteLine("Hello World from Elsa DSL!");
+}
+
+workflow HelloWorldAsActivity(
+ DisplayName: "Hello World as Activity",
+ Description: "Can be embedded as an activity in other workflows.",
+ DefinitionId: "hello-world-activity",
+ DefinitionVersionId: "hello-world-activity-v1",
+ Version: 1,
+ UsableAsActivity: true
+)
+{
+ use expressions js;
+
+ WriteLine(`Inner hello at ${Date.now()}`);
+}
diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json
index f7456f2df..3bdf4969d 100644
--- a/src/apps/Elsa.Server.Web/appsettings.json
+++ b/src/apps/Elsa.Server.Web/appsettings.json
@@ -3,7 +3,8 @@
"LogLevel": {
"Default": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
- "Elsa": "Debug"
+ "Elsa": "Warning",
+ "Elsa.Workflows.ActivityRegistry": "Error"
}
},
"HostBuilder": {
diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
index 2ec34f8b6..0b4dbe0c2 100644
--- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
+++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs
@@ -121,11 +121,16 @@ public static class ActivityExtensions
public static MergeMode GetMergeMode(this JsonObject activity)
{
- return activity.GetProperty("customProperties", "mergeMode") ?? MergeMode.None;
+ var value = activity.GetProperty("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");
}
diff --git a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs
index 3653519b9..5b4ad7e9c 100644
--- a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs
+++ b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs
@@ -160,7 +160,7 @@ public static class JsonObjectExtensions
{
return model.GetProperty(path);
}
- catch (Exception e)
+ catch (Exception)
{
return default;
}
diff --git a/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs
index f76518386..4cfcf48a1 100644
--- a/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs
+++ b/src/clients/Elsa.Api.Client/Resources/Resilience/Models/RetryAttempt.cs
@@ -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 Details { get; set; }
+ public IDictionary Details { get; set; } = null!;
}
\ No newline at end of file
diff --git a/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs b/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs
index 835893b38..eb1b7241c 100644
--- a/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs
+++ b/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs
@@ -5,4 +5,6 @@ namespace Elsa.Api.Client.Resources.StorageDrivers.Models;
///
/// The type name of the storage driver.
/// The display name of the storage driver.
+/// The priority of the storage driver.
+/// Indicates whether the storage driver is deprecated.
public record StorageDriverDescriptor(string TypeName, string DisplayName, double Priority = 0, bool Deprecated = false);
\ No newline at end of file
diff --git a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
index c0a8944ab..20ca54aeb 100644
--- a/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
+++ b/src/clients/Elsa.Api.Client/Shared/Enums/MergeMode.cs
@@ -2,28 +2,51 @@ namespace Elsa.Api.Client.Shared.Enums;
///
/// Specifies the strategy for handling multiple inbound execution paths in a workflow.
+/// Uses flow-based terminology to describe merge behavior.
///
public enum MergeMode
{
///
- /// 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.
///
None,
///
- /// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
- /// Use for required synchronization points.
- ///
- Converge,
-
- ///
- /// 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.
///
Stream,
///
+ /// 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).
+ ///
+ Merge,
+
+ ///
+ /// 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.
+ ///
+ Converge,
+
+ ///
+ /// 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.
+ ///
+ Cascade,
+
+ ///
+ /// 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.
///
Race
}
\ No newline at end of file
diff --git a/src/common/Elsa.Mediator/Contracts/ICommandSender.cs b/src/common/Elsa.Mediator/Contracts/ICommandSender.cs
index 5373afb3b..02d32ca7a 100644
--- a/src/common/Elsa.Mediator/Contracts/ICommandSender.cs
+++ b/src/common/Elsa.Mediator/Contracts/ICommandSender.cs
@@ -28,7 +28,7 @@ public interface ICommandSender
/// The cancellation token.
/// The type of the result.
/// The result.
- Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default);
+ Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default);
///
/// Sends a command using the specified strategy.
@@ -39,7 +39,7 @@ public interface ICommandSender
/// The cancellation token.
/// The type of the result.
/// The result.
- Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary