Merge remote-tracking branch 'origin/main' into codex/7708-notfoundactivity-guard

# Conflicts:
#	src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs
This commit is contained in:
Sipke Schoorstra 2026-06-14 22:26:41 +02:00
commit 46cf0739e6
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
5 changed files with 260 additions and 69 deletions

View file

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

View file

@ -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)
@ -359,8 +361,8 @@ public class HttpWorkflowsMiddleware(RequestDelegate next)
var httpEndpointFaultHandler = serviceProvider.GetRequiredService<IHttpEndpointFaultHandler>();
var workflowInstanceManager = serviceProvider.GetRequiredService<IWorkflowInstanceManager>();
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;
}

View file

@ -27,61 +27,74 @@ 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<NotFoundActivity>();
// 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<NotFoundActivity>();
if (notFoundActivityDescriptor == null)
throw new InvalidOperationException($"Unable to deserialize activity type '{activityTypeName}' because the NotFoundActivity descriptor is not registered. Ensure the activity registry has been populated before deserializing workflows.");
var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity<NotFoundActivity>(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<IDictionary<string, object>>(outerMetadataElement.GetRawText(), clonedOptions);
if (outerMetadata != null)
var activityRoot = doc.RootElement;
var activityTypeName = GetActivityDetails(activityRoot, out var activityTypeVersion, out var activityDescriptor);
var notFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName<NotFoundActivity>();
// 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<NotFoundActivity>();
if (notFoundActivityDescriptor == null)
throw new InvalidOperationException($"Unable to deserialize activity type '{activityTypeName}' because the NotFoundActivity descriptor is not registered. Ensure the activity registry has been populated before deserializing workflows.");
var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity<NotFoundActivity>(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<IDictionary<string, object>>(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)
@ -177,4 +190,4 @@ public class ActivityJsonConverter(
clonedOptions.Converters.Add(new JsonIgnoreCompositeRootConverterFactory(serviceProvider.GetRequiredService<ActivityWriter>()));
return clonedOptions;
}
}
}

View file

@ -1,19 +1,27 @@
using System.Collections;
using System.Reflection;
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 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";
@ -54,6 +62,133 @@ 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<IWorkflowInstanceManager>();
var httpEndpointFaultHandler = Substitute.For<IHttpEndpointFaultHandler>();
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<CancellationToken>()).Returns(Task.FromResult<WorkflowInstance?>(workflowInstance));
var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None);
Assert.True(handled);
await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is<HttpEndpointFaultContext>(context => ReferenceEquals(context.WorkflowState, reloadedWorkflowState)));
}
[Fact]
public async Task HandleWorkflowFaultAsync_FallsBackToExecutionResultState_WhenReloadReturnsNull()
{
var workflowState = CreateFaultedWorkflowState("workflow-2");
var workflowInstanceManager = Substitute.For<IWorkflowInstanceManager>();
var httpEndpointFaultHandler = Substitute.For<IHttpEndpointFaultHandler>();
var serviceProvider = new ServiceCollection()
.AddSingleton(workflowInstanceManager)
.AddSingleton(httpEndpointFaultHandler)
.BuildServiceProvider();
var httpContext = new DefaultHttpContext();
workflowInstanceManager.FindByIdAsync(workflowState.Id, Arg.Any<CancellationToken>()).Returns(Task.FromResult<WorkflowInstance?>(null));
var handled = await HandleWorkflowFaultAsync(serviceProvider, httpContext, CreateRunWorkflowResult(workflowState), CancellationToken.None);
Assert.True(handled);
await httpEndpointFaultHandler.Received(1).HandleAsync(Arg.Is<HttpEndpointFaultContext>(context => ReferenceEquals(context.WorkflowState, workflowState)));
}
private async Task<bool> HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWorkflowResult workflowExecutionResult, CancellationToken cancellationToken)
{
var task = (Task<bool>)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<ActivityIncident>
{
new("activity", "activity-node", "TestActivity", "Boom", null, DateTimeOffset.UtcNow)
}
};
[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<InvalidOperationException>(() => ExecuteWithinTimeoutAsync<int>(_ => 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<OperationCanceledException>(() => ExecuteWithinTimeoutAsync<int>(cancellationToken =>
{
observedToken = cancellationToken;
return Task.FromCanceled<int>(cancellationToken);
}, TimeSpan.FromSeconds(1), httpContext));
Assert.True(observedToken.IsCancellationRequested);
Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted);
}
private async Task<T> ExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<T>> action, TimeSpan? requestTimeout, HttpContext httpContext)
{
var method = ExecuteWithinTimeoutAsyncMethod.MakeGenericMethod(typeof(T));
var task = (Task<T>)method.Invoke(_middleware, [action, requestTimeout, httpContext])!;
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<StoredBookmark> CreateCollidingHttpEndpointBookmarks()
{
yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId);

View file

@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using Elsa.Common.Serialization;
using Elsa.Expressions.Services;
using Elsa.Workflows.Activities;
@ -59,10 +59,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<IActivityRegistry>();
activityRegistry
.Find(NotFoundActivityTypeName)
.Returns(new ActivityDescriptor());
var sut = CreateSut(activityRegistry);
// Act
var result = Execute(sut, NestedNotFoundActivityJson);
// Assert
var notFoundActivity = Assert.IsType<NotFoundActivity>(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"));
}
@ -133,6 +154,13 @@ public sealed class ActivityJsonConverterTests
static IActivity? Execute(ActivityJsonConverter sut, string json) =>
JsonSerializer.Deserialize<IActivity>(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) };
@ -194,6 +222,18 @@ public sealed class ActivityJsonConverterTests
private static readonly WriteLine WriteLineActivity = new("Hello world!");
private static readonly string WriteLineActivityTypeName = ActivityTypeNameHelper.GenerateTypeName<WriteLine>();
private static readonly string NotFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName<NotFoundActivity>();
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 =
"""