Merge remote-tracking branch 'origin/patch/3.5.2' into develop/3.6.0

This commit is contained in:
Sipke Schoorstra 2025-11-11 17:01:22 +01:00
commit 54af5ea714
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
11 changed files with 319 additions and 28 deletions

View file

@ -126,7 +126,7 @@ services.Configure<RecurringTaskOptions>(options =>
});
services.Configure<RuntimeOptions>(options => { options.InactivityThreshold = TimeSpan.FromSeconds(15); });
services.Configure<BookmarkQueuePurgeOptions>(options => options.Ttl = TimeSpan.FromSeconds(10));
services.Configure<BookmarkQueuePurgeOptions>(options => options.Ttl = TimeSpan.FromSeconds(3600));
services.Configure<CachingOptions>(options => options.CacheDuration = TimeSpan.FromDays(1));
services.Configure<IncidentOptions>(options => options.DefaultIncidentStrategy = typeof(ContinueWithIncidentsStrategy));
services.AddHealthChecks();

View file

@ -0,0 +1,105 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "3b42d3276206e00f",
"definitionId": "fb27085e78433f79",
"name": "Triggers",
"createdAt": "2025-10-09T07:37:31.521686\u002B00:00",
"version": 3,
"toolVersion": "3.5.0.0",
"variables": [],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"usableAsActivity": false,
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "83f95a4ca749c950",
"nodeId": "Workflow1:83f95a4ca749c950",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"path": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "triggers"
}
},
"supportedMethods": {
"typeName": "List\u003CString\u003E",
"expression": {
"type": "Object",
"value": "[\u0022GET\u0022]"
}
},
"authorize": {
"typeName": "Boolean",
"expression": {
"type": "Literal",
"value": false
}
},
"policy": {
"typeName": "String",
"expression": {
"type": "Literal"
}
},
"requestTimeout": null,
"requestSizeLimit": null,
"fileSizeLimit": null,
"allowedFileExtensions": null,
"blockedFileExtensions": null,
"allowedMimeTypes": null,
"exposeRequestTooLargeOutcome": false,
"exposeFileTooLargeOutcome": false,
"exposeInvalidFileExtensionOutcome": false,
"exposeInvalidFileMimeTypeOutcome": false,
"parsedContent": null,
"files": null,
"routeData": null,
"queryStringData": null,
"headers": null,
"result": null,
"id": "607c2042a187fe5a",
"nodeId": "Workflow1:83f95a4ca749c950:607c2042a187fe5a",
"name": "HttpEndpoint1",
"type": "Elsa.HttpEndpoint",
"version": 1,
"customProperties": {
"canStartWorkflow": true,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -316.431640625,
"y": -470.017578125
},
"size": {
"width": 195.1328125,
"height": 67.9765625
}
}
}
}
],
"variables": [],
"connections": []
}
}

View file

