From ded765b379e60402492cb228e8bb88ce2f6e0986 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 10 Jun 2026 02:05:50 +0200 Subject: [PATCH 1/4] docs: refresh roadmap --- ROADMAP.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4470b613d..385dde1f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Elsa Roadmap -Last refreshed: 2026-05-29 +Last refreshed: 2026-06-10 This roadmap is a product direction document, not a fixed release calendar. Elsa is developed through a mix of core maintainer work, customer-funded work, and community contributions, so sequencing can change when real-world demand changes. The intent is stable: make Elsa the most productive, dependable, and extensible workflow platform for the .NET ecosystem. @@ -39,7 +39,7 @@ Legend: `[x]` shipped foundation, `[~]` partially shipped or needs productizatio - [x] Structured logs - [x] Console logs - [x] Studio structured-log, console-log, and OpenTelemetry diagnostics foundations -- [~] Durable structured log persistence +- [x] Durable structured log persistence - [~] Scheduler and message-bus foundations through Quartz, Hangfire, MassTransit, Kafka, and Azure Service Bus - [~] OpenTelemetry diagnostics backend and default workflow metrics - [ ] Scheduler/message reliability hardening for clustered production workloads @@ -93,7 +93,7 @@ Legend: `[x]` shipped foundation, `[~]` partially shipped or needs productizatio These are already present in the codebase and should be treated as foundations for the next roadmap slices: - Multi-targeting for `net8.0`, `net9.0`, and `net10.0` in [`src/Directory.Build.props`](src/Directory.Build.props). -- The `3.7.0` release train shipped across [Core](https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0), [Studio](https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.0), and [Extensions](https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.7.0) in May 2026, promoting shell integration, Studio authentication, workflow diagnostics, and extension package metadata into released foundations. +- The `3.7.0` release train shipped across [Core](https://github.com/elsa-workflows/elsa-core/releases/tag/3.7.0), [Studio](https://github.com/elsa-workflows/elsa-studio/releases/tag/3.7.0), and [Extensions](https://github.com/elsa-workflows/elsa-extensions/releases/tag/3.7.0) in May 2026, promoting shell integration, Studio authentication, workflow diagnostics, and extension package metadata into released foundations. The [Core](https://github.com/elsa-workflows/elsa-core/releases/tag/3.8.0-preview1) and [Studio](https://github.com/elsa-workflows/elsa-studio/releases/tag/3.8.0-preview1) `3.8.0-preview1` releases on June 1, 2026 then added the next preview slice of graceful shutdown, richer diagnostics, secrets, and newer designer surfaces. - Modular core packages under [`src/modules`](src/modules), with code-first features and CShells shell features documented in [`doc/wiki/module-system.md`](doc/wiki/module-system.md). - A modular server host using CShells and Nuplane package loading in [`src/apps/Elsa.ModularServer.Web`](src/apps/Elsa.ModularServer.Web). - Runtime admin, quiescence, drain, and interrupted recovery infrastructure in [`Elsa.Workflows.Runtime`](src/modules/Elsa.Workflows.Runtime) and runtime admin endpoints in [`Elsa.Workflows.Api`](src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin). @@ -101,13 +101,14 @@ These are already present in the codebase and should be treated as foundations f - Structured diagnostics with recent/live capture plus SQLite persistence in [`Elsa.Diagnostics.StructuredLogs`](src/modules/Elsa.Diagnostics.StructuredLogs) and [`Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite`](src/modules/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite). - Raw stdout/stderr console diagnostics in [`Elsa.Diagnostics.ConsoleLogs`](src/modules/Elsa.Diagnostics.ConsoleLogs), with the post-3.7 console pipeline now carrying workflow and activity execution context through [PR #7536](https://github.com/elsa-workflows/elsa-core/pull/7536). - Core OpenTelemetry diagnostics are actively in productization through [PR #7537](https://github.com/elsa-workflows/elsa-core/pull/7537), which adds OTLP ingestion, bounded storage, REST APIs, SignalR live updates, collector configuration, security checks, and tests. +- Core `main` now includes `Elsa.AI.Abstractions`, `Elsa.AI.Host`, `Elsa.AI.Copilot`, and `Elsa.AI.Persistence.EFCore` through [PR #7523](https://github.com/elsa-workflows/elsa-core/pull/7523), giving Weaver a merged server-side foundation while Studio UX and broader productization remain roadmap work. - State machine core activity support in [`Elsa.Workflows.Core/Activities/StateMachine`](src/modules/Elsa.Workflows.Core/Activities/StateMachine). - ElsaScript DSL and blob storage integration in [`Elsa.Dsl.ElsaScript`](src/modules/Elsa.Dsl.ElsaScript) and [`Elsa.WorkflowProviders.BlobStorage.ElsaScript`](src/modules/Elsa.WorkflowProviders.BlobStorage.ElsaScript). - Activity unit testing helpers and guidance in [`src/common/Elsa.Testing.Shared`](src/common/Elsa.Testing.Shared) and [`doc/qa/test-guidelines.md`](doc/qa/test-guidelines.md). - Label infrastructure in [`Elsa.Labels`](src/modules/Elsa.Labels), which is the likely backend foundation for workflow categories, tags, and folders. - Elsa Studio is already a modular Blazor product shell with workflow authoring, instance browsing, designer modules, diagnostics, authentication, localization, branding, custom elements, and early React wrapper work in [elsa-workflows/elsa-studio](https://github.com/elsa-workflows/elsa-studio). - Studio `3.7.0` shipped the modern authentication framework, Elsa Identity and OIDC modules, activity call-stack visualization, incident count badges, pending-instance filtering, and custom theme/DataPanel extensibility. -- Studio `main` includes structured-log, console-log, and OpenTelemetry diagnostics modules, newer React Flow/sequence/state-machine designer work, OIDC/identity infrastructure, custom elements, and an alterations module. The OpenTelemetry Studio module landed in [elsa-studio#834](https://github.com/elsa-workflows/elsa-studio/pull/834). +- Studio `3.8.0-preview1` shipped the server logs module, console logs module, structured-log storage diagnostics, the OpenTelemetry diagnostics page from [elsa-studio#834](https://github.com/elsa-workflows/elsa-studio/pull/834), sequence and state-machine designer foundations, the secrets module, and the alterations designer. - Elsa Extensions is an active modular integration repository with 70+ module projects in [elsa-workflows/elsa-extensions](https://github.com/elsa-workflows/elsa-extensions), targeting `net8.0`, `net9.0`, and `net10.0`. - Extensions already provide broad integration foundations: Connections, Secrets, Agents, OpenAPI, SQL/CSV/data tooling, messaging, schedulers, cloud storage, logging, webhooks, persistence providers, and external system activities. - Extensions `3.7.0` adds package manifest metadata, infrastructure attributes, shell features for MassTransit/Quartz/Webhooks, Dapper and MongoDB activity execution-chain lookups, Dapper bookmark queue filtering, Kafka multitenancy/schema-trigger work, Quartz lifecycle/job cleanup fixes, and other operational hardening. @@ -193,7 +194,7 @@ Recommended success measures: High-value items: -- Finish the diagnostics trilogy: structured logs, console logs, and OpenTelemetry. Structured and console logs now exist; Studio `main` has an OpenTelemetry diagnostics page from [elsa-studio#834](https://github.com/elsa-workflows/elsa-studio/pull/834), and Core has an active backend PR for OTLP ingestion, bounded stores, REST endpoints, SignalR live updates, collector configuration, and tests in [#7537](https://github.com/elsa-workflows/elsa-core/pull/7537). The remaining product work is to merge, release, document, and correlate this with workflow incidents. +- Finish the diagnostics trilogy: structured logs, console logs, and OpenTelemetry. Structured and console logs now exist; Studio `3.8.0-preview1` ships an OpenTelemetry diagnostics page from [elsa-studio#834](https://github.com/elsa-workflows/elsa-studio/pull/834), and Core still has an active backend PR for OTLP ingestion, bounded stores, REST endpoints, SignalR live updates, collector configuration, and tests in [#7537](https://github.com/elsa-workflows/elsa-core/pull/7537). The remaining product work is to merge and release the Core backend, document collector setup, and correlate this with workflow incidents. - Add default workflow semantic metrics: started, resumed, suspended, faulted, completed, active, activity executed/faulted, queue depth, recovery count, drain count, and dispatch latency. [#5988](https://github.com/elsa-workflows/elsa-core/issues/5988) remains the durable demand signal, while [#7537](https://github.com/elsa-workflows/elsa-core/pull/7537) supplies the first current module boundary. - Build Studio diagnostics pages that are useful under pressure: live console, structured logs, OpenTelemetry traces/metrics/logs, workflow incident timelines, source health, dropped-event counters, source selection, filters, URL state, export/copy affordances, and direct deep links to workflow instances. - Make execution history easier to reason about: distinguish faulted, interrupted, cancelled, crash-recovered, retried, and operator-modified workflows consistently across API, Studio, logs, and metrics. @@ -231,10 +232,10 @@ Recommended success measures: High-value items: -- Build AI-assisted workflow generation that produces multiple visible activities from intent rather than hiding logic in one script activity. This direction is proposed in [discussion #7367](https://github.com/elsa-workflows/elsa-core/discussions/7367), and [#7523](https://github.com/elsa-workflows/elsa-core/pull/7523) now provides an active Weaver AI Copilot foundation with AI abstractions, provider/session contracts, chat/tool endpoints, audit events, proposal persistence, EF Core storage, and integration/unit tests. +- Build AI-assisted workflow generation that produces multiple visible activities from intent rather than hiding logic in one script activity. This direction is proposed in [discussion #7367](https://github.com/elsa-workflows/elsa-core/discussions/7367), and merged [#7523](https://github.com/elsa-workflows/elsa-core/pull/7523) now provides the first Weaver AI Copilot server foundation with AI abstractions, provider/session contracts, chat/tool endpoints, audit events, proposal persistence, EF Core storage, and integration/unit tests. - Provide an Elsa MCP/tooling surface for reading, validating, editing, and explaining workflow JSON/ElsaScript. This would make Elsa a strong fit for AI-enabled .NET development environments. - Align AI authoring with the Extensions Agents work: provider abstractions, MCP tools, OpenAI/Claude/local model support, tool approval, secrets handling, and Studio UX should share contracts instead of creating parallel AI stacks. -- Build a Studio copilot only after the authoring contracts are stable: validation, generated activity metadata, designer APIs, diagnostics links, and test scaffolding should be available before AI generation becomes prominent. [elsa-studio#553](https://github.com/elsa-workflows/elsa-studio/issues/553) has clear community signal and maintainer interest, while [#7523](https://github.com/elsa-workflows/elsa-core/pull/7523) is still Core/backend-oriented and should not be treated as a complete Studio product surface. +- Build a Studio copilot only after the authoring contracts are stable: validation, generated activity metadata, designer APIs, diagnostics links, and test scaffolding should be available before AI generation becomes prominent. [elsa-studio#553](https://github.com/elsa-workflows/elsa-studio/issues/553) has clear community signal and maintainer interest, while merged [#7523](https://github.com/elsa-workflows/elsa-core/pull/7523) is still Core/backend-oriented and should not be treated as a complete Studio product surface. - Add "explain this workflow", "find risky activities", "suggest tests", and "generate migration notes" capabilities backed by workflow graph metadata. - Pair AI generation with validation: generated workflows should include test scaffolds, required input/output definitions, secrets handling, and clear review diffs. From 9c24f5efe5b571f334fbd68f78fbd151e8a7b9f9 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 11 Jun 2026 20:33:10 +0200 Subject: [PATCH 2/4] Dispose parsed activity JsonDocuments (#7713) * Dispose parsed activity JsonDocuments * Add ActivityJsonConverter disposal regression test * Fix ActivityJsonConverter disposal regression test file * address greptile test coverage feedback --- .../Converters/ActivityJsonConverter.cs | 101 ++++++++++-------- .../Converters/ActivityJsonConverterTests.cs | 48 ++++++++- 2 files changed, 101 insertions(+), 48 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs index 29da39c2f..abd313c05 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs @@ -27,57 +27,70 @@ public class ActivityJsonConverter( if (!JsonDocument.TryParseValue(ref reader, out var doc)) throw new JsonException("Failed to parse JsonDocument"); - var activityRoot = doc.RootElement; - var activityTypeName = GetActivityDetails(activityRoot, out var activityTypeVersion, out var activityDescriptor); - var notFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); - - // If the activity type is a NotFoundActivity, try to extract the original activity type name and version. - if (activityTypeName.Equals(notFoundActivityTypeName) && activityRoot.TryGetProperty("originalActivityJson", out var originalActivityJson)) + using (doc) { - activityRoot = JsonDocument.Parse(originalActivityJson.GetString()!).RootElement; - activityTypeName = GetActivityDetails(activityRoot, out activityTypeVersion, out activityDescriptor); - } + JsonDocument? originalActivityDoc = null; - var clonedOptions = GetClonedOptions(options); - // If the activity type is not found, create a NotFoundActivity instead. - if (activityDescriptor == null) - { - var notFoundActivityDescriptor = activityRegistry.Find()!; - var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity(notFoundActivityDescriptor, activityRoot, clonedOptions); - LogExceptionsIfAny(notFoundActivityResult); - - var notFoundActivity = notFoundActivityResult.Activity; - notFoundActivity.Type = notFoundActivityTypeName; - notFoundActivity.Version = 1; - notFoundActivity.MissingTypeName = activityTypeName; - notFoundActivity.MissingTypeVersion = activityTypeVersion; - notFoundActivity.OriginalActivityJson = activityRoot.ToString(); - - // Extract metadata from doc.RootElement rather than activityRoot. - // In round-trip scenarios, activityRoot may have been reassigned to the inner originalActivityJson (see line 37), - // but we want the metadata from the current activity being deserialized, which represents the NotFoundActivity - // placeholder's position and annotations in the designer. - if (doc.RootElement.TryGetProperty("metadata", out var outerMetadataElement)) + try { - var outerMetadata = JsonSerializer.Deserialize>(outerMetadataElement.GetRawText(), clonedOptions); - if (outerMetadata != null) + var activityRoot = doc.RootElement; + var activityTypeName = GetActivityDetails(activityRoot, out var activityTypeVersion, out var activityDescriptor); + var notFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + + // If the activity type is a NotFoundActivity, try to extract the original activity type name and version. + if (activityTypeName.Equals(notFoundActivityTypeName) && activityRoot.TryGetProperty("originalActivityJson", out var originalActivityJson)) { - notFoundActivity.Metadata = outerMetadata; + originalActivityDoc = JsonDocument.Parse(originalActivityJson.GetString()!); + activityRoot = originalActivityDoc.RootElement; + activityTypeName = GetActivityDetails(activityRoot, out activityTypeVersion, out activityDescriptor); } + + var clonedOptions = GetClonedOptions(options); + // If the activity type is not found, create a NotFoundActivity instead. + if (activityDescriptor == null) + { + var notFoundActivityDescriptor = activityRegistry.Find()!; + var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity(notFoundActivityDescriptor, activityRoot, clonedOptions); + LogExceptionsIfAny(notFoundActivityResult); + + var notFoundActivity = notFoundActivityResult.Activity; + notFoundActivity.Type = notFoundActivityTypeName; + notFoundActivity.Version = 1; + notFoundActivity.MissingTypeName = activityTypeName; + notFoundActivity.MissingTypeVersion = activityTypeVersion; + notFoundActivity.OriginalActivityJson = activityRoot.ToString(); + + // Extract metadata from doc.RootElement rather than activityRoot. + // In round-trip scenarios, activityRoot may have been reassigned to the inner originalActivityJson (see line 42), + // but we want the metadata from the current activity being deserialized, which represents the NotFoundActivity + // placeholder's position and annotations in the designer. + if (doc.RootElement.TryGetProperty("metadata", out var outerMetadataElement)) + { + var outerMetadata = JsonSerializer.Deserialize>(outerMetadataElement.GetRawText(), clonedOptions); + if (outerMetadata != null) + { + notFoundActivity.Metadata = outerMetadata; + } + } + + // Set display text and description after metadata assignment to ensure they always reflect the current state + notFoundActivity.SetDisplayText($"Not Found: {activityTypeName}"); + notFoundActivity.SetDescription($"Could not find activity type {activityTypeName} with version {activityTypeVersion}"); + + return notFoundActivity; + } + + var context = JsonActivityConstructorContextHelper.Create(activityDescriptor, activityRoot, clonedOptions); + var activityResult = activityDescriptor.Constructor(context); + LogExceptionsIfAny(activityResult); + + return activityResult.Activity; + } + finally + { + originalActivityDoc?.Dispose(); } - - // Set display text and description after metadata assignment to ensure they always reflect the current state - notFoundActivity.SetDisplayText($"Not Found: {activityTypeName}"); - notFoundActivity.SetDescription($"Could not find activity type {activityTypeName} with version {activityTypeVersion}"); - - return notFoundActivity; } - - var context = JsonActivityConstructorContextHelper.Create(activityDescriptor, activityRoot, clonedOptions); - var activityResult = activityDescriptor.Constructor(context); - LogExceptionsIfAny(activityResult); - - return activityResult.Activity; } void LogExceptionsIfAny(ActivityConstructionResult result) diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs index 7782aeb13..5d088a57b 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Elsa.Common.Serialization; using Elsa.Expressions.Services; using Elsa.Workflows.Activities; @@ -63,10 +63,31 @@ public sealed class ActivityJsonConverterTests var notFoundActivity = (NotFoundActivity)result; Assert.Equal(UnknownActivityTypeName, notFoundActivity.MissingTypeName); Assert.Equal(0, notFoundActivity.MissingTypeVersion); + AssertEquivalentJson(UnknownActivityJson, notFoundActivity.OriginalActivityJson); + Assert.True(notFoundActivity.Metadata.ContainsKey("displayText")); + Assert.True(notFoundActivity.Metadata.ContainsKey("description")); + } - var expectedJsonDoc = JsonDocument.Parse(UnknownActivityJson); - var actualJsonDoc = JsonDocument.Parse(notFoundActivity.OriginalActivityJson); - Assert.Equal(expectedJsonDoc.RootElement.ToString(), actualJsonDoc.RootElement.ToString()); + [Fact] + public void When_DeserializeNestedNotFoundActivity_Then_PreservesMissingActivityBehavior() + { + // Arrange + var activityRegistry = Substitute.For(); + activityRegistry + .Find(NotFoundActivityTypeName) + .Returns(new ActivityDescriptor()); + + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, NestedNotFoundActivityJson); + + // Assert + var notFoundActivity = Assert.IsType(result); + Assert.Equal(UnknownActivityTypeName, notFoundActivity.MissingTypeName); + Assert.Equal(0, notFoundActivity.MissingTypeVersion); + AssertEquivalentJson(UnknownActivityJson, notFoundActivity.OriginalActivityJson); + Assert.True(notFoundActivity.Metadata.ContainsKey("outerMarker")); Assert.True(notFoundActivity.Metadata.ContainsKey("displayText")); Assert.True(notFoundActivity.Metadata.ContainsKey("description")); } @@ -123,6 +144,13 @@ public sealed class ActivityJsonConverterTests static IActivity? Execute(ActivityJsonConverter sut, string json) => JsonSerializer.Deserialize(json, GetSerializerOptions(sut)); + static void AssertEquivalentJson(string expectedJson, string actualJson) + { + using var expectedJsonDoc = JsonDocument.Parse(expectedJson); + using var actualJsonDoc = JsonDocument.Parse(actualJson); + Assert.Equal(expectedJsonDoc.RootElement.ToString(), actualJsonDoc.RootElement.ToString()); + } + static IActivityRegistry CreateActivityRegistry(string typeName, IActivity activity, int? version = null) { var descriptor = new ActivityDescriptor { Constructor = _ => new(activity) }; @@ -177,6 +205,18 @@ public sealed class ActivityJsonConverterTests private static readonly WriteLine WriteLineActivity = new("Hello world!"); private static readonly string WriteLineActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); private static readonly string NotFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + private static readonly string NestedNotFoundActivityJson = + $$""" + { + "id": "wrapped-not-found", + "type": "{{NotFoundActivityTypeName}}", + "version": 1, + "originalActivityJson": {{JsonSerializer.Serialize(UnknownActivityJson)}}, + "metadata": { + "outerMarker": "preserved" + } + } + """; private const string WriteLineActivityJson_WithVersion = """ From 48a087e71e2b2d8c2f5b01bd9d3e5b80226383eb Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 14 Jun 2026 19:02:23 +0200 Subject: [PATCH 3/4] Restore RequestAborted after timed HTTP workflow failures (#7712) * Fix RequestAborted restoration for timed HTTP workflows * Add timeout restoration tests for HttpWorkflowsMiddleware --- .../Middleware/HttpWorkflowsMiddleware.cs | 16 ++--- .../HttpWorkflowsMiddlewareTests.cs | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs index 53d529d4c..577a062ff 100644 --- a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs @@ -252,13 +252,15 @@ public class HttpWorkflowsMiddleware(RequestDelegate next) // Replace the original cancellation token with the combined one. httpContext.RequestAborted = combinedTokenSource.Token; - // Execute the action. - var result = await action(httpContext.RequestAborted); - - // Restore the original cancellation token. - httpContext.RequestAborted = originalCancellationToken; - - return result; + try + { + return await action(httpContext.RequestAborted); + } + finally + { + // Restore the original cancellation token even when execution faults or is canceled. + httpContext.RequestAborted = originalCancellationToken; + } } private HttpRouteData GetMatchingRoute(IServiceProvider serviceProvider, string path) diff --git a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs index 529b3c226..90eac8bed 100644 --- a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs +++ b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Reflection; using Elsa.Http.Bookmarks; using Elsa.Http.Middleware; using Elsa.Http.Options; @@ -14,6 +15,7 @@ namespace Elsa.Http.UnitTests.Middleware; public class HttpWorkflowsMiddlewareTests { + private static readonly MethodInfo ExecuteWithinTimeoutAsyncMethod = typeof(HttpWorkflowsMiddleware).GetMethod("ExecuteWithinTimeoutAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; private const string CurrentTenantId = "tenant-a"; private const string OtherTenantId = "tenant-b"; private const string BookmarkHash = "http-endpoint:/colliding:get"; @@ -54,6 +56,63 @@ public class HttpWorkflowsMiddlewareTests Assert.False(filter.TenantAgnostic); } + [Fact] + public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterSuccess() + { + using var requestAbortedSource = new CancellationTokenSource(); + var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; + var observedToken = CancellationToken.None; + + var result = await ExecuteWithinTimeoutAsync(async cancellationToken => + { + observedToken = cancellationToken; + Assert.Equal(cancellationToken, httpContext.RequestAborted); + await Task.CompletedTask; + return 42; + }, TimeSpan.FromSeconds(1), httpContext); + + Assert.Equal(42, result); + Assert.NotEqual(requestAbortedSource.Token, observedToken); + Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); + } + + [Fact] + public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterFault() + { + using var requestAbortedSource = new CancellationTokenSource(); + var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; + + await Assert.ThrowsAsync(() => ExecuteWithinTimeoutAsync(_ => throw new InvalidOperationException("Boom"), TimeSpan.FromSeconds(1), httpContext)); + + Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); + } + + [Fact] + public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterCancellation() + { + using var requestAbortedSource = new CancellationTokenSource(); + requestAbortedSource.Cancel(); + + var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; + var observedToken = CancellationToken.None; + + await Assert.ThrowsAnyAsync(() => ExecuteWithinTimeoutAsync(cancellationToken => + { + observedToken = cancellationToken; + return Task.FromCanceled(cancellationToken); + }, TimeSpan.FromSeconds(1), httpContext)); + + Assert.True(observedToken.IsCancellationRequested); + Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); + } + + private async Task ExecuteWithinTimeoutAsync(Func> action, TimeSpan? requestTimeout, HttpContext httpContext) + { + var method = ExecuteWithinTimeoutAsyncMethod.MakeGenericMethod(typeof(T)); + var task = (Task)method.Invoke(_middleware, [action, requestTimeout, httpContext])!; + return await task; + } + private static IEnumerable CreateCollidingHttpEndpointBookmarks() { yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId); From 8f721e1ea2038c48eb8600fc97e4451c287fab7d Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 14 Jun 2026 22:23:38 +0200 Subject: [PATCH 4/4] Guard HTTP fault handling when workflow reload returns null (#7714) * Guard HTTP fault handling when workflow reload returns null * Add HTTP fault handler reload guard tests * Address Greptile review feedback --- .../Middleware/HttpWorkflowsMiddleware.cs | 4 +- .../HttpWorkflowsMiddlewareTests.cs | 78 ++++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs index 577a062ff..019a169c9 100644 --- a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs @@ -361,8 +361,8 @@ public class HttpWorkflowsMiddleware(RequestDelegate next) var httpEndpointFaultHandler = serviceProvider.GetRequiredService(); var workflowInstanceManager = serviceProvider.GetRequiredService(); - var workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cancellationToken))!; - await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState.WorkflowState, cancellationToken)); + var workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cancellationToken))?.WorkflowState ?? workflowExecutionResult.WorkflowState; + await httpEndpointFaultHandler.HandleAsync(new(httpContext, workflowState, cancellationToken)); return true; } diff --git a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs index 90eac8bed..69ec35e6d 100644 --- a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs +++ b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs @@ -4,18 +4,24 @@ using Elsa.Http.Bookmarks; using Elsa.Http.Middleware; using Elsa.Http.Options; using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Models; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Entities; using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.State; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; namespace Elsa.Http.UnitTests.Middleware; public class HttpWorkflowsMiddlewareTests { - private static readonly MethodInfo ExecuteWithinTimeoutAsyncMethod = typeof(HttpWorkflowsMiddleware).GetMethod("ExecuteWithinTimeoutAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + private static readonly MethodInfo HandleWorkflowFaultAsyncMethod = GetRequiredPrivateMethod("HandleWorkflowFaultAsync"); + private static readonly MethodInfo ExecuteWithinTimeoutAsyncMethod = GetRequiredPrivateMethod("ExecuteWithinTimeoutAsync"); private const string CurrentTenantId = "tenant-a"; private const string OtherTenantId = "tenant-b"; private const string BookmarkHash = "http-endpoint:/colliding:get"; @@ -56,6 +62,73 @@ public class HttpWorkflowsMiddlewareTests Assert.False(filter.TenantAgnostic); } + [Fact] + public async Task HandleWorkflowFaultAsync_UsesReloadedWorkflowState_WhenAvailable() + { + var workflowState = CreateFaultedWorkflowState("workflow-1"); + var reloadedWorkflowState = CreateFaultedWorkflowState(workflowState.Id); + var workflowInstanceManager = Substitute.For(); + var httpEndpointFaultHandler = Substitute.For(); + var serviceProvider = new ServiceCollection() + .AddSingleton(workflowInstanceManager) + .AddSingleton(httpEndpointFaultHandler) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext(); + var workflowInstance = new WorkflowInstance + { + Id = workflowState.Id, + DefinitionId = workflowState.DefinitionId, + DefinitionVersionId = workflowState.DefinitionVersionId, + WorkflowState = reloadedWorkflowState + }; + + workflowInstanceManager.FindByIdAsync(workflowState.Id, Arg.Any()).Returns(Task.FromResult(workflowInstance)); + + var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None); + + Assert.True(handled); + await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is(context => ReferenceEquals(context.WorkflowState, reloadedWorkflowState))); + } + + [Fact] + public async Task HandleWorkflowFaultAsync_FallsBackToExecutionResultState_WhenReloadReturnsNull() + { + var workflowState = CreateFaultedWorkflowState("workflow-2"); + var workflowInstanceManager = Substitute.For(); + var httpEndpointFaultHandler = Substitute.For(); + var serviceProvider = new ServiceCollection() + .AddSingleton(workflowInstanceManager) + .AddSingleton(httpEndpointFaultHandler) + .BuildServiceProvider(); + var httpContext = new DefaultHttpContext(); + + workflowInstanceManager.FindByIdAsync(workflowState.Id, Arg.Any()).Returns(Task.FromResult(null)); + + var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None); + + Assert.True(handled); + await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is(context => ReferenceEquals(context.WorkflowState, workflowState))); + } + + private async Task HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWorkflowResult workflowExecutionResult, CancellationToken cancellationToken) + { + var task = (Task)HandleWorkflowFaultAsyncMethod.Invoke(_middleware, [serviceProvider, httpContext, workflowExecutionResult, cancellationToken])!; + return await task; + } + + private static RunWorkflowResult CreateRunWorkflowResult(WorkflowState workflowState) => new(default!, workflowState, default!, null, Journal.Empty); + + private static WorkflowState CreateFaultedWorkflowState(string id) => new() + { + Id = id, + DefinitionId = "definition", + DefinitionVersionId = "definition-version", + Incidents = new List + { + new("activity", "activity-node", "TestActivity", "Boom", null, DateTimeOffset.UtcNow) + } + }; + [Fact] public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterSuccess() { @@ -113,6 +186,9 @@ public class HttpWorkflowsMiddlewareTests return await task; } + private static MethodInfo GetRequiredPrivateMethod(string name) => + typeof(HttpWorkflowsMiddleware).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new MissingMethodException(typeof(HttpWorkflowsMiddleware).FullName, name); + private static IEnumerable CreateCollidingHttpEndpointBookmarks() { yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId);