feat: introduce HTTP webhooks module

Adds a new `Elsa.Http.Webhooks` module, enabling workflows to receive incoming webhook events and dispatch outgoing webhooks. This integrates the WebhooksCore library.

Further improvements include:
- Enhanced validation for configured application instance names, providing clearer feedback, especially regarding Azure Service Bus entity name limits.
- Improved API error reporting for shell reload operations, distinguishing between blueprint not found (404) and other failures (503).
- Updated release announcement rendering to dynamically reference the correct major.minor release line for feedback messages.
This commit is contained in:
Sipke Schoorstra 2026-08-03 02:08:15 +02:00
commit 103028452f
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
35 changed files with 1805 additions and 179 deletions

View file

@ -194,7 +194,7 @@ def render_discord(
intro = discord_intro(product, version, release_kind)
highlight_text = render_discord_highlights(highlights)
upgrade_notes = render_upgrade_notes(release_kind)
validation = render_validation_note(release_kind)
validation = render_validation_note(release_kind, version)
return f"""\
:rocket: **{heading}**
@ -264,10 +264,11 @@ def render_upgrade_notes(release_kind: str) -> str:
return f"{heading}\n{body}"
def render_validation_note(release_kind: str) -> str:
def render_validation_note(release_kind: str, version: str) -> str:
if release_kind == "stable":
return """### :raised_hands: Feedback welcome
Please report issues, regressions, or upgrade notes you run into so we can keep improving the 3.7 line."""
release_line = ".".join(version.split(".")[:2]) if "." in version else version
return f"""### :raised_hands: Feedback welcome
Please report issues, regressions, or upgrade notes you run into so we can keep improving the {release_line} line."""
return """### :test_tube: Please test it
This release is intended for testing and validation before the final stable release. Feedback, bug reports, and PRs are very welcome :raised_hands:"""

View file

@ -202,7 +202,7 @@
<PackageVersion Include="Testcontainers.RabbitMq" Version="4.9.0"/>
<PackageVersion Include="Testcontainers.Redis" Version="4.9.0"/>
<PackageVersion Include="ThrottleDebounce" Version="2.0.2"/>
<PackageVersion Include="WebhooksCore" Version="0.0.6"/>
<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"/>

View file

@ -199,6 +199,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "http", "http", "{C55015F0-E
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Http", "src\modules\Elsa.Http\Elsa.Http.csproj", "{E0B22AB7-7CB5-6D17-562C-4A989DC61F8A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Http.Webhooks", "src\modules\Elsa.Http.Webhooks\Elsa.Http.Webhooks.csproj", "{ABC40471-5A32-0AC0-594B-FF19B66E2493}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.CSharp", "src\modules\Elsa.Expressions.CSharp\Elsa.Expressions.CSharp.csproj", "{16C3FBDE-A832-E3CD-5FBF-F51744D1F79B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.JavaScript", "src\modules\Elsa.Expressions.JavaScript\Elsa.Expressions.JavaScript.csproj", "{099D5DCE-CCF7-16FE-6EDC-A64B694F50BE}"
@ -859,6 +861,18 @@ Global
{E0B22AB7-7CB5-6D17-562C-4A989DC61F8A}.Release|x64.Build.0 = Release|Any CPU
{E0B22AB7-7CB5-6D17-562C-4A989DC61F8A}.Release|x86.ActiveCfg = Release|Any CPU
{E0B22AB7-7CB5-6D17-562C-4A989DC61F8A}.Release|x86.Build.0 = Release|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Debug|x64.ActiveCfg = Debug|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Debug|x64.Build.0 = Debug|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Debug|x86.ActiveCfg = Debug|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Debug|x86.Build.0 = Debug|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Release|Any CPU.Build.0 = Release|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Release|x64.ActiveCfg = Release|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Release|x64.Build.0 = Release|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Release|x86.ActiveCfg = Release|Any CPU
{ABC40471-5A32-0AC0-594B-FF19B66E2493}.Release|x86.Build.0 = Release|Any CPU
{16C3FBDE-A832-E3CD-5FBF-F51744D1F79B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{16C3FBDE-A832-E3CD-5FBF-F51744D1F79B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{16C3FBDE-A832-E3CD-5FBF-F51744D1F79B}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -2214,6 +2228,7 @@ Global
{6F14B066-DF7B-2409-59D8-CCCA90A4974C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{C55015F0-E9DF-4BF9-8131-D7539E938220} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{E0B22AB7-7CB5-6D17-562C-4A989DC61F8A} = {C55015F0-E9DF-4BF9-8131-D7539E938220}
{ABC40471-5A32-0AC0-594B-FF19B66E2493} = {C55015F0-E9DF-4BF9-8131-D7539E938220}
{16C3FBDE-A832-E3CD-5FBF-F51744D1F79B} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76}
{099D5DCE-CCF7-16FE-6EDC-A64B694F50BE} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76}
{08B69CC4-B5F0-44E8-FA7A-6BF6F00CA40A} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76}

View file

@ -0,0 +1,34 @@
:rocket: **Elsa Workflows 3.6.3 is here!**
We've published the stable **Elsa 3.6.3** patch release across **Elsa Core**, **Elsa Studio**, and **Elsa Extensions**.
:point_right: Core: <https://github.com/elsa-workflows/elsa-core/releases/tag/3.6.3>
:point_right: Studio: <https://github.com/elsa-workflows/elsa-studio/releases/tag/3.6.3>
:point_right: Extensions: <https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.6.3>
Packages are available on NuGet.
### :sparkles: Highlights
:tools: **Azure Service Bus startup stability**
Core now supports stable application instance names for clustered hosts, plus deterministic shortening for long names.
:clock3: **Blank Cron expressions behave as disabled again**
Blank Cron values no longer block publishing or throw during workflow execution. Invalid nonblank Cron expressions still fail validation.
:memo: **Better workflow publish feedback**
Publish APIs now surface validation warnings and errors instead of reporting silent success.
:gear: **Quartz and MassTransit extension fixes**
Extensions now ensure durable Quartz jobs before scheduling triggers and use shorter MassTransit endpoint names.
:jigsaw: **Studio package alignment**
Studio now consumes `Elsa.Api.Client` 3.6.3.
### :tools: Upgrade notes
No intentional breaking changes. If you run clustered Elsa hosts on Azure Service Bus, review the stable application instance name option.
### :raised_hands: Feedback welcome
Please report upgrade issues or regressions so we can keep the 3.6 line solid.

View file

@ -0,0 +1,29 @@
:rocket: **Elsa Workflows 3.7.1 is here!**
We've published the stable **Elsa 3.7.1** patch release across **Elsa Core**, **Elsa Studio**, and **Elsa Extensions**. Packages are available on NuGet.
:point_right: Core: <https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.1>
:point_right: Studio: <https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.1>
:point_right: Extensions: <https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.7.1>
### :sparkles: Highlights
:cloud: **Azure Service Bus hosting reliability**
Elsa Core now supports stable application instance names so clustered hosts can reuse Azure Service Bus-backed change-token subscriptions and queues across restarts.
:straight_ruler: **Safer Azure Service Bus entity names**
Long configured instance names are shortened deterministically, and Extensions shortens MassTransit endpoint suffixes to reduce Azure Service Bus entity-name pressure.
:clock3: **Quartz scheduling fix**
Extensions now ensures the durable Quartz workflow job exists before scheduling triggers, preventing trigger storage failures.
:link: **Package alignment**
Studio now consumes `Elsa.Api.Client` 3.7.1, and Extensions aligns with Core and Studio 3.7.1 packages.
### :tools: Upgrade notes
No breaking changes are expected. If you run clustered Elsa hosts with Azure Service Bus, configure a stable per-instance name for each concurrently running host. Quartz-backed scheduling deployments should pick up the durable-job fix automatically.
### :raised_hands: Feedback welcome
Please report upgrade issues, regressions, or deployment notes you run into so we can keep improving the 3.7 line.

View file

@ -0,0 +1,118 @@
# Elsa 3.6.3 Announcement Pack
## Facts
- Product: Elsa Workflows across Elsa Core, Elsa Studio, and Elsa Extensions.
- Version: 3.6.3.
- Release kind: stable patch release.
- Publishing mode: draft-only until approved.
- GitHub releases:
- Elsa Core: https://github.com/elsa-workflows/elsa-core/releases/tag/3.6.3
- Elsa Studio: https://github.com/elsa-workflows/elsa-studio/releases/tag/3.6.3
- Elsa Extensions: https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.6.3
- Package availability verified on NuGet flat-container metadata for representative packages:
- Elsa 3.6.3.
- Elsa.Api.Client 3.6.3.
- Elsa.Studio.Core 3.6.3.
- Elsa.Scheduling.Quartz 3.6.3.
- No intentional breaking changes are called out in the three release notes.
## Discord
:rocket: **Elsa Workflows 3.6.3 is here!**
We've published the stable **Elsa 3.6.3** patch release across **Elsa Core**, **Elsa Studio**, and **Elsa Extensions**.
:point_right: Core: <https://github.com/elsa-workflows/elsa-core/releases/tag/3.6.3>
:point_right: Studio: <https://github.com/elsa-workflows/elsa-studio/releases/tag/3.6.3>
:point_right: Extensions: <https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.6.3>
Packages are available on NuGet.
### :sparkles: Highlights
:tools: **Azure Service Bus startup stability**
Core now supports an opt-in stable application instance name provider so clustered hosts can reuse Azure Service Bus transport identities across restarts instead of accumulating new subscriptions. Long configured instance names are shortened deterministically, which helps with Kubernetes pod names and Azure Service Bus entity-name limits.
:clock3: **Blank Cron expressions behave as disabled again**
Blank or whitespace Cron values no longer block workflow publishing and no longer throw when a Cron activity runs inside a workflow. Invalid nonblank Cron expressions still fail validation.
:memo: **Better workflow publish feedback**
Publish, bulk-publish, and save-and-publish responses now honor failed publish results and surface validation warnings or errors so clients can show actionable feedback instead of reporting silent success.
:gear: **Quartz and MassTransit extension fixes**
Extensions now ensure durable Quartz jobs exist before scheduling triggers, and MassTransit endpoint suffixes were shortened to reduce Azure Service Bus subscription-name pressure.
:jigsaw: **Studio package alignment**
Studio now consumes `Elsa.Api.Client` 3.6.3, keeping the Studio packages aligned with the Core 3.6.3 patch release.
### :tools: Upgrade notes
There are no intentional breaking changes in this patch release. If you run clustered Elsa hosts on Azure Service Bus, review the stable application instance name option and configure a stable unique name per process or pod where appropriate.
### :raised_hands: Feedback welcome
Please report upgrade issues, regressions, or notes from production-like environments so we can keep the 3.6 line solid.
## LinkedIn
Elsa Workflows 3.6.3 is now available.
This is a stable patch release across Elsa Core, Elsa Studio, and Elsa Extensions, focused on reliability fixes for production operators and cleaner feedback for workflow publishing.
The main improvements are around clustered hosting and scheduling:
- Azure Service Bus hosts can now opt into stable application instance names, helping avoid subscription buildup across restarts.
- Long configured instance names are shortened deterministically, which is useful for Kubernetes and transport entity-name limits.
- Blank Cron expressions are treated as disabled again, restoring a smoother workflow publishing experience while still rejecting invalid Cron values.
- Workflow publish APIs now return clearer validation warnings and errors.
- Extensions include Quartz durable trigger scheduling fixes and shorter MassTransit endpoint names.
- Studio has been aligned with the Elsa Core 3.6.3 client packages.
No intentional breaking changes are included.
Release notes:
https://github.com/elsa-workflows/elsa-core/releases/tag/3.6.3
https://github.com/elsa-workflows/elsa-studio/releases/tag/3.6.3
https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.6.3
## X Single-Post Option
Elsa Workflows 3.6.3 is available across Core, Studio, and Extensions.
This stable patch release improves Azure Service Bus clustered hosting, blank Cron handling, publish validation feedback, Quartz durable trigger scheduling, and Studio package alignment.
Core notes: https://github.com/elsa-workflows/elsa-core/releases/tag/3.6.3
## X Thread Option
1/ Elsa Workflows 3.6.3 is available across Core, Studio, and Extensions.
This stable patch release focuses on reliability: Azure Service Bus clustered hosting, Cron publishing behavior, publish validation responses, Quartz scheduling, and package alignment.
Core notes: https://github.com/elsa-workflows/elsa-core/releases/tag/3.6.3
2/ Highlights:
- Stable application instance names for clustered Azure Service Bus hosts.
- Deterministic shortening for long configured instance names.
- Blank Cron expressions behave as disabled again.
- Publish APIs surface validation warnings and errors.
3/ Extensions add Quartz durable trigger scheduling fixes and shorter MassTransit endpoint names.
Studio is aligned with `Elsa.Api.Client` 3.6.3.
No intentional breaking changes.
Studio: https://github.com/elsa-workflows/elsa-studio/releases/tag/3.6.3
Extensions: https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.6.3
## Links
- Elsa Core release: https://github.com/elsa-workflows/elsa-core/releases/tag/3.6.3
- Elsa Studio release: https://github.com/elsa-workflows/elsa-studio/releases/tag/3.6.3
- Elsa Extensions release: https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.6.3
- NuGet package search: https://www.nuget.org/packages?q=Elsa
<!-- Review and approve exact copy before publishing. -->

View file

@ -0,0 +1,90 @@
# Elsa 3.7.1 Announcement Pack
## Facts
- Product: Elsa Workflows
- Version: 3.7.1
- Release kind: stable patch release
- Published: June 21, 2026
- Repositories: Elsa Core, Elsa Studio, Elsa Extensions
- GitHub releases are public and not prereleases.
- Representative NuGet packages verified at 3.7.1:
- Elsa, Elsa.Api.Client, Elsa.Hosting.Management, Elsa.Workflows.Runtime, Elsa.Workflows.Management
- Elsa.Studio, Elsa.Studio.Core, Elsa.Studio.Workflows, Elsa.Studio.Workflows.Designer, Elsa.Studio.Authentication.ElsaIdentity
- Elsa.Scheduling.Quartz, Elsa.Caching.Distributed.MassTransit, Elsa.ServiceBus.MassTransit.AzureServiceBus, Elsa.ServiceBus.MassTransit, Elsa.ServiceBus.AzureServiceBus
## Discord
:rocket: **Elsa Workflows 3.7.1 is here!**
We've published the stable **Elsa 3.7.1** patch release across **Elsa Core**, **Elsa Studio**, and **Elsa Extensions**. Packages are available on NuGet.
:point_right: Core: <https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.1>
:point_right: Studio: <https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.1>
:point_right: Extensions: <https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.7.1>
### :sparkles: Highlights
:cloud: **Azure Service Bus hosting reliability**
Elsa Core now supports stable application instance names so clustered hosts can reuse Azure Service Bus-backed change-token subscriptions and queues across restarts.
:straight_ruler: **Safer Azure Service Bus entity names**
Long configured instance names are shortened deterministically, and Extensions shortens MassTransit endpoint suffixes to reduce Azure Service Bus entity-name pressure.
:clock3: **Quartz scheduling fix**
Extensions now ensures the durable Quartz workflow job exists before scheduling triggers, preventing trigger storage failures.
:link: **Package alignment**
Studio now consumes `Elsa.Api.Client` 3.7.1, and Extensions aligns with Core and Studio 3.7.1 packages.
### :tools: Upgrade notes
No breaking changes are expected. If you run clustered Elsa hosts with Azure Service Bus, configure a stable per-instance name for each concurrently running host. Quartz-backed scheduling deployments should pick up the durable-job fix automatically.
### :raised_hands: Feedback welcome
Please report upgrade issues, regressions, or deployment notes you run into so we can keep improving the 3.7 line.
## LinkedIn
Elsa Workflows 3.7.1 is now available as a stable patch release across Elsa Core, Elsa Studio, and Elsa Extensions.
This release focuses on operational reliability for production deployments:
- Azure Service Bus-backed clustered hosts can now use stable application instance names, reducing restart-driven entity buildup.
- Long configured instance names are shortened deterministically so deployment-provided names can remain stable while fitting Azure Service Bus limits.
- Quartz-backed scheduling now verifies the durable workflow job before scheduling triggers.
- Studio and Extensions packages are aligned with the Elsa 3.7.1 package set.
No breaking changes are expected. If you run Elsa in a clustered Azure Service Bus setup, review the upgrade notes around `ApplicationInstanceOptions.InstanceName` and `ApplicationInstanceOptions.InstanceNameEnvironmentVariable`.
Release notes: https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.1
## X single-post option
Elsa Workflows 3.7.1 is now available across Core, Studio, and Extensions.
This stable patch improves Azure Service Bus clustered hosting reliability, reduces entity-name pressure, fixes Quartz durable trigger scheduling, and aligns Studio/Extensions packages with 3.7.1.
https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.1
## X thread option
1/ Elsa Workflows 3.7.1 is now available across Core, Studio, and Extensions.
This stable patch focuses on production reliability for clustered hosting, Azure Service Bus deployments, Quartz scheduling, and package alignment.
https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.1
2/ Core adds stable application instance names via `ApplicationInstanceOptions.InstanceName` and `InstanceNameEnvironmentVariable`, helping Azure Service Bus-backed clustered hosts reuse subscriptions and queues across restarts.
3/ Extensions reduces Azure Service Bus entity-name pressure by shortening MassTransit endpoint suffixes, and fixes Quartz-backed scheduling by ensuring the durable workflow job exists before triggers are scheduled.
4/ Studio now consumes the published `Elsa.Api.Client` 3.7.1 package, while Extensions aligns with the Elsa Core and Studio 3.7.1 package set.
## Links
- Core: https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.1
- Studio: https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.1
- Extensions: https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.7.1
- NuGet package search: https://www.nuget.org/packages?q=elsa

View file

@ -0,0 +1,395 @@
=== DIRECTORY TREE (max depth 3, source files only) ===
.DS_Store
.agents
.agents/skills
.agents/skills/elsa-release-announcements
.agents/skills/elsa-release-announcements/SKILL.md
.agents/skills/elsa-release-announcements/agents
.agents/skills/elsa-release-announcements/scripts
.claude
.claude/worktrees
.config
.config/dotnet-tools.json
.editorconfig
.github
.github/ISSUE_TEMPLATE
.github/ISSUE_TEMPLATE/bug_report.md
.github/ISSUE_TEMPLATE/documentation.md
.github/ISSUE_TEMPLATE/feature_request.md
.github/ISSUE_TEMPLATE/improvement.md
.github/ISSUE_TEMPLATE/performance.md
.github/ISSUE_TEMPLATE/task.md
.github/agents
.github/agents/copilot-instructions.md
.github/agents/release-notes.agent.md
.github/agents/speckit.analyze.agent.md
.github/agents/speckit.checklist.agent.md
.github/agents/speckit.clarify.agent.md
.github/agents/speckit.constitution.agent.md
.github/agents/speckit.implement.agent.md
.github/agents/speckit.plan.agent.md
.github/agents/speckit.specify.agent.md
.github/agents/speckit.tasks.agent.md
.github/agents/speckit.taskstoissues.agent.md
.github/copilot-instructions.md
.github/copilot-release-notes-playbook.md
.github/dependabot.yml
.github/prompts
.github/prompts/speckit.analyze.prompt.md
.github/prompts/speckit.checklist.prompt.md
.github/prompts/speckit.clarify.prompt.md
.github/prompts/speckit.constitution.prompt.md
.github/prompts/speckit.implement.prompt.md
.github/prompts/speckit.plan.prompt.md
.github/prompts/speckit.specify.prompt.md
.github/prompts/speckit.tasks.prompt.md
.github/prompts/speckit.taskstoissues.prompt.md
.github/pull_request_template.md
.github/workflows
.github/workflows/bounty.yml
.github/workflows/claude-code-review.yml
.github/workflows/claude.yml
.github/workflows/copilot-setup-steps.yml
.github/workflows/docker-ca.yml
.github/workflows/packages.yml
.github/workflows/pr.yml
.github/workflows/stale.yml
.gitignore
.idea
.idea/.idea.Elsa
.idea/.idea.Elsa/.idea
.idea/.idea.Elsa/.idea/.gitignore
.idea/.idea.Elsa/.idea/.name
.idea/.idea.Elsa/.idea/copilotDiffState.xml
.idea/.idea.Elsa/.idea/dataSources
.idea/.idea.Elsa/.idea/dataSources.local.xml
.idea/.idea.Elsa/.idea/dataSources.xml
.idea/.idea.Elsa/.idea/db-forest-config.xml
.idea/.idea.Elsa/.idea/encodings.xml
.idea/.idea.Elsa/.idea/indexLayout.xml
.idea/.idea.Elsa/.idea/projectSettingsUpdater.xml
.idea/.idea.Elsa/.idea/vcs.xml
.idea/.idea.Elsa/.idea/workspace.xml
.nuke
.nuke/build.schema.json
.nuke/parameters.json
.specify
.specify/memory
.specify/memory/constitution.md
.specify/scripts
.specify/scripts/bash
.specify/scripts/bash/check-prerequisites.sh
.specify/scripts/bash/common.sh
.specify/scripts/bash/create-new-feature.sh
.specify/scripts/bash/setup-plan.sh
.specify/scripts/bash/update-agent-context.sh
.specify/templates
.specify/templates/agent-file-template.md
.specify/templates/checklist-template.md
.specify/templates/constitution-template.md
.specify/templates/plan-template.md
.specify/templates/spec-template.md
.specify/templates/tasks-template.md
.vscode
.vscode/settings.json
CONTRIBUTING.md
Directory.Build.props
Directory.Build.targets
Directory.Packages.props
Elsa.sln
Elsa.sln.DotSettings
Elsa.sln.DotSettings.user
LICENSE
NuGet.Config
README.md
announcements
announcements/discord-3.6.3.md
announcements/discord-3.7.1.md
announcements/elsa-3.6.3-announcement-pack.md
announcements/elsa-3.7.1-announcement-pack.md
artifacts
artifacts/.DS_Store
artifacts/package-manifest-check
artifacts/package-manifest-check/Elsa.Caching.1.0.0.nupkg
artifacts/package-manifest-check/Elsa.Caching.1.0.0.snupkg
artifacts/tls-smoke
artifacts/tls-smoke/TlsSmoke
artifacts/tls-smoke/TlsSmoke.deps.json
artifacts/tls-smoke/TlsSmoke.dll
artifacts/tls-smoke/TlsSmoke.pdb
artifacts/tls-smoke/TlsSmoke.runtimeconfig.json
artifacts/tls-smoke/TlsSmoke.xml
build.cmd
build.ps1
build.sh
design
design/.DS_Store
design/artwork
design/artwork/android-elsa-portrait.png
design/artwork/creative-elsa-animation.gif
design/artwork/elsa-3.1-art.png
design/artwork/elsa-logo-art.png
design/artwork/elsa-logo-art.psd
design/artwork/elsa-v3-avatar.png
design/artwork/elsa-waves.mp4
design/artwork/glass.ai
design/custom
design/custom/1x
design/custom/1x/Artboard 1.png
design/custom/2x
design/custom/2x/Artboard 1@2x.png
design/custom/2x/elsa-logo.png
design/custom/icon.ai
design/custom/icon.png
design/custom/icon.psd
design/elsa-workflows-workflow-engine-for-dotnet.png
design/github-social-preview-banner-for-elsa.psd
design/icon
design/icon/dark
design/icon/dark/android-icon-144x144.png
design/icon/dark/android-icon-192x192.png
design/icon/dark/android-icon-36x36.png
design/icon/dark/android-icon-48x48.png
design/icon/dark/android-icon-72x72.png
design/icon/dark/android-icon-96x96.png
design/icon/dark/apple-icon-114x114.png
design/icon/dark/apple-icon-120x120.png
design/icon/dark/apple-icon-144x144.png
design/icon/dark/apple-icon-152x152.png
design/icon/dark/apple-icon-180x180.png
design/icon/dark/apple-icon-57x57.png
design/icon/dark/apple-icon-60x60.png
design/icon/dark/apple-icon-72x72.png
design/icon/dark/apple-icon-76x76.png
design/icon/dark/apple-icon-precomposed.png
design/icon/dark/apple-icon.png
design/icon/dark/browserconfig.xml
design/icon/dark/favicon-16x16.png
design/icon/dark/favicon-32x32.png
design/icon/dark/favicon-96x96.png
design/icon/dark/favicon.ico
design/icon/dark/manifest.json
design/icon/dark/ms-icon-144x144.png
design/icon/dark/ms-icon-150x150.png
design/icon/dark/ms-icon-310x310.png
design/icon/dark/ms-icon-70x70.png
design/icon/light
design/icon/light/android-icon-144x144.png
design/icon/light/android-icon-192x192.png
design/icon/light/android-icon-36x36.png
design/icon/light/android-icon-48x48.png
design/icon/light/android-icon-72x72.png
design/icon/light/android-icon-96x96.png
design/icon/light/apple-icon-114x114.png
design/icon/light/apple-icon-120x120.png
design/icon/light/apple-icon-144x144.png
design/icon/light/apple-icon-152x152.png
design/icon/light/apple-icon-180x180.png
design/icon/light/apple-icon-57x57.png
design/icon/light/apple-icon-60x60.png
design/icon/light/apple-icon-72x72.png
design/icon/light/apple-icon-76x76.png
design/icon/light/apple-icon-precomposed.png
design/icon/light/apple-icon.png
design/icon/light/browserconfig.xml
design/icon/light/favicon-16x16.png
design/icon/light/favicon-32x32.png
design/icon/light/favicon-96x96.png
design/icon/light/favicon.ico
design/icon/light/manifest.json
design/icon/light/ms-icon-144x144.png
design/icon/light/ms-icon-150x150.png
=== STACK DETECTION (manifest files) ===
--- Elsa.sln ---

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.34003.232
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{61017E64-6D00-49CB-9E81-5002DC8F7D5F}"
ProjectSection(SolutionItems) = preProject
src\Directory.Build.props = src\Directory.Build.props
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{C6658DE0-2B2F-47F0-BB61-2CA66D435C09}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution", "solution", "{7D21EB5E-D6B4-48A5-A9BC-A26387A65F0B}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
Directory.Build.props = Directory.Build.props
Directory.Packages.props = Directory.Packages.props
icon.png = icon.png
NuGet.Config = NuGet.Config
README.md = README.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{90031D64-CA0F-46D0-9AF4-8DC023A5FFCD}"
ProjectSection(SolutionItems) = preProject
test\Directory.Build.props = test\Directory.Build.props
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.Api", "src\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj", "{C9539BD8-D2AE-4A8D-8281-71A05B3FBF31}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.Core", "src\modules\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj", "{169E2C9B-6687-427F-A278-30BF849BFEDC}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.Management", "src\modules\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj", "{2DD5D66B-85E9-4AF9-911C-C9F963234159}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.Runtime", "src\modules\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj", "{8A050229-DB79-4E0B-9AFF-7565E87F2954}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Features", "src\common\Elsa.Features\Elsa.Features.csproj", "{C237BA1A-3A7D-4AB2-BE09-2696F3C082A4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Expressions", "src\modules\Elsa.Expressions\Elsa.Expressions.csproj", "{80529478-A383-4FEA-B744-C71264969E9A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Api.Common", "src\common\Elsa.Api.Common\Elsa.Api.Common.csproj", "{39CD855E-83B1-4A96-93F7-01608211EBE3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Identity", "src\modules\Elsa.Identity\Elsa.Identity.csproj", "{30B49E42-AE61-4F7C-981F-923F07BB3986}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Common", "src\modules\Elsa.Common\Elsa.Common.csproj", "{D229105F-6879-4452-9189-75DE060C0F4C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{986E5482-0482-448C-B9E4-EC67A9474B85}"
ProjectSection(SolutionItems) = preProject
docker\.dockerignore = docker\.dockerignore
docker\docker-compose.yml = docker\docker-compose.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}"
ProjectSection(SolutionItems) = preProject
test\unit\Directory.Build.props = test\unit\Directory.Build.props
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}"
ProjectSection(SolutionItems) = preProject
test\integration\Directory.Build.props = test\integration\Directory.Build.props
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}"
ProjectSection(SolutionItems) = preProject
test\component\Directory.Build.props = test\component\Directory.Build.props
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Workflows.IntegrationTests", "test\integration\Elsa.Workflows.IntegrationTests\Elsa.Workflows.IntegrationTests.csproj", "{E9652738-2B3D-4357-B84B-54F0EA161382}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "clients", "clients", "{89608AA5-5ADE-4832-AC7B-871C4AE64210}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Api.Client", "src\clients\Elsa.Api.Client\Elsa.Api.Client.csproj", "{1FCB2200-28B8-4703-8E89-73241AAED047}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "expressions", "expressions", "{6EF07978-A6D2-40EB-891D-7D70C5F37E76}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "security", "security", "{5948B0A5-7873-4DBB-BA03-EB283D6EA91B}"
[TRUNCATED] Showing first 80 of 1336 lines.
=== ENTRY POINTS ===
No common entry points found. Check 'main' or 'scripts.start' in manifest files above.
=== LINTING AND FORMATTING CONFIG ===
Found: .editorconfig
=== ENVIRONMENT VARIABLE TEMPLATES ===
No .env.example or .env.template found. Identify required environment variables by searching the code and config for environment variable reads.
=== TODO / FIXME / HACK (production code only, test dirs excluded) ===
src/common/Elsa.Mediator/Middleware/Notification/Components/NotificationLoggingMiddleware.cs:20: // TODO: Log notification.
src/modules/Elsa.Alterations/Services/DefaultAlterationRunner.cs:91: // TODO: Importing back into the workflow runtime makes sense, but this also causes another SAVE ction of the workflow instance in the DB, which also happens in the previous step during the commit action.
src/modules/Elsa.Expressions.Liquid/Services/LiquidTemplateManager.cs:61: // TODO: add signal based cache invalidation.
src/modules/Elsa.Http/Parsers/TextHtmlHttpContentParser.cs:8: // TODO: found a library to use a Html Content Parser and use a complexe object Type, until this, this class allow to accept request send using text/html content-type
src/modules/Elsa.Http/DownloadableContentHandlers/UrlDownloadableContentHandler.cs:35: // TODO: Uncomment the next two lines if we implement file caching for this handler.
src/modules/Elsa.Http/Activities/WriteFileHttpResponse.cs:153: // TODO: Delete the cached file after the workflow completes.
src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs:704: // TODO: Check if we should not use the target subStatus here instead.
src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs:150: // TODO: Disable filtering for now until we redesign log sanitization.
src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs:73: // TODO: This should be configurable, because this won't work for e.g. file streams and other non-serializable types.
src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs:79: // TODO: This is a temporary solution. We need to find a better way to handle this.
src/modules/Elsa.Workflows.Management/Mappers/WorkflowDefinitionMapper.cs:96: // TODO: Remove this in the future when users have migrated workflows to use the new UsableAsActivity options property.
src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs:105: // TODO: The following code handles backward compatibility with variable definitions referencing older .NET type namespaces.
=== GIT RECENT COMMITS (last 20) ===
c7a97c641 Shorten configured application instance names
0028604ca Merge pull request #7742 from elsa-workflows/sfmskywalker-port-asb-stable-instance-371
1aef08381 fix: address greptile stable instance feedback
fc48af062 fix: add stable application instance name option
d8d7eae20 Merge branch 'release/3.6.2' into release/3.7.0
a76e7a666 chore(packages): add Snappier
b88af1e02 fix: handle arrays of generic types in GetFriendlyTypeName (issue - #7369) (#7400)
8ee0b43b3 Add Claude Code GitHub Workflow (#7411)
ad90e81af Enhance logging configuration, update HttpFeature dependencies, and add new identity persistence options
e7eaa4b1f Integrate `Elsa.Workflows.Runtime.Distributed` into `Elsa.ModularServer.Web` and configure shell features for enhanced distributed runtime support.
3d8d3b7de Merge remote-tracking branch 'origin/release/3.6.1'
9f8423932 Handle null activities in `RunWorkflowResultAssertions`
632d1a383 Refactor shells setup and clean up NuGet package source mapping.
29d8a64a9 Update package versions: CShells to 0.0.14 and Nuplane to 0.0.1 in props file.
6ff391745 Remove unused `Elsa.Workflows.Api.Contracts` import from `WorkflowsApiFeature`.
3decb1268 feat: extend shells integration and modular server support (#7399)
8d7d1a986 Fix BulkDispatchWorkflows sharing input dictionary across dispatches (#7284)
a459904e1 refactor | Added a new property to expose the ability to disable the sorting (#7360)
793ae15bf Fix tenant scope for interrupted workflow restarts (#7389)
3a3766861 fix: correct workflow summary pagination on release 3.6.1 (#7392)
=== HIGH-CHURN FILES (last 90 days, top 20) ===
4 Directory.Packages.props
3 src/modules/Elsa.Hosting.Management/Services/ConfiguredApplicationInstanceNameProvider.cs
3 test/unit/Elsa.Hosting.Management.UnitTests/Services/ConfiguredApplicationInstanceNameProviderTests.cs
3 src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj
3 src/apps/Elsa.ModularServer.Web/Program.cs
2 src/modules/Elsa.Hosting.Management/Options/ApplicationInstanceOptions.cs
2 Elsa.sln
2 src/modules/Elsa.Common/Elsa.Common.csproj
2 src/apps/Elsa.ModularServer.Web/appsettings.Development.json
2 src/apps/Elsa.ModularServer.Web/appsettings.json
2 NuGet.Config
2 src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs
2 README.md
1 src/modules/Elsa.Hosting.Management/AssemblyInfo.cs
1 src/modules/Elsa.Hosting.Management/Features/ClusteringFeature.cs
1 test/unit/Elsa.Hosting.Management.UnitTests/Elsa.Hosting.Management.UnitTests.csproj
1 src/modules/Elsa.Expressions/Extensions/TypeExtensions.cs
1 test/unit/Elsa.Expressions.UnitTests/Extensions/TypeExtensionsTests.cs
1 .github/workflows/claude-code-review.yml
1 .github/workflows/claude.yml
=== MONOREPO SIGNALS ===
No monorepo signals detected.
=== CODE METRICS ===
Total files scanned: 3908
Total lines of code: 217582
Files by language:
C#: 3265
Other: 631
Java: 6
JavaScript: 4
Python: 2
Top 10 largest files:
design/artwork/elsa-logo-art.psd: 13979.6KB
src/apps/Elsa.ModularServer.Web/elsa_logs.db: 9796.0KB
design/artwork/elsa-v3-avatar.png: 5827.9KB
design/artwork/creative-elsa-animation.gif: 3461.2KB
design/artwork/elsa-waves.mp4: 3178.4KB
design/logo/Esla_Source.ai: 2043.9KB
src/apps/Elsa.ModularServer.Web/console-log-streaming-vanilla-sample.db: 1532.0KB
src/apps/Elsa.ModularServer.Web/elsa_workflows.db: 1416.0KB
design/logo/Elsa.ai: 914.8KB
design/logo/Esla-13.png: 429.9KB
=== CI/CD PIPELINES ===
CI/CD: GitHub Actions
=== CONTAINERS & ORCHESTRATION ===
No containerization configs detected.
=== SECURITY & COMPLIANCE ===
No security configs detected.
=== PERFORMANCE & TESTING ===
No performance testing configs detected.
=== SCAN COMPLETE ===

View file

@ -0,0 +1,304 @@
# Reply to Jordi - Delay bookmarks and manual recovery
Good morning Jordi,
Thanks for the detailed explanation.
Before we go too deep into the recovery options, can you please confirm the exact Elsa version you are running? You can usually check this with:
```http
GET /elsa/api/package/version
```
Alternatively, please send us the Elsa NuGet package versions used by the application.
## Short answer
The endpoint you are currently calling is not the right one for a Delay activity bookmark:
```http
POST /elsa/api/events/{eventName}/trigger
```
That endpoint is for Elsa `Event` activities. It publishes an event stimulus. A `200 OK` response means that the event request was accepted, but it does not mean that a Delay bookmark was matched or resumed.
For a `Delay` activity, Elsa stores a bookmark named:
```text
Elsa.Delay
```
The bookmark is matched by bookmark id, workflow instance id, activity instance id, and/or the generated bookmark hash. It is not resumed by using the designer activity name such as `Delay8` or `Delay.8`.
Normally, Quartz should resume this Delay automatically. Quartz schedules a task that resumes the workflow instance using the stored `WorkflowInstanceId` and `BookmarkId`.
## Recommended first step: reproduce or inspect the stuck case
The best next step is to determine why Quartz did not resume the Delay bookmark. If we can reproduce the issue, we can fix the root cause instead of only providing a manual workaround.
For one stuck instance, please capture:
- Elsa version.
- Workflow instance id.
- Bookmark id.
- Activity instance id of the Delay activity.
- The `Elsa.Bookmarks` row for that workflow instance.
- The matching Quartz job/trigger row.
- Logs around the expected Delay resume time.
- Whether the workflow instance is still `Running`, or whether it was already cancelled/faulted/finished.
For example, for a stuck Delay bookmark, the relevant values would look like:
```text
BookmarkId: <bookmark-id>
WorkflowInstanceId: <workflow-instance-id>
ActivityInstanceId: <delay-activity-instance-id>
Bookmark name: Elsa.Delay
```
## How to manually resume a stuck Delay safely
I would not recommend directly updating workflow instance state in the database. Resuming a workflow is not just a database state change. The Elsa runtime has to:
- Load the workflow instance.
- Resume the correct bookmarked activity.
- Remove the consumed bookmark.
- Persist the new workflow state.
- Save variables.
- Write execution logs.
- Emit the notifications that scheduling cleanup depends on.
The safest manual recovery option is to expose a temporary authenticated admin endpoint in your Elsa server that calls Elsa's runtime service `IWorkflowResumer`.
Example:
```csharp
using Elsa.Workflows.Runtime;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("admin/elsa/bookmarks")]
[Authorize] // Restrict this further in production.
public class ElsaBookmarkAdminController : ControllerBase
{
[HttpPost("{bookmarkId}/resume")]
public async Task<IActionResult> Resume(
string bookmarkId,
[FromBody] ResumeBookmarkAdminRequest request,
[FromServices] IWorkflowResumer workflowResumer,
CancellationToken cancellationToken)
{
var responses = await workflowResumer.ResumeAsync(new ResumeBookmarkRequest
{
BookmarkId = bookmarkId,
WorkflowInstanceId = request.WorkflowInstanceId,
ActivityInstanceId = request.ActivityInstanceId,
Input = request.Input
}, cancellationToken);
var responseList = responses.ToList();
if (responseList.Count == 0)
return NotFound(new
{
message = "No matching bookmark was found or resumed.",
bookmarkId,
request.WorkflowInstanceId,
request.ActivityInstanceId
});
return Ok(new
{
resumed = responseList.Count,
responses = responseList
});
}
}
public class ResumeBookmarkAdminRequest
{
public string WorkflowInstanceId { get; set; } = default!;
public string? ActivityInstanceId { get; set; }
public IDictionary<string, object>? Input { get; set; }
}
```
Example request:
```http
POST /admin/elsa/bookmarks/2cdf1537300ebbde/resume
Content-Type: application/json
{
"workflowInstanceId": "2f95829eb7abe851",
"activityInstanceId": "2aacb800a50923f4"
}
```
If the bookmark is still valid and the workflow instance is still running, this should resume the Delay through the Elsa runtime.
## Why not use `/events/{eventName}/trigger`?
This endpoint resumes workflows waiting on an Elsa `Event` activity. Internally, it publishes an event stimulus.
A Delay bookmark uses the scheduling stimulus name:
```text
Elsa.Delay
```
and its payload contains the `resumeAt` timestamp. So calling:
```http
POST /elsa/api/events/Delay8/trigger
```
or:
```http
POST /elsa/api/events/Delay.8/trigger
```
will not match a Delay bookmark.
## Built-in bookmark resume endpoint
Elsa also has a built-in bookmark resume endpoint:
```http
POST /elsa/api/bookmarks/resume?t={token}
```
However, this endpoint expects a signed bookmark token, not just the bookmark id from the database. It is useful when a workflow generated a bookmark resume URL/token, but it is usually not the practical recovery path for an internal Delay activity that was scheduled by Quartz.
For this scenario, the small admin endpoint above is more appropriate.
## If you still want a database-level fallback
If you absolutely need a database-level operation, I would avoid editing these directly:
- `Elsa.WorkflowInstances`
- `Elsa.Bookmarks`
- Quartz trigger state, unless we are only cleaning up after a cancelled/deleted instance
Changing those manually can leave the workflow state, runtime bookmarks, variables, logs, and scheduler state inconsistent.
The least invasive database-style nudge is to insert a bookmark queue item and let Elsa's bookmark queue processor resume it. This still requires the Elsa application to be running and processing bookmark queue items.
Important: the exact table and column names can vary by Elsa version and provider, so please confirm your version before using SQL like this.
Illustrative SQL Server example:
```sql
INSERT INTO Elsa.BookmarkQueueItems
(
Id,
WorkflowInstanceId,
BookmarkId,
ActivityInstanceId,
ActivityTypeName,
CreatedAt,
SerializedOptions,
TenantId
)
VALUES
(
LOWER(REPLACE(CONVERT(varchar(36), NEWID()), '-', '')),
'2f95829eb7abe851',
'2cdf1537300ebbde',
'2aacb800a50923f4',
'Elsa.Delay',
SYSUTCDATETIME(),
NULL,
NULL
);
```
Notes:
- This does not directly resume the workflow.
- It only creates work for Elsa's bookmark queue processor.
- If the queue processor is not running, nothing will happen.
- A direct DB insert bypasses Elsa's in-process queue signaler, so you may need to wait for the recurring queue worker or restart the Elsa application.
- This is still less preferable than calling an application endpoint that invokes `IWorkflowResumer` or `IBookmarkQueue`.
If you want a slightly safer variant of this queue approach, expose an admin endpoint that enqueues the bookmark through Elsa instead of inserting into the database manually:
```csharp
using Elsa.Workflows.Runtime;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("admin/elsa/bookmark-queue")]
[Authorize]
public class ElsaBookmarkQueueAdminController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Enqueue(
[FromBody] EnqueueBookmarkRequest request,
[FromServices] IBookmarkQueue bookmarkQueue,
CancellationToken cancellationToken)
{
await bookmarkQueue.EnqueueAsync(new NewBookmarkQueueItem
{
WorkflowInstanceId = request.WorkflowInstanceId,
BookmarkId = request.BookmarkId,
ActivityInstanceId = request.ActivityInstanceId,
ActivityTypeName = "Elsa.Delay"
}, cancellationToken);
return Accepted();
}
}
public class EnqueueBookmarkRequest
{
public string WorkflowInstanceId { get; set; } = default!;
public string BookmarkId { get; set; } = default!;
public string? ActivityInstanceId { get; set; }
}
```
That endpoint has the advantage that it uses Elsa's queue abstraction and triggers the queue processor.
## About cancelled instances and Quartz rows
If a workflow instance is cancelled, it should not resume anymore. Any remaining Delay bookmark or Quartz job for that instance is stale data.
In the normal flow, when bookmarks are deleted through Elsa, the scheduling layer is notified and the corresponding scheduled jobs are unscheduled. However, whether cancellation also removes the persisted bookmark and Quartz job in your exact setup depends on the Elsa version and the cancellation path being used.
That is why I would like to verify your version before giving a definitive answer here.
If cancelled instances still leave `Elsa.Delay` bookmarks or Quartz jobs behind, please send one example and we can check whether:
- this is already fixed in a newer Elsa version;
- the cancellation path is bypassing the bookmark cleanup flow;
- the Quartz job is stale and can be cleaned safely;
- or we need to add a cleanup/fix.
## About the 3-second Delay workaround
The 3-second Delay before the child workflow finishes sounds like a workaround for a race/timing issue in how values are propagated back to the parent workflow.
Variable propagation from child to parent should not depend on sleeping. Once I know your Elsa version and can see a minimal reproduction, I can check whether this is a known issue, a fixed issue, or whether there is a better pattern for returning values from the child workflow to the parent.
## Suggested recovery order
For the current stuck instances, I suggest this order:
1. Confirm the Elsa version with `GET /elsa/api/package/version`.
2. Pick one stuck workflow instance as an example.
3. Verify the workflow instance is still `Running`.
4. Verify the `Elsa.Bookmarks` row exists and has `Name = 'Elsa.Delay'`.
5. Verify the Quartz job/trigger exists for the same bookmark id.
6. Check logs around the expected resume time.
7. Try manual resume through the temporary `IWorkflowResumer` admin endpoint.
8. If that works, we know the workflow itself can continue and the problem is likely in scheduling/Quartz execution.
9. If it does not work, capture the response/logs because the bookmark may no longer match the workflow state, or the instance may no longer be resumable.
10. Only use database queue insertion as a last resort, and only after confirming the exact Elsa version/schema.
Best regards,
Sipke

View file

@ -0,0 +1,181 @@
# Draft response to Pau
Hi Pau,
Thanks for the detailed information and screenshots. The confirmed Elsa version `3.6.1` is very helpful.
## TL;DR
- We started a focused `3.6.1` reproduction on our side.
- So far, seeing only `Elsa.Delay` in the active `Bookmarks` table after the parent has passed `Dispatch Workflow` looks expected.
- The current active blocker appears to be the Delay bookmark, so the next checks are Delay payload, Quartz trigger/job, logs, and workflow instance state.
- Please send the smallest Studio export that reproduces the issue.
- If possible, configure that reproduction with SQLite and include the SQLite database file captured after the workflow gets stuck.
We have also started a focused `3.6.1` reproduction on our side, using a small parent/child workflow with `Dispatch Workflow`, `Wait for Completion`, and a subsequent `Delay`. So far, the active bookmark table containing only `Elsa.Delay` after the parent continues past `Dispatch Workflow` looks expected. Once the dispatch bookmark has resumed the parent, it should be consumed and disappear from the active `Bookmarks` table.
At the same time, from what you sent, I do not yet see enough to reproduce your exact scenario end to end. The fastest route for us to investigate this properly, and to fix the full issue if it is a bug, would still be for you to send us a minimal reproduction exported from Elsa Studio: the smallest set of workflow definitions that still reproduces the issue.
Ideally this would include:
- The parent workflow exported from Studio.
- The child workflow exported from Studio.
- Only the minimum activities required to reproduce the problem.
- The exact configuration of `Dispatch Workflow` / `Wait for Completion`.
- The final `Delay` activity.
- Any variables/outputs involved in passing data from the child workflow back to the parent.
- The approximate reproduction rate, for example “1 in 10 executions” or “always when run under load”.
- If possible, a reproduction configured against SQLite, with the SQLite database file included after the issue occurs. That would let us inspect the workflow definitions, workflow instances, bookmarks, queue items, execution logs, and persisted state exactly as they are after the failure.
Without that, we can investigate the general mechanism and likely failure modes, but we may still miss a detail that only exists in your actual workflow structure, variable/output configuration, persistence setup, or Quartz configuration.
## What I think is happening
In Elsa `3.6.1`, `Dispatch Workflow` with `Wait for Completion` works roughly like this:
1. The parent dispatches the child workflow.
2. The parent creates a bookmark named `Elsa.DispatchWorkflow`.
3. When the child workflow finishes, Elsa enqueues a bookmark queue item for the parent.
4. The bookmark queue processor resumes the parent using the `Elsa.DispatchWorkflow` bookmark.
5. The parent continues execution.
6. If the parent later reaches a `Delay`, Elsa creates a new bookmark named `Elsa.Delay`.
Because of that, if the parent has already continued past `Dispatch Workflow` and then stopped at the `Delay`, it is expected that the active bookmark table contains only the `Elsa.Delay` bookmark. In that state, the `Elsa.DispatchWorkflow` bookmark should normally no longer be present in the active `Bookmarks` table because it has already been consumed.
So the database result showing only `Elsa.Delay` for the parent instance is not necessarily wrong. It can mean that `Wait for Completion` did resume correctly, and the current active blocker is the `Delay`.
The part I would like to verify is the workflow state view that still shows `Elsa.DispatchWorkflow`. If that is the latest persisted state for the same parent instance, then we may have an inconsistency between the serialized workflow state and the active bookmark index. If it is an older state snapshot, a cached UI view, or a different instance, then the database result may be the more accurate indicator of the current active bookmark.
## How to determine if the workflow is actually stuck
For a given parent workflow instance, please check these together:
1. `WorkflowInstances`: status/sub-status.
2. `Bookmarks`: active bookmarks for that workflow instance.
3. `BookmarkQueueItems`: pending resume requests for that workflow instance.
4. `WorkflowExecutionLogRecords`: last execution messages for that workflow instance.
5. `ActivityExecutionRecords`: latest activity records, especially `Elsa.DispatchWorkflow` and `Elsa.Delay`.
6. Quartz job/trigger tables: scheduled job for the active `Elsa.Delay` bookmark ID.
In the state JSON from your screenshot, `Status = 0` means `Running` and `SubStatus = 2` means `Suspended`. That is normal for a workflow waiting on a bookmark. A workflow is “stuck” only if it is suspended on a bookmark that should have been resumed already, for example a Delay whose `ResumeAt` is in the past and whose Quartz trigger has not fired or cannot resume the instance.
## What database tables to inspect
For one affected parent workflow instance ID, please inspect:
```sql
-- Active bookmarks for the parent instance.
select *
from [Elsa].[Bookmarks]
where [WorkflowInstanceId] = '<parent-workflow-instance-id>'
order by [CreatedAt] desc;
-- Pending bookmark queue items for the parent instance.
select *
from [Elsa].[BookmarkQueueItems]
where [WorkflowInstanceId] = '<parent-workflow-instance-id>'
order by [CreatedAt] desc;
-- Activity execution records around DispatchWorkflow and Delay.
select *
from [Elsa].[ActivityExecutionRecords]
where [WorkflowInstanceId] = '<parent-workflow-instance-id>'
order by [StartedAt] desc;
-- Workflow execution log.
select *
from [Elsa].[WorkflowExecutionLogRecords]
where [WorkflowInstanceId] = '<parent-workflow-instance-id>'
order by [Timestamp] desc, [Sequence] desc;
```
Also inspect the workflow instance row itself and confirm that the workflow state shown in the UI belongs to the same parent instance ID that you used in the bookmark query.
For Quartz, check whether there is a job/trigger for the active `Elsa.Delay` bookmark ID. The Delay bookmark ID should be the scheduler task/job key Elsa uses for that Delay.
## Logs to enable
For a short reproduction window, I suggest:
```json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Elsa": "Debug",
"Elsa.Workflows.Runtime": "Debug",
"Elsa.Scheduling": "Debug",
"Quartz": "Debug",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}
```
If the logs are too noisy, keep `Elsa.Workflows.Runtime`, `Elsa.Scheduling`, and `Quartz` at `Debug`, and keep the rest at `Information`.
The most useful log points are:
- Creation of the `Elsa.DispatchWorkflow` bookmark.
- Enqueueing of the bookmark queue item when the child workflow finishes.
- Processing of `BookmarkQueueItems`.
- Successful resume of the parent workflow.
- Creation of the `Elsa.Delay` bookmark.
- Scheduling of the Delay bookmark in Quartz.
- Quartz firing the Delay job.
- Resume attempt for the Delay bookmark.
## About a possible race condition
Yes, this area is timing-sensitive by nature: the child can finish before or around the time the parent has committed its `Elsa.DispatchWorkflow` bookmark.
However, Elsa `3.6.1` has a bookmark queue mechanism intended to handle exactly this kind of race. When the child finishes, Elsa does not require the parent bookmark to already exist at that exact millisecond. It enqueues a bookmark queue item, and the queue processor retries matching it against the bookmark store.
That means the first thing I would check is not only the `Bookmarks` table, but also `BookmarkQueueItems`. If old `Elsa.DispatchWorkflow` queue items remain there, the parent resume request may not be matching the bookmark. If there are no old dispatch queue items and the parent is currently waiting on `Elsa.Delay`, then the dispatch/wait part probably completed and the issue is more likely in Delay/Quartz resume.
## Could Delay be related?
Yes, but based on the screenshots it may be the current active blocker rather than the cause of the dispatch bookmark issue.
If the parent reaches `Delay`, creates an `Elsa.Delay` bookmark, and then never resumes, the key questions are:
- Does the `Elsa.Delay` bookmark payload contain a `ResumeAt` time that is already in the past?
- Does Quartz have a trigger for that exact bookmark ID?
- Did Quartz fire the trigger?
- Did Elsa receive/process the resume request?
- Did the resume fail because the instance was no longer `Running`, was faulted, or had a bookmark mismatch?
## What you can do to minimize impact
Until we have a reproduction, the practical mitigations are:
1. Monitor `BookmarkQueueItems` for old rows. Old rows can indicate resume requests that never matched a bookmark.
2. Monitor `Bookmarks` for `Elsa.Delay` rows whose resume time is in the past.
3. Monitor Quartz triggers for Delay jobs that should already have fired.
4. Add temporary diagnostic logging around Elsa runtime, scheduling, and Quartz.
5. Avoid relying on the 3-second Delay as a synchronization mechanism if possible; if output propagation needs a delay to work reliably, that is a separate symptom we should include in the reproduction.
## What I need from you next
Please send a minimal Studio export that reproduces the issue. If possible, include:
1. Parent workflow.
2. Child workflow.
3. Any required variables and outputs.
4. Steps to run it.
5. Expected behavior.
6. Actual behavior.
7. Preferably, a SQLite-backed reproduction database captured after the workflow gets stuck.
8. One affected parent workflow instance ID with matching rows from:
- `Bookmarks`
- `BookmarkQueueItems`
- `WorkflowInstances`
- `ActivityExecutionRecords`
- `WorkflowExecutionLogRecords`
- Quartz trigger/job tables
Once we can reproduce it locally, we can determine whether this is a configuration issue, a stale Quartz schedule, a bookmark queue issue, or a bug in Elsa `3.6.1`.
Best regards,
Sipke

View file

@ -80,164 +80,7 @@
"DefaultType": "reject",
"AllowDatabaseConnectionOverride": true
},
"Connections": [
{
"Id": "keycloak-idp-configuration",
"Key": "keycloak-idp",
"AdapterType": "openid-connect",
"AdapterSettingsVersion": 2,
"AdapterSettings": {
"mode": "discovery",
"discoveryUrl": "https://localhost:8443/realms/elsa/.well-known/openid-configuration",
"clientId": "elsa-studio-idp",
"clientAuthenticationMethod": "client_secret_basic",
"scopes": [
"profile",
"email"
]
},
"SecretBindings": {
"clientSecret": {
"Ownership": "External",
"ResolverType": "configuration",
"Reference": "ExternalAuthentication:Secrets:KeycloakGeneralClientSecret",
"ExpectedType": "text",
"ExpectedScope": "external-authentication"
}
},
"DisplayName": "Keycloak General",
"DisplayOrder": 10,
"IsPreferred": true,
"IsEnabled": true,
"UnlinkedPolicy": {
"Type": "create-user",
"SettingsVersion": 1,
"Settings": {
"defaultRoleIds": [
"admin"
]
}
},
"ClaimProjection": {
"AllowedClaimTypes": [
"preferred_username",
"name",
"given_name",
"family_name",
"email"
],
"RedactedClaimTypes": [],
"MaximumClaimCount": 64,
"MaximumValueLength": 1024,
"MaximumTotalBytes": 16384
},
"UpstreamLogoutMode": "UserChoice"
},
{
"Id": "keycloak-workforce-configuration",
"Key": "keycloak-workforce",
"AdapterType": "openid-connect",
"AdapterSettingsVersion": 2,
"AdapterSettings": {
"mode": "discovery",
"discoveryUrl": "https://localhost:8443/realms/elsa-workforce/.well-known/openid-configuration",
"clientId": "elsa-workforce-idp",
"clientAuthenticationMethod": "client_secret_basic",
"scopes": [
"profile",
"email"
]
},
"SecretBindings": {
"clientSecret": {
"Ownership": "External",
"ResolverType": "configuration",
"Reference": "ExternalAuthentication:Secrets:KeycloakWorkforceClientSecret",
"ExpectedType": "text",
"ExpectedScope": "external-authentication"
}
},
"DisplayName": "Keycloak Workforce",
"DisplayOrder": 20,
"IsPreferred": false,
"IsEnabled": true,
"UnlinkedPolicy": {
"Type": "create-user",
"SettingsVersion": 1,
"Settings": {
"defaultRoleIds": [
"admin"
]
}
},
"ClaimProjection": {
"AllowedClaimTypes": [
"preferred_username",
"name",
"given_name",
"family_name",
"email"
],
"RedactedClaimTypes": [],
"MaximumClaimCount": 64,
"MaximumValueLength": 1024,
"MaximumTotalBytes": 16384
},
"UpstreamLogoutMode": "UserChoice"
},
{
"Id": "keycloak-partners-configuration",
"Key": "keycloak-partners",
"AdapterType": "openid-connect",
"AdapterSettingsVersion": 2,
"AdapterSettings": {
"mode": "discovery",
"discoveryUrl": "https://localhost:8443/realms/elsa-partners/.well-known/openid-configuration",
"clientId": "elsa-partners-idp",
"clientAuthenticationMethod": "client_secret_basic",
"scopes": [
"profile",
"email"
]
},
"SecretBindings": {
"clientSecret": {
"Ownership": "External",
"ResolverType": "configuration",
"Reference": "ExternalAuthentication:Secrets:KeycloakPartnersClientSecret",
"ExpectedType": "text",
"ExpectedScope": "external-authentication"
}
},
"DisplayName": "Keycloak Partners",
"DisplayOrder": 30,
"IsPreferred": false,
"IsEnabled": true,
"UnlinkedPolicy": {
"Type": "create-user",
"SettingsVersion": 1,
"Settings": {
"defaultRoleIds": [
"admin"
]
}
},
"ClaimProjection": {
"AllowedClaimTypes": [
"preferred_username",
"name",
"given_name",
"family_name",
"email"
],
"RedactedClaimTypes": [],
"MaximumClaimCount": 64,
"MaximumValueLength": 1024,
"MaximumTotalBytes": 16384
},
"UpstreamLogoutMode": "UserChoice"
}
],
"Connections": [],
"AuthenticationClients": [
{
"ClientId": "elsa-studio-server",

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Elsa.Hosting.Management.UnitTests")]

View file

@ -72,13 +72,28 @@ public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNam
private static string ResolveConfiguredInstanceName(string value, string source, ILogger logger)
{
var instanceName = value.Trim();
var isTooLong = instanceName.Length > ConfiguredInstanceNameMaxLength;
var hasInvalidCharacters = !IsValidConfiguredInstanceName(instanceName);
if (!IsValidConfiguredInstanceName(instanceName))
throw new InvalidOperationException(
if (hasInvalidCharacters)
{
var errors = new List<string>
{
$"The configured application instance name from {source} contains invalid characters. " +
"Use only letters, numbers, periods, hyphens, or underscores, and start and end the value with a letter or number.");
"Use only letters, numbers, periods, hyphens, or underscores, and start and end the value with a letter or number."
};
if (instanceName.Length <= ConfiguredInstanceNameMaxLength)
if (isTooLong)
{
errors.Add(
$"The configured application instance name from {source} is {instanceName.Length} characters long, but it must be {ConfiguredInstanceNameMaxLength} characters or fewer. " +
$"The value is used to create per-instance transport entities such as '{instanceName}{TriggerChangeTokenSignalEndpointNameSuffix}', which must fit within Azure Service Bus's {AzureServiceBusSubscriptionNameMaxLength}-character subscription name limit.");
}
throw new InvalidOperationException(string.Join(" ", errors));
}
if (!isTooLong)
return instanceName;
var shortenedName = ShortenConfiguredInstanceName(instanceName);
@ -96,7 +111,8 @@ public class ConfiguredApplicationInstanceNameProvider : IApplicationInstanceNam
private static bool IsValidConfiguredInstanceName(string instanceName)
{
return IsAsciiLetterOrDigit(instanceName[0])
return instanceName.Length > 0
&& IsAsciiLetterOrDigit(instanceName[0])
&& IsAsciiLetterOrDigit(instanceName[^1])
&& instanceName.All(c => IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_');
}

View file

@ -0,0 +1,75 @@
using Elsa.Expressions.Helpers;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
using Elsa.Http.Webhooks.Stimuli;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Models;
using WebhooksCore;
namespace Elsa.Http.Webhooks.Activities;
public class WebhookEventReceived : Trigger<WebhookEvent>, INotification
{
/// <summary>
/// The webhook event type to be received.
/// </summary>
[Input(Description = "The webhook event type to be received.")]
public string EventType { get; set; } = null!;
/// <summary>
/// The webhook event payload type to be received.
/// </summary>
[Input(Description = "The webhook event payload type to be received.")]
public Type? PayloadType { get; set; }
/// <summary>
/// The webhook payload, if any.
/// </summary>
[Output(Description = "The webhook payload, if any.")]
public Output<object?> Payload { get; set; } = null!;
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
if (!context.IsTriggerOfWorkflow())
{
context.CreateBookmark(GetStimulus(context.ExpressionExecutionContext), OnResumeAsync);
return;
}
await ExecuteInternalAsync(context);
}
protected override object GetTriggerPayload(TriggerIndexingContext context)
{
return new WebhookEventReceivedStimulus(EventType);
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
await ExecuteInternalAsync(context);
}
private object GetStimulus(ExpressionExecutionContext context)
{
return new WebhookEventReceivedStimulus(EventType);
}
private async Task ExecuteInternalAsync(ActivityExecutionContext context)
{
var receivedWebhookEvent = context.GetWorkflowInput<WebhookEvent>();
var payload = receivedWebhookEvent.Payload;
var payloadType = PayloadType;
if (payloadType != null && payload != null)
{
var deserializedPayload = payload.ConvertTo(payloadType);
payload = deserializedPayload;
}
context.SetResult(receivedWebhookEvent);
Payload.Set(context, payload);
await context.CompleteActivityAsync();
}
}

View file

@ -0,0 +1,62 @@
using Elsa.Http.Webhooks.Activities;
using Elsa.Workflows;
using Elsa.Workflows.Models;
using WebhooksCore;
namespace Elsa.Http.Webhooks.ActivityProviders;
/// <summary>
/// An activity provider that generates activity types based on webhook sources.
/// </summary>
public class WebhookEventActivityProvider(IWebhookSourceProvider webhookSourceProvider, IActivityDescriber activityDescriber) : IActivityProvider
{
public async ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{
var sources = await webhookSourceProvider.ListAsync(cancellationToken);
var activities = (await Task.WhenAll(sources.Select(async x => await CreateActivityDescriptors(x, cancellationToken)))).SelectMany(x => x).ToList();
return activities;
}
private async Task<IEnumerable<ActivityDescriptor>> CreateActivityDescriptors(WebhookSource webhookSource, CancellationToken cancellationToken = default)
{
var descriptorTasks = webhookSource.EventTypes
.Where(x => x.ActivityBinding != null)
.Select(async eventType => await CreateActivityDescriptorAsync(webhookSource, eventType, cancellationToken))
.ToList();
return await Task.WhenAll(descriptorTasks);
}
private async Task<ActivityDescriptor> CreateActivityDescriptorAsync(WebhookSource webhookSource, WebhookSourceEventType eventType, CancellationToken cancellationToken = default)
{
var activityBinding = eventType.ActivityBinding!;
var eventTypeDescription = string.IsNullOrWhiteSpace(activityBinding.Description) ? $"Handles {eventType.EventType} webhook events" : activityBinding.Description;
var fullTypeName = activityBinding.TypeName; //webhookSource.GetWebhookActivityTypeName(eventType.EventType);
var activityDescriptor = await activityDescriber.DescribeActivityAsync(typeof(WebhookEventReceived), cancellationToken);
activityDescriptor.TypeName = fullTypeName;
activityDescriptor.Name = activityBinding.DisplayName;
activityDescriptor.DisplayName = activityBinding.DisplayName;
activityDescriptor.Category = webhookSource.Name;
activityDescriptor.Description = eventTypeDescription;
activityDescriptor.Constructor = context =>
{
var result = context.CreateActivity<WebhookEventReceived>();
var activity = result.Activity;
activity.Type = fullTypeName;
activity.EventType = eventType.EventType;
activity.PayloadType = eventType.PayloadType;
return result;
};
var eventTypeDescriptor = activityDescriptor.Inputs.First(x => x.Name == nameof(WebhookEventReceived.EventType));
var eventPayloadTypeDescriptor = activityDescriptor.Inputs.First(x => x.Name == nameof(WebhookEventReceived.PayloadType));
var payloadOutputDescriptor = activityDescriptor.Outputs.First(x => x.Name == nameof(WebhookEventReceived.Payload));
eventTypeDescriptor.IsBrowsable = false;
eventPayloadTypeDescriptor.IsBrowsable = false;
payloadOutputDescriptor.Type = eventType.PayloadType ?? typeof(object);
return activityDescriptor;
}
}

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>
Provides a way to register webhook endpoints that should be invoked when certain event occur.
</Description>
<PackageTags>elsa extension module, webhooks</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" />
<PackageReference Include="WebhooksCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=extensions/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -0,0 +1,41 @@
using Elsa.Abstractions;
using Elsa.Mediator.Contracts;
using Elsa.Http.Webhooks.Notifications;
using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
using WebhooksCore;
namespace Elsa.Http.Webhooks.Endpoints.Webhooks;
/// <summary>
/// An API endpoint that receives webhook events from a webhook source.
/// </summary>
[PublicAPI]
internal class Post(IWebhookSourceProvider webhookSourceProvider, INotificationSender notificationSender, IStimulusSender stimulusSender) : ElsaEndpointWithoutRequest
{
/// <inheritdoc />
public override void Configure()
{
Routes("/webhooks");
Verbs(FastEndpoints.Http.POST);
AllowAnonymous();
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var webhookEvent = (await HttpContext.Request.ReadFromJsonAsync<WebhookEvent>(cancellationToken))!;
var webhookSources = (await webhookSourceProvider.ListAsync(cancellationToken)).ToList();
var matchingSource = webhookSources.FirstOrDefault(source => source.EventTypes.Any(eventType => eventType.EventType == webhookEvent.EventType));
if (matchingSource == null)
{
await Send.OkAsync(cancellationToken);
return;
}
var notification = new WebhookEventReceived(webhookEvent, matchingSource);
await notificationSender.SendAsync(notification, cancellationToken);
}
}

View file

@ -0,0 +1,20 @@
using Elsa.Features.Services;
using Elsa.Http.Webhooks.Features;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Adds extensions to <see cref="IModule"/> that enables the <see cref="WebhooksFeature"/> feature.
/// </summary>
public static class ModuleExtensions
{
/// <summary>
/// Enables and configures the <see cref="WebhooksFeature"/> feature.
/// </summary>
public static IModule UseWebhooks(this IModule module, Action<WebhooksFeature>? configure = null)
{
module.Configure(configure);
return module;
}
}

View file

@ -0,0 +1,52 @@
using Elsa.Extensions;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using WebhooksCore;
using WebhooksCore.Options;
namespace Elsa.Http.Webhooks;
[UsedImplicitly]
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the specified webhook with <see cref="WebhookSinksOptions"/>
/// </summary>
public static IServiceCollection RegisterWebhookSink(this IServiceCollection services, Uri endpoint)
{
var sink = new WebhookSink
{
Id = endpoint.ToString(),
Url = endpoint
};
return services.RegisterSink(sink);
}
/// <summary>
/// Registers the specified webhook with <see cref="WebhookSinksOptions"/>
/// </summary>
public static IServiceCollection RegisterSink(this IServiceCollection services, WebhookSink sink) => RegisterSinks(services, sink);
/// <summary>
/// Registers the specified webhooks with <see cref="WebhookSinksOptions"/>
/// </summary>
public static IServiceCollection RegisterSinks(this IServiceCollection services, params WebhookSink[] sinks)
{
services.Configure<WebhookSinksOptions>(options => options.Sinks.AddRange(sinks));
return services;
}
/// <summary>
/// Registers the specified webhook source with <see cref="WebhookSourcesOptions"/>
/// </summary>
public static IServiceCollection RegisterWebhookSource(this IServiceCollection services, WebhookSource source) => services.RegisterWebhookSources(source);
/// <summary>
/// Registers the specified webhook sources with <see cref="WebhookSourcesOptions"/>
/// </summary>
public static IServiceCollection RegisterWebhookSources(this IServiceCollection services, params WebhookSource[] sources)
{
services.Configure<WebhookSourcesOptions>(options => options.Sources.AddRange(sources));
return services;
}
}

View file

@ -0,0 +1,19 @@
using Humanizer;
using WebhooksCore;
namespace Elsa.Http.Webhooks;
public static class WebhookActivityTypeNameHelper
{
public static string GetWebhookActivityTypeName(this WebhookSource webhookSource, string eventType)
{
var ns = GetWebhookActivityNamespace(webhookSource);
return $"{ns}.{eventType.Dehumanize()}";
}
public static string GetWebhookActivityNamespace(this WebhookSource webhookSource)
{
var webhookSourceName = webhookSource.Name.Dehumanize();
return $"Webhooks.{webhookSourceName}";
}
}

View file

@ -0,0 +1,79 @@
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.Http.Webhooks.ActivityProviders;
using Microsoft.Extensions.DependencyInjection;
using WebhooksCore;
using WebhooksCore.Options;
namespace Elsa.Http.Webhooks.Features;
/// <summary>
/// Installs and configures webhook services.
/// </summary>
public class WebhooksFeature(IModule module) : FeatureBase(module)
{
public Action<WebhookSinksOptions> ConfigureSinks { get; set; } = _ => { };
public Action<WebhookSourcesOptions> ConfigureSources { get; set; } = _ => { };
public Action<IHttpClientBuilder> ConfigureHttpClient { get; set; } = _ => { };
/// <summary>
/// Registers the specified webhook with <see cref="WebhookSinksOptions"/>
/// </summary>
public WebhooksFeature RegisterWebhookSink(Uri endpoint)
{
var sink = new WebhookSink
{
Id = endpoint.ToString(),
Url = endpoint
};
return RegisterSink(sink);
}
/// <summary>
/// Registers the specified webhook with <see cref="WebhookSinksOptions"/>
/// </summary>
public WebhooksFeature RegisterSink(WebhookSink sink) => RegisterSinks(sink);
/// <summary>
/// Registers the specified webhooks with <see cref="WebhookSinksOptions"/>
/// </summary>
public WebhooksFeature RegisterSinks(params WebhookSink[] sinks)
{
ConfigureSinks += options => options.Sinks.AddRange(sinks);
return this;
}
/// <summary>
/// Registers the specified webhook source with <see cref="WebhookSourcesOptions"/>
/// </summary>
public WebhooksFeature RegisterWebhookSource(WebhookSource source) => RegisterWebhookSources(source);
/// <summary>
/// Registers the specified webhook sources with <see cref="WebhookSourcesOptions"/>
/// </summary>
public WebhooksFeature RegisterWebhookSources(params WebhookSource[] sources)
{
ConfigureSources += options => options.Sources.AddRange(sources);
return this;
}
public override void Configure()
{
Module
.AddVariableTypeAndAlias<WebhookEvent>("WebhookEvent", "Webhooks")
.AddFastEndpointsAssembly(GetType());
}
/// <inheritdoc />
public override void Apply()
{
Services.Configure(ConfigureSinks);
Services.Configure(ConfigureSources);
Services
.AddWebhooksCore(ConfigureHttpClient)
.AddActivityProvider<WebhookEventActivityProvider>()
.AddNotificationHandlersFrom<WebhooksFeature>();
}
}

View file

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

View file

@ -0,0 +1,30 @@
using Elsa.Mediator.Contracts;
using Elsa.Http.Webhooks.Notifications;
using Elsa.Http.Webhooks.Stimuli;
using Elsa.Workflows.Runtime;
using WebhooksCore;
namespace Elsa.Http.Webhooks.Handlers;
/// <summary>
/// Invokes webhook activities.
/// </summary>
public class InvokeWebhookActivities(IStimulusSender stimulusSender) : INotificationHandler<WebhookEventReceived>
{
public async Task HandleAsync(WebhookEventReceived notification, CancellationToken cancellationToken)
{
var matchingSource = notification.WebhookSource;
var webhookEvent = notification.WebhookEvent;
var fullTypeName = matchingSource.GetWebhookActivityTypeName(webhookEvent.EventType);
var stimulus = new WebhookEventReceivedStimulus(webhookEvent.EventType);
var input = new Dictionary<string, object>
{
[nameof(WebhookEvent)] = webhookEvent
};
var metadata = new StimulusMetadata
{
Input = input
};
await stimulusSender.SendAsync(fullTypeName, stimulus, metadata, cancellationToken);
}
}

View file

@ -0,0 +1,41 @@
using Elsa.Mediator.Contracts;
using Elsa.Http.Webhooks.Models;
using Elsa.Workflows.Runtime.Notifications;
using JetBrains.Annotations;
using WebhooksCore;
namespace Elsa.Http.Webhooks.Handlers;
/// <summary>
/// Handles the <see cref="RunTaskRequest"/> notification and asynchronously invokes all registered webhook endpoints.
/// </summary>
[UsedImplicitly]
public class RunTaskHandler(IWebhookEventBroadcaster webhookDispatcher) : INotificationHandler<RunTaskRequest>
{
/// <inheritdoc />
public async Task HandleAsync(RunTaskRequest notification, CancellationToken cancellationToken)
{
var activityExecutionContext = notification.ActivityExecutionContext;
var workflowExecutionContext = activityExecutionContext.WorkflowExecutionContext;
var workflowInstanceId = workflowExecutionContext.Id;
var correlationId = workflowExecutionContext.CorrelationId;
var workflow = workflowExecutionContext.Workflow;
var workflowDefinitionId = workflow.Identity.DefinitionId;
var workflowName = workflow.WorkflowMetadata.Name;
var tenantId = workflowExecutionContext.Workflow.Identity.TenantId;
var payload = new RunTaskWebhookPayload(
workflowInstanceId,
workflowDefinitionId,
tenantId,
workflowName,
correlationId,
notification.TaskId,
notification.TaskName,
notification.TaskPayload
);
var webhookEvent = new NewWebhookEvent("Elsa.RunTask", payload);
await webhookDispatcher.BroadcastAsync(webhookEvent, cancellationToken);
}
}

View file

@ -0,0 +1,14 @@
namespace Elsa.Http.Webhooks.Models;
/// <summary>
/// Stores payload information about the RunTask webhook event type.
/// </summary>
public record RunTaskWebhookPayload(
string WorkflowInstanceId,
string WorkflowDefinitionId,
string? TenantId,
string? WorkflowName,
string? CorrelationId,
string TaskId,
string TaskName,
object? TaskPayload);

View file

@ -0,0 +1,6 @@
using Elsa.Mediator.Contracts;
using WebhooksCore;
namespace Elsa.Http.Webhooks.Notifications;
public record WebhookEventReceived(WebhookEvent WebhookEvent, WebhookSource WebhookSource) : INotification;

View file

@ -0,0 +1,36 @@
using CShells.FastEndpoints.Features;
using CShells.Features;
using Elsa.Http.Webhooks.ActivityProviders;
using Elsa.Workflows.Management.Extensions;
using Microsoft.Extensions.DependencyInjection;
using WebhooksCore;
namespace Elsa.Http.Webhooks.ShellFeatures;
/// <summary>
/// Installs and configures webhook services.
/// </summary>
[ShellFeature(DisplayName = "Webhooks", Description = "Provides support for sending and receiving webhooks")]
public class WebhooksFeature : IFastEndpointsShellFeature
{
public void ConfigureServices(IServiceCollection services)
{
services.AddVariableTypeAndAlias<WebhookEvent>("WebhookEvent", "Webhooks");
services
.AddWebhooksCore(ConfigureHttpClient)
.AddActivityProvider<WebhookEventActivityProvider>()
.AddNotificationHandlersFrom<WebhooksFeature>();
}
private void ConfigureHttpClient(IHttpClientBuilder httpClient)
{
// TODO: Configure HTTP client with configurable parameters and resilience.
httpClient.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
EnableMultipleHttp2Connections = true
});
}
}

View file

@ -0,0 +1,3 @@
namespace Elsa.Http.Webhooks.Stimuli;
public record WebhookEventReceivedStimulus(string EventType);

View file

@ -19,20 +19,47 @@ internal class Reload(IShellRegistry shellRegistry, IApiSerializer apiSerializer
public override async Task HandleAsync(CancellationToken cancellationToken)
{
var shellId = Route<string>("shellId")!;
var result = await shellRegistry.ReloadAsync(shellId, cancellationToken);
ReloadResult result;
// CShells 0.0.15 surfaces failures via ReloadResult.Error rather than throwing — translate to 404
// (which historically signalled "blueprint not found") for any composition / initialization error.
if (result.Error is not null)
try
{
var failedResponse = new ShellReloadResponse
result = await shellRegistry.ReloadAsync(shellId, cancellationToken);
}
catch (ShellBlueprintNotFoundException ex)
{
var notFoundResponse = new ShellReloadResponse
{
Status = ShellReloadStatus.NotFound,
RequestedShellId = shellId,
Timestamp = DateTimeOffset.UtcNow,
Message = ex.Message
};
await SendResponseAsync(notFoundResponse, StatusCodes.Status404NotFound, cancellationToken);
return;
}
catch (ShellBlueprintUnavailableException ex)
{
var unavailableResponse = new ShellReloadResponse
{
Status = ShellReloadStatus.Failed,
RequestedShellId = shellId,
Timestamp = DateTimeOffset.UtcNow,
Message = ex.Message
};
await SendResponseAsync(unavailableResponse, StatusCodes.Status503ServiceUnavailable, cancellationToken);
return;
}
if (result.Error is not null)
{
var failedResponse = new ShellReloadResponse
{
Status = ShellReloadStatus.Failed,
RequestedShellId = shellId,
Timestamp = DateTimeOffset.UtcNow,
Message = result.Error.Message
};
await SendResponseAsync(failedResponse, StatusCodes.Status404NotFound, cancellationToken);
await SendResponseAsync(failedResponse, StatusCodes.Status503ServiceUnavailable, cancellationToken);
return;
}

View file

@ -0,0 +1,14 @@
using Elsa.Common.ShellFeatures;
namespace Elsa.Common.UnitTests;
public class CShellsCompatibilityTests
{
[Fact]
public void ElsaCommonTypesCanBeLoadedAgainstQuartzMinimumCShellsVersion()
{
var exception = Record.Exception(() => typeof(MultitenancyFeature).Assembly.GetTypes());
Assert.Null(exception);
}
}

View file

@ -5,6 +5,10 @@
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CShells.Abstractions" VersionOverride="0.0.21" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Common\Elsa.Common.csproj" />

View file

@ -126,6 +126,7 @@ public class ConfiguredApplicationInstanceNameProviderTests
Assert.False(string.IsNullOrWhiteSpace(name2));
Assert.Matches(@"^[0-9a-f]+$", name1);
Assert.Matches(@"^[0-9a-f]+$", name2);
Assert.NotEqual(name1, name2);
}
[Fact]
@ -135,9 +136,13 @@ public class ConfiguredApplicationInstanceNameProviderTests
Environment.SetEnvironmentVariable(variable, null);
var name1 = CreateProvider(new() { InstanceNameEnvironmentVariable = variable }).GetName();
var name2 = CreateProvider(new() { InstanceNameEnvironmentVariable = variable }).GetName();
Assert.False(string.IsNullOrWhiteSpace(name1));
Assert.False(string.IsNullOrWhiteSpace(name2));
Assert.Matches(@"^[0-9a-f]+$", name1);
Assert.Matches(@"^[0-9a-f]+$", name2);
Assert.NotEqual(name1, name2);
}
[Fact]
@ -176,6 +181,18 @@ public class ConfiguredApplicationInstanceNameProviderTests
Assert.Contains("contains invalid characters", exception.Message);
}
[Fact]
public void ExplicitInstanceName_InvalidCharactersAndTooLong_ReportsBothProblems()
{
var instanceName = new string('a', ConfiguredInstanceNameMaxLength) + " b";
var exception = Assert.Throws<InvalidOperationException>(() => CreateProvider(new() { InstanceName = instanceName }));
Assert.Contains("contains invalid characters", exception.Message);
Assert.Contains($"{ConfiguredInstanceNameMaxLength} characters or fewer", exception.Message);
Assert.Contains("Azure Service Bus", exception.Message);
}
[Fact]
public void EnvironmentVariableValue_TooLong_IsShortenedDeterministically()
{

View file

@ -24,11 +24,9 @@ public class ReloadTests : ShellsApiTestBase
[Fact]
public async Task Post_WhenShellNotFound_Returns404WithNotFoundStatus()
{
// CShells 0.0.15 surfaces failures via ReloadResult.Error rather than throwing — the endpoint maps any
// non-null Error to a 404 (the historical "blueprint not found" response).
ShellRegistry
.ReloadAsync(ShellId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new ReloadResult(ShellId, null, null, new InvalidOperationException("Shell 'test-shell' not found"))));
.Returns(Task.FromException<ReloadResult>(new ShellBlueprintNotFoundException(ShellId)));
var response = await HttpClient.PostAsync($"/shells/{ShellId}/reload", null);
@ -37,6 +35,40 @@ public class ReloadTests : ShellsApiTestBase
Assert.NotNull(body);
Assert.Equal("NotFound", body.Status);
Assert.Equal(ShellId, body.RequestedShellId);
Assert.Equal("Shell 'test-shell' not found", body.Message);
Assert.Contains(ShellId, body.Message);
}
[Fact]
public async Task Post_WhenReloadFails_Returns503WithFailedStatus()
{
ShellRegistry
.ReloadAsync(ShellId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new ReloadResult(ShellId, null, null, new InvalidOperationException("Shell reload failed"))));
var response = await HttpClient.PostAsync($"/shells/{ShellId}/reload", null);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ShellReloadResult>(JsonOptions);
Assert.NotNull(body);
Assert.Equal("Failed", body.Status);
Assert.Equal(ShellId, body.RequestedShellId);
Assert.Equal("Shell reload failed", body.Message);
}
[Fact]
public async Task Post_WhenBlueprintUnavailable_Returns503WithFailedStatus()
{
ShellRegistry
.ReloadAsync(ShellId, Arg.Any<CancellationToken>())
.Returns(Task.FromException<ReloadResult>(new ShellBlueprintUnavailableException(ShellId, new InvalidOperationException())));
var response = await HttpClient.PostAsync($"/shells/{ShellId}/reload", null);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ShellReloadResult>(JsonOptions);
Assert.NotNull(body);
Assert.Equal("Failed", body.Status);
Assert.Equal(ShellId, body.RequestedShellId);
Assert.Contains(ShellId, body.Message);
}
}