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:
commit
46cf0739e6
15
ROADMAP.md
15
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in a new issue