@ -217,11 +217,16 @@ public static class ObjectConverter
var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType);
if (sourceTypeConverter.CanConvertTo(underlyingTargetType))
{
var isValid = targetTypeConverter.IsValid(value);
if (isValid)
return sourceTypeConverter.ConvertTo(value, underlyingTargetType);
{
// TypeConverter.IsValid is not supported for ConvertTo, so we have to try and ignore any exceptions.
try
{
return sourceTypeConverter.ConvertTo(value, underlyingTargetType);
}
catch
{
// Ignore and try other conversion strategies.
}
}
if (underlyingTargetType.IsEnum)

View file

@ -37,7 +37,7 @@ public class FlowScope
/// </summary>
/// <param name="activity">The activity to check.</param>
/// <returns>The visit count of the activity.</returns>
private long GetActivityVisitCount(IActivity activity) => ActivitiesVisitCount.TryGetValue(activity.Id, out var count) ? count : 0;
private long GetActivityVisitCount(IActivity activity) => ActivitiesVisitCount.GetValueOrDefault(activity.Id, 0);
/// <summary>
/// Registers a visit to the specified connection and records whether it was followed.
@ -46,7 +46,7 @@ public class FlowScope
/// <param name="followed">Indicates whether the connection was followed.</param>
public void RegisterConnectionVisit(Connection connection, bool followed)
{
string connectionId = connection.ToString();
var connectionId = connection.ToString();
ConnectionVisitCount.TryAdd(connectionId, 0);
ConnectionVisitCount[connectionId]++;
ConnectionLastVisitFollowed[connectionId] = followed;
@ -57,14 +57,14 @@ public class FlowScope
/// </summary>
/// <param name="connection">The connection to check.</param>
/// <returns>The visit count of the connection.</returns>
private long GetConnectionVisitCount(Connection connection) => ConnectionVisitCount.TryGetValue(connection.ToString(), out var count) ? count : 0;
private long GetConnectionVisitCount(Connection connection) => ConnectionVisitCount.GetValueOrDefault(connection.ToString(), 0);
/// <summary>
/// Determines whether the last visit to the specified connection was followed.
/// </summary>
/// <param name="connection">The connection to check.</param>
/// <returns>True if the connection was followed on the last visit, otherwise false.</returns>
private bool GetConnectionLastVisitFollowed(Connection connection) => ConnectionLastVisitFollowed.TryGetValue(connection.ToString(), out var followed) ? followed : false;
public bool GetConnectionLastVisitFollowed(Connection connection) => ConnectionLastVisitFollowed.GetValueOrDefault(connection.ToString(), false);
/// <summary>
/// Determines whether all inbound connections to the specified activity have been visited.
@ -76,7 +76,7 @@ public class FlowScope
{
var forwardInboundConnections = flowGraph.GetForwardInboundConnections(activity);
var outboundActivityVisitCount = GetActivityVisitCount(activity);
var minConnectionVisitCount = forwardInboundConnections.Min(c => GetConnectionVisitCount(c));
var minConnectionVisitCount = forwardInboundConnections.Min(GetConnectionVisitCount);
return minConnectionVisitCount > outboundActivityVisitCount;
}
@ -90,7 +90,7 @@ public class FlowScope
{
var forwardInboundConnections = flowGraph.GetForwardInboundConnections(activity);
var outboundActivityVisitCount = GetActivityVisitCount(activity);
var maxConnectionVisitCount = forwardInboundConnections.Max(c => GetConnectionVisitCount(c));
var maxConnectionVisitCount = forwardInboundConnections.Max(GetConnectionVisitCount);
return maxConnectionVisitCount > outboundActivityVisitCount
&& forwardInboundConnections.Any(c => GetConnectionVisitCount(c) == maxConnectionVisitCount && GetConnectionLastVisitFollowed(c));
}

View file

@ -83,8 +83,8 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
await AssignIdentities(materializedWorkflow.Workflow, cancellationToken);
var workflowDefinition = await AddOrUpdateAsync(materializedWorkflow, cancellationToken);
if (indexTriggers)
await IndexTriggersAsync(workflowDefinition, cancellationToken);
if (indexTriggers && workflowDefinition.IsPublished)
await _triggerIndexer.IndexTriggersAsync(workflowDefinition, cancellationToken);
return workflowDefinition;
}
@ -260,8 +260,6 @@ public class DefaultWorkflowDefinitionStorePopulator : IWorkflowDefinitionStoreP
}
}
private async Task IndexTriggersAsync(WorkflowDefinition workflowDefinition, CancellationToken cancellationToken) => await _triggerIndexer.IndexTriggersAsync(workflowDefinition, cancellationToken);
/// <summary>
/// Syncs the items in the primary list with existing items in the secondary list, even when the object instances are not the same (but their IDs are).
/// </summary>

View file

@ -89,7 +89,7 @@ public class TriggerIndexer : ITriggerIndexer
// Collect new triggers **if the workflow is published**.
var newTriggers = workflow.Publication.IsPublished
? await GetTriggersInternalAsync(workflow, cancellationToken).ToListAsync(cancellationToken)
: new List<StoredTrigger>(0);
: new(0);
// Diff triggers.
var diff = Diff.For(currentTriggers, newTriggers, new WorkflowTriggerEqualityComparer());

View file

