fix: register built-in activation strategy serialization aliases (#8049)

* fix: register built-in activation strategy serialization aliases

3.8 type-resolution hardening no longer falls back to Type.GetType, so
Studio-selected and 3.7-era SQN activation strategy types failed to
deserialize. Register the four built-in strategies with legacy names
and advertise registry aliases from the descriptor endpoint.

Fixes https://github.com/elsa-workflows/elsa-core/issues/8049

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test: DRY activation strategy serialization test setup

Extract shared registry/JSON arrange helpers so activation alias tests
share setup without collapsing their assertions.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test: qualify Options.Create in serialization test helpers

Avoid Elsa.Options colliding with Microsoft.Extensions.Options.Options.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test: keep activation alias setup local and obvious

Drop the shared Testing.Shared helper and extra Runtime wrapper. Those
hid one-line registry construction. Leave same-class helpers only.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix: advertise resolvable activation strategy type names

When the registry has no preferred alias, fall back to the simple
assembly-qualified name so Studio stores an identifier the resolver
can load. FullName is not a registered alias.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sipke Schoorstra 2026-09-11 18:09:51 -07:00 committed by GitHub
parent 8191ae3055
commit 4f90cfbbdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 200 additions and 12 deletions

View file

@ -4,14 +4,16 @@ using System.Reflection;
using Elsa.Abstractions;
using Elsa.Extensions;
using Elsa.Models;
using Elsa.Workflows;
using Humanizer;
using Elsa.Common.Serialization;
namespace Elsa.Workflows.Api.Endpoints.WorkflowActivationStrategies.List;
/// <summary>
/// Returns list of available <see cref="IWorkflowActivationStrategy" /> implementations.
/// </summary>
internal class List(IEnumerable<IWorkflowActivationStrategy> strategies) : ElsaEndpointWithoutRequest<ListResponse<WorkflowActivationStrategyDescriptor>>
internal class List(IEnumerable<IWorkflowActivationStrategy> strategies, ISerializationTypeRegistry workflowJsonTypeRegistry) : ElsaEndpointWithoutRequest<ListResponse<WorkflowActivationStrategyDescriptor>>
{
public override void Configure()
{
@ -21,15 +23,15 @@ internal class List(IEnumerable<IWorkflowActivationStrategy> strategies) : ElsaE
public override Task<ListResponse<WorkflowActivationStrategyDescriptor>> ExecuteAsync(CancellationToken cancellationToken)
{
var descriptors = strategies.Select(WorkflowActivationStrategyDescriptor.FromStrategy).OrderBy(x => x.DisplayName).ToList();
var response =new ListResponse<WorkflowActivationStrategyDescriptor>(descriptors);
var descriptors = strategies.Select(x => WorkflowActivationStrategyDescriptor.FromStrategy(x, workflowJsonTypeRegistry)).OrderBy(x => x.DisplayName).ToList();
var response = new ListResponse<WorkflowActivationStrategyDescriptor>(descriptors);
return Task.FromResult(response);
}
}
internal record WorkflowActivationStrategyDescriptor(string DisplayName, string Description, string TypeName)
{
public static WorkflowActivationStrategyDescriptor FromStrategy(IWorkflowActivationStrategy strategy)
public static WorkflowActivationStrategyDescriptor FromStrategy(IWorkflowActivationStrategy strategy, ISerializationTypeRegistry workflowJsonTypeRegistry)
{
var type = strategy.GetType();
var displayNameAttribute = type.GetCustomAttribute<DisplayNameAttribute>();
@ -38,6 +40,7 @@ internal record WorkflowActivationStrategyDescriptor(string DisplayName, string
var displayName = displayNameAttribute?.DisplayName ?? displayAttribute?.Name ?? type.Name.Replace("Strategy", "").Humanize();
var description = descriptionAttribute?.Description ?? displayAttribute?.Description ?? "";
return new WorkflowActivationStrategyDescriptor(displayName, description, type.GetSimpleAssemblyQualifiedName());
var typeName = workflowJsonTypeRegistry.TryGetAlias(type, out var alias) ? alias : type.GetSimpleAssemblyQualifiedName();
return new WorkflowActivationStrategyDescriptor(displayName, description, typeName);
}
}

View file

@ -183,6 +183,7 @@ public class WorkflowsFeature : FeatureBase
options.AddTypeAliasWithLegacyName<MemoryStorageDriver>(nameof(MemoryStorageDriver));
options.AddTypeAliasWithLegacyName<FaultStrategy>(nameof(FaultStrategy));
options.AddTypeAliasWithLegacyName<ContinueWithIncidentsStrategy>(nameof(ContinueWithIncidentsStrategy));
options.AddTypeAliasWithLegacyName<AllowAlwaysStrategy>(nameof(AllowAlwaysStrategy));
options.AddTypeAlias<Exception>(nameof(Exception));
options.AddTypeAlias<ArgumentException>(nameof(ArgumentException));
options.AddTypeAlias<ArgumentNullException>(nameof(ArgumentNullException));

View file

@ -104,6 +104,7 @@ public class WorkflowsFeature : IShellFeature
options.AddTypeAliasWithLegacyName<MemoryStorageDriver>(nameof(MemoryStorageDriver));
options.AddTypeAliasWithLegacyName<FaultStrategy>(nameof(FaultStrategy));
options.AddTypeAliasWithLegacyName<ContinueWithIncidentsStrategy>(nameof(ContinueWithIncidentsStrategy));
options.AddTypeAliasWithLegacyName<AllowAlwaysStrategy>(nameof(AllowAlwaysStrategy));
options.AddTypeAlias<Exception>(nameof(Exception));
options.AddTypeAlias<ArgumentException>(nameof(ArgumentException));
options.AddTypeAlias<ArgumentNullException>(nameof(ArgumentNullException));

View file

@ -1,6 +1,7 @@
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime.ActivationValidators;
using Elsa.Workflows.Runtime.Bookmarks;
using Elsa.Workflows.Runtime.Stimuli;
using Elsa.Common.Serialization;
@ -24,6 +25,9 @@ internal static class WorkflowRuntimeTypeAliasRegistrar
options.AddTypeAlias<EventStimulus>();
options.AddTypeAlias<ExecuteWorkflowStimulus>();
options.AddTypeAlias<RunTaskStimulus>();
options.AddTypeAliasWithLegacyName<SingletonStrategy>(nameof(SingletonStrategy));
options.AddTypeAliasWithLegacyName<CorrelatedSingletonStrategy>(nameof(CorrelatedSingletonStrategy));
options.AddTypeAliasWithLegacyName<CorrelationStrategy>(nameof(CorrelationStrategy));
foreach (var workflowType in workflowTypes.Where(IsConcreteWorkflowType).Distinct())
options.RegisterTypeAlias(workflowType, workflowType.GetSimpleAssemblyQualifiedName());

View file

@ -0,0 +1,45 @@
using Elsa.Extensions;
using Elsa.Workflows.ActivationValidators;
using Elsa.Workflows.Api.Endpoints.WorkflowActivationStrategies.List;
using Elsa.Workflows.Options;
using Elsa.Workflows.Services;
using Elsa.Common.Serialization;
namespace Elsa.Workflows.Api.UnitTests.Endpoints.WorkflowActivationStrategies;
public class ListTests
{
[Fact]
public async Task ExecuteAsync_ReturnsWorkflowJsonTypeIdentifier_ForActivationStrategyTypeName()
{
var options = new SerializationTypeOptions();
options.RegisterTypeAlias(typeof(AllowAlwaysStrategy), nameof(AllowAlwaysStrategy));
options.RegisterLegacySimpleAssemblyQualifiedName(typeof(AllowAlwaysStrategy));
var registry = new SerializationTypeRegistry(Microsoft.Extensions.Options.Options.Create(options));
var endpoint = new List([new AllowAlwaysStrategy()], registry);
var response = await endpoint.ExecuteAsync(CancellationToken.None);
var descriptor = Assert.Single(response.Items);
Assert.Equal(nameof(AllowAlwaysStrategy), descriptor.TypeName);
Assert.True(registry.TryGetType(typeof(AllowAlwaysStrategy).GetSimpleAssemblyQualifiedName(), out var legacyType));
Assert.Equal(typeof(AllowAlwaysStrategy), legacyType);
}
[Fact]
public async Task ExecuteAsync_WhenOnlyLegacyNameIsRegistered_AdvertisesResolvableIdentifier()
{
var options = new SerializationTypeOptions();
options.RegisterLegacySimpleAssemblyQualifiedName(typeof(AllowAlwaysStrategy));
var registry = new SerializationTypeRegistry(Microsoft.Extensions.Options.Options.Create(options));
var endpoint = new List([new AllowAlwaysStrategy()], registry);
var response = await endpoint.ExecuteAsync(CancellationToken.None);
var descriptor = Assert.Single(response.Items);
var expectedTypeName = typeof(AllowAlwaysStrategy).GetSimpleAssemblyQualifiedName();
Assert.Equal(expectedTypeName, descriptor.TypeName);
Assert.True(registry.TryGetType(descriptor.TypeName, out var resolvedType));
Assert.Equal(typeof(AllowAlwaysStrategy), resolvedType);
}
}

View file

@ -5,6 +5,7 @@ using Elsa.Expressions.Services;
using Elsa.Extensions;
using Elsa.Workflows.Exceptions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Elsa.Workflows.Serialization.Converters;
using Elsa.Workflows.Services;
@ -153,11 +154,7 @@ public sealed class SerializationTypeResolverTests
[Fact]
public void When_ConfigureWorkflowsFeature_Then_RegistersCoreAliases()
{
var services = new ServiceCollection();
var module = services.CreateModule();
module.UseWorkflows();
module.Apply();
using var serviceProvider = services.BuildServiceProvider();
using var serviceProvider = CreateWorkflowsServices();
var registry = serviceProvider.GetRequiredService<ISerializationTypeRegistry>();
var aliasRegistered = registry.TryGetAlias(typeof(NullReferenceException), out var alias);
@ -177,6 +174,31 @@ public sealed class SerializationTypeResolverTests
Assert.Equal(nameof(Elsa.Workflows.IncidentStrategies.ContinueWithIncidentsStrategy), incidentStrategyAlias);
Assert.True(registry.TryGetType(typeof(Elsa.Workflows.IncidentStrategies.ContinueWithIncidentsStrategy).GetSimpleAssemblyQualifiedName(), out var legacyIncidentStrategyType));
Assert.Equal(typeof(Elsa.Workflows.IncidentStrategies.ContinueWithIncidentsStrategy), legacyIncidentStrategyType);
Assert.True(registry.TryGetAlias(typeof(Elsa.Workflows.ActivationValidators.AllowAlwaysStrategy), out var activationStrategyAlias));
Assert.Equal(nameof(Elsa.Workflows.ActivationValidators.AllowAlwaysStrategy), activationStrategyAlias);
Assert.True(registry.TryGetType(typeof(Elsa.Workflows.ActivationValidators.AllowAlwaysStrategy).GetSimpleAssemblyQualifiedName(), out var legacyActivationStrategyType));
Assert.Equal(typeof(Elsa.Workflows.ActivationValidators.AllowAlwaysStrategy), legacyActivationStrategyType);
}
[Fact]
public void When_ConfigureWorkflowsFeature_Then_WorkflowOptionsRoundTripActivationStrategyType()
{
using var serviceProvider = CreateWorkflowsServices();
var serializer = serviceProvider.GetRequiredService<IApiSerializer>();
var strategyType = typeof(Elsa.Workflows.ActivationValidators.AllowAlwaysStrategy);
var byAlias = serializer.Deserialize<WorkflowOptions>("""{"activationStrategyType":"AllowAlwaysStrategy"}""");
Assert.Equal(strategyType, byAlias.ActivationStrategyType);
var byLegacyName = serializer.Deserialize<WorkflowOptions>($$"""{"activationStrategyType":{{JsonString(strategyType.GetSimpleAssemblyQualifiedName())}}}""");
Assert.Equal(strategyType, byLegacyName.ActivationStrategyType);
var serialized = serializer.Serialize(new WorkflowOptions { ActivationStrategyType = strategyType });
Assert.Contains("AllowAlwaysStrategy", serialized);
Assert.DoesNotContain("UnregisteredClrType:", serialized);
var roundTrip = serializer.Deserialize<WorkflowOptions>(serialized);
Assert.Equal(strategyType, roundTrip.ActivationStrategyType);
}
[Fact]
@ -338,6 +360,15 @@ public sealed class SerializationTypeResolverTests
Assert.Equal(typeof(LateRegisteredPayload), result);
}
private static ServiceProvider CreateWorkflowsServices()
{
var services = new ServiceCollection();
var module = services.CreateModule();
module.UseWorkflows();
module.Apply();
return services.BuildServiceProvider();
}
private static JsonSerializerOptions CreateOptions(ISerializationTypeRegistry workflowJsonTypeRegistry) => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,

View file

@ -1,17 +1,18 @@
using System.Reflection;
using Elsa.Common.Serialization;
using Elsa.Extensions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Features;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime.ActivationValidators;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Providers;
using NSubstitute;
using RuntimeFeature = Elsa.Workflows.Runtime.Features.WorkflowRuntimeFeature;
using ShellRuntimeFeature = Elsa.Workflows.Runtime.ShellFeatures.WorkflowRuntimeFeature;
using Elsa.Common.Serialization;
namespace Elsa.Workflows.Runtime.UnitTests.Features;
@ -143,6 +144,32 @@ public class WorkflowRuntimeFeatureTests
Assert.Contains(typeof(WorkflowsFeature), dependencyTypes);
}
[Fact]
public void RegisterWorkflowTypeAliases_RegistersBuiltInActivationStrategyAliases()
{
var options = new SerializationTypeOptions();
RegisterWorkflowTypeAliases(_feature, options);
var registry = new SerializationTypeRegistry(Microsoft.Extensions.Options.Options.Create(options));
AssertActivationStrategyAlias(options, registry, typeof(SingletonStrategy), nameof(SingletonStrategy));
AssertActivationStrategyAlias(options, registry, typeof(CorrelatedSingletonStrategy), nameof(CorrelatedSingletonStrategy));
AssertActivationStrategyAlias(options, registry, typeof(CorrelationStrategy), nameof(CorrelationStrategy));
}
[Fact]
public void ShellRegisterWorkflowTypeAliases_RegistersBuiltInActivationStrategyAliases()
{
var options = new SerializationTypeOptions();
RegisterWorkflowTypeAliases(_shellFeature, options);
var registry = new SerializationTypeRegistry(Microsoft.Extensions.Options.Options.Create(options));
AssertActivationStrategyAlias(options, registry, typeof(SingletonStrategy), nameof(SingletonStrategy));
AssertActivationStrategyAlias(options, registry, typeof(CorrelatedSingletonStrategy), nameof(CorrelatedSingletonStrategy));
AssertActivationStrategyAlias(options, registry, typeof(CorrelationStrategy), nameof(CorrelationStrategy));
}
[Fact]
public void RegisterWorkflowTypeAliases_RegistersOnlyTrackedWorkflowTypes()
{
@ -211,4 +238,12 @@ public class WorkflowRuntimeFeatureTests
.GetMethod("RegisterWorkflowTypeAliases", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(feature, new object[] { options });
}
private static void AssertActivationStrategyAlias(SerializationTypeOptions options, ISerializationTypeRegistry registry, Type strategyType, string alias)
{
Assert.Equal(strategyType, options.AliasTypeDictionary[alias]);
Assert.Equal(alias, options.TypeAliasDictionary[strategyType]);
Assert.True(registry.TryGetType(strategyType.GetSimpleAssemblyQualifiedName(), out var legacyType));
Assert.Equal(strategyType, legacyType);
}
}

View file

@ -0,0 +1,68 @@
using System.Text.Json;
using Elsa.Common.Serialization;
using Elsa.Extensions;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime.ActivationValidators;
using Elsa.Workflows.Serialization.Converters;
using Elsa.Workflows.Services;
namespace Elsa.Workflows.Runtime.UnitTests.Serialization;
public class ActivationStrategyTypeAliasTests
{
public static TheoryData<Type, string> BuiltInActivationStrategies() => new()
{
{ typeof(SingletonStrategy), nameof(SingletonStrategy) },
{ typeof(CorrelatedSingletonStrategy), nameof(CorrelatedSingletonStrategy) },
{ typeof(CorrelationStrategy), nameof(CorrelationStrategy) }
};
[Theory]
[MemberData(nameof(BuiltInActivationStrategies))]
public void When_RegisterRuntimeAliases_Then_ResolvesPreferredAndLegacyNames(Type strategyType, string alias)
{
var registry = CreateRegistry();
Assert.True(registry.TryGetAlias(strategyType, out var registeredAlias));
Assert.Equal(alias, registeredAlias);
Assert.True(registry.TryGetType(alias, out var byAlias));
Assert.Equal(strategyType, byAlias);
Assert.True(registry.TryGetType(strategyType.GetSimpleAssemblyQualifiedName(), out var byLegacyName));
Assert.Equal(strategyType, byLegacyName);
}
[Theory]
[MemberData(nameof(BuiltInActivationStrategies))]
public void When_DeserializeWorkflowOptions_Then_ResolvesActivationStrategyType(Type strategyType, string alias)
{
var options = CreateJsonOptions();
var byAlias = JsonSerializer.Deserialize<WorkflowOptions>($$"""{"activationStrategyType":{{JsonSerializer.Serialize(alias)}}}""", options);
Assert.Equal(strategyType, byAlias!.ActivationStrategyType);
var byLegacyName = JsonSerializer.Deserialize<WorkflowOptions>($$"""{"activationStrategyType":{{JsonSerializer.Serialize(strategyType.GetSimpleAssemblyQualifiedName())}}}""", options);
Assert.Equal(strategyType, byLegacyName!.ActivationStrategyType);
var serialized = JsonSerializer.Serialize(new WorkflowOptions { ActivationStrategyType = strategyType }, options);
Assert.Contains(alias, serialized);
Assert.DoesNotContain("UnregisteredClrType:", serialized);
var roundTrip = JsonSerializer.Deserialize<WorkflowOptions>(serialized, options);
Assert.Equal(strategyType, roundTrip!.ActivationStrategyType);
}
private static ISerializationTypeRegistry CreateRegistry()
{
var options = new SerializationTypeOptions();
WorkflowRuntimeTypeAliasRegistrar.Register(options, []);
return new SerializationTypeRegistry(Microsoft.Extensions.Options.Options.Create(options));
}
private static JsonSerializerOptions CreateJsonOptions() => new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new TypeJsonConverter(CreateRegistry()) }
};
}