@ -7,6 +7,7 @@ using Elsa.Workflows.ComponentTests.Materializers;
using Elsa.Workflows.ComponentTests.WorkflowProviders;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Filters;
using Humanizer;
using Microsoft.Extensions.DependencyInjection;
@ -20,6 +21,8 @@ public class ReloadWorkflowTests : AppComponentTest
private readonly IWorkflowDefinitionManager _workflowDefinitionManager;
private readonly IWorkflowDefinitionService _workflowDefinitionService;
private readonly IWorkflowDefinitionsReloader _workflowDefinitionsReloader;
private readonly IWorkflowDefinitionPublisher _workflowDefinitionPublisher;
private readonly ITriggerStore _triggerStore;
public ReloadWorkflowTests(App app) : base(app)
{
@ -27,7 +30,9 @@ public class ReloadWorkflowTests : AppComponentTest
_workflowDefinitionsReloader = Scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionsReloader>();
_workflowBuilderFactory = Scope.ServiceProvider.GetRequiredService<IWorkflowBuilderFactory>();
_workflowDefinitionService = Scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionService>();
_workflowDefinitionPublisher = Scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionPublisher>();
_activityRegistry = Scope.ServiceProvider.GetRequiredService<IActivityRegistry>();
_triggerStore = Scope.ServiceProvider.GetRequiredService<ITriggerStore>();
var workflowsProviders = Scope.ServiceProvider.GetRequiredService<IEnumerable<IWorkflowsProvider>>();
_testWorkflowProvider = (TestWorkflowProvider)workflowsProviders.First(x => x is TestWorkflowProvider);
}
@ -37,9 +42,9 @@ public class ReloadWorkflowTests : AppComponentTest
{
var client = WorkflowServer.CreateHttpWorkflowClient();
await _workflowDefinitionManager.DeleteByDefinitionIdAsync("f68b09bc-2013-4617-b82f-d76b6819a624", CancellationToken.None);
var firstResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "reload-test"));
var firstResponse = await client.SendAsync(new(HttpMethod.Get, "reload-test"));
await _workflowDefinitionsReloader.ReloadWorkflowDefinitionsAsync();
var secondResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "reload-test"));
var secondResponse = await client.SendAsync(new(HttpMethod.Get, "reload-test"));
Assert.Equal(HttpStatusCode.NotFound, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
}
@ -103,6 +108,36 @@ public class ReloadWorkflowTests : AppComponentTest
await _workflowDefinitionManager.DeleteByDefinitionIdAsync(definitionId, CancellationToken.None);
}
[Fact]
public async Task Reloading_AfterPublishingNewVersion_ShouldPersistTriggers()
{
// Get the initial workflow definition.
const string definitionId = "f68b09bc-2013-4617-b82f-d76b6819a624";
var initialDefinition = await _workflowDefinitionService.FindWorkflowDefinitionAsync(definitionId, VersionOptions.Published, CancellationToken.None);
Assert.NotNull(initialDefinition);
// Assert that triggers exist initially.
var initialTrigger = await _triggerStore.FindAsync(new(){ WorkflowDefinitionId = definitionId}, CancellationToken.None);
Assert.NotNull(initialTrigger);
// Publish a new version of the workflow.
var draftDefinition = await _workflowDefinitionPublisher.GetDraftAsync(definitionId, VersionOptions.Latest);
Assert.NotNull(draftDefinition);
await _workflowDefinitionPublisher.PublishAsync(draftDefinition, CancellationToken.None);
// Assert we are at version 2.
var v2Definition = await _workflowDefinitionService.FindWorkflowDefinitionAsync(definitionId, VersionOptions.Published, CancellationToken.None);
Assert.NotNull(v2Definition);
Assert.Equal(2, v2Definition.Version);
// Reload the workflow definitions.
await _workflowDefinitionsReloader.ReloadWorkflowDefinitionsAsync();
// Assert that triggers still exist after reload.
var reloadedTrigger = await _triggerStore.FindAsync(new(){ WorkflowDefinitionId = definitionId}, CancellationToken.None);
Assert.NotNull(reloadedTrigger);
}
private async Task<MaterializedWorkflow> BuildWorkflowAsync(string definitionId, string definitionVersionId, int version)
{
var builder = _workflowBuilderFactory.CreateBuilder();
@ -114,6 +149,6 @@ public class ReloadWorkflowTests : AppComponentTest
builder.WorkflowOptions.UsableAsActivity = true;
var workflow = await builder.BuildWorkflowAsync();
workflow.Name = definitionId;
return new MaterializedWorkflow(workflow, _testWorkflowProvider.Name, TestWorkflowMaterializer.MaterializerName);
return new(workflow, _testWorkflowProvider.Name, TestWorkflowMaterializer.MaterializerName);
}
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{ca65b7d5-57e8-4932-b8b8-732d040c9b3a}</ProjectGuid>
@ -23,6 +23,9 @@
<None Update="Scenarios\DependencyWorkflowsPublishing\Workflows\parent.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\ImplicitJoins\Workflows\decision-implicit-join-on-both-outcomes.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\ImplicitJoins\Workflows\fork-decision-join-converge.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View file

@ -1,9 +1,8 @@
namespace Elsa.Workflows.Core.UnitTests.ObjectConversion
{
public class Person
{
public double? Age { get; set; }
namespace Elsa.Workflows.Core.UnitTests.ObjectConversion;
public string? Name { get; set; }
}
public class Person
{
public double? Age { get; set; }
public string? Name { get; set; }
}

View file

@ -0,0 +1,50 @@
using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
namespace Elsa.Workflows.Core.UnitTests.ObjectConversion;
public class PersonTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{
// Allow conversion from string to Person
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
{
// Allow conversion from Person to string
return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
}
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
{
if (value == null)
{
return null;
}
if (value is string json)
{
if (string.IsNullOrEmpty(json))
{
return null;
}
return JsonSerializer.Deserialize<Person>(json);
}
return base.ConvertFrom(context, culture, value);
}
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
{
if (destinationType == typeof(string) && value is Person person)
{
return JsonSerializer.Serialize(person);
}
return base.ConvertTo(context, culture, value, destinationType);
}
}

View file

@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -352,4 +353,99 @@ public class Tests
if(shouldThrow)
Assert.Throws<TypeConversionException>(() => input.ConvertTo(targetType, options));
}
[Fact]
public void ConvertTo_WithRegisteredPersonTypeConverter_ConvertsFromJsonToPerson()
{
try
{
// Arrange
TypeDescriptor.AddAttributes(typeof(Person), new TypeConverterAttribute(typeof(PersonTypeConverter)));
var json = "{\"Name\":\"Alice\",\"Age\":30}";
// Act
var result = json.ConvertTo<Person>(_objectConverterOptions);
// Assert
Assert.NotNull(result);
Assert.IsType<Person>(result);
Assert.Equal("Alice", result.Name);
Assert.Equal(30, result.Age);
}
finally
{
// Clean up type descriptor cache so other tests aren't affected
TypeDescriptor.Refresh(typeof(Person));
}
}
[Fact]
public void ConvertTo_WithRegisteredPersonTypeConverter_NullInput_ReturnsNull()
{
try
{
// Arrange
TypeDescriptor.AddAttributes(typeof(Person), new TypeConverterAttribute(typeof(PersonTypeConverter)));
string? json = null;
// Act
var result = json.ConvertTo<Person>(_objectConverterOptions);
// Assert
Assert.Null(result);
}
finally
{
TypeDescriptor.Refresh(typeof(Person));
}
}
[Fact]
public void ConvertTo_WithRegisteredPersonTypeConverter_ConvertsFromPersonToJson()
{
try
{
// Arrange
TypeDescriptor.AddAttributes(typeof(Person), new TypeConverterAttribute(typeof(PersonTypeConverter)));
var person = new Person { Name = "Bob", Age = 42 };
// Act
var result = person.ConvertTo<string>(_objectConverterOptions);
// Assert
Assert.NotNull(result);
var json = Assert.IsType<string>(result);
Assert.Contains("\"Name\":\"Bob\"", json);
Assert.Contains("\"Age\":42", json);
}
finally
{
// Clean up to avoid polluting TypeDescriptor globally
TypeDescriptor.Refresh(typeof(Person));
}
}
[Fact]
public void ConvertTo_WithRegisteredPersonTypeConverter_NullPersonToString_ReturnsNull()
{
try
{
// Arrange
TypeDescriptor.AddAttributes(typeof(Person), new TypeConverterAttribute(typeof(PersonTypeConverter)));
Person? person = null;
// Act
var result = person.ConvertTo<string>(_objectConverterOptions);
// Assert
Assert.Null(result);
}
finally
{
TypeDescriptor.Refresh(typeof(Person));
}
}
}