From 7214a0f6edbb62660cde9965624d3e5bc0c13469 Mon Sep 17 00:00:00 2001
From: FuJa0815 <30809803+FuJa0815@users.noreply.github.com>
Date: Tue, 11 Nov 2025 17:41:47 +0100
Subject: [PATCH 01/33] pass inputs to RunWorkflowInstanceRequest in
RunWorkflowUntilEndAsync (#7022)
---
.../RunWorkflowExtensions.cs | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
index 429ce6f99..818f7787e 100644
--- a/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
+++ b/src/common/Elsa.Testing.Shared.Integration/RunWorkflowExtensions.cs
@@ -26,11 +26,13 @@ public static class RunWorkflowExtensions
/// The services.
/// The ID of the workflow definition.
/// An optional dictionary of input values.
+ /// An optional correlation id of the workflow.
/// An optional set of options to specify the version of the workflow definition to retrieve.
/// The workflow state.
public static async Task RunWorkflowUntilEndAsync(this IServiceProvider services,
string workflowDefinitionId,
IDictionary? input = null,
+ string? correlationId = null,
VersionOptions? versionOptions = null)
{
var workflowDefinitionService = services.GetRequiredService();
@@ -44,7 +46,8 @@ public static class RunWorkflowExtensions
var response = await workflowClient.CreateAndRunInstanceAsync(new()
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowGraph.Workflow.Identity.Id),
- Input = input
+ Input = input,
+ CorrelationId = correlationId
});
var bookmarkStore = services.GetRequiredService();
@@ -64,7 +67,8 @@ public static class RunWorkflowExtensions
{
var runRequest = new RunWorkflowInstanceRequest
{
- BookmarkId = bookmark.Id
+ BookmarkId = bookmark.Id,
+ Input = input
};
response = await workflowClient.RunInstanceAsync(runRequest);
}
From 175addd8d197f7f6b6688195091d7a0438e3b7b1 Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Wed, 12 Nov 2025 09:30:54 +0100
Subject: [PATCH 02/33] Simplify GitHub Actions workflow by removing the test
phase from the Compile step.
---
.github/workflows/packages.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
index 7e23ef7d4..5380f863e 100644
--- a/.github/workflows/packages.yml
+++ b/.github/workflows/packages.yml
@@ -139,8 +139,8 @@ jobs:
with:
dotnet-version: 9.x
- - name: Compile+Test+Pack
- run: ./build.sh Compile+Test+Pack --version ${VERSION} --analyseCode true
+ - name: Compile+Pack
+ run: ./build.sh Compile+Pack --version ${VERSION} --analyseCode true
- name: Upload artifact
uses: actions/upload-artifact@v4
From c017082f015b8f07ea2fa77ec07b5c093980ecb9 Mon Sep 17 00:00:00 2001
From: FuJa0815 <30809803+FuJa0815@users.noreply.github.com>
Date: Wed, 12 Nov 2025 11:07:33 +0100
Subject: [PATCH 03/33] Added IEnumerableTypeConverter (#7020)
* Added IEnumerableTypeConverter
* fixed bug and added tests
* Add resource disposal mechanism in ScheduledCronTask
- Introduced `_disposed` flag to prevent execution after disposal.
- Updated disposal logic to ensure proper release of resources.
- Adjusted unit tests to verify new disposal behavior and prevent unintended timer actions.
* Enhance scheduling tasks with edge case handling and disposal improvements
- Added `_disposed` flag to `ScheduledRecurringTask` and `ScheduledSpecificInstantTask` to prevent execution after disposal.
- Adjusted timer setup logic to handle zero/negative delays with a minimum delay of 1ms.
- Updated disposal logic to ensure proper resource cleanup even during timer callbacks.
- Introduced extensive unit tests for edge cases such as small, zero, or negative delay scenarios and proper disposal behavior.
* Update test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update src/modules/Elsa.Common/Serialization/IEnumerableTypeConverter.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update src/modules/Elsa.Common/Serialization/IEnumerableTypeConverter.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Add integration tests for EnumerableTypeConverter and update solution file
- Introduced `Elsa.Common.IntegrationTests` project for testing serialization behavior in `EnumerableTypeConverter`.
- Added tests to verify proper handling of strings, byte arrays, and collections during JSON serialization.
- Registered `EnumerableTypeConverter` in `DefaultFormattersFeature`.
- Renamed `IEnumerableTypeConverter` to `EnumerableTypeConverter` for consistency.
- Updated solution file to include the new integration test project.
---------
Co-authored-by: Sipke Schoorstra
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
Elsa.sln | 7 +
.../Features/DefaultFormattersFeature.cs | 2 +
.../Serialization/EnumerableTypeConverter.cs | 37 ++++
.../ScheduledTasks/ScheduledCronTask.cs | 9 +-
.../ScheduledTasks/ScheduledRecurringTask.cs | 88 +++++----
.../ScheduledSpecificInstantTask.cs | 18 +-
.../Activities/RemoveTopElementStep.cs | 13 ++
.../VariablesArrayWorkflowTests.cs | 79 ++++++++
.../Workflows/VariableArrayWorkflow.cs | 34 ++++
.../Elsa.Common.IntegrationTests.csproj | 8 +
.../EnumerableTypeConverterTests.cs | 52 +++++
.../Elsa.Common.IntegrationTests/Usings.cs | 1 +
.../Scenarios/SetGetVariablesArray/Tests.cs | 28 +++
.../SetGetVariablesArray/Workflows.cs | 33 ++++
.../ScheduledTasks/ScheduledCronTaskTests.cs | 16 +-
.../ScheduledRecurringTaskTests.cs | 177 ++++++++++++++++++
.../ScheduledSpecificInstantTaskTests.cs | 142 ++++++++++++++
17 files changed, 692 insertions(+), 52 deletions(-)
create mode 100644 src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/VariablesArrayWorkflowTests.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Workflows/VariableArrayWorkflow.cs
create mode 100644 test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj
create mode 100644 test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs
create mode 100644 test/integration/Elsa.Common.IntegrationTests/Usings.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Tests.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Workflows.cs
create mode 100644 test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs
create mode 100644 test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledSpecificInstantTaskTests.cs
diff --git a/Elsa.sln b/Elsa.sln
index 0b50488db..66d805dc4 100644
--- a/Elsa.sln
+++ b/Elsa.sln
@@ -311,6 +311,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Expressions.UnitTests"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Scheduling.UnitTests", "test\unit\Elsa.Scheduling.UnitTests\Elsa.Scheduling.UnitTests.csproj", "{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Common.IntegrationTests", "test\integration\Elsa.Common.IntegrationTests\Elsa.Common.IntegrationTests.csproj", "{6C451CC5-280E-475E-B95E-23ADCF0CCBFD}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -619,6 +621,10 @@ Global
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6C451CC5-280E-475E-B95E-23ADCF0CCBFD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -714,6 +720,7 @@ Global
{20C75446-CAE0-48D1-85D9-459541C4D8B6} = {18453B51-25EB-4317-A4B3-B10518252E92}
{67E1F0CC-C436-4D71-AD2C-AE42E3AA8A0B} = {18453B51-25EB-4317-A4B3-B10518252E92}
{9AEE941A-3F23-4E4B-9B59-1A2F57FC762D} = {18453B51-25EB-4317-A4B3-B10518252E92}
+ {6C451CC5-280E-475E-B95E-23ADCF0CCBFD} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
diff --git a/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs b/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs
index 97ea1f3d6..05307f011 100644
--- a/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs
+++ b/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs
@@ -1,3 +1,4 @@
+using System.Collections;
using System.ComponentModel;
using Elsa.Common.Serialization;
using Elsa.Common.Services;
@@ -11,6 +12,7 @@ public class DefaultFormattersFeature(IModule module) : FeatureBase(module)
{
public override void Configure()
{
+ TypeDescriptor.AddAttributes(typeof(IEnumerable), new TypeConverterAttribute(typeof(EnumerableTypeConverter)));
TypeDescriptor.AddAttributes(typeof(Type), new TypeConverterAttribute(typeof(TypeTypeConverter)));
Module.Services.AddSingleton();
}
diff --git a/src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs b/src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs
new file mode 100644
index 000000000..cb4327df4
--- /dev/null
+++ b/src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs
@@ -0,0 +1,37 @@
+using System.Collections;
+using System.ComponentModel;
+using System.Globalization;
+using System.Text.Json;
+
+namespace Elsa.Common.Serialization;
+
+///
+/// A type converter that converts types to and from strings using JSON serialization.
+///
+public class EnumerableTypeConverter : TypeConverter
+{
+ public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
+ {
+ return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
+ }
+
+ public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
+ {
+ if (destinationType == typeof(string) && value is IEnumerable enumerable)
+ {
+ // string, byte[], Memory, ReadOnlyMemory, and Span types implement IEnumerable but should not be serialized as JSON arrays
+ if (value is string or byte[]) return value;
+
+ var valueType = value.GetType();
+ if (valueType.IsGenericType)
+ {
+ var genericTypeDef = valueType.GetGenericTypeDefinition();
+ if (genericTypeDef == typeof(Memory<>) || genericTypeDef == typeof(ReadOnlyMemory<>))
+ return value;
+ }
+
+ return JsonSerializer.Serialize(enumerable);
+ }
+ return base.ConvertTo(context, culture, value, destinationType);
+ }
+}
diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs
index 349e6b4a1..c0be4ddd8 100644
--- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs
+++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledCronTask.cs
@@ -23,6 +23,7 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
private readonly SemaphoreSlim _executionSemaphore = new(1, 1);
private bool _executing;
private bool _cancellationRequested;
+ private bool _disposed;
///
/// Initializes a new instance of .
@@ -107,6 +108,9 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
_timer?.Dispose();
_timer = null;
+ // Check if disposed before proceeding
+ if (_disposed) return;
+
using var scope = _scopeFactory.CreateScope();
var commandSender = scope.ServiceProvider.GetRequiredService();
@@ -136,18 +140,19 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
finally
{
_executing = false;
- if (acquired)
+ if (acquired && !_disposed)
_executionSemaphore.Release();
}
}
- if (!cancellationToken.IsCancellationRequested)
+ if (!cancellationToken.IsCancellationRequested && !_disposed)
Schedule();
};
}
void IDisposable.Dispose()
{
+ _disposed = true;
_timer?.Dispose();
_cancellationTokenSource.Dispose();
_executionSemaphore.Dispose();
diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs
index 3cfd8c464..e82e39979 100644
--- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs
+++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledRecurringTask.cs
@@ -23,6 +23,7 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
private Timer? _timer;
private bool _executing;
private bool _cancellationRequested;
+ private bool _disposed;
///
/// Initializes a new instance of .
@@ -77,7 +78,13 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
private void SetupTimer(TimeSpan delay)
{
- if (delay < TimeSpan.Zero) delay = TimeSpan.FromSeconds(1);
+ // Handle edge cases where delay is zero or negative (e.g., due to clock drift, fast execution, or time alignment)
+ // Instead of silently returning, use a minimum delay to ensure the timer fires and workflow continues scheduling
+ if (delay <= TimeSpan.Zero)
+ {
+ _logger.LogWarning("Calculated delay is {Delay} which is not positive. Using minimum delay of 1ms to ensure timer fires", delay);
+ delay = TimeSpan.FromMilliseconds(1);
+ }
_timer = new(delay.TotalMilliseconds)
{
@@ -86,58 +93,59 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
_timer.Elapsed += async (_, _) =>
{
- try
+ _timer?.Dispose();
+ _timer = null;
+
+ // Check if disposed before proceeding
+ if (_disposed) return;
+
+ _startAt = _systemClock.UtcNow + _interval;
+
+ using var scope = _scopeFactory.CreateScope();
+ var commandSender = scope.ServiceProvider.GetRequiredService();
+
+ // Check disposed again before accessing CancellationTokenSource
+ if (_disposed) return;
+
+ var cancellationToken = _cancellationTokenSource.Token;
+ if (!cancellationToken.IsCancellationRequested)
{
-
- _timer?.Dispose();
- _timer = null;
- _startAt = _systemClock.UtcNow + _interval;
-
- using var scope = _scopeFactory.CreateScope();
- var commandSender = scope.ServiceProvider.GetRequiredService();
- var cancellationToken = _cancellationTokenSource.Token;
- if (!cancellationToken.IsCancellationRequested)
+ var acquired = false;
+ try
{
- var acquired = false;
- try
- {
- acquired = await _executionSemaphore.WaitAsync(0, cancellationToken);
- if (!acquired) return;
- _executing = true;
- await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken);
+ acquired = await _executionSemaphore.WaitAsync(0, cancellationToken);
+ if (!acquired) return;
+ _executing = true;
+ await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken);
- if (_cancellationRequested)
- {
- _cancellationRequested = false;
- _cancellationTokenSource.Cancel();
- }
- }
- catch (Exception e)
+ if (_cancellationRequested)
{
- _logger.LogError(e, "Error executing scheduled task");
- }
- finally
- {
- _executing = false;
- if (acquired)
- _executionSemaphore.Release();
+ _cancellationRequested = false;
+ _cancellationTokenSource.Cancel();
}
}
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error executing scheduled task");
+ }
+ finally
+ {
+ _executing = false;
+ if (acquired && !_disposed)
+ _executionSemaphore.Release();
+ }
+ }
- if (!cancellationToken.IsCancellationRequested)
- Schedule();
- }
- catch (ObjectDisposedException ex)
- {
- _logger.LogWarning(ex, "Service Provider was disposed.");
- }
+ if (!cancellationToken.IsCancellationRequested && !_disposed)
+ Schedule();
};
}
void IDisposable.Dispose()
{
- _cancellationTokenSource.Dispose();
+ _disposed = true;
_timer?.Dispose();
+ _cancellationTokenSource.Dispose();
_executionSemaphore.Dispose();
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs
index 8d10f4e1a..71fa87535 100644
--- a/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs
+++ b/src/modules/Elsa.Scheduling/ScheduledTasks/ScheduledSpecificInstantTask.cs
@@ -22,6 +22,7 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
private Timer? _timer;
private bool _executing;
private bool _cancellationRequested;
+ private bool _disposed;
///
/// Initializes a new instance of .
@@ -57,8 +58,13 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
var now = _systemClock.UtcNow;
var delay = _startAt - now;
+ // Handle edge cases where delay is zero or negative (e.g., due to clock drift, fast execution, or time alignment)
+ // Instead of silently returning, use a minimum delay to ensure the timer fires and workflow continues scheduling
if (delay <= TimeSpan.Zero)
+ {
+ _logger.LogWarning("Calculated delay is {Delay} which is not positive. Using minimum delay of 1ms to ensure timer fires", delay);
delay = TimeSpan.FromMilliseconds(1);
+ }
_timer = new(delay.TotalMilliseconds)
{
@@ -70,8 +76,15 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
_timer?.Dispose();
_timer = null;
+ // Check if disposed before proceeding
+ if (_disposed) return;
+
using var scope = _scopeFactory.CreateScope();
var commandSender = scope.ServiceProvider.GetRequiredService();
+
+ // Check disposed again before accessing CancellationTokenSource
+ if (_disposed) return;
+
var cancellationToken = _cancellationTokenSource.Token;
if (!cancellationToken.IsCancellationRequested)
{
@@ -96,7 +109,7 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
finally
{
_executing = false;
- if (acquired)
+ if (acquired && !_disposed)
_executionSemaphore.Release();
}
}
@@ -105,8 +118,9 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
void IDisposable.Dispose()
{
- _cancellationTokenSource.Dispose();
+ _disposed = true;
_timer?.Dispose();
+ _cancellationTokenSource.Dispose();
_executionSemaphore.Dispose();
}
}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs
new file mode 100644
index 000000000..7f6a507fe
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Activities/RemoveTopElementStep.cs
@@ -0,0 +1,13 @@
+using Elsa.Extensions;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Activities;
+
+public class RemoveTopElementStep : Activity
+{
+ protected override void Execute(ActivityExecutionContext context)
+ {
+ var elements = context.GetVariable("Elements");
+ context.SetVariable("Elements", elements!.Skip(1).ToArray());
+ context.CreateBookmark();
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/VariablesArrayWorkflowTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/VariablesArrayWorkflowTests.cs
new file mode 100644
index 000000000..a54ca4b5f
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/VariablesArrayWorkflowTests.cs
@@ -0,0 +1,79 @@
+using Elsa.Common.Models;
+using Elsa.Expressions.Helpers;
+using Elsa.Extensions;
+using Elsa.Workflows.ComponentTests.Abstractions;
+using Elsa.Workflows.ComponentTests.Fixtures;
+using Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Workflows;
+using Elsa.Workflows.Management;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Runtime;
+using Elsa.Workflows.Runtime.Entities;
+using Elsa.Workflows.Runtime.Messages;
+using Elsa.Workflows.State;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.VariablesArray;
+
+public class VariablesArrayWorkflowTests(App app) : AppComponentTest(app)
+{
+ [Fact(DisplayName = "Array variable is persisted across workflow runs")]
+ public async Task VariableIsPersistedAcrossWorkflowRuns()
+ {
+ var workflowRuntime = Scope.ServiceProvider.GetRequiredService();
+ var workflowClient = await workflowRuntime.CreateClientAsync();
+ var workflowInstanceStore = Scope.ServiceProvider.GetRequiredService();
+ var workflowDefinitionStore = Scope.ServiceProvider.GetRequiredService();
+ var bookmarkStore = Scope.ServiceProvider.GetRequiredService();
+ var runAndCreateRequest = new CreateAndRunWorkflowInstanceRequest
+ {
+ WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(VariableArrayWorkflow.DefinitionId, VersionOptions.Latest),
+ };
+ var runResponse = await workflowClient.CreateAndRunInstanceAsync(runAndCreateRequest);
+ var workflowInstanceId = runResponse.WorkflowInstanceId;
+ var createdBookmarks = await bookmarkStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ });
+ var bookmarks = new Stack(createdBookmarks);
+ var expectedElementLength = 3;
+
+ var result = await workflowDefinitionStore.FindLastVersionAsync(new Management.Filters.WorkflowDefinitionFilter()
+ {
+ DefinitionId = VariableArrayWorkflow.DefinitionId
+ }, default);
+
+ Assert.Equal(["Element 1", "Element 2", "Element 3"], result?.Variables.FirstOrDefault(v => v.Id == "elementsVariable")?.Value as IEnumerable);
+
+ while (bookmarks.Any())
+ {
+ var workflowInstance = await workflowInstanceStore.FindAsync(workflowInstanceId);
+ var workflowState = workflowInstance!.WorkflowState;
+ var rootWorkflowActivityExecutionContext = workflowState.ActivityExecutionContexts.Single(x => x.ParentContextId == null);
+ var variables = GetVariablesDictionary(rootWorkflowActivityExecutionContext);
+ var actualElements = variables["elementsVariable"].ConvertTo();
+ Assert.Equal(--expectedElementLength, actualElements?.Length);
+
+ var bookmark = bookmarks.Pop();
+ var runRequest = new RunWorkflowInstanceRequest
+ {
+ BookmarkId = bookmark.Id,
+ };
+
+ await workflowClient.RunInstanceAsync(runRequest);
+
+ createdBookmarks = await bookmarkStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ });
+
+ foreach (var newBookmark in createdBookmarks)
+ bookmarks.Push(newBookmark);
+ }
+ Assert.Equal(0, expectedElementLength);
+ }
+
+ private VariablesDictionary GetVariablesDictionary(ActivityExecutionContextState context)
+ {
+ return context.Properties.GetOrAdd(WorkflowInstanceStorageDriver.VariablesDictionaryStateKey, () => new VariablesDictionary());
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Workflows/VariableArrayWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Workflows/VariableArrayWorkflow.cs
new file mode 100644
index 000000000..e82046ae4
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/VariablesArray/Workflows/VariableArrayWorkflow.cs
@@ -0,0 +1,34 @@
+using Elsa.Extensions;
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Activities;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.VariablesArray.Workflows;
+
+public class VariableArrayWorkflow : WorkflowBase
+{
+ public static readonly string DefinitionId = Guid.NewGuid().ToString();
+
+ protected override void Build(IWorkflowBuilder builder)
+ {
+ builder.WithDefinitionId(DefinitionId);
+ var elements = builder.WithVariable("Elements", ["Element 1", "Element 2", "Element 3"]).WithWorkflowStorage();
+
+ builder.Root = new Sequence
+ {
+ Activities =
+ {
+ new While(context => elements.Get(context)!.Length > 0)
+ {
+ Body = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine(context => $"Top Element: {elements.Get(context)![0]}"),
+ new RemoveTopElementStep()
+ }
+ }
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj
new file mode 100644
index 000000000..a3f092e77
--- /dev/null
+++ b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs b/test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs
new file mode 100644
index 000000000..0cc3efe7a
--- /dev/null
+++ b/test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs
@@ -0,0 +1,52 @@
+using Elsa.Common.Serialization;
+
+namespace Elsa.Common.IntegrationTests.Serialization;
+
+public class EnumerableTypeConverterTests
+{
+ [Fact(DisplayName = "String variable is not serialized as JSON array")]
+ public void StringVariableIsNotSerializedAsJsonArray()
+ {
+ var testString = "Hello World";
+ AssertTypeConverterPreservesValue(testString);
+ }
+
+ [Fact(DisplayName = "Byte array variable is not serialized as JSON array")]
+ public void ByteArrayVariableIsNotSerializedAsJsonArray()
+ {
+ var testByteArray = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xFF };
+ AssertTypeConverterPreservesValue(testByteArray);
+ }
+
+ [Fact(DisplayName = "String array is serialized as JSON array")]
+ public void StringArrayIsSerializedAsJsonArray()
+ {
+ var testArray = new[] { "Element 1", "Element 2", "Element 3" };
+ AssertTypeConverterSerializesToJson(testArray, "[\"Element 1\",\"Element 2\",\"Element 3\"]");
+ }
+
+ [Fact(DisplayName = "List is serialized as JSON array")]
+ public void ListIsSerializedAsJsonArray()
+ {
+ var testList = new List { 1, 2, 3 };
+ AssertTypeConverterSerializesToJson(testList, "[1,2,3]");
+ }
+
+ private void AssertTypeConverterPreservesValue(T expectedValue)
+ {
+ var converter = new EnumerableTypeConverter();
+ var result = converter.ConvertTo(null, null, expectedValue, typeof(string));
+
+ Assert.Equal(expectedValue, result);
+ Assert.IsType(result);
+ }
+
+ private void AssertTypeConverterSerializesToJson(T value, string expectedJson)
+ {
+ var converter = new EnumerableTypeConverter();
+ var result = converter.ConvertTo(null, null, value, typeof(string));
+
+ Assert.IsType(result);
+ Assert.Equal(expectedJson, result);
+ }
+}
diff --git a/test/integration/Elsa.Common.IntegrationTests/Usings.cs b/test/integration/Elsa.Common.IntegrationTests/Usings.cs
new file mode 100644
index 000000000..c802f4480
--- /dev/null
+++ b/test/integration/Elsa.Common.IntegrationTests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Tests.cs
new file mode 100644
index 000000000..0720b0cbd
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Tests.cs
@@ -0,0 +1,28 @@
+using Elsa.Testing.Shared;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.SetGetVariablesArray;
+
+public class Tests
+{
+ private readonly IWorkflowRunner _workflowRunner;
+ private readonly CapturingTextWriter _capturingTextWriter = new();
+ private readonly IServiceProvider _services;
+
+ public Tests(ITestOutputHelper testOutputHelper)
+ {
+ _services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
+ _services.GetRequiredService();
+ _workflowRunner = _services.GetRequiredService();
+ }
+
+ [Fact(DisplayName = "Workflow can set variable")]
+ public async Task Test1()
+ {
+ await _services.PopulateRegistriesAsync();
+ await _workflowRunner.RunAsync();
+ var lines = _capturingTextWriter.Lines.ToList();
+ Assert.Equal(new[] { "Line 1", "Line 2" }, lines);
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Workflows.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Workflows.cs
new file mode 100644
index 000000000..2caa4d735
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SetGetVariablesArray/Workflows.cs
@@ -0,0 +1,33 @@
+using Elsa.Extensions;
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.Memory;
+using Elsa.Workflows.Models;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.SetGetVariablesArray;
+
+class SetGetVariableArrayWorkflow : WorkflowBase
+{
+ protected override void Build(IWorkflowBuilder workflow)
+ {
+ var variable1 = new Variable("Variable1", []);
+ var currentValueVariable = new Variable("CurrentValue", null!);
+
+ workflow.Root = new Sequence
+ {
+ Variables =
+ {
+ variable1
+ },
+
+ Activities =
+ {
+ new SetVariable(variable1, ["Line 1", "Line 2"]),
+ new ForEach(new Input>(variable1))
+ {
+ CurrentValue = new Output(currentValueVariable),
+ Body = new WriteLine(currentValueVariable)
+ }
+ }
+ };
+ }
+}
diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs
index 9bf59db61..5752968c3 100644
--- a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs
+++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs
@@ -1,8 +1,6 @@
using Elsa.Common;
using Elsa.Mediator.Contracts;
-using Elsa.Scheduling;
using Elsa.Scheduling.ScheduledTasks;
-using Elsa.Scheduling.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -16,8 +14,7 @@ public class ScheduledCronTaskTests : IDisposable
{
private const string DefaultCronExpression = "0 */5 * * * *";
private static readonly DateTimeOffset DefaultNow = new(2025, 11, 06, 22, 50, 00, 0, TimeSpan.Zero);
-
- private readonly ServiceCollection _services;
+
private readonly ServiceProvider _serviceProvider;
private readonly ISystemClock _systemClock;
private readonly ICronParser _cronParser;
@@ -26,13 +23,13 @@ public class ScheduledCronTaskTests : IDisposable
public ScheduledCronTaskTests()
{
- _services = new ServiceCollection();
+ var services = new ServiceCollection();
_systemClock = Substitute.For();
_cronParser = Substitute.For();
_logger = Substitute.For>();
- _services.AddSingleton(Substitute.For());
- _serviceProvider = _services.BuildServiceProvider();
+ services.AddSingleton(Substitute.For());
+ _serviceProvider = services.BuildServiceProvider();
}
private ScheduledCronTask CreateScheduledTask(
@@ -137,7 +134,7 @@ public class ScheduledCronTaskTests : IDisposable
// Act - This should not crash and should set up a timer with minimum delay
CreateScheduledTask();
- // Assert - Should call GetNextOccurrence twice (initial + retry) and log warning
+ // Assert - Should call GetNextOccurrence twice (initial + retry) and log warning once (on final attempt)
_cronParser.Received(2).GetNextOccurrence(DefaultCronExpression);
AssertWarningLogged();
}
@@ -180,10 +177,13 @@ public class ScheduledCronTaskTests : IDisposable
public void Dispose()
{
+ // Dispose tasks first to stop timers before disposing ServiceProvider
foreach (var task in _tasksToDispose)
{
((IDisposable)task).Dispose();
}
+ // Small delay to ensure any timer callbacks have completed
+ Thread.Sleep(10);
_serviceProvider.Dispose();
}
}
diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs
new file mode 100644
index 000000000..5fc93d315
--- /dev/null
+++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs
@@ -0,0 +1,177 @@
+using Elsa.Common;
+using Elsa.Mediator.Contracts;
+using Elsa.Scheduling.ScheduledTasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace Elsa.Scheduling.UnitTests.ScheduledTasks;
+
+///
+/// Tests for ScheduledRecurringTask to ensure recurring tasks handle edge cases correctly.
+///
+public class ScheduledRecurringTaskTests : IDisposable
+{
+ private static readonly DateTimeOffset DefaultNow = new(2025, 11, 06, 22, 50, 00, 0, TimeSpan.Zero);
+ private static readonly TimeSpan DefaultInterval = TimeSpan.FromMinutes(5);
+
+ private readonly ServiceProvider _serviceProvider;
+ private readonly ISystemClock _systemClock;
+ private readonly ILogger _logger;
+ private readonly List _tasksToDispose = new();
+
+ public ScheduledRecurringTaskTests()
+ {
+ var services = new ServiceCollection();
+ _systemClock = Substitute.For();
+ _logger = Substitute.For>();
+
+ services.AddSingleton(Substitute.For());
+ _serviceProvider = services.BuildServiceProvider();
+ }
+
+ private ScheduledRecurringTask CreateScheduledTask(
+ DateTimeOffset? startAt = null,
+ TimeSpan? interval = null,
+ ISystemClock? systemClock = null)
+ {
+ var task = Substitute.For();
+ var scheduledTask = new ScheduledRecurringTask(
+ task,
+ startAt ?? DefaultNow.AddMinutes(5),
+ interval ?? DefaultInterval,
+ systemClock ?? _systemClock,
+ _serviceProvider.CreateScope().ServiceProvider.GetRequiredService(),
+ _logger
+ );
+ _tasksToDispose.Add(scheduledTask);
+ return scheduledTask;
+ }
+
+ private void SetupSystemClock(params DateTimeOffset[] times)
+ {
+ _systemClock.UtcNow.Returns(times[0], times.Skip(1).ToArray());
+ }
+
+ private void AssertNoErrorLogged()
+ {
+ _logger.DidNotReceive().Log(
+ LogLevel.Error,
+ Arg.Any(),
+ Arg.Any
/// The type name of the storage driver.
/// The display name of the storage driver.
+/// The priority of the storage driver.
+/// Indicates whether the storage driver is deprecated.
public record StorageDriverDescriptor(string TypeName, string DisplayName, double Priority = 0, bool Deprecated = false);
\ No newline at end of file
diff --git a/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs b/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs
index 5ac381cb0..695268c58 100644
--- a/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs
+++ b/src/common/Elsa.Mediator/Extensions/HandlerExtensions.cs
@@ -42,8 +42,7 @@ public static class HandlerExtensions
///
/// The handler to invoke.
/// The handle method.
- /// The notification to handle.
- /// The cancellation token.
+ /// The notification context containing the notification and cancellation token.
public static Task InvokeAsync(this INotificationHandler handler, MethodBase handleMethod, NotificationContext notificationContext)
{
var notification = notificationContext.Notification;
diff --git a/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs b/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs
index 47cceb0d9..645181b95 100644
--- a/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs
+++ b/src/common/Elsa.Mediator/Middleware/Command/Components/CommandHandlerInvokerMiddleware.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using Elsa.Mediator.Contexts;
using Elsa.Mediator.Contracts;
using Elsa.Mediator.Middleware.Command.Contracts;
@@ -13,6 +14,7 @@ namespace Elsa.Mediator.Middleware.Command.Components;
public class CommandHandlerInvokerMiddleware(CommandMiddlewareDelegate next) : ICommandMiddleware
{
///
+ [UnconditionalSuppressMessage("Trimming", "IL2060:Call to MakeGenericMethod can not be statically analyzed", Justification = "The result type is determined at runtime from command types and handlers are registered in DI.")]
public async ValueTask InvokeAsync(CommandContext context)
{
// Find all handlers for the specified command.
diff --git a/src/common/Elsa.Mediator/Services/DefaultMediator.cs b/src/common/Elsa.Mediator/Services/DefaultMediator.cs
index 40587c3b7..3428e283f 100644
--- a/src/common/Elsa.Mediator/Services/DefaultMediator.cs
+++ b/src/common/Elsa.Mediator/Services/DefaultMediator.cs
@@ -28,6 +28,7 @@ public class DefaultMediator : IMediator
/// The command pipeline.
/// The notification pipeline.
/// The mediator options.
+ /// The service provider.
public DefaultMediator(
IRequestPipeline requestPipeline,
ICommandPipeline commandPipeline,
diff --git a/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs b/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs
index 8b994b647..0338c82d6 100644
--- a/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs
+++ b/src/modules/Elsa.Alterations.Core/Models/NewAlterationJob.cs
@@ -2,6 +2,6 @@ namespace Elsa.Alterations.Core.Models;
public class NewAlterationJob
{
- public string PlanId { get; set; }
- public string WorkflowInstanceId { get; set; }
+ public string PlanId { get; set; } = null!;
+ public string WorkflowInstanceId { get; set; } = null!;
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs
index f888d378f..7738820fa 100644
--- a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs
+++ b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Response.cs
@@ -1,6 +1,6 @@
namespace Elsa.Alterations.Endpoints.Alterations.Submit;
///
-/// The response from the endpoint.
+/// The response from the Submit endpoint.
///
public record Response(string PlanId);
\ No newline at end of file
diff --git a/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs b/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs
index bfb1a786a..e37ee6074 100644
--- a/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs
+++ b/src/modules/Elsa.Common/DistributedHosting/Options/DistributedLockingOptions.cs
@@ -3,7 +3,7 @@ using JetBrains.Annotations;
namespace Elsa.Common.DistributedHosting;
///
-/// Provides options related to distributed locking, which is used by .
+/// Provides options related to distributed locking, which is used by the workflow runtime.
///
[UsedImplicitly]
public class DistributedLockingOptions
diff --git a/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs b/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs
index 0d717c0f0..6491ac9a0 100644
--- a/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs
+++ b/src/modules/Elsa.Expressions.JavaScript/Extensions/TypeAliasRegistryExtensions.cs
@@ -9,8 +9,9 @@ namespace Elsa.Expressions.JavaScript.Extensions;
public static class TypeAliasRegistryExtensions
{
///
- /// Register type with the specified alias.
+ /// Register type with the specified alias.
///
+ /// The type to register.
public static void RegisterType(this ITypeAliasRegistry registry, string alias) => registry.RegisterType(typeof(T), alias);
///
diff --git a/src/modules/Elsa.Http/Contexts/DownloadableContext.cs b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs
index cfe7b7c89..daa889c2d 100644
--- a/src/modules/Elsa.Http/Contexts/DownloadableContext.cs
+++ b/src/modules/Elsa.Http/Contexts/DownloadableContext.cs
@@ -7,10 +7,10 @@ namespace Elsa.Http.Contexts;
///
/// The manager.
/// The content to get downloadables from.
-/// An optional ETag.
+/// The downloadable options.
/// The cancellation token.
public record DownloadableContext(
- IDownloadableManager Manager,
- object Content,
+ IDownloadableManager Manager,
+ object Content,
DownloadableOptions Options,
CancellationToken CancellationToken);
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs b/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs
index fee5eb3ab..24c3f7146 100644
--- a/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs
+++ b/src/modules/Elsa.Http/UIHints/HttpContentTypeOptionsProvider.cs
@@ -5,7 +5,7 @@ using Elsa.Workflows.UIHints.Dropdown;
namespace Elsa.Http.UIHints;
///
-/// Provides options for the activity's property.
+/// Provides options for the activity's property.
///
public class HttpContentTypeOptionsProvider : DropDownOptionsProviderBase
{
diff --git a/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs
index a0badc6b2..de6762a28 100644
--- a/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs
+++ b/src/modules/Elsa.Identity/Extensions/ModuleExtensions.cs
@@ -10,7 +10,7 @@ namespace Elsa.Extensions;
public static class ModuleExtensions
{
///
- /// Installs & configures the feature.
+ /// Installs and configures the feature.
///
public static IModule UseIdentity(this IModule module, Action? configure = null)
{
@@ -19,7 +19,7 @@ public static class ModuleExtensions
}
///
- /// Installs & configures the feature.
+ /// Installs and configures the feature.
///
public static IModule UseIdentity(this IModule module, string signingKey, string issuer = "http://elsa.api", string audience = "http://elsa.api", TimeSpan? tokenLifetime = null)
{
@@ -34,7 +34,7 @@ public static class ModuleExtensions
}
///
- /// Installs & configures the feature.
+ /// Installs and configures the feature.
///
public static IModule UseDefaultAuthentication(this IModule module, Action? configure = null)
{
diff --git a/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs b/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs
index 5b9b8127a..bdcccf587 100644
--- a/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs
+++ b/src/modules/Elsa.Persistence.EFCore.Common/DbSchemaAwareMigrationAssembly.cs
@@ -1,4 +1,5 @@
-using System.Reflection;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -10,6 +11,7 @@ namespace Elsa.Persistence.EFCore;
///
/// Class That enable Schema change for Migration
///
+[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to customize migration assembly behavior for schema-aware migrations.")]
public class DbSchemaAwareMigrationAssembly(
ICurrentDbContext currentContext,
IDbContextOptions options,
diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj b/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj
index f6c2ff912..4306e5c1f 100644
--- a/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj
+++ b/src/modules/Elsa.Persistence.EFCore.Common/Elsa.Persistence.EFCore.Common.csproj
@@ -5,6 +5,7 @@
Provides common Entity Framework Core types for implementing EF Core persistence of varipus ELsa modules.
elsa extension module persistence efcore
+ Elsa.Persistence.EFCore
diff --git a/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs
index b2a279fe5..0311b1a88 100644
--- a/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs
+++ b/src/modules/Elsa.Resilience/Modifiers/ResilientActivityDescriptorModifier.cs
@@ -4,7 +4,7 @@ using Elsa.Workflows.Models;
namespace Elsa.Resilience.Modifiers;
///
-/// Modifies the of the activity.
+/// Modifies the for resilient activities.
///
public class ResilientActivityDescriptorModifier : IActivityDescriptorModifier
{
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs
index bad95b669..94e4e61d9 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityDescriptors/Get/Models.cs
@@ -2,6 +2,6 @@ namespace Elsa.Workflows.Api.Endpoints.ActivityDescriptors.Get;
internal class Request
{
- public string TypeName { get; set; }
+ public string TypeName { get; set; } = null!;
public int? Version { get; set; }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs
index 71115061f..ee01d5b34 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Variables/Post/Endpoint.cs
@@ -36,7 +36,7 @@ internal class List(IWorkflowInstanceVariableManager workflowInstanceVariableMan
internal class Request
{
- public ICollection Variables { get; set; }
+ public ICollection Variables { get; set; } = null!;
}
internal record ResolvedVariableModel(string Id, string Name, object? Value);
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs b/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs
index 551f7b529..2da6a890e 100644
--- a/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs
+++ b/src/modules/Elsa.Workflows.Api/Models/ActivityPathSegment.cs
@@ -7,4 +7,5 @@ namespace Elsa.Workflows.Api.Models;
/// The ID of the activity.
/// The type of the activity.
/// The name of the port.
+/// The name of the activity.
public record ActivityPathSegment(string ActivityNodeId, string ActivityId, string ActivityType, string PortName, string ActivityName);
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs b/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs
index 2af9c2334..febe80558 100644
--- a/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs
+++ b/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -45,6 +46,7 @@ public class ArgumentJsonConverter : JsonConverter
}
///
+ [UnconditionalSuppressMessage("Trimming", "IL2055:Call to MakeGenericType can not be statically analyzed", Justification = "Types are dynamically resolved from workflow definitions and registered in the well-known type registry.")]
public override ArgumentDefinition Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var jsonObject = (JsonObject)JsonNode.Parse(ref reader)!;
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs
index 03db3b0d1..7e0194259 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs
@@ -63,7 +63,6 @@ public partial class Flowchart
/// This method determines whether an activity should be scheduled based on visited connections,
/// forward traversal rules, and backward connections.
///
- /// The flowchart containing the activities.
/// The graph representation of the flowchart.
/// Tracks activity and connection visits.
/// The execution context of the flowchart.
diff --git a/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs b/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs
index 9d9d06901..09efb9cc9 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/ForEachT.cs
@@ -9,8 +9,9 @@ using Elsa.Workflows.Models;
namespace Elsa.Workflows.Activities;
///
-/// A strongly-typed for-each construct where is the item type.
+/// A strongly-typed for-each construct where is the item type.
///
+/// The type of items in the collection to iterate over.
public class ForEach : Activity
{
private const string CurrentIndexProperty = "CurrentIndex";
diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs
index de652e991..00f0b0382 100644
--- a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs
+++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs
@@ -131,7 +131,6 @@ public interface IWorkflowStateSerializer
/// Deserializes the specified serialized state.
///
/// The serialized state.
- /// The cancellation token.
/// The deserialized workflow state.
[RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")]
T Deserialize(string serializedState);
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs
index e90a454bd..d8a53bbe4 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/MemoryBlockReferenceExtensions.cs
@@ -15,7 +15,7 @@ public static class MemoryBlockReferenceExtensions
///
/// The to get the value from.
/// The ID of the memory block to get the value for.
- /// The value of the memory reference referenced by the specified .
+ /// The value of the memory reference referenced by the specified block ID.
public static object? Get(this ExpressionExecutionContext context, string blockId)
{
var matchingContext = context.FindContextContainingBlock(blockId) ?? context;
@@ -27,7 +27,7 @@ public static class MemoryBlockReferenceExtensions
///
/// The to get the value from.
/// The ID of the memory block to get the value for.
- /// The value of the memory reference referenced by the specified .
+ /// The value of the memory reference referenced by the specified block ID.
public static object? Get(this ActivityExecutionContext context, string blockId) => context.ExpressionExecutionContext.Get(blockId);
///
diff --git a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs
index e2ba868f8..4b5e7520d 100644
--- a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs
+++ b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs
@@ -15,6 +15,7 @@ namespace Elsa.Workflows.Models;
/// The date and time the bookmark was created.
/// Whether or not the bookmark should be automatically burned.
/// The name of the method on the activity class to invoke when the bookmark is resumed.
+/// Whether or not the activity should be automatically completed when the bookmark is resumed.
/// Custom properties associated with the bookmark.
public record Bookmark(
string Id,
diff --git a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs
index 4380ecae1..878a98474 100644
--- a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs
+++ b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs
@@ -8,7 +8,7 @@ public class CheckList
///
/// The radio list.
///
- public IEnumerable Items { get; set; }
+ public IEnumerable Items { get; set; } = null!;
///
/// The name of the provider that will provide the select list.
diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs
index ace833be9..a0274cfb6 100644
--- a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs
+++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs
@@ -8,7 +8,7 @@ public class RadioList
///
/// The radio list.
///
- public IEnumerable Items { get; set; }
+ public IEnumerable Items { get; set; } = null!;
///
/// The name of the provider that will provide the select list.
diff --git a/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs b/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs
index 598155655..b9badee90 100644
--- a/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs
+++ b/src/modules/Elsa.Workflows.Management/Models/WorkflowDefinitionModel.cs
@@ -60,8 +60,8 @@ public class WorkflowDefinitionModel
Root = root;
}
- public string Id { get; set; }
- public string DefinitionId { get; set; }
+ public string Id { get; set; } = null!;
+ public string DefinitionId { get; set; } = null!;
public string? TenantId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
diff --git a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs
index 62c84c96c..eed280722 100644
--- a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs
+++ b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionPublished.cs
@@ -9,5 +9,6 @@ namespace Elsa.Workflows.Management.Notifications;
/// A notification that is sent when a workflow definition is published.
///
/// The workflow definition.
+/// The affected workflows.
[PublicAPI]
public record WorkflowDefinitionPublished(WorkflowDefinition WorkflowDefinition, AffectedWorkflows AffectedWorkflows) : INotification;
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs
index 967c56686..2da147834 100644
--- a/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs
+++ b/src/modules/Elsa.Workflows.Management/Notifications/WorkflowDefinitionValidating.cs
@@ -8,4 +8,5 @@ namespace Elsa.Workflows.Management.Notifications;
/// A request to validate a workflow definition.
///
/// The workflow materialized from the definition.
+/// The collection of validation errors.
public record WorkflowDefinitionValidating(Workflow Workflow, ICollection ValidationErrors) : INotification;
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs b/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs
index 9619e1d73..22046821f 100644
--- a/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Activities/DispatchWorkflow.cs
@@ -59,7 +59,7 @@ public class DispatchWorkflow : Activity
/// Indicates whether a new trace context should be started for the workflow execution.
///
[Input(Description = "Start a new trace context when using Open Telemetry.", Category = "Open Telemetry")]
- public Input StartNewTrace { get; set; }
+ public Input StartNewTrace { get; set; } = null!;
///
/// The channel to dispatch the workflow to.
diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs
index 1eabb75bd..389ec0630 100644
--- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDefinitionStorePopulator.cs
@@ -15,7 +15,7 @@ public interface IWorkflowDefinitionStorePopulator
Task> PopulateStoreAsync(CancellationToken cancellationToken = default);
///
- /// Populates the with workflow definitions provided from implementations.
+ /// Populates the with workflow definitions provided from implementations.
///
/// Whether to index triggers.
/// The cancellation token.
diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs
index d7402bbfe..e5ab1d60f 100644
--- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs
@@ -105,12 +105,12 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module)
public Func BackgroundActivityScheduler { get; set; } = sp => ActivatorUtilities.CreateInstance(sp);
///
- /// A factory that instantiates an for an .
+ /// A factory that instantiates a log record sink for an .
///
public Func> ActivityExecutionLogSink { get; set; } = sp => sp.GetRequiredService();
///
- /// A factory that instantiates an for an .
+ /// A factory that instantiates a log record sink for an .
///
public Func> WorkflowExecutionLogSink { get; set; } = sp => sp.GetRequiredService();
diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs
index 500564992..f8861ab3c 100644
--- a/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Notifications/IndexedWorkflowBookmarks.cs
@@ -3,14 +3,14 @@ using Elsa.Workflows.Models;
namespace Elsa.Workflows.Runtime.Notifications;
///
-/// Contains the bookmarks that were added, removed, or unchanged.
+/// Contains the bookmarks that were added, removed, or unchanged.
///
-/// The workflow instance ID.
+/// The workflow execution context.
/// The bookmarks that were added.
/// The bookmarks that were removed.
/// The bookmarks that were unchanged.
public record IndexedWorkflowBookmarks(
WorkflowExecutionContext WorkflowExecutionContext,
- ICollection AddedBookmarks,
- ICollection RemovedBookmarks,
+ ICollection AddedBookmarks,
+ ICollection RemovedBookmarks,
ICollection UnchangedBookmarks);
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs
index 2aaddb867..10e73c572 100644
--- a/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Notifications/RunTaskRequest.cs
@@ -5,7 +5,7 @@ namespace Elsa.Workflows.Runtime.Notifications;
///
/// A domain event that applications can subscribe to in order to handle tasks requested by a workflow.
///
-/// name="ActivityExecutionContext">The context of the activity that requested the task to run.
+/// The context of the activity that requested the task to run.
/// A unique identifier for an individual task request.
/// The name of the task requested to run.
/// Any additional parameters to send to the task.
diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs
index 6303effd1..fe1ddd4dc 100644
--- a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs
@@ -5,7 +5,6 @@ namespace Elsa.Workflows.Runtime.Notifications;
///
/// A notification that is sent when the bookmarks of a workflow instance have been indexed.
///
-/// The workflow execution context.
/// The bookmarks that were added, removed, or unchanged.
public record WorkflowBookmarksIndexed(IndexedWorkflowBookmarks IndexedWorkflowBookmarks) : INotification;
diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs
index b5b5f2fb2..2d176c39b 100644
--- a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs
@@ -7,6 +7,5 @@ namespace Elsa.Workflows.Runtime.Notifications;
///
/// Published when the bookmarks of a workflow instance have been persisted.
///
-/// The workflow execution context.
/// The bookmarks that were added, removed, or unchanged.
public record WorkflowBookmarksPersisted(Diff Diff) : INotification;
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs b/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs
index b6e6b145d..44e7e377b 100644
--- a/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Requests/UpdateBookmarksRequest.cs
@@ -6,7 +6,7 @@ namespace Elsa.Workflows.Runtime.Requests;
///
/// Published when bookmarks needs to be updated.
///
-/// The workflow instance ID.
+/// The workflow execution context.
/// A diff of the bookmarks.
/// The correlation ID, if any.
public record UpdateBookmarksRequest(WorkflowExecutionContext WorkflowExecutionContext, Diff Diff, string? CorrelationId = default);
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs
index 33cafbe82..f836f98a7 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs
@@ -1,3 +1,4 @@
+
using Elsa.Common.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Filters;
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs b/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs
index 6a9c448a1..1126c3031 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs
@@ -9,6 +9,7 @@ namespace Elsa.Workflows.Runtime;
/// Validates the workflow request before dispatching it to the workflow dispatcher.
///
/// The workflow dispatcher to decorate.
+/// The workflow dispatcher options.
public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService, IOptions dispatcherOptions) : IWorkflowDispatcher
{
private IWorkflowDispatcher DecoratedService { get; set; } = decoratedService;
diff --git a/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs
index 7379ea8d5..12b15ca1d 100644
--- a/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs
+++ b/test/unit/Elsa.Activities.UnitTests/Http/WriteFileHttpResponseTests.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.IO.Compression;
using Elsa.Common;
@@ -29,9 +30,9 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
- activity.ContentType = new Input(contentType);
- activity.Filename = new Input(filename);
+ activity.Content = new(testContent);
+ activity.ContentType = new(contentType);
+ activity.Filename = new(filename);
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -49,8 +50,8 @@ public class WriteFileHttpResponseTests
{
// Arrange
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
- activity.Content = new Input(content);
- activity.Filename = new Input("test.txt");
+ activity.Content = new(content);
+ activity.Filename = new("test.txt");
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -68,8 +69,8 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testBytes = "Hello World"u8.ToArray();
- activity.Content = new Input(testBytes);
- activity.Filename = new Input("test.bin");
+ activity.Content = new(testBytes);
+ activity.Filename = new("test.bin");
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -88,8 +89,8 @@ public class WriteFileHttpResponseTests
var testContent = "Stream content";
var stream = new MemoryStream(Encoding.UTF8.GetBytes(testContent));
- activity.Content = new Input(stream);
- activity.Filename = new Input("stream.txt");
+ activity.Content = new(stream);
+ activity.Filename = new("stream.txt");
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -107,8 +108,8 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testUri = new Uri("https://example.com/file.txt");
- activity.Content = new Input(testUri);
- activity.Filename = new Input("downloaded.txt");
+ activity.Content = new(testUri);
+ activity.Filename = new("downloaded.txt");
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -129,8 +130,8 @@ public class WriteFileHttpResponseTests
"File 2 content"u8.ToArray()
};
- activity.Content = new Input(files);
- activity.Filename = new Input("archive.zip");
+ activity.Content = new(files);
+ activity.Filename = new("archive.zip");
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -146,7 +147,7 @@ public class WriteFileHttpResponseTests
{
// Arrange
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
- activity.Content = new Input((object?)null!, string.Empty);
+ activity.Content = new((object?)null!, string.Empty);
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -161,7 +162,7 @@ public class WriteFileHttpResponseTests
{
// Arrange
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
- activity.Content = new Input(Array.Empty());
+ activity.Content = new(Array.Empty());
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -181,9 +182,9 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
- activity.EntityTag = new Input(entityTag);
- activity.EnableResumableDownloads = new Input(true);
+ activity.Content = new(testContent);
+ activity.EntityTag = new(entityTag);
+ activity.EnableResumableDownloads = new(true);
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -202,9 +203,9 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
- activity.EnableResumableDownloads = new Input(true);
- activity.EntityTag = new Input("\"test-etag\"");
+ activity.Content = new(testContent);
+ activity.EnableResumableDownloads = new(true);
+ activity.EntityTag = new("\"test-etag\"");
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -223,9 +224,9 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
- activity.EnableResumableDownloads = new Input(false);
- activity.EntityTag = new Input("\"test-etag\"");
+ activity.Content = new(testContent);
+ activity.EnableResumableDownloads = new(false);
+ activity.EntityTag = new("\"test-etag\"");
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -247,8 +248,8 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
- activity.Filename = new Input(filename);
+ activity.Content = new(testContent);
+ activity.Filename = new(filename);
// ContentType not set, should be determined from filename
// Act
@@ -266,7 +267,7 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
+ activity.Content = new(testContent);
// Filename not set, should default to "file.bin"
// Act
@@ -306,7 +307,7 @@ public class WriteFileHttpResponseTests
// Arrange
var activity = new WriteFileHttpResponse();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
+ activity.Content = new(testContent);
var fixture = new ActivityTestFixture(activity);
var httpContext = CreateMockHttpContext();
@@ -361,7 +362,7 @@ public class WriteFileHttpResponseTests
ETag = "\"metadata-etag\""
};
- activity.Content = new Input(downloadable);
+ activity.Content = new(downloadable);
// Act
var context = await ExecuteActivityAsync(activity, httpContext);
@@ -379,9 +380,9 @@ public class WriteFileHttpResponseTests
var (activity, httpContext) = CreateWriteFileHttpResponseActivity();
var testContent = "Hello World"u8.ToArray();
- activity.Content = new Input(testContent);
- activity.EntityTag = new Input("W/\"weak-etag\""); // Malformed weak ETag format
- activity.EnableResumableDownloads = new Input(true);
+ activity.Content = new(testContent);
+ activity.EntityTag = new("W/\"weak-etag\""); // Malformed weak ETag format
+ activity.EnableResumableDownloads = new(true);
// Act & Assert
var exception = await Assert.ThrowsAsync(async () =>
@@ -548,14 +549,14 @@ public class WriteFileHttpResponseTests
}
else
{
- services.AddSingleton(_ => new object());
+ services.AddSingleton(_ => new());
}
}
private static object CreateZipManagerInstance(IServiceProvider serviceProvider)
{
var zipManagerType = typeof(WriteFileHttpResponse).Assembly.GetType("Elsa.Http.Services.ZipManager");
- if (zipManagerType == null) return new object();
+ if (zipManagerType == null) return new();
try
{
@@ -591,13 +592,14 @@ public class WriteFileHttpResponseTests
};
}
+ [UnconditionalSuppressMessage("Trimming", "IL2060:Call to MakeGenericMethod can not be statically analyzed", Justification = "Test helper method that creates mocks dynamically for any logger type.")]
private static object CreateLoggerMock(Type loggerType)
{
try
{
var mockLoggerMethod = typeof(Substitute).GetMethods()
.FirstOrDefault(m => m is { Name: "For", IsGenericMethodDefinition: true } && m.GetParameters().Length == 0);
-
+
if (mockLoggerMethod != null)
{
var genericMethod = mockLoggerMethod.MakeGenericMethod(loggerType);
@@ -634,7 +636,7 @@ public class WriteFileHttpResponseTests
{
// Simplified mock creation for different content types
case byte[] byteArray:
- yield return new Downloadable
+ yield return new()
{
Stream = new MemoryStream(byteArray),
ContentType = null,
@@ -642,7 +644,7 @@ public class WriteFileHttpResponseTests
};
break;
case string text:
- yield return new Downloadable
+ yield return new()
{
Stream = new MemoryStream(Encoding.UTF8.GetBytes(text)),
ContentType = null,
@@ -651,7 +653,7 @@ public class WriteFileHttpResponseTests
break;
case Stream stream:
// For Stream objects, return them as-is
- yield return new Downloadable
+ yield return new()
{
Stream = stream,
ContentType = null,
@@ -662,7 +664,7 @@ public class WriteFileHttpResponseTests
{
// For Uri objects, simulate downloaded content
var mockContent = $"Downloaded content from {uri}";
- yield return new Downloadable
+ yield return new()
{
Stream = new MemoryStream(Encoding.UTF8.GetBytes(mockContent)),
ContentType = null,
@@ -710,7 +712,7 @@ public class WriteFileHttpResponseTests
}
memoryStream.Position = 0;
- return new Downloadable
+ return new()
{
Stream = memoryStream,
ContentType = "application/zip",
From 2c0b3da5de6ff4a1ecb45d6c79485db93899d2f1 Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Wed, 12 Nov 2025 16:56:31 +0100
Subject: [PATCH 06/33] Addresses warnings and enforces null safety (#7051)
* Enhance null-safety annotations across modules and refactor for improved consistency:
- Added `null!` annotations to enforce non-nullability expectations.
- Updated workflows, tests, and runtime services to handle default null values reliably.
- Removed obsolete and unused APIs, simplifying interfaces and improving maintainability.
- Refactored methods and properties for clarity, thread-safety, and consistency.
- Adjusted test configurations for code coverage tracking and integration improvements.
* Refactor activity iteration in container serialization tests to simplify type casting.
---
.../Extensions/JsonObjectExtensions.cs | 2 +-
.../Elsa.Mediator/Contracts/ICommandSender.cs | 8 +++---
.../Extensions/MediatorExtensions.cs | 4 +--
.../Elsa.Mediator/Services/DefaultMediator.cs | 5 ++--
.../DispatchWorkflowExtensions.cs | 2 +-
src/common/Elsa.Testing.Shared/XunitLogger.cs | 19 ++++----------
.../Helpers/ConfigurationSectionWrapper.cs | 13 +++-------
.../Elsa.Http/Activities/HttpEndpoint.cs | 2 +-
.../Activities/SendHttpRequestBase.cs | 4 +--
.../Elsa.Http/Activities/WriteHttpResponse.cs | 2 +-
.../FormUrlEncodedHttpContentFactory.cs | 2 +-
.../HttpActivityExecutionContextExtensions.cs | 2 +-
.../Elsa.Http/Handlers/UpdateRouteTable.cs | 6 ++---
.../Extensions/BulkUpsertExtensions.cs | 18 ++++++-------
.../JsonValueComparer.cs | 20 +++++---------
.../JsonValueConverterHelper.cs | 2 +-
.../Management/WorkflowDefinitionStore.cs | 2 +-
.../Runtime/ActivityExecutionLogStore.cs | 6 +----
.../WorkflowDefinitions/Export/Endpoint.cs | 7 ++---
.../WorkflowInstances/List/Endpoint.cs | 2 +-
.../Requirements/NotReadOnlyRequirement.cs | 7 ++---
.../Serialization/ArgumentJsonConverter.cs | 2 +-
.../Activities/ParallelForEachT.cs | 2 +-
.../Builders/WorkflowBuilder.cs | 2 +-
.../Contexts/WorkflowExecutionContext.cs | 2 +-
.../Activities/ExceptionHandlingMiddleware.cs | 3 +--
.../Converters/PolymorphicObjectConverter.cs | 7 +++--
.../PolymorphicObjectConverterFactory.cs | 2 +-
.../WorkflowInstanceStorageDriver.cs | 2 +-
.../WorkflowDefinitionActivity.cs | 7 +++++
.../Mappers/VariableDefinitionMapper.cs | 2 +-
.../Contracts/IWorkflowDispatcher.cs | 8 +++---
.../Contracts/IWorkflowExecutionLogStore.cs | 5 ----
.../Features/WorkflowRuntimeFeature.cs | 1 -
.../Filters/TriggerFilter.cs | 2 +-
.../ResumeDispatchWorkflowActivity.cs | 3 +--
.../Services/ObsoleteWorkflowRuntime.cs | 10 +++----
.../Services/StimulusProxyWorkflowInbox.cs | 2 +-
.../Services/StoreActivityExecutionLogSink.cs | 3 +--
.../Services/ValidatingWorkflowDispatcher.cs | 8 +++---
.../Helpers/Activities/TriggerSignal.cs | 2 +-
.../BulkDispatchWorkflowsTests.cs | 2 +-
.../Scenarios/FlowchartNextActivity/Tests.cs | 2 +-
.../Incidents/IncidentStrategyTests.cs | 2 +-
.../Scenarios/Serialization/Tests.cs | 4 +--
.../ContainerSerialization/Tests.cs | 26 +++++++++++--------
.../Serialization/JsonSerialization/Tests.cs | 2 +-
.../ActivityUIHintDescriberTests.cs | 4 +--
.../ConsoleActivitiesBenchmark.cs | 8 +++---
.../Composition/ExecuteWorkflowTests.cs | 4 +--
50 files changed, 119 insertions(+), 145 deletions(-)
diff --git a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs
index 3653519b9..5b4ad7e9c 100644
--- a/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs
+++ b/src/clients/Elsa.Api.Client/Extensions/JsonObjectExtensions.cs
@@ -160,7 +160,7 @@ public static class JsonObjectExtensions
{
return model.GetProperty(path);
}
- catch (Exception e)
+ catch (Exception)
{
return default;
}
diff --git a/src/common/Elsa.Mediator/Contracts/ICommandSender.cs b/src/common/Elsa.Mediator/Contracts/ICommandSender.cs
index 5373afb3b..02d32ca7a 100644
--- a/src/common/Elsa.Mediator/Contracts/ICommandSender.cs
+++ b/src/common/Elsa.Mediator/Contracts/ICommandSender.cs
@@ -28,7 +28,7 @@ public interface ICommandSender
/// The cancellation token.
/// The type of the result.
/// The result.
- Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default);
+ Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default);
///
/// Sends a command using the specified strategy.
@@ -39,7 +39,7 @@ public interface ICommandSender
/// The cancellation token.
/// The type of the result.
/// The result.
- Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary headers, CancellationToken cancellationToken = default);
+ Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default);
///
/// Sends a command using the default strategy.
@@ -54,7 +54,7 @@ public interface ICommandSender
/// The command to send.
/// The command strategy to use.
/// The cancellation token.
- Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default);
+ Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default);
///
/// Sends a command using the specified strategy.
@@ -63,5 +63,5 @@ public interface ICommandSender
/// The command strategy to use.
/// Any headers to pass along.
/// The cancellation token.
- Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary headers, CancellationToken cancellationToken = default);
+ Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs b/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs
index e1b307658..69328381c 100644
--- a/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs
+++ b/src/common/Elsa.Mediator/Extensions/MediatorExtensions.cs
@@ -39,7 +39,7 @@ public static class MediatorExtensions
/// The cancellation token.
public static async Task SendAsync(this IMediator mediator, ICommand command, CancellationToken cancellationToken = default)
{
- await mediator.SendAsync(command, default, cancellationToken);
+ await mediator.SendAsync(command, null, cancellationToken);
}
///
@@ -50,6 +50,6 @@ public static class MediatorExtensions
/// The cancellation token.
public static async Task SendAsync(this ICommandSender commandSender, ICommand command, CancellationToken cancellationToken = default)
{
- await commandSender.SendAsync(command, default, cancellationToken);
+ await commandSender.SendAsync(command, null, cancellationToken);
}
}
\ No newline at end of file
diff --git a/src/common/Elsa.Mediator/Services/DefaultMediator.cs b/src/common/Elsa.Mediator/Services/DefaultMediator.cs
index 3428e283f..96744ec57 100644
--- a/src/common/Elsa.Mediator/Services/DefaultMediator.cs
+++ b/src/common/Elsa.Mediator/Services/DefaultMediator.cs
@@ -54,9 +54,10 @@ public class DefaultMediator : IMediator
return (T)context.Response;
}
- public async Task SendAsync(ICommand command, ICommandStrategy strategy, IDictionary headers, CancellationToken cancellationToken = default)
+ public async Task SendAsync(ICommand command, ICommandStrategy? strategy, IDictionary headers, CancellationToken cancellationToken = default)
{
var resultType = typeof(T);
+ strategy ??= _defaultCommandStrategy;
var context = new CommandContext(command, strategy, resultType, headers, _serviceProvider, cancellationToken);
await _commandPipeline.InvokeAsync(context);
@@ -92,7 +93,7 @@ public class DefaultMediator : IMediator
}
///
- public Task SendAsync(ICommand command, ICommandStrategy strategy, CancellationToken cancellationToken = default)
+ public Task SendAsync(ICommand command, ICommandStrategy? strategy, CancellationToken cancellationToken = default)
{
return SendAsync(command, strategy, new Dictionary(), cancellationToken);
}
diff --git a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs
index 578802abd..df3436068 100644
--- a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs
+++ b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs
@@ -63,7 +63,7 @@ public static class DispatchWorkflowExtensions
var workflowDispatcher = serviceProvider.GetRequiredService();
var dispatchWorkflowResponse = await workflowDispatcher.DispatchAsync(new DispatchWorkflowDefinitionRequest
{
- DefinitionVersionId = workflow.DefinitionHandle.DefinitionVersionId,
+ DefinitionVersionId = workflow.DefinitionHandle.DefinitionVersionId!,
InstanceId = instanceId ?? Guid.NewGuid().ToString(),
});
dispatchWorkflowResponse.ThrowIfFailed();
diff --git a/src/common/Elsa.Testing.Shared/XunitLogger.cs b/src/common/Elsa.Testing.Shared/XunitLogger.cs
index 08c947157..e02d37fb5 100644
--- a/src/common/Elsa.Testing.Shared/XunitLogger.cs
+++ b/src/common/Elsa.Testing.Shared/XunitLogger.cs
@@ -3,33 +3,24 @@ using Xunit.Abstractions;
namespace Elsa.Testing.Shared;
-public class XunitLogger : ILogger
+public class XunitLogger(ITestOutputHelper testOutputHelper, string categoryName) : ILogger
{
- private readonly ITestOutputHelper _testOutputHelper;
- private readonly string _categoryName;
-
- public XunitLogger(ITestOutputHelper testOutputHelper, string categoryName)
- {
- _testOutputHelper = testOutputHelper;
- _categoryName = categoryName;
- }
-
- public IDisposable BeginScope(TState state) => NoopDisposable.Instance;
+ public IDisposable BeginScope(TState state) where TState : notnull => NoopDisposable.Instance;
public bool IsEnabled(LogLevel logLevel)
=> true;
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
- _testOutputHelper.WriteLine($"{_categoryName} [{eventId}] {formatter(state, exception)}");
+ testOutputHelper.WriteLine($"{categoryName} [{eventId}] {formatter(state, exception)}");
if (exception != null)
- _testOutputHelper.WriteLine(exception.ToString());
+ testOutputHelper.WriteLine(exception.ToString());
}
private class NoopDisposable : IDisposable
{
- public static readonly NoopDisposable Instance = new NoopDisposable();
+ public static readonly NoopDisposable Instance = new();
public void Dispose()
{
diff --git a/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs b/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs
index 894e3d85e..3533f98d3 100644
--- a/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs
+++ b/src/modules/Elsa.Expressions.Liquid/Helpers/ConfigurationSectionWrapper.cs
@@ -2,16 +2,9 @@
namespace Elsa.Expressions.Liquid.Helpers;
-public class ConfigurationSectionWrapper
+public class ConfigurationSectionWrapper(IConfigurationSection section)
{
- private readonly IConfigurationSection _section;
+ public override string ToString() => section.Value!;
- public ConfigurationSectionWrapper(IConfigurationSection section)
- {
- _section = section;
- }
-
- public override string ToString() => _section.Value;
-
- public ConfigurationSectionWrapper GetSection(string name) => new(_section.GetSection(name));
+ public ConfigurationSectionWrapper GetSection(string name) => new(section.GetSection(name));
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs
index 6b6590aac..c1a583ed5 100644
--- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs
+++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs
@@ -461,7 +461,7 @@ public class HttpEndpoint : Trigger
var contentType = httpRequest.ContentType!;
var headers = httpRequest.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray());
- return await context.ParseContentAsync(contentStream, contentType, targetType, headers, cancellationToken);
+ return await context.ParseContentAsync(contentStream, contentType, targetType, headers!, cancellationToken);
}
private static bool HasContent(HttpRequest httpRequest) => httpRequest.Headers.ContentLength > 0;
diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
index 34e90abc7..bb8930ab5 100644
--- a/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
+++ b/src/modules/Elsa.Http/Activities/SendHttpRequestBase.cs
@@ -240,8 +240,8 @@ public abstract class SendHttpRequestBase(string? source = null, int? line = nul
_ => typeof(string)
};
- var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.Cast().ToArray(), StringComparer.OrdinalIgnoreCase);
- var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.Cast().ToArray(), StringComparer.OrdinalIgnoreCase);
+ var contentHeadersDictionary = contentHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase);
+ var responseHeadersDictionary = responseHeaders.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.OrdinalIgnoreCase);
var headersDictionary = contentHeadersDictionary.Concat(responseHeadersDictionary).ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase);
return await context.ParseContentAsync(contentStream, contentType, targetType, headersDictionary, cancellationToken);
}
diff --git a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs
index 7b21e3353..2691cced5 100644
--- a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs
+++ b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs
@@ -101,7 +101,7 @@ public class WriteHttpResponse : Activity
// Add headers.
var headers = context.GetHeaders(ResponseHeaders);
foreach (var header in headers)
- response.Headers.Add(header.Key, header.Value);
+ response.Headers[header.Key] = header.Value;
// Get content and content type.
var content = context.Get(Content);
diff --git a/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs
index 5536181ea..ae4fc8539 100644
--- a/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs
+++ b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentFactory.cs
@@ -20,7 +20,7 @@ public class FormUrlEncodedHttpContentFactory : IHttpContentFactory
return dictionary.ToDictionary(x => x.Key, x => x.Value.ToString() ?? string.Empty);
if (content is string or JsonObject)
- return JsonSerializer.Deserialize>(JsonSerializer.Serialize(content));
+ return JsonSerializer.Deserialize>(JsonSerializer.Serialize(content))!;
var jsonElement = JsonSerializer.SerializeToElement(content);
return jsonElement.EnumerateObject().ToDictionary(x => x.Name, x => x.Value.ToString());
diff --git a/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs b/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs
index 3538ace13..85d45ff5e 100644
--- a/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs
+++ b/src/modules/Elsa.Http/Extensions/HttpActivityExecutionContextExtensions.cs
@@ -8,7 +8,7 @@ namespace Elsa.Extensions;
internal static class HttpActivityExecutionContextExtensions
{
- public static async Task ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary headers, CancellationToken cancellationToken)
+ public static async Task ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, Dictionary headers, CancellationToken cancellationToken)
{
var parsers = context.GetServices().OrderByDescending(x => x.Priority).ToList();
var httpResponseParserContext = new HttpResponseParserContext(content, contentType, returnType, headers, cancellationToken);
diff --git a/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs b/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs
index 4d3571006..0ade01163 100644
--- a/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs
+++ b/src/modules/Elsa.Http/Handlers/UpdateRouteTable.cs
@@ -1,8 +1,6 @@
-using Elsa.Http.Options;
-using Elsa.Mediator.Contracts;
+using Elsa.Mediator.Contracts;
using Elsa.Workflows.Runtime.Notifications;
using JetBrains.Annotations;
-using Microsoft.Extensions.Options;
namespace Elsa.Http.Handlers;
@@ -10,7 +8,7 @@ namespace Elsa.Http.Handlers;
/// A handler that updates the route table when workflow triggers and bookmarks are indexed.
///
[UsedImplicitly]
-public class UpdateRouteTable(IRouteTableUpdater routeTableUpdater, IOptions options) :
+public class UpdateRouteTable(IRouteTableUpdater routeTableUpdater) :
INotificationHandler,
INotificationHandler
{
diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs
index 9f24898ea..fadf509a7 100644
--- a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs
+++ b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs
@@ -113,7 +113,7 @@ public static class BulkUpsertExtensions
var paramName = $"{{{parameterCount++}}}";
// If it's a shadow property, retrieve value via Entry(..).Property(..)
- object? value = property.IsShadowProperty()
+ var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@@ -176,7 +176,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
- object? value = property.IsShadowProperty()
+ var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@@ -185,7 +185,7 @@ public static class BulkUpsertExtensions
value = converter.ConvertToProvider(value);
placeholders.Add(paramName);
- parameters.Add(value);
+ parameters.Add(value!);
}
sb.Append($"({string.Join(", ", placeholders)})");
@@ -238,7 +238,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
- object? value = property.IsShadowProperty()
+ var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@@ -255,7 +255,7 @@ public static class BulkUpsertExtensions
else
placeholders.Add(paramName);
- parameters.Add(value);
+ parameters.Add(value!);
}
sb.Append($"({string.Join(", ", placeholders)})");
@@ -308,7 +308,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
- object? value = property.IsShadowProperty()
+ var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@@ -317,7 +317,7 @@ public static class BulkUpsertExtensions
value = converter.ConvertToProvider(value);
placeholders.Add(paramName);
- parameters.Add(value);
+ parameters.Add(value!);
}
sb.Append($"({string.Join(", ", placeholders)})");
@@ -374,7 +374,7 @@ public static class BulkUpsertExtensions
{
var paramName = $"{{{parameterCount++}}}";
- object? value = property.IsShadowProperty()
+ var value = property.IsShadowProperty()
? dbContext.Entry(entity).Property(property.Name).CurrentValue
: property.PropertyInfo?.GetValue(entity);
@@ -382,7 +382,7 @@ public static class BulkUpsertExtensions
if (converter != null)
value = converter.ConvertToProvider(value);
- parameters.Add(value);
+ parameters.Add(value!);
// Oracle aliases must match the column name
var alias = property.GetColumnName(storeObject);
diff --git a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs
index 04fd79a67..db9d92238 100644
--- a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs
+++ b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueComparer.cs
@@ -13,7 +13,10 @@ namespace Elsa.Persistence.EFCore;
/// For plain objects, fall back to deep equality comparison using JSON serialization
/// (safe, but inefficient).
///
-public class JsonValueComparer : ValueComparer {
+public class JsonValueComparer() : ValueComparer((t1, t2) => DoEquals(t1!, t2!),
+ t => DoGetHashCode(t),
+ t => DoGetSnapshot(t))
+{
private static string Json(T instance) {
return JsonSerializer.Serialize(instance);
@@ -24,9 +27,7 @@ public class JsonValueComparer : ValueComparer {
if (instance is ICloneable cloneable)
return (T)cloneable.Clone();
- var result = (T)JsonSerializer.Deserialize(Json(instance));
- return result;
-
+ return JsonSerializer.Deserialize(Json(instance))!;
}
private static int DoGetHashCode(T instance) {
@@ -43,15 +44,6 @@ public class JsonValueComparer : ValueComparer {
if (left is IEquatable equatable)
return equatable.Equals(right);
- var result = Json(left).Equals(Json(right));
- return result;
-
+ return Json(left).Equals(Json(right));
}
-
- public JsonValueComparer() : base(
- (t1, t2) => DoEquals(t1, t2),
- t => DoGetHashCode(t),
- t => DoGetSnapshot(t)) {
- }
-
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs
index d529731bf..6df4e4608 100644
--- a/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs
+++ b/src/modules/Elsa.Persistence.EFCore.Common/JsonValueConverterHelper.cs
@@ -15,7 +15,7 @@ internal static class JsonValueConverterHelper
public static string Serialize(T obj) where T : class
{
- return (obj == null ? null : JsonSerializer.Serialize(obj, JsonSerializerOptions))!;
+ return (obj == null! ? null : JsonSerializer.Serialize(obj, JsonSerializerOptions))!;
}
private static JsonSerializerOptions CreateJsonSerializerOptions()
diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs
index 203348715..7c2ffc896 100644
--- a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs
+++ b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs
@@ -221,7 +221,7 @@ public class EFCoreWorkflowDefinitionStore(EntityStore x.IsSystem == true)
- : queryable.Where(x => x.IsSystem == false || x.IsSystem == null);
+ : queryable.Where(x => x.IsSystem == false || x.IsSystem == null!);
if (filter.IsReadonly != null) queryable = queryable.Where(x => x.IsReadonly == filter.IsReadonly);
return queryable;
diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs
index f10ded7fd..c345550c0 100644
--- a/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs
+++ b/src/modules/Elsa.Persistence.EFCore/Modules/Runtime/ActivityExecutionLogStore.cs
@@ -6,7 +6,6 @@ using Elsa.Common.Codecs;
using Elsa.Common.Entities;
using Elsa.Extensions;
using Elsa.Workflows;
-using Elsa.Workflows.Management.Options;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Extensions;
@@ -15,7 +14,6 @@ using Elsa.Workflows.Runtime.OrderDefinitions;
using Elsa.Workflows.State;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Options;
using Open.Linq.AsyncExtensions;
namespace Elsa.Persistence.EFCore.Modules.Runtime;
@@ -26,10 +24,8 @@ namespace Elsa.Persistence.EFCore.Modules.Runtime;
[UsedImplicitly]
public class EFCoreActivityExecutionStore(
EntityStore store,
- ISafeSerializer safeSerializer,
IPayloadSerializer payloadSerializer,
- ICompressionCodecResolver compressionCodecResolver,
- IOptions options) : IActivityExecutionStore
+ ICompressionCodecResolver compressionCodecResolver) : IActivityExecutionStore
{
///
public async Task SaveAsync(ActivityExecutionRecord record, CancellationToken cancellationToken = default) => await store.SaveAsync(record, OnSaveAsync, cancellationToken);
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs
index 4f876a7ce..364696e9c 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/Export/Endpoint.cs
@@ -48,13 +48,14 @@ internal class Export : ElsaEndpoint
{
if (request.DefinitionId != null)
await DownloadSingleWorkflowAsync(request.DefinitionId, request.VersionOptions, cancellationToken);
- else
+ else if (request.Ids != null)
await DownloadMultipleWorkflowsAsync(request.Ids, cancellationToken);
+ else await Send.NoContentAsync(cancellationToken);
}
private async Task DownloadMultipleWorkflowsAsync(ICollection ids, CancellationToken cancellationToken)
{
- List definitions = (await _store.FindManyAsync(new WorkflowDefinitionFilter
+ List definitions = (await _store.FindManyAsync(new()
{
Ids = ids
}, cancellationToken)).ToList();
@@ -88,7 +89,7 @@ internal class Export : ElsaEndpoint
private async Task DownloadSingleWorkflowAsync(string definitionId, string? versionOptions, CancellationToken cancellationToken)
{
var parsedVersionOptions = string.IsNullOrEmpty(versionOptions) ? VersionOptions.Latest : VersionOptions.FromString(versionOptions);
- WorkflowDefinition? definition = (await _store.FindManyAsync(new WorkflowDefinitionFilter
+ WorkflowDefinition? definition = (await _store.FindManyAsync(new()
{
DefinitionId = definitionId,
VersionOptions = parsedVersionOptions
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs
index 718c0ca99..c8ad5401b 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs
@@ -151,7 +151,7 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint
{
- KeySelector = p => p.Name,
+ KeySelector = p => p.Name!,
Direction = direction
};
diff --git a/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs b/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs
index 594896485..f11b00195 100644
--- a/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs
+++ b/src/modules/Elsa.Workflows.Api/Requirements/NotReadOnlyRequirement.cs
@@ -26,18 +26,19 @@ public class NotReadOnlyRequirementHandler : AuthorizationHandler
- protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, NotReadOnlyRequirement requirement, NotReadOnlyResource resource)
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, NotReadOnlyRequirement requirement, NotReadOnlyResource resource)
{
if (_managementOptions.Value.IsReadOnlyMode)
{
- context.Fail(new AuthorizationFailureReason(this, "Workflow edit is not allowed when the read-only mode is enabled."));
+ context.Fail(new(this, "Workflow edit is not allowed when the read-only mode is enabled."));
}
if (resource.WorkflowDefinition != null && (resource.WorkflowDefinition.IsReadonly || resource.WorkflowDefinition.IsSystem))
{
- context.Fail(new AuthorizationFailureReason(this, "Workflow edit is not allowed for a readonly or system workflow."));
+ context.Fail(new(this, "Workflow edit is not allowed for a readonly or system workflow."));
}
context.Succeed(requirement);
+ return Task.CompletedTask;
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs b/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs
index febe80558..303b38aac 100644
--- a/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs
+++ b/src/modules/Elsa.Workflows.Api/Serialization/ArgumentJsonConverter.cs
@@ -33,7 +33,7 @@ public class ArgumentJsonConverter : JsonConverter
var typeAlias = _wellKnownTypeRegistry.TryGetAlias(typeName, out var alias) ? alias : null;
var isArray = typeName.IsArray;
var isCollection = typeName.IsCollectionType();
- var elementTypeName = isArray ? typeName.GetElementType() : isCollection ? typeName.GenericTypeArguments[0] : typeName;
+ var elementTypeName = isArray ? typeName.GetElementType()! : isCollection ? typeName.GenericTypeArguments[0] : typeName;
var elementTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(elementTypeName);
var isAliasedArray = (isArray || isCollection) && typeAlias != null;
var finalTypeAlias = isArray || isCollection ? typeAlias ?? elementTypeAlias : elementTypeAlias;
diff --git a/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs b/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs
index 274141172..9aa4c311c 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/ParallelForEachT.cs
@@ -90,7 +90,7 @@ public class ParallelForEach : Activity
private ICollection GetTagList(ActivityExecutionContext context, string propertyName)
{
// Read the list of tags from the context using the specified property name. The value is stored as JsonArray, so we need to deserialize it.
- var jsonArray = context.GetProperty(propertyName);
+ var jsonArray = context.GetProperty(propertyName)!;
return jsonArray.Select(x => x.ConvertTo()).ToList();
}
diff --git a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs
index b30d7669e..fd5608325 100644
--- a/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs
+++ b/src/modules/Elsa.Workflows.Core/Builders/WorkflowBuilder.cs
@@ -6,7 +6,7 @@ using Elsa.Workflows.Models;
namespace Elsa.Workflows.Builders;
///
-public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphService identityGraphService, IActivityRegistry activityRegistry, IIdentityGenerator identityGenerator)
+public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphService identityGraphService, IActivityRegistry activityRegistry)
: IWorkflowBuilder
{
///
diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs
index fa1f67c30..33906c603 100644
--- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs
+++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs
@@ -402,7 +402,7 @@ public partial class WorkflowExecutionContext : IExecutionContext
///
/// The expression execution context for the current workflow execution.
///
- public ExpressionExecutionContext? ExpressionExecutionContext { get; private set; }
+ public ExpressionExecutionContext ExpressionExecutionContext { get; private set; } = null!;
///
public IEnumerable Variables => Workflow.Variables;
diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs
index b7028e2b8..e2237498c 100644
--- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs
+++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs
@@ -1,4 +1,3 @@
-using Elsa.Common;
using Elsa.Extensions;
using Elsa.Workflows.Pipelines.ActivityExecution;
using Microsoft.Extensions.Logging;
@@ -19,7 +18,7 @@ public static class ExceptionHandlingMiddlewareExtensions
///
/// Catches any exceptions thrown by downstream components and transitions the workflow into the faulted state.
///
-public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncidentStrategyResolver incidentStrategyResolver, ISystemClock systemClock, ILogger logger)
+public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncidentStrategyResolver incidentStrategyResolver, ILogger logger)
: IActivityExecutionMiddleware
{
///
diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs
index 00c49da52..838dae398 100644
--- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs
+++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs
@@ -4,7 +4,6 @@ using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-using Elsa.Expressions.Contracts;
using Elsa.Extensions;
using Elsa.Workflows.Serialization.ReferenceHandlers;
using Newtonsoft.Json.Linq;
@@ -14,7 +13,7 @@ namespace Elsa.Workflows.Serialization.Converters;
///
/// Reads objects as primitive types rather than values while also maintaining the .NET type name for reconstructing the actual type.
///
-public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegistry) : JsonConverter
+public class PolymorphicObjectConverter : JsonConverter
{
private const string TypePropertyName = "_type";
private const string ItemsPropertyName = "_items";
@@ -77,7 +76,7 @@ public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegi
{
var parsedModel = JsonElement.ParseValue(ref reader);
var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString();
- return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonObject.Parse(systemTextJson) : new JsonObject();
+ return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonNode.Parse(systemTextJson)! : new JsonObject();
}
var isJsonArray = targetType == typeof(JsonArray);
@@ -86,7 +85,7 @@ public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegi
{
var parsedModel = JsonElement.ParseValue(ref reader);
var systemTextJson = parsedModel.GetProperty(IslandPropertyName).GetString();
- return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonArray.Parse(systemTextJson) : new JsonArray();
+ return !string.IsNullOrWhiteSpace(systemTextJson) ? JsonNode.Parse(systemTextJson)! : new JsonArray();
}
var isDictionary = typeof(IDictionary).IsAssignableFrom(targetType);
diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs
index 4a8cdcb7e..2317a6372 100644
--- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs
+++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverterFactory.cs
@@ -51,6 +51,6 @@ public class PolymorphicObjectConverterFactory : JsonConverterFactory
if (typeof(IDictionary).IsAssignableFrom(typeToConvert))
return new PolymorphicDictionaryConverter(options, _wellKnownTypeRegistry);
- return new PolymorphicObjectConverter(_wellKnownTypeRegistry);
+ return new PolymorphicObjectConverter();
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs b/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs
index bb93720e3..469969041 100644
--- a/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs
+++ b/src/modules/Elsa.Workflows.Core/VariableStorageDrivers/WorkflowInstanceStorageDriver.cs
@@ -33,7 +33,7 @@ public class WorkflowInstanceStorageDriver(IPayloadSerializer payloadSerializer,
try
{
var node = JsonSerializer.SerializeToNode(value);
- dictionary[id] = node;
+ dictionary[id] = node!;
}
catch (Exception ex) when (ex is JsonException or NotSupportedException or ObjectDisposedException)
{
diff --git a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs
index e5f69bb47..e618ad467 100644
--- a/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs
+++ b/src/modules/Elsa.Workflows.Management/Activities/WorkflowDefinitionActivity/WorkflowDefinitionActivity.cs
@@ -125,6 +125,13 @@ public class WorkflowDefinitionActivity : Composite, IInitializable
var serviceProvider = context.GetRequiredService();
var activityDescriptor = await FindActivityDescriptorAsync(serviceProvider);
+ if (activityDescriptor == null)
+ {
+ var logger = serviceProvider.GetRequiredService>();
+ logger.LogWarning("Could not find activity descriptor for activity type {ActivityType}", Type);
+ return;
+ }
+
DeclareInputAsVariables(activityDescriptor, (descriptor, variable) =>
{
var inputName = descriptor.Name;
diff --git a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs
index c1c3e491c..cd0bc53b7 100644
--- a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs
+++ b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs
@@ -81,7 +81,7 @@ public class VariableDefinitionMapper(IWellKnownTypeRegistry wellKnownTypeRegist
var isArray = valueType.IsArray;
var isCollection = valueType.IsCollectionType();
- var elementValueType = isArray ? valueType.GetElementType() : isCollection ? valueType.GenericTypeArguments[0] : valueType;
+ var elementValueType = isArray ? valueType.GetElementType()! : isCollection ? valueType.GenericTypeArguments[0] : valueType;
var elementTypeAlias = wellKnownTypeRegistry.GetAliasOrDefault(elementValueType);
return new(source.Id, source.Name, elementTypeAlias, isArray, serializedValue, storageDriverTypeName);
diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs
index 0a99360f1..19aca5c02 100644
--- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowDispatcher.cs
@@ -11,20 +11,20 @@ public interface IWorkflowDispatcher
///
/// Dispatches a request to execute the specified workflow definition.
///
- Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default);
+ Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default);
///
/// Dispatches a request to execute the specified workflow instance.
///
- Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default);
+ Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default);
///
/// Starts all workflows and resumes existing workflow instances based on the specified activity type and bookmark payload.
///
- Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default);
+ Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default);
///
/// Resumes the workflow waiting for the specified bookmark.
///
- Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions options, CancellationToken cancellationToken = default);
+ Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions? options, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs
index 08bbe25cc..50a4767a0 100644
--- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionLogStore.cs
@@ -15,11 +15,6 @@ public interface IWorkflowExecutionLogStore : ILogRecordStore
Task AddAsync(WorkflowExecutionLogRecord record, CancellationToken cancellationToken = default);
- ///
- /// Adds the specified set of objects to te persistence store.
- ///
- Task AddManyAsync(IEnumerable records, CancellationToken cancellationToken = default);
-
///
/// Adds or updates the specified in the persistence store.
///
diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs
index e5ab1d60f..501a67276 100644
--- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs
@@ -17,7 +17,6 @@ using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Handlers;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Providers;
-using Elsa.Workflows.Runtime.Services;
using Elsa.Workflows.Runtime.Stores;
using Elsa.Workflows.Runtime.Tasks;
using Elsa.Workflows.Runtime.UIHints;
diff --git a/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs b/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs
index cab0d7536..8435349aa 100644
--- a/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Filters/TriggerFilter.cs
@@ -69,7 +69,7 @@ public class TriggerFilter
if (WorkflowDefinitionVersionId != null) queryable = queryable.Where(x => x.WorkflowDefinitionVersionId == WorkflowDefinitionVersionId);
if (WorkflowDefinitionVersionIds != null) queryable = queryable.Where(x => WorkflowDefinitionVersionIds.Contains(x.WorkflowDefinitionVersionId));
if (Name != null) queryable = queryable.Where(x => x.Name == Name);
- if (Names != null) queryable = queryable.Where(x => Names.Contains(x.Name));
+ if (Names != null) queryable = queryable.Where(x => Names.Contains(x.Name!));
if (Hash != null) queryable = queryable.Where(x => x.Hash == Hash);
return queryable;
}
diff --git a/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs b/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs
index 37b95583a..0b5046134 100644
--- a/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Handlers/ResumeDispatchWorkflowActivity.cs
@@ -4,7 +4,6 @@ using Elsa.Workflows.Notifications;
using Elsa.Workflows.Runtime.Activities;
using Elsa.Workflows.Runtime.Stimuli;
using JetBrains.Annotations;
-using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.Runtime.Handlers;
@@ -12,7 +11,7 @@ namespace Elsa.Workflows.Runtime.Handlers;
/// Resumes any blocking activities when its child workflow completes.
///
[PublicAPI]
-internal class ResumeDispatchWorkflowActivity(IBookmarkQueue bookmarkQueue, IStimulusHasher stimulusHasher, ILogger logger) : INotificationHandler
+internal class ResumeDispatchWorkflowActivity(IBookmarkQueue bookmarkQueue, IStimulusHasher stimulusHasher) : INotificationHandler
{
private static readonly string ActivityTypeName = ActivityTypeNameHelper.GenerateTypeName();
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs
index f836f98a7..f317b7403 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/ObsoleteWorkflowRuntime.cs
@@ -67,7 +67,7 @@ public class ObsoleteWorkflowRuntime(
TriggerActivityId = options?.TriggerActivityId
};
var response = await client.CreateAndRunInstanceAsync(createRequest, cancellationToken);
- return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents, null, null);
+ return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents, null, new Dictionary());
}
public async Task> StartWorkflowsAsync(string activityTypeName, object bookmarkPayload, TriggerWorkflowsOptions? options = null)
@@ -82,7 +82,7 @@ public class ObsoleteWorkflowRuntime(
Input = options?.Input
};
var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken);
- var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList();
+ var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, new Dictionary())).ToList();
return results;
}
@@ -110,7 +110,7 @@ public class ObsoleteWorkflowRuntime(
var response = await workflowClient.RunInstanceAsync(runWorkflowRequest, cancellationToken);
- return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents,null, null);
+ return new(response.WorkflowInstanceId, response.Status, response.SubStatus, response.Bookmarks, response.Incidents,null, new Dictionary());
}
public async Task> ResumeWorkflowsAsync(string activityTypeName, object bookmarkPayload, TriggerWorkflowsOptions? options = null)
@@ -125,7 +125,7 @@ public class ObsoleteWorkflowRuntime(
Input = options?.Input
};
var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken);
- var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList();
+ var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, new Dictionary())).ToList();
return results;
}
@@ -141,7 +141,7 @@ public class ObsoleteWorkflowRuntime(
Input = options?.Input
};
var result = await stimulusSender.SendAsync(activityTypeName, bookmarkPayload, metadata, cancellationToken);
- var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, null)).ToList();
+ var results = result.WorkflowInstanceResponses.Select(x => new WorkflowExecutionResult(x.WorkflowInstanceId, x.Status, x.SubStatus, x.Bookmarks, x.Incidents, null, new Dictionary())).ToList();
return new(results);
}
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs b/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs
index 9a16af564..3a5d233aa 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/StimulusProxyWorkflowInbox.cs
@@ -182,7 +182,7 @@ public class StimulusProxyWorkflowInbox(
new List(),
response.Incidents,
null,
- null)
+ new Dictionary())
);
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs b/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs
index 72325955b..b3abf9795 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/StoreActivityExecutionLogSink.cs
@@ -3,14 +3,13 @@ using Elsa.Mediator.Contracts;
using Elsa.Workflows.Runtime.Entities;
using Elsa.Workflows.Runtime.Notifications;
-namespace Elsa.Workflows.Runtime.Services;
+namespace Elsa.Workflows.Runtime;
///
/// This implementation saves directly through the store.
///
public class StoreActivityExecutionLogSink(
IActivityExecutionStore activityExecutionStore,
- IActivityExecutionMapper mapper,
INotificationSender notificationSender)
: ILogRecordSink
{
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs b/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs
index 1126c3031..41361af8b 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/ValidatingWorkflowDispatcher.cs
@@ -15,7 +15,7 @@ public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService,
private IWorkflowDispatcher DecoratedService { get; set; } = decoratedService;
///
- public async Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default)
+ public async Task DispatchAsync(DispatchWorkflowDefinitionRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default)
{
if (!ValidateChannel(options?.Channel))
return DispatchWorkflowResponse.UnknownChannel();
@@ -24,7 +24,7 @@ public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService,
}
///
- public async Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default)
+ public async Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default)
{
if (!ValidateChannel(options?.Channel))
return DispatchWorkflowResponse.UnknownChannel();
@@ -33,7 +33,7 @@ public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService,
}
///
- public async Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default)
+ public async Task DispatchAsync(DispatchTriggerWorkflowsRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default)
{
if (!ValidateChannel(options?.Channel))
return DispatchWorkflowResponse.UnknownChannel();
@@ -42,7 +42,7 @@ public class ValidatingWorkflowDispatcher(IWorkflowDispatcher decoratedService,
}
///
- public async Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default)
+ public async Task DispatchAsync(DispatchResumeWorkflowsRequest request, DispatchWorkflowOptions? options = null, CancellationToken cancellationToken = default)
{
if (!ValidateChannel(options?.Channel))
return DispatchWorkflowResponse.UnknownChannel();
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs
index 66e936878..75efc701a 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Activities/TriggerSignal.cs
@@ -45,7 +45,7 @@ public class TriggerSignal : CodeActivity
///
public TriggerSignal(Input eventName, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(source, line) => EventName = eventName;
- public Input EventName { get; set; }
+ public Input EventName { get; set; } = null!;
protected override void Execute(ActivityExecutionContext context)
{
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs
index 448fed7a8..34af7f849 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/Composition/BulkDispatchWorkflows/BulkDispatchWorkflowsTests.cs
@@ -127,7 +127,7 @@ public class BulkDispatchWorkflowsTests : AppComponentTest
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowExecutionContext.SubStatus);
}
- private async Task GetWorkflowVariableAsync(TestWorkflowExecutionResult result, string variableName)
+ private async Task GetWorkflowVariableAsync(TestWorkflowExecutionResult result, string variableName)
{
var variableManager = Scope.ServiceProvider.GetRequiredService();
var variables = await variableManager.GetVariablesAsync(result.WorkflowExecutionContext);
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
index 7a0fe6d1c..dd25a4f73 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs
@@ -188,7 +188,7 @@ public class FlowchartNextActivityTests
var result = await _workflowRunner.RunAsync(workflow);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus);
- Assert.Equal(1, result.WorkflowState.Incidents.Count());
+ Assert.Single(result.WorkflowState.Incidents);
Assert.Equal("Invalid backward connection: Every path from the source ('WriteLineE') must go through the target ('WriteLineC') when tracing back to the start.", result.WorkflowState.Incidents.First().Message);
Assert.Equal(new[]
{
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs
index 9f82e7d03..ad8880b2c 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Incidents/IncidentStrategyTests.cs
@@ -30,6 +30,6 @@ public class IncidentStrategyTests
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(expectedOutput, lines);
Assert.Equal(expectedSubStatus, workflowState.SubStatus);
- Assert.Equal(1, workflowState.Incidents.Count);
+ Assert.Single(workflowState.Incidents);
}
}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs
index 786c7a8ba..9c44a3efe 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/Serialization/Tests.cs
@@ -24,8 +24,8 @@ public class Tests
public async Task Test1()
{
await _services.PopulateRegistriesAsync();
- var workflowDefinition = _publisher.New();
- var root = _serializer.Deserialize(workflowDefinition.StringData);
+ var workflowDefinition = await _publisher.NewAsync();
+ var root = _serializer.Deserialize(workflowDefinition.StringData!);
Assert.NotNull(root);
}
}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs
index e3f2c9716..ae7af0a84 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs
@@ -79,8 +79,8 @@ public class Tests
new(start, writeLine),
new(writeLine, end),
},
+ RunAsynchronously = false
};
- container.RunAsynchronously = false;
// Act
@@ -93,12 +93,11 @@ public class Tests
}
[Fact]
- public async void SerializeSequenceContainerTest()
+ public async Task SerializeSequenceContainerTest()
{
await _services.PopulateRegistriesAsync();
// Arrange
-
var container = new Sequence
{
Id = "sequence",
@@ -127,9 +126,9 @@ public class Tests
{ "int", 10 },
{ "bool", false },
{ "string", "str"},
- }
+ },
+ RunAsynchronously = false
};
- container.RunAsynchronously = false;
// Act
@@ -142,12 +141,11 @@ public class Tests
}
[Fact]
- public async void SerializeParallelContainerTest()
+ public async Task SerializeParallelContainerTest()
{
await _services.PopulateRegistriesAsync();
// Arrange
-
var container = new Workflows.Activities.Parallel
{
Id = "parallel",
@@ -177,9 +175,9 @@ public class Tests
{ "int", 10 },
{ "bool", false },
{ "string", "str"},
- }
+ },
+ RunAsynchronously = false
};
- container.RunAsynchronously = false;
// Act
@@ -199,10 +197,16 @@ public class Tests
// Assert.Equivalent has trouble with the Behavior.Owner reference - since these aren't serialzied anyway, ignore them
deserializedContainer.Behaviors.Clear();
container.Behaviors.Clear();
- foreach (Activity activity in deserializedContainer.Activities)
+ foreach (var activity1 in deserializedContainer.Activities)
+ {
+ var activity = (Activity)activity1;
activity.Behaviors.Clear();
- foreach (Activity activity in container.Activities)
+ }
+
+ foreach (var activity in container.Activities.Cast())
+ {
activity.Behaviors.Clear();
+ }
// strict:false here allows "actual" to have extra public members that aren't part of "expected", and collection
// comparison allows "actual" to have more data in it than is present in "expected".
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs
index f4a853f28..e8e6343a9 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/JsonSerialization/Tests.cs
@@ -158,7 +158,7 @@ public class SerializationTests(ITestOutputHelper testOutputHelper)
"StatusCode", "Created"
},
{
- "Content", isArray ? (type == typeof(JArray) ? JArray.Parse(jsonContent) : JsonArray.Parse(jsonContent)) : (type == typeof(JObject) ? JObject.Parse(jsonContent) : JsonObject.Parse(jsonContent))
+ "Content", (isArray ? type == typeof(JArray) ? JArray.Parse(jsonContent) : JsonNode.Parse(jsonContent) : type == typeof(JObject) ? JObject.Parse(jsonContent) : JsonNode.Parse(jsonContent))!
}
};
return dict;
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs
index 284d665c8..efcdc8e39 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/UIHintSerializiation/ActivityUIHintDescriberTests.cs
@@ -45,11 +45,11 @@ public class Tests
var description = await activityDescriber.DescribeActivityAsync(typeof(TestActivity));
var inputDescription = description.Inputs.First();
- Assert.True(inputDescription.UISpecifications.ContainsKey(InputUIHints.DropDown));
+ Assert.True(inputDescription.UISpecifications!.ContainsKey(InputUIHints.DropDown));
Assert.True(inputDescription.UISpecifications[InputUIHints.DropDown] is DropDownProps);
var dropDownProperties = (DropDownProps) inputDescription.UISpecifications[InputUIHints.DropDown];
- Assert.Collection(dropDownProperties.SelectList.Items,
+ Assert.Collection(dropDownProperties.SelectList!.Items,
item => { Assert.Equal("OptionsAreNice", item.Text); Assert.Equal("OptionsAreNice", item.Value); },
item => { Assert.Equal("ToHave", item.Text); Assert.Equal("ToHave", item.Value); },
item => { Assert.Equal("IfYouCanChooseThem", item.Text); Assert.Equal("IfYouCanChooseThem", item.Value); });
diff --git a/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs b/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs
index 9f214cf1b..f8d6041fa 100644
--- a/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs
+++ b/test/performance/Elsa.Workflows.PerformanceTests/ConsoleActivitiesBenchmark.cs
@@ -8,9 +8,9 @@ namespace Elsa.Workflows.PerformanceTests;
[Config(typeof(Config))]
public class ConsoleActivitiesBenchmark
{
- private WriteLine _writeLineWorkflow;
- private IWorkflowRunner _workflowRunner;
- private ServiceProvider _serviceProvider;
+ private WriteLine _writeLineWorkflow = null!;
+ private IWorkflowRunner _workflowRunner = null!;
+ private ServiceProvider _serviceProvider = null!;
[GlobalSetup]
public void GlobalSetup()
@@ -21,7 +21,7 @@ public class ConsoleActivitiesBenchmark
_serviceProvider = services.BuildServiceProvider();
_workflowRunner = _serviceProvider.GetRequiredService();
- _writeLineWorkflow = new WriteLine("Hello, World!");
+ _writeLineWorkflow = new("Hello, World!");
}
[Benchmark]
diff --git a/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs b/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs
index 1dd0a993c..c95c226a2 100644
--- a/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs
+++ b/test/unit/Elsa.Activities.UnitTests/Composition/ExecuteWorkflowTests.cs
@@ -50,7 +50,7 @@ public class ExecuteWorkflowTests
opts.CorrelationId == DefaultCorrelationId &&
opts.Input != null && opts.Input.ContainsKey("Key1") && (string)opts.Input["Key1"] == "Value1" &&
opts.Input.ContainsKey("ParentInstanceId") && (string)opts.Input["ParentInstanceId"] == parentInstanceId &&
- opts.Properties.ContainsKey("ParentInstanceId") && (string)opts.Properties["ParentInstanceId"] == parentInstanceId &&
+ opts.Properties!.ContainsKey("ParentInstanceId") && (string)opts.Properties["ParentInstanceId"] == parentInstanceId &&
(waitForCompletion ? opts.Properties.ContainsKey("WaitForCompletion") && (bool)opts.Properties["WaitForCompletion"] : !opts.Properties.ContainsKey("WaitForCompletion"))
),
Arg.Any()
@@ -226,7 +226,7 @@ public class ExecuteWorkflowTests
{
Status = status,
SubStatus = subStatus,
- Output = output
+ Output = output ?? new Dictionary()
};
var workflowResult = new RunWorkflowResult(null!, workflowState, workflow, null, Journal.Empty);
From dc444cb1c77b4ea9b3050df8c67ff9c6a054d1c0 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Sat, 15 Nov 2025 10:49:59 +0100
Subject: [PATCH 07/33] Enhance Copilot instructions with runtime guidance and
resources (#7061)
* Initial plan
* Enhance Copilot instructions with running apps, troubleshooting, and resources
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
---
.github/copilot-instructions.md | 77 +++++++++++++++++++++++++++++++++
1 file changed, 77 insertions(+)
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index cb262164f..6d57db0ee 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -216,6 +216,83 @@ Multiple Docker configurations available:
dotnet build --no-restore
```
+## Running the Applications
+
+### Development Workflow Server
+
+To run the workflow server for development:
+
+```bash
+cd src/apps/Elsa.Server.Web
+dotnet restore --ignore-failed-sources
+dotnet run
+```
+
+The server will start on the configured ports (check `appsettings.json` or environment variables).
+
+### Combined Server and Studio
+
+To run both the workflow server and visual studio together:
+
+```bash
+cd src/apps/Elsa.ServerAndStudio.Web
+dotnet restore --ignore-failed-sources
+dotnet run
+```
+
+Access the studio at the configured URL (typically `http://localhost:5000` or as configured).
+
+### Using Docker
+
+For quick testing with Docker (see README for full details):
+
+```bash
+docker pull elsaworkflows/elsa-server-and-studio-v3:latest
+docker run -t -i -e ASPNETCORE_ENVIRONMENT='Development' -e HTTP_PORTS=8080 -e HTTP__BASEURL=http://localhost:13000 -p 13000:8080 elsaworkflows/elsa-server-and-studio-v3:latest
+```
+
+Default credentials: username `admin`, password `password`
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Missing External Packages**: If you encounter `NU1101` errors for Elsa.Studio packages:
+ - This is expected for studio-related apps
+ - Focus development on core workflow modules instead
+ - Or use Docker images that have pre-built studio components
+
+2. **Build Fails on Server Apps**: If `Elsa.Server.Web` fails due to WebhooksCore:
+ - This is a known issue with external package feeds
+ - Try building individual core modules instead
+ - Use `--ignore-failed-sources` flag consistently
+
+3. **Test Failures**: If many tests fail to run:
+ - External package dependencies may be unavailable
+ - Run tests for specific core modules individually
+ - Focus on tests that don't require studio packages
+
+4. **Slow Initial Build**: First restore and compile can take 5-10+ minutes:
+ - This is normal for a large solution with 100+ projects
+ - Subsequent builds are much faster (incremental)
+ - Consider building specific projects/modules when iterating
+
+## Additional Resources
+
+### Documentation
+- **Official Documentation**: [https://docs.elsaworkflows.io/](https://docs.elsaworkflows.io/)
+- **README**: See [README.md](../README.md) for quick start and features overview
+- **Contributing Guide**: See [CONTRIBUTING.md](../CONTRIBUTING.md) for contribution guidelines
+
+### Community Support
+- **GitHub Issues**: [Report bugs and request features](https://github.com/elsa-workflows/elsa-core/issues)
+- **GitHub Discussions**: [Ask questions and discuss](https://github.com/elsa-workflows/elsa-core/discussions)
+- **Discord**: [Join the community chat](https://discord.gg/hhChk5H472)
+- **Stack Overflow**: [Tag: elsa-workflows](http://stackoverflow.com/questions/tagged/elsa-workflows)
+
+### Enterprise Support
+- **ELSA-X**: [Professional support and enterprise solutions](https://elsa-x.io)
+
## Important Notes for Coding Agents
1. **Always use `--ignore-failed-sources`** when restoring packages
From 5655192fb61734279d922db9898401b8fc1f69b5 Mon Sep 17 00:00:00 2001
From: Matt
Date: Mon, 17 Nov 2025 22:50:25 +0000
Subject: [PATCH 08/33] Update dependencies and refactor DbContext factory
Updated multiple package versions in `Directory.Packages.props` to ensure compatibility, security, and access to the latest features. Key updates include `MicrosoftVersion` to `9.0.11`, `BenchmarkDotNet` to `0.15.6`, `FastEndpoints` to `7.1.1`, and `System.CommandLine` to `2.0.0`.
Refactored `DesignTimeDbContextFactoryBase` to align with the updated `System.CommandLine` API. Simplified `RootCommand` initialization and replaced deprecated `GetValueForOption` with `GetValue`.
---
Directory.Packages.props | 50 +++++++++----------
.../DesignTimeDbContextFactoryBase.cs | 13 +++--
2 files changed, 31 insertions(+), 32 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1c5331171..4029049ab 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
false
- 9.0.10
+ 9.0.11
9.10.0
@@ -22,17 +22,17 @@
-
+
-
+
-
+
@@ -42,15 +42,15 @@
-
-
-
+
+
+
-
+
-
-
+
+
@@ -60,10 +60,10 @@
-
-
-
-
+
+
+
+
@@ -105,14 +105,14 @@
-
+
-
+
@@ -129,12 +129,12 @@
-
-
-
+
+
+
-
+
@@ -158,17 +158,17 @@
-
+
-
+
-
-
+
+
@@ -176,7 +176,7 @@
-
+
\ No newline at end of file
diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs b/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs
index da0ab2a7b..7ea1b7c1d 100644
--- a/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs
+++ b/src/modules/Elsa.Persistence.EFCore.Common/Abstractions/DesignTimeDbContextFactoryBase.cs
@@ -1,5 +1,4 @@
using System.CommandLine;
-using System.CommandLine.Parsing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
@@ -16,13 +15,13 @@ public abstract class DesignTimeDbContextFactoryBase : IDesignTimeDb
{
var builder = new DbContextOptionsBuilder();
var connectionStringOption = new Option("--connectionString", "Specifies the connection string.");
- var command = new RootCommand();
+ var command = new RootCommand
+ {
+ connectionStringOption
+ };
- command.AddOption(connectionStringOption);
-
- var parser = new Parser(command);
- var parseResult = parser.Parse(args);
- var connectionString = parseResult.GetValueForOption(connectionStringOption) ?? "Data Source=local";
+ var parseResult = command.Parse(args);
+ var connectionString = parseResult.GetValue(connectionStringOption) ?? "Data Source=local";
var services = new ServiceCollection();
ConfigureServices(services);
From 2f9eac110e9d6e72303e2dd5ba7bdd9df6d9fc68 Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Fri, 21 Nov 2025 20:55:30 +0100
Subject: [PATCH 09/33] Upgrade projects to target .NET 10, add conditional
`System.Linq.Async` dependencies for compatibility with earlier frameworks,
and update project files for consistency across the solution. (#7062)
* Upgrade projects to target .NET 10, add conditional `System.Linq.Async` dependencies for compatibility with earlier frameworks, and update project files for consistency across the solution.
* Suppress null comparison warning in `WorkflowDefinitionStore` and remove xUnit references from performance test project.
* Refactor performance test projects to remove xUnit references and update MSBuild properties for BenchmarkDotNet.
---
src/Directory.Build.props | 2 +-
src/apps/Directory.Build.props | 2 +-
.../Elsa.Expressions.JavaScript.csproj | 4 ++
.../Management/WorkflowDefinitionStore.cs | 2 +
.../Elsa.Workflows.Core.csproj | 4 ++
test/Directory.Build.props | 2 +-
.../Elsa.Workflows.ComponentTests.csproj | 1 -
.../Elsa.Activities.IntegrationTests.csproj | 1 -
.../Elsa.JavaScript.IntegrationTests.csproj | 1 -
.../Elsa.Resilience.IntegrationTests.csproj | 1 -
test/performance/Directory.Build.props | 38 +++++++++++++++++--
.../Elsa.Workflows.PerformanceTests.csproj | 10 +----
12 files changed, 48 insertions(+), 20 deletions(-)
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 84212213e..6330b1711 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
- net8.0;net9.0
+ net8.0;net9.0;net10.0
diff --git a/src/apps/Directory.Build.props b/src/apps/Directory.Build.props
index 50f33a49d..0d8817302 100644
--- a/src/apps/Directory.Build.props
+++ b/src/apps/Directory.Build.props
@@ -1,6 +1,6 @@
- net9.0
+ net10.0
latest
enable
enable
diff --git a/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj b/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj
index 7bf175399..782df8f86 100644
--- a/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj
+++ b/src/modules/Elsa.Expressions.JavaScript/Elsa.Expressions.JavaScript.csproj
@@ -9,6 +9,10 @@
+
+
+
+
diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs
index 7c2ffc896..e805caeaa 100644
--- a/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs
+++ b/src/modules/Elsa.Persistence.EFCore/Modules/Management/WorkflowDefinitionStore.cs
@@ -221,7 +221,9 @@ public class EFCoreWorkflowDefinitionStore(EntityStore x.IsSystem == true)
+#pragma warning disable CS0472 // The result of the expression is always the same since a value of this type is never equal to 'null'
: queryable.Where(x => x.IsSystem == false || x.IsSystem == null!);
+#pragma warning restore CS0472 // The result of the expression is always the same since a value of this type is never equal to 'null'
if (filter.IsReadonly != null) queryable = queryable.Where(x => x.IsReadonly == filter.IsReadonly);
return queryable;
diff --git a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj
index 9c4e5234e..e400062f4 100644
--- a/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj
+++ b/src/modules/Elsa.Workflows.Core/Elsa.Workflows.Core.csproj
@@ -19,6 +19,10 @@
+
+
+
+
diff --git a/test/Directory.Build.props b/test/Directory.Build.props
index b2da2195f..42bf5fb48 100644
--- a/test/Directory.Build.props
+++ b/test/Directory.Build.props
@@ -3,7 +3,7 @@
- net9.0
+ net10.0
enable
enable
false
diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj
index 4922b31c2..7d4751127 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj
+++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj
@@ -1,7 +1,6 @@
- net9.0
enable
enable
diff --git a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj
index aa9c67c89..511e3c7ff 100644
--- a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj
+++ b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj
@@ -1,7 +1,6 @@
- net9.0
[Elsa.Workflows.Core]*
25
diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj b/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj
index 0c109a83e..b7f1ee64d 100644
--- a/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj
+++ b/test/integration/Elsa.JavaScript.IntegrationTests/Elsa.JavaScript.IntegrationTests.csproj
@@ -1,7 +1,6 @@
- net9.0
[Elsa.Expressions.JavaScript]*
diff --git a/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj b/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj
index ba05d918f..a77331da1 100644
--- a/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj
+++ b/test/integration/Elsa.Resilience.IntegrationTests/Elsa.Resilience.IntegrationTests.csproj
@@ -1,7 +1,6 @@
- net9.0
[Elsa.Resilience]*,[Elsa.Http]*,[Elsa.Workflows.Core]*
diff --git a/test/performance/Directory.Build.props b/test/performance/Directory.Build.props
index 674d5ca9b..9565fdad1 100644
--- a/test/performance/Directory.Build.props
+++ b/test/performance/Directory.Build.props
@@ -1,7 +1,37 @@
-
-
-
-
+
+
+
+
+ enable
+ enable
+ false
+ Exe
+ false
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj
index 244833891..57ec54ffe 100644
--- a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj
+++ b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj
@@ -1,14 +1,6 @@
-
- net9.0
- enable
- enable
-
- false
- Exe
- false
-
+
From aee09bb06fd9c1605657c3438aad5a46d3dcaf8d Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Fri, 21 Nov 2025 20:58:38 +0100
Subject: [PATCH 10/33] Improves workflow deletion and task scheduling (#7088)
* Refactor workflow instance deletion to use `IWorkflowRuntime` for enhanced coordination and separation of concerns.
* Remove `EnumerableTypeConverter` and update related usages for serialization.
- Deleted the `EnumerableTypeConverter` class and its JSON serialization logic.
- Removed associated type descriptor attribute in `DefaultFormattersFeature`.
- Updated `ObjectFormatter` to handle collection serialization directly with JSON.
* Remove `EnumerableTypeConverter` tests and consolidate serialization logic into `ObjectFormatter`.
- Deleted `EnumerableTypeConverterTests` as the related functionality was removed.
- Added comprehensive tests in `ObjectFormatterTests` to handle serialization of collections and arrays with JSON.
* Add integration tests for `TriggerIndexer` to handle workflows with failing materialization
- Introduced comprehensive test scenarios verifying `DeleteTriggersAsync` behavior when workflows fail to load or partially succeed.
- Enhanced error handling in `TriggerIndexer` to skip failed workflows while ensuring remaining workflows are processed.
* Add exception handling in `TriggerIndexer.DeleteTriggersAsync` and integration tests
- Enhanced `DeleteTriggersAsync` with exception handling to skip failed workflows while processing others.
- Logged warnings for failed workflows without halting execution.
- Added comprehensive integration tests to verify behavior across success, failure, and mixed scenarios.
- Refactored tests for improved clarity, maintainability, and consistency.
* Add exception handling for `ResumeWorkflowTask` to skip deleted workflow instances
- Enhanced `ResumeWorkflowTask.ExecuteAsync` to handle `WorkflowInstanceNotFoundException` gracefully when a scheduled workflow instance is missing.
- Logged warnings for skipped executions to improve observability.
- Ensured remaining workflows and scheduled tasks are processed seamlessly without disruption.
* Add thread safety to `LocalScheduler` to prevent race conditions during concurrent scheduling
- Introduced a `lock` object to synchronize access to internal dictionaries.
- Resolved `IndexOutOfRangeException` caused by concurrent modifications during startup.
- Ensured thread-safe operations in `ScheduleAsync`, `ClearScheduleAsync`, and related methods.
- Improved reliability and stability of scheduling under concurrent workloads.
* Improve exception handling, thread safety, and workflow instance deletion
- Added exception handling in `TriggerIndexer.DeleteTriggersAsync` to skip failed workflows while continuing processing.
- Enhanced `ResumeWorkflowTask` to handle missing workflow instances gracefully and log warnings.
- Introduced thread synchronization in `LocalScheduler` with `lock` to prevent concurrent access issues.
- Implemented and refactored tests to ensure behavior consistency and improve maintainability.
- Added component tests for workflow deletion scenarios, covering running, completed, and non-existent workflows.
* Add component tests for workflow instance deletion and refactor bulk delete logic
- Added comprehensive component tests for workflow instance deletion scenarios (running, completed, bulk, and non-existent instances).
- Refactored `BulkDelete` API to use `IWorkflowInstanceManager` for proper cleanup of related records (execution logs, activity executions, bookmarks).
* Add integration tests and fakes for `TriggerIndexer` to verify behavior with failing and successful workflows
- Introduced `FailingMaterializer` and `WorkingMaterializer` for simulating failing and successful workflow materializations.
- Added `TriggerDeletionTestScenario`, `TriggerTestDataBuilder`, and related test data classes to define comprehensive test cases.
- Updated `DeleteTriggersAsync` tests with scenarios for materialization failures and mixed success.
- Improved test coverage and maintainability with reusable test data builders and utilities.
---
...binding-issue_enumerable-type-converter.md | 153 +++++++
...-20_trigger-deletion-exception-handling.md | 294 +++++++++++++
...-instance-deletion_runtime-coordination.md | 393 ++++++++++++++++++
src/apps/Elsa.Server.Web/Program.cs | 7 +-
.../Features/DefaultFormattersFeature.cs | 2 -
.../Serialization/EnumerableTypeConverter.cs | 37 --
.../Helpers/ObjectFormatter.cs | 38 +-
.../Services/LocalScheduler.cs | 24 +-
.../Tasks/ResumeWorkflowTask.cs | 42 +-
.../WorkflowInstances/BulkDelete/Endpoint.cs | 41 +-
.../WorkflowInstances/Delete/Endpoint.cs | 9 +-
.../Services/DistributedWorkflowClient.cs | 6 +
.../Contracts/IWorkflowClient.cs | 7 +-
.../Services/LocalWorkflowClient.cs | 33 +-
.../Services/TriggerIndexer.cs | 19 +-
.../Activities/LongRunningActivity.cs | 30 ++
.../RelatedRecordsDeletionTests.cs | 144 +++++++
.../WorkflowInstanceDeletionTests.cs | 151 +++++++
.../Workflows/CompletingWorkflow.cs | 22 +
.../Workflows/SuspendingWorkflow.cs | 28 ++
.../Workflows/WorkflowWithRelatedRecords.cs | 33 ++
.../EnumerableTypeConverterTests.cs | 52 ---
.../Serialization/ObjectFormatterTests.cs | 107 +++++
.../Fakes/FailingMaterializer.cs | 18 +
.../Fakes/WorkingMaterializer.cs | 22 +
.../TestData/TriggerDeletionTestScenario.cs | 13 +
.../TestData/TriggerTestDataBuilder.cs | 57 +++
.../TestData/WorkflowConfig.cs | 6 +
.../Scenarios/TriggerIndexing/Tests.cs | 140 +++++++
.../ScheduledTasks/ScheduledCronTaskTests.cs | 44 +-
.../ScheduledRecurringTaskTests.cs | 44 +-
31 files changed, 1851 insertions(+), 165 deletions(-)
create mode 100644 agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md
create mode 100644 agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md
create mode 100644 agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md
delete mode 100644 src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/RelatedRecordsDeletionTests.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs
create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/WorkflowWithRelatedRecords.cs
delete mode 100644 test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs
create mode 100644 test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/FailingMaterializer.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/WorkingMaterializer.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerDeletionTestScenario.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerTestDataBuilder.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/WorkflowConfig.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs
diff --git a/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md b/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md
new file mode 100644
index 000000000..550dcbee4
--- /dev/null
+++ b/agent-logs/7019/2025-11-20_configuration-binding-issue_enumerable-type-converter.md
@@ -0,0 +1,153 @@
+# Configuration Binding Issue: SigningKey Not Bound from appsettings.json
+
+**Date**: 2025-11-20
+**Issue**: IdentityTokenOptions.SigningKey property remaining null despite being present in appsettings.json
+**Related GitHub Issue**: #7019 (Array serialization)
+
+---
+
+## Symptoms
+
+- `IdentityTokenOptions.SigningKey` property was not being bound from configuration
+- Other properties in the same section (`AccessTokenLifetime`, `RefreshTokenLifetime`) were binding correctly
+- Manual debugging showed:
+ - ✅ `identityTokenSection["SigningKey"]` returned the correct value
+ - ✅ `configuration.GetSection("Identity:Tokens:SigningKey").Value` returned the correct value
+ - ❌ `identityTokenSection.GetValue("SigningKey")` returned `null`
+ - ❌ `identityTokenSection.Bind(options)` did not populate `SigningKey`
+- Creating a fresh instance worked: `new IdentityTokenOptions()` could be bound successfully
+
+## Initial False Leads
+
+During troubleshooting, several potential causes were investigated but ruled out:
+
+1. **Property initializers** - Initially suspected that default values like `= "http://elsa.api"` prevented binding, but this was disproven when fresh instances with the same defaults worked
+2. **Configuration section retrieval** - The section was correctly retrieved and contained the value
+3. **Nullable vs non-nullable** - Making `SigningKey` nullable didn't resolve the issue
+4. **JSON encoding issues** - No BOM or hidden characters found in appsettings.json
+5. **Configuration overrides** - No environment variables or other sources overriding the value
+6. **Lambda capture issues** - The configuration section was correctly captured and accessible
+
+## Root Cause
+
+The actual root cause was **global TypeConverter registration interfering with the configuration binder**.
+
+In `DefaultFormattersFeature.cs`, the following registration was made:
+
+```csharp
+TypeDescriptor.AddAttributes(typeof(IEnumerable),
+ new TypeConverterAttribute(typeof(EnumerableTypeConverter)));
+```
+
+This caused a cascade of issues:
+
+1. **String binding failure**: Since `string` implements `IEnumerable`, the configuration binder attempted to use `EnumerableTypeConverter` for string properties
+2. **Silent failure**: Even though `EnumerableTypeConverter.CanConvertTo()` correctly returned `false` for strings, the global registration interfered with the fallback mechanism
+3. **Partial success**: TimeSpan properties worked because they have built-in conversion support that bypassed the string converter issue
+
+## Solution
+
+### Phase 1: Remove Global TypeConverter Registration
+
+**File**: `src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs`
+
+**Change**: Removed the global `TypeDescriptor.AddAttributes` call for `IEnumerable`
+
+**Result**:
+- ✅ Configuration binding now works for all string properties
+- ❌ Arrays started serializing as `"Int32[] Array"` instead of JSON (exposed GitHub issue #7019)
+
+### Phase 2: Fix Array Serialization
+
+**File**: `src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs`
+
+**Change**: Added explicit JSON serialization for arrays and collections:
+
+```csharp
+// Byte arrays are base64-encoded
+if (value is byte[] byteArray)
+ return Convert.ToBase64String(byteArray);
+
+// Memory-like types preserved via TypeDescriptor
+if (IsMemoryLike(sourceType))
+{
+ var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType);
+ if (sourceTypeConverter.CanConvertTo(typeof(string)))
+ return (string?)sourceTypeConverter.ConvertTo(value, typeof(string));
+ return value.ToString();
+}
+
+// Serialize arrays and collections to JSON
+if (value is IEnumerable)
+{
+ return JsonSerializer.Serialize(value);
+}
+```
+
+**Result**:
+- ✅ Arrays serialize to JSON: `[1,2,3]` instead of `"Int32[] Array"`
+- ✅ Configuration binding still works
+- ✅ No TypeConverter interference with .NET type system
+
+### Phase 3: Consolidate Logic (Cleanup)
+
+**File Deleted**: `src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs`
+
+**Rationale**: The `EnumerableTypeConverter` was only used by `ObjectFormatter.Format()`, so consolidating the logic directly into `ObjectFormatter` simplified the codebase and eliminated the need for TypeConverter registration entirely.
+
+## Key Learnings
+
+1. **Global TypeConverter registration is dangerous**: It can interfere with .NET's type system, configuration binding, and other framework features
+2. **String implements IEnumerable**: This often-forgotten fact can cause unexpected behavior with type converters
+3. **Configuration binder behavior is opaque**: When `.GetValue()` fails but indexer `["key"]` works, it indicates type conversion issues
+4. **Explicit is better than implicit**: Direct serialization logic in `ObjectFormatter` is clearer and safer than relying on TypeConverter infrastructure
+5. **Test fresh instances vs captured instances**: Configuration binding through the Options pattern behaves differently than direct `.Bind()` calls
+
+## Verification
+
+### Tests Added/Consolidated
+**File**: `test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs`
+
+All tests consolidated into a single test class with 9 tests:
+1. String is preserved as-is
+2. Byte array is serialized as base64 string
+3. Integer array is serialized as JSON array
+4. String array is serialized as JSON array
+5. String array with multiple elements is serialized as JSON array
+6. Custom class array is serialized as JSON array
+7. List of integers is serialized as JSON array
+8. List with different values is serialized as JSON array
+9. Null returns null
+
+### Runtime Verification
+- ✅ Server starts successfully
+- ✅ `IdentityTokenOptions.SigningKey` binds correctly from configuration
+- ✅ No `MissingConfigurationException` thrown
+- ✅ JWT authentication works
+- ✅ Workflow variable arrays serialize to JSON
+- ✅ All 9 integration tests pass
+
+## Files Modified
+
+1. `src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs` - Removed global TypeConverter registration
+2. `src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs` - Added explicit JSON serialization for collections
+3. `src/modules/Elsa.Identity/Options/IdentityTokenOptions.cs` - Cleaned up property initializers
+4. `test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs` - Consolidated all tests (9 total)
+
+## Files Deleted
+
+1. `src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs` - Consolidated into ObjectFormatter
+2. `test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs` - Consolidated into ObjectFormatterTests
+
+---
+
+## Future Reference
+
+**Filename Pattern**: `YYYY-MM-DD_issue-description_root-cause.md`
+
+When encountering similar configuration binding issues:
+1. Check if the value exists using the indexer: `section["key"]`
+2. Compare with `GetValue("key")` - if they differ, suspect type conversion issues
+3. Look for global TypeConverter registrations that might interfere
+4. Test with a fresh instance to isolate Options pattern behavior
+5. Check if the property type implements unexpected interfaces (like `string` implementing `IEnumerable`)
diff --git a/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md b/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md
new file mode 100644
index 000000000..4abe30641
--- /dev/null
+++ b/agent-logs/7077/2025-11-20_trigger-deletion-exception-handling.md
@@ -0,0 +1,294 @@
+# Exception Handling Improvements for Trigger Deletion and Scheduled Workflows
+
+**Date**: November 20, 2025
+**Issue**: #7077
+**Branch**: bug/7077
+**Type**: Bug Fix + Integration Tests
+
+## Problem
+
+When deleting triggers, the `TriggerIndexer.DeleteTriggersAsync` method would fail completely if any workflow failed to load due to a missing materializer or other exceptions. This resulted in the error:
+
+```
+System.Exception: Provider not found
+ at Elsa.Workflows.Management.Services.WorkflowDefinitionService.MaterializeWorkflowAsync
+```
+
+The method would stop processing remaining workflows, leaving triggers in an inconsistent state.
+
+## Solution
+
+### 1. Exception Handling (`src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs`)
+
+Added a try-catch block in the `DeleteTriggersAsync` method (lines 67-79):
+
+```csharp
+foreach (var workflowDefinitionVersionId in workflowDefinitionVersionIds)
+{
+ try
+ {
+ var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(
+ workflowDefinitionVersionId, cancellationToken);
+
+ if (workflowGraph == null)
+ continue;
+
+ await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Failed to load workflow graph for workflow definition version {WorkflowDefinitionVersionId}. " +
+ "Skipping trigger deletion for this workflow.",
+ workflowDefinitionVersionId);
+ }
+}
+```
+
+**Benefits**:
+- Logs warning with full exception details for debugging
+- Continues processing remaining workflows instead of failing completely
+- Maintains system stability when individual workflows have issues
+
+### 2. Integration Tests (`test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/`)
+
+Created comprehensive integration tests in a new `TriggerIndexing` scenario folder with `Tests.cs`:
+
+#### Test 1: All Workflows Fail to Load
+```csharp
+[Fact(DisplayName = "DeleteTriggersAsync should continue deleting triggers even when workflow fails to load")]
+public async Task DeleteTriggersAsync_WorkflowFailsToLoad_ContinuesWithOtherTriggers()
+```
+
+- Verifies the method completes without throwing when all workflows fail
+- Tests multiple failure types (JSON serialization errors, provider not found)
+- Confirms all triggers remain when workflows can't be loaded
+
+#### Test 2: Mixed Success and Failure
+```csharp
+[Fact(DisplayName = "DeleteTriggersAsync processes all workflows even when one fails")]
+public async Task DeleteTriggersAsync_OneWorkflowFails_ProcessesAllWorkflows()
+```
+
+- Tests scenario where one workflow fails (workflow2 with "Provider not found")
+- Two other workflows load successfully (workflow1 and workflow3)
+- Verifies triggers for successful workflows are deleted
+- Confirms failed workflow's trigger remains
+- **This is the key test** - proves exception handling allows processing to continue
+
+#### Test Infrastructure
+
+**Custom Materializers**:
+- `FailingMaterializer`: Throws "Provider not found" exception to simulate the reported issue
+- `WorkingMaterializer`: Successfully materializes workflows from a registry for valid test cases
+
+**Helper Methods** (DRY principle):
+- `CreateWorkflowDefinition()`: Factory method for workflow definitions
+- `CreateTrigger()`: Factory method for triggers
+- `SaveWorkflowsAndTriggersAsync()`: Common save and verification logic
+
+**Modern C# Features**:
+- `file` scoped classes for helper materializers (limits visibility)
+- Primary constructors for `WorkingMaterializer`
+- Expression-bodied members for simple methods
+
+## Code Quality Improvements
+
+### Test Refactoring
+- Renamed from `TriggerIndexerTests.cs` to `Tests.cs` (project convention)
+- Reduced test methods from ~90 lines to ~30 lines each
+- Eliminated duplicate workflow/trigger creation code
+- Improved readability with array-based test data
+- Clear Arrange-Act-Assert structure
+
+### Benefits
+- **More maintainable**: Changes only need to be made in one place
+- **More readable**: Test intent is clearer with less boilerplate
+- **More consistent**: Follows project naming conventions
+- **Equally functional**: All tests pass with same behavior
+
+## Test Results
+
+Both tests pass successfully:
+
+```
+Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 304 ms
+
+✓ DeleteTriggersAsync should continue deleting triggers even when workflow fails to load [153 ms]
+✓ DeleteTriggersAsync processes all workflows even when one fails [49 ms]
+```
+
+## 3. Resume Workflow Task Exception Handling (`src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs`)
+
+### Problem
+
+When a scheduled workflow instance is deleted before its scheduled resume time, the `ResumeWorkflowTask` would throw an unhandled exception:
+
+```
+Elsa.Workflows.Runtime.Exceptions.WorkflowInstanceNotFoundException: Workflow instance not found.
+ at Elsa.Workflows.Runtime.LocalWorkflowClient.GetWorkflowInstanceAsync
+```
+
+This would cause the scheduled task to fail, even though the workflow instance no longer exists and should simply be skipped.
+
+### Solution
+
+Added exception handling to gracefully handle missing workflow instances:
+
+```csharp
+public async ValueTask ExecuteAsync(TaskExecutionContext context)
+{
+ var cancellationToken = context.CancellationToken;
+ var workflowRuntime = context.ServiceProvider.GetRequiredService();
+ var logger = context.ServiceProvider.GetRequiredService>();
+
+ try
+ {
+ var workflowClient = await workflowRuntime.CreateClientAsync(_request.WorkflowInstanceId, cancellationToken);
+ var request = new RunWorkflowInstanceRequest
+ {
+ Input = _request.Input,
+ Properties = _request.Properties,
+ ActivityHandle = _request.ActivityHandle,
+ BookmarkId = _request.BookmarkId
+ };
+ await workflowClient.RunInstanceAsync(request, cancellationToken);
+ }
+ catch (WorkflowInstanceNotFoundException ex)
+ {
+ logger.LogWarning(
+ "Scheduled workflow instance {WorkflowInstanceId} no longer exists and was likely deleted. Skipping execution.",
+ ex.InstanceId);
+ }
+}
+```
+
+**Benefits**:
+- Scheduled tasks no longer fail when workflow instances are deleted
+- Clear logging explains why the task was skipped
+- System continues operating normally
+- No unnecessary error noise in logs
+
+## 4. LocalScheduler Thread Safety (`src/modules/Elsa.Scheduling/Services/LocalScheduler.cs`)
+
+### Problem
+
+The `LocalScheduler` was experiencing race conditions during concurrent access, particularly during application startup when multiple workflows are being scheduled simultaneously:
+
+```
+System.IndexOutOfRangeException: Index was outside the bounds of the array.
+ at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
+ at System.Collections.Generic.Dictionary`2.set_Item(TKey key, TValue value)
+ at Elsa.Scheduling.Services.LocalScheduler.RegisterScheduledTask
+```
+
+This exception occurs when multiple threads modify the internal `Dictionary` instances concurrently without synchronization.
+
+### Root Cause
+
+Two dictionaries were being accessed concurrently without any thread safety:
+- `_scheduledTasks`: Maps task names to scheduled tasks
+- `_scheduledTaskKeys`: Maps scheduled tasks to their keys
+
+Methods like `ScheduleAsync`, `ClearScheduleAsync`, and internal helper methods all modified these dictionaries without locks.
+
+### Solution
+
+Added thread synchronization using a lock object:
+
+```csharp
+public class LocalScheduler : IScheduler
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IDictionary _scheduledTasks = new Dictionary();
+ private readonly IDictionary> _scheduledTaskKeys = new Dictionary>();
+ private readonly object _lock = new(); // Added lock object
+
+ public ValueTask ScheduleAsync(string name, ITask task, ISchedule schedule, IEnumerable? keys = null, CancellationToken cancellationToken = default)
+ {
+ var scheduleContext = new ScheduleContext(_serviceProvider, task);
+ var scheduledTask = schedule.Schedule(scheduleContext);
+
+ lock (_lock) // Protected dictionary access
+ {
+ RegisterScheduledTask(name, scheduledTask, keys);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask ClearScheduleAsync(string name, CancellationToken cancellationToken = default)
+ {
+ lock (_lock) // Protected dictionary access
+ {
+ RemoveScheduledTask(name);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask ClearScheduleAsync(IEnumerable keys, CancellationToken cancellationToken = default)
+ {
+ lock (_lock) // Protected dictionary access
+ {
+ RemoveScheduledTasks(keys);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+}
+```
+
+**Benefits**:
+- Eliminates race conditions during concurrent scheduling
+- Prevents `IndexOutOfRangeException` during dictionary modifications
+- Ensures thread-safe access to internal collections
+- Simple and efficient locking strategy
+
+**Implementation Notes**:
+- Lock is only held during dictionary operations (minimal lock duration)
+- Schedule creation happens outside the lock to minimize contention
+- Private methods (`RegisterScheduledTask`, `RemoveScheduledTask`, `RemoveScheduledTasks`) are called within locks, so they don't need additional synchronization
+
+**Why `lock` instead of `SemaphoreSlim`?**
+
+The implementation uses a simple `lock` rather than `SemaphoreSlim` for optimal performance:
+
+| Aspect | lock | SemaphoreSlim |
+|--------|------|---------------|
+| **Overhead** | Zero allocation | Heap allocations |
+| **Complexity** | Automatic release | Manual try/finally |
+| **Use Case** | Perfect for synchronous ops | Better for async ops |
+| **Performance** | Compiler optimized | Additional overhead |
+| **Async Support** | Cannot cross await | Can use WaitAsync |
+
+**Decision Rationale**:
+1. ✅ **All critical sections are synchronous** - Only dictionary operations (add/remove/lookup)
+2. ✅ **Methods return `ValueTask.CompletedTask`** - Not truly async, just implementing interface signature
+3. ✅ **No await inside critical sections** - Schedule creation happens outside the lock
+4. ✅ **Zero allocation overhead** - `lock` is ideal for fast synchronous operations
+
+`SemaphoreSlim` would be appropriate if:
+- Critical sections contained `await` statements
+- Methods were truly asynchronous with I/O operations
+- Cancellation support was needed during lock acquisition
+
+For this use case, `lock` is the optimal choice for simplicity, performance, and correctness.
+
+## Files Changed
+
+- `src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs` - Added exception handling for workflow loading failures
+- `test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs` - New integration tests
+- `src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs` - Added exception handling for missing workflow instances
+- `src/modules/Elsa.Scheduling/Services/LocalScheduler.cs` - Added thread synchronization for concurrent access
+
+## Impact
+
+- **Stability**: System no longer fails when workflows or instances have issues
+- **Thread Safety**: LocalScheduler now handles concurrent scheduling operations safely
+- **Observability**: Warnings are logged with clear explanations for workflow and instance issues
+- **Reliability**: Other workflows and scheduled tasks continue to be processed correctly
+- **Coverage**: Comprehensive tests ensure trigger deletion behavior is maintained
+- **Resilience**:
+ - Scheduled tasks handle deleted workflow instances gracefully
+ - Scheduler handles concurrent access during startup and runtime
diff --git a/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md b/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md
new file mode 100644
index 000000000..485c6b8c0
--- /dev/null
+++ b/agent-logs/7077/2025-11-20_workflow-instance-deletion_runtime-coordination.md
@@ -0,0 +1,393 @@
+# Workflow Instance Deletion: Runtime Coordination and Separation of Concerns
+
+**Date**: 2025-11-20
+**Branch**: bug/7077
+**Commit**: 1f326295a38eee236b84d60e69afdf9e3a1eda1a
+
+---
+
+## Problem Statement
+
+Workflow instances were not being properly deleted when using the Delete and BulkDelete API endpoints. The deletion operations were failing silently or inconsistently, particularly for running workflow instances.
+
+## Root Cause
+
+The deletion endpoints were directly using `IWorkflowInstanceManager` to delete workflow instances from the database without proper coordination with the workflow runtime. This caused several issues:
+
+1. **No coordination for running workflows**: Running workflows need to be properly stopped/cancelled before deletion
+2. **Race conditions**: In distributed scenarios, concurrent operations could cause database inconsistencies
+3. **Missing cleanup**: Runtime state, bookmarks, and other related data weren't being cleaned up properly
+4. **Violation of separation of concerns**: API layer was directly manipulating persistence layer without going through the runtime
+
+## Solution Architecture
+
+The solution refactors the deletion logic to properly use the workflow runtime infrastructure, ensuring proper coordination and cleanup.
+
+### Key Design Principles
+
+1. **Runtime coordination**: All workflow instance operations should go through `IWorkflowRuntime` and `IWorkflowClient`
+2. **Proper cancellation**: Running workflows must be cancelled before deletion
+3. **Distributed locking**: Use the same distributed locks as execution to prevent concurrent modifications
+4. **Separation by status**: Handle running and finished instances differently
+
+### Implementation Details
+
+#### 1. Added Delete Method to IWorkflowClient
+
+**File**: `src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs`
+
+```csharp
+///
+/// Deletes the workflow instance. This method ensures that the instance is properly stopped before deletion.
+///
+Task DeleteAsync(CancellationToken cancellationToken = default);
+```
+
+**Rationale**: The `IWorkflowClient` is the proper abstraction for workflow instance operations. Adding deletion here ensures:
+- Consistent API surface
+- Proper coordination with other operations
+- Support for both local and distributed scenarios
+
+#### 2. Implemented LocalWorkflowClient.DeleteAsync
+
+**File**: `src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs`
+
+**Key logic**:
+```csharp
+public async Task DeleteAsync(CancellationToken cancellationToken = default)
+{
+ // Load the workflow instance (single DB call)
+ var workflowInstance = await TryGetWorkflowInstanceAsync(cancellationToken);
+ if (workflowInstance == null)
+ return false;
+
+ // Cancel if running (ensures proper cleanup)
+ await CancelAsync(workflowInstance, cancellationToken);
+
+ // Delete the workflow instance
+ var filter = new WorkflowInstanceFilter { Id = workflowInstanceId };
+ await workflowInstanceManager.DeleteAsync(filter, cancellationToken);
+ return true;
+}
+```
+
+**Key features**:
+- **Single DB read**: Loads the instance once and reuses it
+- **Proper cancellation**: Ensures running workflows are stopped first
+- **Graceful handling**: Returns `false` if instance doesn't exist
+- **Clean deletion**: Uses the manager's delete method for proper cleanup
+
+**Supporting refactoring**:
+- Extracted `CancelAsync(WorkflowInstance)` private method to avoid loading the instance twice
+- Added `TryGetWorkflowInstanceAsync()` helper for non-throwing retrieval
+
+#### 3. Implemented DistributedWorkflowClient.DeleteAsync
+
+**File**: `src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs`
+
+```csharp
+public async Task DeleteAsync(CancellationToken cancellationToken = default)
+{
+ // Use the same distributed lock as for execution to prevent concurrent DB writes
+ return await WithLockAsync(async () => await _localWorkflowClient.DeleteAsync(cancellationToken));
+}
+```
+
+**Key feature**: Uses the same distributed lock mechanism (`workflow-instance:{id}`) as execution operations, preventing:
+- Concurrent modifications during deletion
+- Race conditions between delete and execute operations
+- Inconsistent state in distributed scenarios
+
+#### 4. Refactored Single Instance Delete Endpoint
+
+**File**: `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs`
+
+**Before**:
+```csharp
+internal class Delete(IWorkflowInstanceManager store) : ElsaEndpoint
+{
+ public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
+ {
+ var filter = new WorkflowInstanceFilter { Id = request.Id };
+ var deleted = await store.DeleteAsync(filter, cancellationToken);
+ // ...
+ }
+}
+```
+
+**After**:
+```csharp
+internal class Delete(IWorkflowRuntime workflowRuntime) : ElsaEndpoint
+{
+ public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
+ {
+ var client = await workflowRuntime.CreateClientAsync(request.Id, cancellationToken);
+ var deleted = await client.DeleteAsync(cancellationToken);
+ // ...
+ }
+}
+```
+
+**Changes**:
+- ✅ Uses `IWorkflowRuntime` instead of `IWorkflowInstanceManager`
+- ✅ Creates a workflow client for proper coordination
+- ✅ Relies on runtime infrastructure for proper deletion
+
+#### 5. Refactored Bulk Delete Endpoint
+
+**File**: `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs`
+
+**Strategy**: Split deletion into two phases based on workflow status:
+
+**Phase 1: Delete Running Instances Individually**
+```csharp
+// Step 1: Delete running instances individually (requires coordination by the workflow runtime).
+var runningFilter = new WorkflowInstanceFilter
+{
+ Ids = baseFilter.Ids,
+ DefinitionId = baseFilter.DefinitionId,
+ DefinitionIds = baseFilter.DefinitionIds,
+ WorkflowStatus = WorkflowStatus.Running
+};
+
+var runningInstanceIds = await workflowInstanceStore.FindManyIdsAsync(runningFilter, cancellationToken);
+var count = 0L;
+
+foreach (var instanceId in runningInstanceIds)
+{
+ var client = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken);
+ var deleted = await client.DeleteAsync(cancellationToken);
+ if (deleted)
+ count++;
+}
+```
+
+**Phase 2: Bulk Delete Finished Instances**
+```csharp
+// Step 2: Bulk delete finished instances (no coordination needed).
+// Use IWorkflowInstanceManager to ensure related records are also deleted.
+var finishedFilter = new WorkflowInstanceFilter
+{
+ Ids = baseFilter.Ids,
+ DefinitionId = baseFilter.DefinitionId,
+ DefinitionIds = baseFilter.DefinitionIds,
+ WorkflowStatus = WorkflowStatus.Finished
+};
+
+var finishedDeletedCount = await workflowInstanceManager.BulkDeleteAsync(finishedFilter, cancellationToken);
+count += finishedDeletedCount;
+```
+
+**Rationale**:
+- **Running instances**: Need individual coordination through the runtime (cancellation, locks, cleanup)
+- **Finished instances**: Can be safely bulk-deleted as they have no active runtime state
+- **IWorkflowInstanceManager vs IWorkflowInstanceStore**: Using `IWorkflowInstanceManager.BulkDeleteAsync()` ensures that related records (execution logs, bookmarks, activity execution records, etc.) are also deleted, not just the workflow instance record
+- **Performance**: Avoids unnecessary overhead for finished workflows while ensuring safety for running ones
+- **Correctness**: Each running workflow gets proper cancellation and distributed locking
+
+**Trade-offs**:
+- ✅ **Correctness**: Ensures proper cleanup and coordination
+- ✅ **Safety**: Prevents race conditions and inconsistencies
+- ⚠️ **Performance**: Running instances are deleted one-by-one instead of bulk
+ - This is acceptable because:
+ - Running instances are typically a small percentage of total instances
+ - Proper coordination is more important than bulk performance
+ - Finished instances still use efficient bulk deletion
+
+## Benefits
+
+### 1. Proper Coordination
+- Running workflows are properly cancelled before deletion
+- Runtime state is cleaned up correctly
+- Bookmarks and related data are removed
+
+### 2. Distributed Safety
+- Uses the same distributed locks as execution
+- Prevents race conditions in multi-node deployments
+- Ensures consistency across the cluster
+
+### 3. Separation of Concerns
+- API layer uses runtime abstractions
+- No direct persistence manipulation
+- Easier to maintain and extend
+
+### 4. Consistent Behavior
+- Same deletion logic for single and bulk operations
+- Predictable behavior across all scenarios
+- Proper error handling and return values
+
+## Testing Verification
+
+### Automated Component Tests
+
+Created comprehensive component tests in `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/`:
+
+**Test Infrastructure:**
+1. **Custom Activity**: `LongRunningActivity`
+ - Simulates a long-running workflow operation (3 seconds)
+ - Uses `SignalManager` to notify tests when execution starts
+ - Keeps workflow in memory without hitting a bookmark (crucial for testing the race condition)
+
+2. **Test Workflows**:
+ - `SuspendingWorkflow`: Uses `LongRunningActivity` to create running workflow instances
+ - `CompletingWorkflow`: Completes immediately for testing finished workflow deletion
+
+3. **Helper Methods** (following DRY principle):
+ - `StartRunningWorkflowAsync()`: Starts a workflow and waits for it to begin executing
+ - `AssertWorkflowInstanceDeletedAsync()`: Verifies a workflow instance is deleted
+ - Property accessors for common services (`WorkflowRuntime`, `SignalManager`, `WorkflowInstanceStore`)
+
+**Test Cases:**
+1. ✅ `DeleteRunningWorkflowInstance_ShouldCancelAndRemove`
+ - Tests deletion of a workflow actively executing in memory
+ - Verifies the instance is deleted and not found afterward
+
+2. ✅ `DeleteFinishedWorkflowInstance_ShouldRemove`
+ - Tests deletion of a completed workflow instance
+ - Verifies proper cleanup of finished workflows
+
+3. ✅ `DeleteNonExistentWorkflowInstance_ShouldThrowException`
+ - Tests error handling for non-existent instances
+ - Expects `Refit.ApiException` (404 Not Found)
+
+4. ✅ `BulkDeleteMixedInstances_ShouldDeleteAll`
+ - Tests bulk deletion of 2 running + 2 finished instances
+ - Verifies all instances are properly deleted
+
+5. ✅ `DeleteWorkflowInstance_ShouldNotBeResurrectedByPersistence` **(Core #7077 test)**
+ - **Critical test** that validates the distributed locking fix
+ - Starts a workflow with `LongRunningActivity` (stays in memory)
+ - Attempts deletion while workflow is still executing
+ - **Without the fix**: Workflow would be deleted but then resurrected when it completes
+ - **With the fix**: Deletion blocks until workflow completes (distributed lock), preventing resurrection
+ - Verifies the instance remains deleted after workflow completion
+
+**Key Testing Patterns:**
+- **SignalManager pattern**: Elegant coordination between workflow execution and test assertions
+ - Handles race conditions gracefully (signal can fire before or after wait)
+ - Uses `ConcurrentDictionary` with `TaskCompletionSource` for thread-safe signaling
+ - No static state to clean up between tests
+- **WorkflowEvents pattern**: Used to wait for workflow state commits when needed
+- **DRY principle**: Extracted common patterns into reusable helper methods
+- **Readable tests**: Focus on "what" not "how" - test intent is clear and concise
+
+All tests pass successfully, validating the fix works correctly.
+
+### Manual Testing
+- ✅ Delete single running workflow instance
+- ✅ Delete single finished workflow instance
+- ✅ Delete non-existent workflow instance (returns false)
+- ✅ Bulk delete mixed running and finished instances
+- ✅ Bulk delete by definition ID
+- ✅ Verify proper cancellation before deletion
+- ✅ Verify distributed locking in multi-node scenario
+
+### Edge Cases Handled
+- **Non-existent instance**: Returns `false` gracefully
+- **Already cancelled/finished**: No-op on cancellation, proceeds to delete
+- **Concurrent operations**: Distributed lock prevents conflicts
+- **Partial bulk delete**: Counts successful deletions accurately
+
+## Performance Considerations
+
+### Single Delete
+- **Before**: 1 query (direct delete)
+- **After**: 2 queries (load + cancel if needed + delete)
+- **Impact**: Minimal - proper coordination is worth the extra query
+
+### Bulk Delete
+- **Running instances**: N queries (where N = number of running instances)
+ - Acceptable because running instances are typically few
+ - Proper coordination requires individual handling
+- **Finished instances**: 1 query (bulk delete)
+ - Maintains performance for the common case
+ - Most instances are finished, not running
+
+### Optimization Opportunities
+- Could add a `BulkCancelAsync` method for running instances
+- Could batch running instance deletions with parallel processing
+- Current approach prioritizes correctness over optimization
+
+## Migration Notes
+
+### Breaking Changes
+None - this is a bug fix that doesn't change the API surface.
+
+### Behavioral Changes
+1. **Running workflows are now cancelled before deletion**
+ - Previous behavior: Direct delete (potentially inconsistent state)
+ - New behavior: Cancel then delete (proper cleanup)
+
+2. **Bulk delete now handles running vs finished instances differently**
+ - Previous behavior: Attempted bulk delete of all instances
+ - New behavior: Individual delete for running, bulk for finished
+
+### Upgrade Path
+No action required - changes are fully backward compatible.
+
+## Files Modified
+
+### Core Implementation
+
+1. `src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs`
+ - Added `DeleteAsync()` method
+
+2. `src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs`
+ - Implemented `DeleteAsync()` with proper cancellation
+ - Refactored `CancelAsync()` to avoid duplicate loading
+ - Added `TryGetWorkflowInstanceAsync()` helper
+
+3. `src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs`
+ - Implemented `DeleteAsync()` with distributed locking
+
+4. `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs`
+ - Changed to use `IWorkflowRuntime` instead of `IWorkflowInstanceManager`
+ - Uses workflow client for deletion
+
+5. `src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs`
+ - Refactored to handle running and finished instances separately
+ - Changed to use `IWorkflowRuntime` for running instances
+ - Changed to use `IWorkflowInstanceManager.BulkDeleteAsync()` for finished instances (ensures related records are deleted)
+
+### Test Files
+
+6. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs` **(NEW)**
+ - Custom activity that simulates long-running operation with 3-second delay
+ - Uses `SignalManager` to notify tests when execution starts
+ - Keeps workflow in memory for testing race conditions
+
+7. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs` **(NEW)**
+ - Test workflow using `LongRunningActivity`
+ - Used to create running workflow instances for testing
+
+8. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs` **(NEW)**
+ - Test workflow that completes immediately
+ - Used for testing finished workflow deletion
+
+9. `test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs` **(NEW)**
+ - 5 comprehensive component tests covering all deletion scenarios
+ - Tests the core #7077 fix (workflow resurrection prevention)
+ - Uses DRY principles with helper methods for maintainability
+
+## Related Issues
+
+- **Bug #7077**: Workflow instance not being deleted
+- Related to proper runtime coordination and distributed locking
+
+## Future Enhancements
+
+1. **Batch cancellation**: Add `BulkCancelAsync` for running instances
+2. **Parallel deletion**: Process running instances in parallel with configurable concurrency
+3. **Soft delete**: Add option for soft delete with retention period
+4. **Delete callbacks**: Add hooks for cleanup of related resources
+5. **Metrics**: Track deletion performance and success rates
+
+---
+
+## Key Takeaways
+
+1. **Always use runtime abstractions**: Don't bypass `IWorkflowClient` to directly manipulate persistence
+2. **Coordinate running workflows**: Active workflows need proper cancellation before deletion
+3. **Use distributed locks**: Prevent race conditions in multi-node deployments
+4. **Separate concerns by status**: Running and finished workflows have different coordination needs
+5. **Performance vs correctness**: Prioritize correctness, optimize performance within those constraints
diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs
index 742a8820a..2f6c5658a 100644
--- a/src/apps/Elsa.Server.Web/Program.cs
+++ b/src/apps/Elsa.Server.Web/Program.cs
@@ -4,14 +4,10 @@ using Elsa.Common.RecurringTasks;
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
using Elsa.Features.Services;
-using Elsa.Identity.Multitenancy;
using Elsa.Persistence.EFCore.Extensions;
using Elsa.Persistence.EFCore.Modules.Management;
using Elsa.Persistence.EFCore.Modules.Runtime;
using Elsa.Server.Web.Filters;
-using Elsa.Tenants.AspNetCore;
-using Elsa.Tenants.Extensions;
-using Elsa.Workflows;
using Elsa.Workflows.Api;
using Elsa.Workflows.CommitStates.Strategies;
using Elsa.Workflows.IncidentStrategies;
@@ -46,7 +42,7 @@ services
.AddWorkflowsFrom()
.UseIdentity(identity =>
{
- identity.TokenOptions = options => identityTokenSection.Bind(options);
+ identity.TokenOptions += options => identityTokenSection.Bind(options);
identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options));
identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options));
@@ -136,6 +132,7 @@ services.AddCors(cors => cors.AddDefaultPolicy(policy => policy.AllowAnyHeader()
// Build the web application.
var app = builder.Build();
+
// Configure the pipeline.
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage();
diff --git a/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs b/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs
index 05307f011..97ea1f3d6 100644
--- a/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs
+++ b/src/modules/Elsa.Common/Features/DefaultFormattersFeature.cs
@@ -1,4 +1,3 @@
-using System.Collections;
using System.ComponentModel;
using Elsa.Common.Serialization;
using Elsa.Common.Services;
@@ -12,7 +11,6 @@ public class DefaultFormattersFeature(IModule module) : FeatureBase(module)
{
public override void Configure()
{
- TypeDescriptor.AddAttributes(typeof(IEnumerable), new TypeConverterAttribute(typeof(EnumerableTypeConverter)));
TypeDescriptor.AddAttributes(typeof(Type), new TypeConverterAttribute(typeof(TypeTypeConverter)));
Module.Services.AddSingleton();
}
diff --git a/src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs b/src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs
deleted file mode 100644
index cb4327df4..000000000
--- a/src/modules/Elsa.Common/Serialization/EnumerableTypeConverter.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System.Collections;
-using System.ComponentModel;
-using System.Globalization;
-using System.Text.Json;
-
-namespace Elsa.Common.Serialization;
-
-///
-/// A type converter that converts types to and from strings using JSON serialization.
-///
-public class EnumerableTypeConverter : TypeConverter
-{
- public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
- {
- return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
- }
-
- public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
- {
- if (destinationType == typeof(string) && value is IEnumerable enumerable)
- {
- // string, byte[], Memory, ReadOnlyMemory, and Span types implement IEnumerable but should not be serialized as JSON arrays
- if (value is string or byte[]) return value;
-
- var valueType = value.GetType();
- if (valueType.IsGenericType)
- {
- var genericTypeDef = valueType.GetGenericTypeDefinition();
- if (genericTypeDef == typeof(Memory<>) || genericTypeDef == typeof(ReadOnlyMemory<>))
- return value;
- }
-
- return JsonSerializer.Serialize(enumerable);
- }
- return base.ConvertTo(context, culture, value, destinationType);
- }
-}
diff --git a/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs b/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs
index 3cfce6bf3..9f1bef0cd 100644
--- a/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs
+++ b/src/modules/Elsa.Expressions/Helpers/ObjectFormatter.cs
@@ -1,4 +1,6 @@
+using System.Collections;
using System.ComponentModel;
+using System.Text.Json;
namespace Elsa.Expressions.Helpers;
@@ -26,11 +28,41 @@ public static class ObjectFormatter
if (underlyingSourceType == typeof(string))
return value as string;
- var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType);
+ // Byte arrays are base64-encoded for serialization
+ if (value is byte[] byteArray)
+ return Convert.ToBase64String(byteArray);
- if (sourceTypeConverter.CanConvertTo(typeof(string)))
- return (string?)sourceTypeConverter.ConvertTo(value, typeof(string));
+ // Memory-like types are preserved via TypeDescriptor
+ if (IsMemoryLike(sourceType))
+ {
+ var sourceTypeConverter = TypeDescriptor.GetConverter(underlyingSourceType);
+ if (sourceTypeConverter.CanConvertTo(typeof(string)))
+ return (string?)sourceTypeConverter.ConvertTo(value, typeof(string));
+
+ return value.ToString();
+ }
+
+ // Serialize arrays and collections to JSON instead of "T[] Array" or "(Collection)".
+ // Fixes GitHub issue #7019.
+ if (value is IEnumerable)
+ {
+ return JsonSerializer.Serialize(value);
+ }
+
+ var converter = TypeDescriptor.GetConverter(underlyingSourceType);
+
+ if (converter.CanConvertTo(typeof(string)))
+ return (string?)converter.ConvertTo(value, typeof(string));
return value.ToString();
}
+
+ private static bool IsMemoryLike(Type type)
+ {
+ if (!type.IsGenericType)
+ return false;
+
+ var genericTypeDef = type.GetGenericTypeDefinition();
+ return genericTypeDef == typeof(Memory<>) || genericTypeDef == typeof(ReadOnlyMemory<>);
+ }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs b/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs
index 779b1ac46..11814a44b 100644
--- a/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs
+++ b/src/modules/Elsa.Scheduling/Services/LocalScheduler.cs
@@ -11,6 +11,13 @@ public class LocalScheduler : IScheduler
private readonly IDictionary _scheduledTasks = new Dictionary();
private readonly IDictionary> _scheduledTaskKeys = new Dictionary>();
+ // Note: Using lock instead of SemaphoreSlim because:
+ // 1. All critical sections are synchronous (dictionary operations only)
+ // 2. Methods return ValueTask.CompletedTask (not truly async)
+ // 3. No await inside critical sections
+ // 4. lock has zero allocation overhead, perfect for fast synchronous operations
+ private readonly object _lock = new();
+
///
/// Initializes a new instance of the class.
///
@@ -31,21 +38,32 @@ public class LocalScheduler : IScheduler
var scheduleContext = new ScheduleContext(_serviceProvider, task);
var scheduledTask = schedule.Schedule(scheduleContext);
- RegisterScheduledTask(name, scheduledTask, keys);
+ lock (_lock)
+ {
+ RegisterScheduledTask(name, scheduledTask, keys);
+ }
+
return ValueTask.CompletedTask;
}
///
public ValueTask ClearScheduleAsync(string name, CancellationToken cancellationToken = default)
{
- RemoveScheduledTask(name);
+ lock (_lock)
+ {
+ RemoveScheduledTask(name);
+ }
+
return ValueTask.CompletedTask;
}
///
public ValueTask ClearScheduleAsync(IEnumerable keys, CancellationToken cancellationToken = default)
{
- RemoveScheduledTasks(keys);
+ lock (_lock)
+ {
+ RemoveScheduledTasks(keys);
+ }
return ValueTask.CompletedTask;
}
diff --git a/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs b/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs
index 83e845af1..89959face 100644
--- a/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs
+++ b/src/modules/Elsa.Scheduling/Tasks/ResumeWorkflowTask.cs
@@ -1,38 +1,40 @@
using Elsa.Workflows.Runtime;
+using Elsa.Workflows.Runtime.Exceptions;
using Elsa.Workflows.Runtime.Messages;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
namespace Elsa.Scheduling.Tasks;
///
/// A task that resumes a workflow.
///
-public class ResumeWorkflowTask : ITask
+public class ResumeWorkflowTask(ScheduleExistingWorkflowInstanceRequest request) : ITask
{
- private readonly ScheduleExistingWorkflowInstanceRequest _request;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The dispatch workflow instance request.
- public ResumeWorkflowTask(ScheduleExistingWorkflowInstanceRequest request)
- {
- _request = request;
- }
-
///
public async ValueTask ExecuteAsync(TaskExecutionContext context)
{
var cancellationToken = context.CancellationToken;
var workflowRuntime = context.ServiceProvider.GetRequiredService();
- var workflowClient = await workflowRuntime.CreateClientAsync(_request.WorkflowInstanceId, cancellationToken);
- var request = new RunWorkflowInstanceRequest
+ var logger = context.ServiceProvider.GetRequiredService>();
+
+ try
{
- Input = _request.Input,
- Properties = _request.Properties,
- ActivityHandle = _request.ActivityHandle,
- BookmarkId = _request.BookmarkId
- };
- await workflowClient.RunInstanceAsync(request, cancellationToken);
+ var workflowClient = await workflowRuntime.CreateClientAsync(request.WorkflowInstanceId, cancellationToken);
+ var runWorkflowInstanceRequest = new RunWorkflowInstanceRequest
+ {
+ Input = request.Input,
+ Properties = request.Properties,
+ ActivityHandle = request.ActivityHandle,
+ BookmarkId = request.BookmarkId
+ };
+ await workflowClient.RunInstanceAsync(runWorkflowInstanceRequest, cancellationToken);
+ }
+ catch (WorkflowInstanceNotFoundException ex)
+ {
+ logger.LogWarning(
+ "Scheduled workflow instance {WorkflowInstanceId} no longer exists and was likely deleted. Skipping execution.",
+ ex.InstanceId);
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs
index b05f39821..f9d152260 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkDelete/Endpoint.cs
@@ -1,12 +1,13 @@
using Elsa.Abstractions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Filters;
+using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.BulkDelete;
[PublicAPI]
-internal class BulkDelete(IWorkflowInstanceManager store) : ElsaEndpoint
+internal class BulkDelete(IWorkflowInstanceStore workflowInstanceStore, IWorkflowInstanceManager workflowInstanceManager, IWorkflowRuntime workflowRuntime) : ElsaEndpoint
{
public override void Configure()
{
@@ -19,14 +20,46 @@ internal class BulkDelete(IWorkflowInstanceManager store) : ElsaEndpoint ExecuteAsync(Request request, CancellationToken cancellationToken)
{
- var filter = new WorkflowInstanceFilter
+ var baseFilter = new WorkflowInstanceFilter
{
Ids = request.Ids,
DefinitionId = request.WorkflowDefinitionId,
DefinitionIds = request.WorkflowDefinitionIds,
};
- var count = await store.BulkDeleteAsync(filter, cancellationToken);
- return new(count);
+ // Step 1: Delete running instances individually (requires coordination by the workflow runtime).
+ var runningFilter = new WorkflowInstanceFilter
+ {
+ Ids = baseFilter.Ids,
+ DefinitionId = baseFilter.DefinitionId,
+ DefinitionIds = baseFilter.DefinitionIds,
+ WorkflowStatus = WorkflowStatus.Running
+ };
+
+ var runningInstanceIds = await workflowInstanceStore.FindManyIdsAsync(runningFilter, cancellationToken);
+ var count = 0L;
+
+ foreach (var instanceId in runningInstanceIds)
+ {
+ var client = await workflowRuntime.CreateClientAsync(instanceId, cancellationToken);
+ var deleted = await client.DeleteAsync(cancellationToken);
+ if (deleted)
+ count++;
+ }
+
+ // Step 2: Bulk delete finished instances (no coordination needed).
+ // Use IWorkflowInstanceManager to ensure related records are also deleted.
+ var finishedFilter = new WorkflowInstanceFilter
+ {
+ Ids = baseFilter.Ids,
+ DefinitionId = baseFilter.DefinitionId,
+ DefinitionIds = baseFilter.DefinitionIds,
+ WorkflowStatus = WorkflowStatus.Finished
+ };
+
+ var finishedDeletedCount = await workflowInstanceManager.BulkDeleteAsync(finishedFilter, cancellationToken);
+ count += finishedDeletedCount;
+
+ return new(count);
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs
index 075756859..d8bfdb086 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Delete/Endpoint.cs
@@ -1,12 +1,11 @@
using Elsa.Abstractions;
-using Elsa.Workflows.Management;
-using Elsa.Workflows.Management.Filters;
+using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.Delete;
[PublicAPI]
-internal class Delete(IWorkflowInstanceManager store) : ElsaEndpoint
+internal class Delete(IWorkflowRuntime workflowRuntime) : ElsaEndpoint
{
public override void Configure()
{
@@ -16,8 +15,8 @@ internal class Delete(IWorkflowInstanceManager store) : ElsaEndpoint
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
- var filter = new WorkflowInstanceFilter { Id = request.Id };
- var deleted = await store.DeleteAsync(filter, cancellationToken);
+ var client = await workflowRuntime.CreateClientAsync(request.Id, cancellationToken);
+ var deleted = await client.DeleteAsync(cancellationToken);
if (deleted)
await Send.NoContentAsync(cancellationToken);
diff --git a/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs
index 251ac52dd..80aa11d5a 100644
--- a/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs
+++ b/src/modules/Elsa.Workflows.Runtime.Distributed/Services/DistributedWorkflowClient.cs
@@ -75,6 +75,12 @@ public class DistributedWorkflowClient(
return await _localWorkflowClient.InstanceExistsAsync(cancellationToken);
}
+ public async Task DeleteAsync(CancellationToken cancellationToken = default)
+ {
+ // Use the same distributed lock as for execution to prevent concurrent DB writes
+ return await WithLockAsync(async () => await _localWorkflowClient.DeleteAsync(cancellationToken));
+ }
+
private async Task WithLockAsync(Func> func)
{
var lockKey = $"workflow-instance:{WorkflowInstanceId}";
diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs
index 8753a76bd..9e22e0522 100644
--- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowClient.cs
@@ -42,6 +42,11 @@ public interface IWorkflowClient
/// Imports the specified .
///
Task ImportStateAsync(WorkflowState workflowState, CancellationToken cancellationToken = default);
-
+
Task InstanceExistsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes the workflow instance. This method ensures that the instance is properly stopped before deletion.
+ ///
+ Task DeleteAsync(CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs b/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs
index b72871f69..3fc283d3e 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/LocalWorkflowClient.cs
@@ -1,5 +1,6 @@
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
+using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Mappers;
using Elsa.Workflows.Management.Options;
using Elsa.Workflows.Models;
@@ -81,6 +82,11 @@ public class LocalWorkflowClient(
public async Task CancelAsync(CancellationToken cancellationToken = default)
{
var workflowInstance = await GetWorkflowInstanceAsync(cancellationToken);
+ await CancelAsync(workflowInstance, cancellationToken);
+ }
+
+ private async Task CancelAsync(WorkflowInstance workflowInstance, CancellationToken cancellationToken)
+ {
if (workflowInstance.Status != WorkflowStatus.Running) return;
var workflowGraph = await GetWorkflowGraphAsync(workflowInstance, cancellationToken);
var workflowState = await workflowCanceler.CancelWorkflowAsync(workflowGraph, workflowInstance.WorkflowState, cancellationToken);
@@ -106,6 +112,22 @@ public class LocalWorkflowClient(
return workflowInstanceManager.ExistsAsync(workflowInstanceId, cancellationToken);
}
+ ///
+ public async Task DeleteAsync(CancellationToken cancellationToken = default)
+ {
+ // Load the workflow instance (single DB call)
+ var workflowInstance = await TryGetWorkflowInstanceAsync(cancellationToken);
+ if (workflowInstance == null)
+ return false;
+
+ await CancelAsync(workflowInstance, cancellationToken);
+
+ // Delete the workflow instance
+ var filter = new WorkflowInstanceFilter { Id = workflowInstanceId };
+ await workflowInstanceManager.DeleteAsync(filter, cancellationToken);
+ return true;
+ }
+
public async Task RunInstanceAsync(WorkflowInstance workflowInstance, RunWorkflowInstanceRequest request, CancellationToken cancellationToken = default)
{
var workflowState = workflowInstance.WorkflowState;
@@ -167,11 +189,16 @@ public class LocalWorkflowClient(
private async Task GetWorkflowInstanceAsync(CancellationToken cancellationToken)
{
- var workflowInstance = await workflowInstanceManager.FindByIdAsync(WorkflowInstanceId, cancellationToken);
- if (workflowInstance == null) throw new WorkflowInstanceNotFoundException($"Workflow instance not found.", WorkflowInstanceId);
+ var workflowInstance = await TryGetWorkflowInstanceAsync(cancellationToken);
+ if (workflowInstance == null) throw new WorkflowInstanceNotFoundException("Workflow instance not found.", WorkflowInstanceId);
return workflowInstance;
}
+ private Task TryGetWorkflowInstanceAsync(CancellationToken cancellationToken)
+ {
+ return workflowInstanceManager.FindByIdAsync(WorkflowInstanceId, cancellationToken);
+ }
+
private async Task GetWorkflowGraphAsync(WorkflowInstance workflowInstance, CancellationToken cancellationToken)
{
var handle = WorkflowDefinitionHandle.ByDefinitionVersionId(workflowInstance.DefinitionVersionId);
@@ -181,7 +208,7 @@ public class LocalWorkflowClient(
private async Task GetWorkflowGraphAsync(WorkflowDefinitionHandle definitionHandle, CancellationToken cancellationToken)
{
var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(definitionHandle, cancellationToken);
- if (workflowGraph == null) throw new WorkflowGraphNotFoundException($"Workflow graph not found.", definitionHandle);
+ if (workflowGraph == null) throw new WorkflowGraphNotFoundException("Workflow graph not found.", definitionHandle);
return workflowGraph;
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs
index 474dff551..008d75e30 100644
--- a/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Services/TriggerIndexer.cs
@@ -62,14 +62,21 @@ public class TriggerIndexer : ITriggerIndexer
var triggers = (await _triggerStore.FindManyAsync(filter, cancellationToken)).ToList();
var workflowDefinitionVersionIds = triggers.Select(x => x.WorkflowDefinitionVersionId).Distinct().ToList();
- foreach (string workflowDefinitionVersionId in workflowDefinitionVersionIds)
+ foreach (var workflowDefinitionVersionId in workflowDefinitionVersionIds)
{
- var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionVersionId, cancellationToken);
+ try
+ {
+ var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionVersionId, cancellationToken);
- if (workflowGraph == null)
- continue;
-
- await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken);
+ if (workflowGraph == null)
+ continue;
+
+ await DeleteTriggersAsync(workflowGraph.Workflow, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to load workflow graph for workflow definition version {WorkflowDefinitionVersionId}. Skipping trigger deletion for this workflow.", workflowDefinitionVersionId);
+ }
}
}
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs
new file mode 100644
index 000000000..e768d7586
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Activities/LongRunningActivity.cs
@@ -0,0 +1,30 @@
+using Elsa.Testing.Shared.Services;
+using Elsa.Workflows.Attributes;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Activities;
+
+///
+/// A custom activity that simulates a long-running operation to test workflow deletion during execution.
+/// This activity sends a signal when it starts, then delays for a specified duration.
+///
+[Activity("Elsa.Workflows.ComponentTests", "LongRunningActivity", "A long-running activity for testing")]
+public class LongRunningActivity : CodeActivity
+{
+ ///
+ /// The duration to delay for (in milliseconds)
+ ///
+ public int DelayMilliseconds { get; set; } = 3000;
+
+ protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
+ {
+ var signalManager = context.GetRequiredService();
+ var workflowInstanceId = context.WorkflowExecutionContext.Id;
+
+ // Send a signal to notify the test that the activity has started
+ signalManager.Trigger("workflow-started", workflowInstanceId);
+
+ // Simulate a long-running operation by using Task.Delay
+ // This keeps the workflow in memory and executing
+ await Task.Delay(DelayMilliseconds, context.CancellationToken);
+ }
+}
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/RelatedRecordsDeletionTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/RelatedRecordsDeletionTests.cs
new file mode 100644
index 000000000..83e5f8333
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/RelatedRecordsDeletionTests.cs
@@ -0,0 +1,144 @@
+using Elsa.Api.Client.Resources.WorkflowInstances.Contracts;
+using Elsa.Common.Models;
+using Elsa.Workflows.ComponentTests.Abstractions;
+using Elsa.Workflows.ComponentTests.Fixtures;
+using Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows;
+using Elsa.Workflows.Management;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Runtime;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion;
+
+///
+/// Tests that verify workflow instance deletion also deletes all related records
+/// (execution logs, activity execution records, bookmarks, etc.)
+///
+public class RelatedRecordsDeletionTests(App app) : AppComponentTest(app)
+{
+ private IWorkflowRuntime WorkflowRuntime => Scope.ServiceProvider.GetRequiredService();
+ private IWorkflowInstanceStore WorkflowInstanceStore => Scope.ServiceProvider.GetRequiredService();
+ private IBookmarkStore BookmarkStore => Scope.ServiceProvider.GetRequiredService();
+ private IActivityExecutionStore ActivityExecutionStore => Scope.ServiceProvider.GetRequiredService();
+ private IWorkflowExecutionLogStore WorkflowExecutionLogStore => Scope.ServiceProvider.GetRequiredService();
+
+ [Fact(DisplayName = "Delete via API endpoint should remove all related records")]
+ public async Task DeleteViaApiEndpoint_ShouldRemoveAllRelatedRecords()
+ {
+ // Arrange
+ var workflowInstanceId = await CreateWorkflowWithRelatedRecordsAsync();
+ await AssertRelatedRecordsExistAsync(workflowInstanceId);
+
+ // Act
+ var deleteClient = WorkflowServer.CreateApiClient();
+ await deleteClient.DeleteAsync(workflowInstanceId);
+
+ // Assert
+ await AssertAllRecordsDeletedAsync(workflowInstanceId);
+ }
+
+ [Fact(DisplayName = "Bulk delete via API endpoint should remove all related records")]
+ public async Task BulkDeleteViaApiEndpoint_ShouldRemoveAllRelatedRecords()
+ {
+ // Arrange - Create 2 workflow instances
+ var workflowInstanceId1 = await CreateWorkflowWithRelatedRecordsAsync();
+ var workflowInstanceId2 = await CreateWorkflowWithRelatedRecordsAsync();
+
+ await AssertRelatedRecordsExistAsync(workflowInstanceId1);
+ await AssertRelatedRecordsExistAsync(workflowInstanceId2);
+
+ var allInstanceIds = new[]
+ {
+ workflowInstanceId1, workflowInstanceId2
+ };
+
+ // Act
+ var bulkDeleteClient = WorkflowServer.CreateApiClient();
+ await bulkDeleteClient.BulkDeleteAsync(new()
+ {
+ Ids = allInstanceIds
+ }, CancellationToken.None);
+
+ // Assert
+ foreach (var instanceId in allInstanceIds)
+ {
+ await AssertAllRecordsDeletedAsync(instanceId);
+ }
+ }
+
+ [Fact(DisplayName = "Delete via runtime client should remove all related records")]
+ public async Task DeleteViaRuntimeClient_ShouldRemoveAllRelatedRecords()
+ {
+ // Arrange
+ var workflowInstanceId = await CreateWorkflowWithRelatedRecordsAsync();
+ await AssertRelatedRecordsExistAsync(workflowInstanceId);
+
+ // Act
+ var instanceClient = await WorkflowRuntime.CreateClientAsync(workflowInstanceId);
+ var deleted = await instanceClient.DeleteAsync();
+
+ Assert.True(deleted);
+
+ // Assert
+ await AssertAllRecordsDeletedAsync(workflowInstanceId);
+ }
+
+ private async Task CreateWorkflowWithRelatedRecordsAsync()
+ {
+ var workflowClient = await WorkflowRuntime.CreateClientAsync();
+ var runResponse = await workflowClient.CreateAndRunInstanceAsync(new()
+ {
+ WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(
+ WorkflowWithRelatedRecords.DefinitionId,
+ VersionOptions.Published)
+ });
+ return runResponse.WorkflowInstanceId;
+ }
+
+ private async Task AssertRelatedRecordsExistAsync(string workflowInstanceId)
+ {
+ var bookmarks = await BookmarkStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ });
+ var activityExecutions = await ActivityExecutionStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ });
+ var executionLogs = await WorkflowExecutionLogStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ }, PageArgs.All);
+
+ Assert.NotEmpty(bookmarks);
+ Assert.NotEmpty(activityExecutions);
+ Assert.NotEmpty(executionLogs.Items);
+ }
+
+ private async Task AssertAllRecordsDeletedAsync(string workflowInstanceId)
+ {
+ var workflowInstance = await WorkflowInstanceStore.FindAsync(new()
+ {
+ Id = workflowInstanceId
+ });
+ Assert.Null(workflowInstance);
+
+ var remainingBookmarks = await BookmarkStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ });
+ Assert.Empty(remainingBookmarks);
+
+ var remainingActivityExecutions = await ActivityExecutionStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ });
+ Assert.Empty(remainingActivityExecutions);
+
+ var remainingExecutionLogs = await WorkflowExecutionLogStore.FindManyAsync(new()
+ {
+ WorkflowInstanceId = workflowInstanceId
+ }, PageArgs.All);
+ Assert.Empty(remainingExecutionLogs.Items);
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs
new file mode 100644
index 000000000..931913139
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/WorkflowInstanceDeletionTests.cs
@@ -0,0 +1,151 @@
+using Elsa.Api.Client.Resources.WorkflowInstances.Contracts;
+using Elsa.Common.Models;
+using Elsa.Testing.Shared.Services;
+using Elsa.Workflows.ComponentTests.Abstractions;
+using Elsa.Workflows.ComponentTests.Fixtures;
+using Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows;
+using Elsa.Workflows.Management;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Runtime;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion;
+
+///
+/// Tests that workflow instances can be deleted reliably, even when they are running or being persisted.
+/// This addresses issue #7077 where in-memory instances could be written back to the DB after deletion.
+///
+public class WorkflowInstanceDeletionTests(App app) : AppComponentTest(app)
+{
+ private const string WorkflowStartedSignal = "workflow-started";
+
+ private IWorkflowRuntime WorkflowRuntime => Scope.ServiceProvider.GetRequiredService();
+ private SignalManager SignalManager => Scope.ServiceProvider.GetRequiredService();
+ private IWorkflowInstanceStore WorkflowInstanceStore => Scope.ServiceProvider.GetRequiredService();
+
+ private async Task StartRunningWorkflowAsync()
+ {
+ var workflowClient = await WorkflowRuntime.CreateClientAsync();
+ await workflowClient.CreateAndRunInstanceAsync(new()
+ {
+ WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(SuspendingWorkflow.DefinitionId, VersionOptions.Published)
+ });
+
+ // Wait for the workflow to signal it has started
+ return await SignalManager.WaitAsync(WorkflowStartedSignal);
+ }
+
+ private async Task AssertWorkflowInstanceDeletedAsync(string workflowInstanceId)
+ {
+ var instance = await WorkflowInstanceStore.FindAsync(new() { Id = workflowInstanceId });
+ Assert.Null(instance);
+ }
+
+ [Fact(DisplayName = "Delete running workflow instance should cancel and remove it")]
+ public async Task DeleteRunningWorkflowInstance_ShouldCancelAndRemove()
+ {
+ // Arrange - Start a workflow that will be running in memory
+ var workflowInstanceId = await StartRunningWorkflowAsync();
+
+ // Act - Delete the running workflow instance using the API
+ var deleteClient = WorkflowServer.CreateApiClient();
+ await deleteClient.DeleteAsync(workflowInstanceId);
+
+ // Assert - Instance should be deleted
+ await AssertWorkflowInstanceDeletedAsync(workflowInstanceId);
+ }
+
+ [Fact(DisplayName = "Delete finished workflow instance should remove it")]
+ public async Task DeleteFinishedWorkflowInstance_ShouldRemove()
+ {
+ // Arrange - Create and run a workflow that completes immediately
+ var workflowClient = await WorkflowRuntime.CreateClientAsync();
+ var runResponse = await workflowClient.CreateAndRunInstanceAsync(new()
+ {
+ WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(CompletingWorkflow.DefinitionId, VersionOptions.Published)
+ });
+ var workflowInstanceId = runResponse.WorkflowInstanceId;
+
+ // Verify the workflow finished
+ var finishedInstance = await WorkflowInstanceStore.FindAsync(new() { Id = workflowInstanceId });
+ Assert.NotNull(finishedInstance);
+ Assert.Equal(WorkflowStatus.Finished, finishedInstance.WorkflowState.Status);
+
+ // Act - Delete the finished workflow instance
+ var deleteClient = WorkflowServer.CreateApiClient();
+ await deleteClient.DeleteAsync(workflowInstanceId);
+
+ // Assert - Instance should be deleted
+ await AssertWorkflowInstanceDeletedAsync(workflowInstanceId);
+ }
+
+ [Fact(DisplayName = "Delete non-existent workflow instance should throw exception")]
+ public async Task DeleteNonExistentWorkflowInstance_ShouldThrowException()
+ {
+ // Arrange - Use a non-existent workflow instance ID
+ var nonExistentId = "non-existent-workflow-instance-id";
+
+ // Act & Assert - Should throw an ApiException for non-existent instance (404 Not Found)
+ var deleteClient = WorkflowServer.CreateApiClient();
+ await Assert.ThrowsAsync(async () => await deleteClient.DeleteAsync(nonExistentId));
+ }
+
+ [Fact(DisplayName = "Bulk delete mixed running and finished instances should delete all")]
+ public async Task BulkDeleteMixedInstances_ShouldDeleteAll()
+ {
+ // Arrange - Create 2 running and 2 finished workflow instances
+ var workflowClient = await WorkflowRuntime.CreateClientAsync();
+
+ // Start 2 running instances
+ var runningId1 = await StartRunningWorkflowAsync();
+ var runningId2 = await StartRunningWorkflowAsync();
+
+ // Start 2 finished instances
+ var finishedResult1 = await workflowClient.CreateAndRunInstanceAsync(new()
+ {
+ WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(CompletingWorkflow.DefinitionId, VersionOptions.Published)
+ });
+ var finishedResult2 = await workflowClient.CreateAndRunInstanceAsync(new()
+ {
+ WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(CompletingWorkflow.DefinitionId, VersionOptions.Published)
+ });
+
+ var allInstanceIds = new[] { runningId1, runningId2, finishedResult1.WorkflowInstanceId, finishedResult2.WorkflowInstanceId };
+
+ // Act - Bulk delete all instances
+ var bulkDeleteClient = WorkflowServer.CreateApiClient();
+ await bulkDeleteClient.BulkDeleteAsync(new() { Ids = allInstanceIds }, CancellationToken.None);
+
+ // Assert - All instances should be deleted
+ foreach (var instanceId in allInstanceIds)
+ {
+ await AssertWorkflowInstanceDeletedAsync(instanceId);
+ }
+ }
+
+ [Fact(DisplayName = "Delete workflow instance should not be resurrected by persistence")]
+ public async Task DeleteWorkflowInstance_ShouldNotBeResurrectedByPersistence()
+ {
+ // This test addresses the core issue in #7077: ensuring that a deleted workflow instance
+ // is not written back to the database by the persistence mechanism while it's still executing.
+ //
+ // The race condition occurs when:
+ // 1. Workflow starts executing (stays in memory)
+ // 2. Delete request comes in
+ // 3. Without the fix: deletion happens, but workflow continues and persists itself again
+ // 4. With the fix: deletion blocks until workflow completes, using distributed locking
+
+ // Arrange - Start a workflow that will be running in memory
+ var workflowInstanceId = await StartRunningWorkflowAsync();
+
+ // Act - Attempt to delete using the runtime client while workflow is executing
+ // With the fix, this will block until the workflow completes (about 3 seconds)
+ var instanceClient = await WorkflowRuntime.CreateClientAsync(workflowInstanceId);
+ var deleted = await instanceClient.DeleteAsync();
+
+ Assert.True(deleted);
+
+ // Assert - Instance should be deleted and NOT resurrected
+ await AssertWorkflowInstanceDeletedAsync(workflowInstanceId);
+ }
+}
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs
new file mode 100644
index 000000000..2a256ac17
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/CompletingWorkflow.cs
@@ -0,0 +1,22 @@
+using Elsa.Workflows.Activities;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows;
+
+public class CompletingWorkflow : WorkflowBase
+{
+ public static readonly string DefinitionId = "completing-workflow-test";
+
+ protected override void Build(IWorkflowBuilder builder)
+ {
+ builder.WithDefinitionId(DefinitionId);
+
+ builder.Root = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine("Starting workflow"),
+ new WriteLine("Completing immediately")
+ }
+ };
+ }
+}
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs
new file mode 100644
index 000000000..ffb329ee3
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/SuspendingWorkflow.cs
@@ -0,0 +1,28 @@
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Activities;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows;
+
+///
+/// A workflow that uses a long-running activity to test deletion during active execution.
+/// This simulates the race condition where a workflow instance is deleted while still executing in memory.
+///
+public class SuspendingWorkflow : WorkflowBase
+{
+ public static readonly string DefinitionId = "suspending-workflow-test";
+
+ protected override void Build(IWorkflowBuilder builder)
+ {
+ builder.WithDefinitionId(DefinitionId);
+
+ builder.Root = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine("Starting workflow"),
+ new LongRunningActivity { DelayMilliseconds = 3000 }, // Runs for 3 seconds in memory
+ new WriteLine("Workflow completed")
+ }
+ };
+ }
+}
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/WorkflowWithRelatedRecords.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/WorkflowWithRelatedRecords.cs
new file mode 100644
index 000000000..b0e97a14c
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowInstanceDeletion/Workflows/WorkflowWithRelatedRecords.cs
@@ -0,0 +1,33 @@
+using Elsa.Scheduling.Activities;
+using Elsa.Workflows.Activities;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.WorkflowInstanceDeletion.Workflows;
+
+///
+/// A workflow that generates related records: execution logs, activity execution records, and bookmarks.
+/// This is used to test that all related records are properly deleted when the workflow instance is deleted.
+///
+public class WorkflowWithRelatedRecords : WorkflowBase
+{
+ public static readonly string DefinitionId = "workflow-with-related-records-test";
+
+ protected override void Build(IWorkflowBuilder builder)
+ {
+ builder.WithDefinitionId(DefinitionId);
+
+ builder.Root = new Sequence
+ {
+ Activities =
+ {
+ // This will create activity execution records and execution logs
+ new WriteLine("Step 1: Starting workflow"),
+ new WriteLine("Step 2: About to delay"),
+
+ // This will create a bookmark (suspends the workflow)
+ new Delay(TimeSpan.FromHours(1)),
+
+ new WriteLine("Step 3: This should never execute")
+ }
+ };
+ }
+}
diff --git a/test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs b/test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs
deleted file mode 100644
index 0cc3efe7a..000000000
--- a/test/integration/Elsa.Common.IntegrationTests/Serialization/EnumerableTypeConverterTests.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using Elsa.Common.Serialization;
-
-namespace Elsa.Common.IntegrationTests.Serialization;
-
-public class EnumerableTypeConverterTests
-{
- [Fact(DisplayName = "String variable is not serialized as JSON array")]
- public void StringVariableIsNotSerializedAsJsonArray()
- {
- var testString = "Hello World";
- AssertTypeConverterPreservesValue(testString);
- }
-
- [Fact(DisplayName = "Byte array variable is not serialized as JSON array")]
- public void ByteArrayVariableIsNotSerializedAsJsonArray()
- {
- var testByteArray = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xFF };
- AssertTypeConverterPreservesValue(testByteArray);
- }
-
- [Fact(DisplayName = "String array is serialized as JSON array")]
- public void StringArrayIsSerializedAsJsonArray()
- {
- var testArray = new[] { "Element 1", "Element 2", "Element 3" };
- AssertTypeConverterSerializesToJson(testArray, "[\"Element 1\",\"Element 2\",\"Element 3\"]");
- }
-
- [Fact(DisplayName = "List is serialized as JSON array")]
- public void ListIsSerializedAsJsonArray()
- {
- var testList = new List { 1, 2, 3 };
- AssertTypeConverterSerializesToJson(testList, "[1,2,3]");
- }
-
- private void AssertTypeConverterPreservesValue(T expectedValue)
- {
- var converter = new EnumerableTypeConverter();
- var result = converter.ConvertTo(null, null, expectedValue, typeof(string));
-
- Assert.Equal(expectedValue, result);
- Assert.IsType(result);
- }
-
- private void AssertTypeConverterSerializesToJson(T value, string expectedJson)
- {
- var converter = new EnumerableTypeConverter();
- var result = converter.ConvertTo(null, null, value, typeof(string));
-
- Assert.IsType(result);
- Assert.Equal(expectedJson, result);
- }
-}
diff --git a/test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs b/test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs
new file mode 100644
index 000000000..f252f81bb
--- /dev/null
+++ b/test/integration/Elsa.Common.IntegrationTests/Serialization/ObjectFormatterTests.cs
@@ -0,0 +1,107 @@
+using Elsa.Expressions.Helpers;
+
+namespace Elsa.Common.IntegrationTests.Serialization;
+
+///
+/// Tests for ObjectFormatter.Format() to ensure proper serialization of different types:
+/// - Strings are preserved as-is
+/// - Byte arrays are base64-encoded
+/// - Arrays and collections serialize to JSON instead of "T[] Array"
+/// Related to GitHub issue #7019.
+///
+public class ObjectFormatterTests
+{
+ [Fact(DisplayName = "String is preserved as-is")]
+ public void StringIsPreservedAsIs()
+ {
+ var testString = "Hello World";
+ var result = testString.Format();
+
+ Assert.Equal("Hello World", result);
+ }
+
+ [Fact(DisplayName = "Byte array is serialized as base64 string")]
+ public void ByteArrayIsSerializedAsBase64String()
+ {
+ var testByteArray = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xFF };
+ var result = testByteArray.Format();
+
+ // Byte arrays are base64-encoded for serialization
+ Assert.NotNull(result);
+ var expectedBase64 = Convert.ToBase64String(testByteArray);
+ Assert.Equal(expectedBase64, result);
+ }
+
+ [Fact(DisplayName = "Integer array is serialized as JSON array")]
+ public void IntegerArrayIsSerializedAsJsonArray()
+ {
+ var testArray = new[] { 1, 2, 3, 4, 5 };
+ var result = testArray.Format();
+
+ Assert.Equal("[1,2,3,4,5]", result);
+ }
+
+ [Fact(DisplayName = "String array is serialized as JSON array")]
+ public void StringArrayIsSerializedAsJsonArray()
+ {
+ var testArray = new[] { "Hello", "World" };
+ var result = testArray.Format();
+
+ Assert.Equal("[\"Hello\",\"World\"]", result);
+ }
+
+ [Fact(DisplayName = "String array with multiple elements is serialized as JSON array")]
+ public void StringArrayWithMultipleElementsIsSerializedAsJsonArray()
+ {
+ var testArray = new[] { "Element 1", "Element 2", "Element 3" };
+ var result = testArray.Format();
+
+ Assert.Equal("[\"Element 1\",\"Element 2\",\"Element 3\"]", result);
+ }
+
+ [Fact(DisplayName = "Custom class array is serialized as JSON array")]
+ public void CustomClassArrayIsSerializedAsJsonArray()
+ {
+ var testArray = new[] { new TestClass { Name = "Item1" }, new TestClass { Name = "Item2" } };
+ var result = testArray.Format();
+
+ // Should be JSON, not "TestClass[] Array"
+ Assert.NotNull(result);
+ Assert.StartsWith("[", result);
+ Assert.Contains("Item1", result);
+ Assert.Contains("Item2", result);
+ }
+
+ [Fact(DisplayName = "List of integers is serialized as JSON array")]
+ public void ListOfIntegersIsSerializedAsJsonArray()
+ {
+ var testList = new List { 1, 2, 3 };
+ var result = testList.Format();
+
+ Assert.Equal("[1,2,3]", result);
+ }
+
+ [Fact(DisplayName = "List with different values is serialized as JSON array")]
+ public void ListWithDifferentValuesIsSerializedAsJsonArray()
+ {
+ var testList = new List { 10, 20, 30 };
+ var result = testList.Format();
+
+ Assert.Equal("[10,20,30]", result);
+ }
+
+ [Fact(DisplayName = "Null returns null")]
+ public void NullReturnsNull()
+ {
+ object? testValue = null;
+ var result = testValue.Format();
+
+ Assert.Null(result);
+ }
+
+ private class TestClass
+ {
+ public string Name { get; set; } = string.Empty;
+ }
+}
+
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/FailingMaterializer.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/FailingMaterializer.cs
new file mode 100644
index 000000000..b977e6b4e
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/FailingMaterializer.cs
@@ -0,0 +1,18 @@
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.Management;
+using Elsa.Workflows.Management.Entities;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.Fakes;
+
+///
+/// A custom materializer that throws an exception when materialization is attempted.
+/// This simulates the "Provider not found" scenario.
+///
+public class FailingMaterializer : IWorkflowMaterializer
+{
+ public const string MaterializerName = "FailingMaterializer";
+ public string Name => MaterializerName;
+
+ public ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) =>
+ ValueTask.FromException(new("Provider not found"));
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/WorkingMaterializer.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/WorkingMaterializer.cs
new file mode 100644
index 000000000..7761c2c0e
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Fakes/WorkingMaterializer.cs
@@ -0,0 +1,22 @@
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.Management;
+using Elsa.Workflows.Management.Entities;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.Fakes;
+
+///
+/// A custom materializer that successfully materializes workflows from a registry.
+///
+public class WorkingMaterializer(Dictionary workflowRegistry) : IWorkflowMaterializer
+{
+ public const string MaterializerName = "WorkingMaterializer";
+ public string Name => MaterializerName;
+
+ public ValueTask MaterializeAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default)
+ {
+ if (workflowRegistry.TryGetValue(definition.Id, out var workflow))
+ return ValueTask.FromResult(workflow);
+
+ throw new InvalidOperationException($"Workflow {definition.Id} not found in registry");
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerDeletionTestScenario.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerDeletionTestScenario.cs
new file mode 100644
index 000000000..0dea2fc55
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerDeletionTestScenario.cs
@@ -0,0 +1,13 @@
+namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData;
+
+///
+/// Represents a test scenario for trigger deletion with expected outcomes.
+///
+public record TriggerDeletionTestScenario
+{
+ public required string DisplayName { get; init; }
+ public required WorkflowConfig[] Workflows { get; init; }
+ public required string[] ExpectedRemainingTriggerIds { get; init; }
+
+ public override string ToString() => DisplayName;
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerTestDataBuilder.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerTestDataBuilder.cs
new file mode 100644
index 000000000..594ffb333
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/TriggerTestDataBuilder.cs
@@ -0,0 +1,57 @@
+using Elsa.Workflows.Management.Entities;
+using Elsa.Workflows.Runtime.Entities;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData;
+
+///
+/// Fluent builder for constructing test data with workflows and triggers.
+///
+public class TriggerTestDataBuilder
+{
+ private readonly List<(string workflowId, string definitionId, string materializerName)> _workflows = [];
+
+ public TriggerTestDataBuilder AddWorkflow(string workflowId, string definitionId, string materializerName)
+ {
+ _workflows.Add((workflowId, definitionId, materializerName));
+ return this;
+ }
+
+ public (WorkflowDefinition[] Workflows, StoredTrigger[] Triggers) Build()
+ {
+ var workflows = _workflows
+ .Select(config => CreateWorkflowDefinition(config.workflowId, config.definitionId, config.materializerName))
+ .ToArray();
+
+ var triggers = _workflows
+ .Select((config, index) => CreateTrigger($"trigger{index + 1}", config.definitionId, config.workflowId, $"act{index + 1}"))
+ .ToArray();
+
+ return (workflows, triggers);
+ }
+
+ private static WorkflowDefinition CreateWorkflowDefinition(string id, string definitionId, string materializerName)
+ {
+ return new()
+ {
+ Id = id,
+ DefinitionId = definitionId,
+ Version = 1,
+ IsPublished = true,
+ IsLatest = true,
+ MaterializerName = materializerName,
+ StringData = "{\"root\":{\"type\":\"Elsa.Sequence\",\"id\":\"seq1\",\"version\":1,\"activities\":[]}}"
+ };
+ }
+
+ private static StoredTrigger CreateTrigger(string id, string definitionId, string versionId, string activityId)
+ {
+ return new()
+ {
+ Id = id,
+ WorkflowDefinitionId = definitionId,
+ WorkflowDefinitionVersionId = versionId,
+ Name = "TestTrigger",
+ ActivityId = activityId
+ };
+ }
+}
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/WorkflowConfig.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/WorkflowConfig.cs
new file mode 100644
index 000000000..e43148fbe
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/TestData/WorkflowConfig.cs
@@ -0,0 +1,6 @@
+namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData;
+
+///
+/// Configuration for a workflow in a test scenario.
+///
+public record WorkflowConfig(string Id, string DefinitionId, string MaterializerName, bool ShouldSucceed);
\ No newline at end of file
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs
new file mode 100644
index 000000000..5bd8b05ef
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/TriggerIndexing/Tests.cs
@@ -0,0 +1,140 @@
+using Elsa.Testing.Shared;
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.Fakes;
+using Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing.TestData;
+using Elsa.Workflows.Management;
+using Elsa.Workflows.Management.Entities;
+using Elsa.Workflows.Runtime;
+using Elsa.Workflows.Runtime.Entities;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.TriggerIndexing;
+
+public class Tests
+{
+ private readonly IServiceProvider _services;
+ private readonly ITriggerIndexer _triggerIndexer;
+ private readonly ITriggerStore _triggerStore;
+ private readonly IWorkflowDefinitionStore _workflowDefinitionStore;
+ private readonly Dictionary _workflowRegistry = new();
+
+ public Tests(ITestOutputHelper testOutputHelper)
+ {
+ _services = new TestApplicationBuilder(testOutputHelper).ConfigureServices(services =>
+ {
+ services.AddSingleton();
+ services.AddSingleton(_ => new WorkingMaterializer(_workflowRegistry));
+ })
+ .Build();
+
+ _triggerIndexer = _services.GetRequiredService();
+ _triggerStore = _services.GetRequiredService();
+ _workflowDefinitionStore = _services.GetRequiredService();
+ }
+
+ [Theory(DisplayName = "DeleteTriggersAsync handles workflow materialization failures correctly")]
+ [MemberData(nameof(GetTriggerDeletionTestData))]
+ public async Task DeleteTriggersAsync_HandlesWorkflowFailures(TriggerDeletionTestScenario scenario)
+ {
+ // Arrange
+ var builder = new TriggerTestDataBuilder();
+
+ foreach (var workflow in scenario.Workflows)
+ {
+ builder.AddWorkflow(workflow.Id, workflow.DefinitionId, workflow.MaterializerName);
+
+ // Register working workflows in the registry
+ if (workflow.ShouldSucceed)
+ RegisterWorkflowInRegistry(workflow.Id, workflow.DefinitionId);
+ }
+
+ var testData = builder.Build();
+ await SetupTestScenarioAsync(testData);
+
+ // Act
+ await DeleteTriggersAsync();
+
+ // Assert
+ await AssertRemainingTriggers(scenario.ExpectedRemainingTriggerIds);
+ }
+
+ public static IEnumerable GetTriggerDeletionTestData()
+ {
+ // Scenario 1: All workflows fail to load - all triggers should remain
+ yield return
+ [
+ new TriggerDeletionTestScenario
+ {
+ DisplayName = "All workflows fail to load",
+ Workflows =
+ [
+ new("workflow1", "def1", "Json", ShouldSucceed: false),
+ new("workflow2", "def2", FailingMaterializer.MaterializerName, ShouldSucceed: false),
+ new("workflow3", "def3", "Json", ShouldSucceed: false)
+ ],
+ ExpectedRemainingTriggerIds = ["trigger1", "trigger2", "trigger3"]
+ }
+ ];
+
+ // Scenario 2: One workflow fails, others succeed - only the failing workflow's trigger remains
+ yield return
+ [
+ new TriggerDeletionTestScenario
+ {
+ DisplayName = "One workflow fails, others succeed",
+ Workflows =
+ [
+ new("workflow1", "def1", WorkingMaterializer.MaterializerName, ShouldSucceed: true),
+ new("workflow2", "def2", FailingMaterializer.MaterializerName, ShouldSucceed: false),
+ new("workflow3", "def3", WorkingMaterializer.MaterializerName, ShouldSucceed: true)
+ ],
+ ExpectedRemainingTriggerIds = ["trigger2"]
+ }
+ ];
+ }
+
+ private async Task AssertRemainingTriggers(string[] expectedTriggerIds)
+ {
+ var remainingTriggerIds = await GetRemainingTriggerIdsAsync();
+ Assert.Equal(expectedTriggerIds.OrderBy(id => id), remainingTriggerIds);
+ }
+
+ private async Task SaveWorkflowsAndTriggersAsync(WorkflowDefinition[] workflows, StoredTrigger[] triggers)
+ {
+ foreach (var workflow in workflows)
+ await _workflowDefinitionStore.SaveAsync(workflow);
+
+ foreach (var trigger in triggers)
+ await _triggerStore.SaveAsync(trigger);
+
+ var allTriggers = await _triggerStore.FindManyAsync(new());
+ Assert.Equal(triggers.Length, allTriggers.Count());
+ }
+
+
+ private void RegisterWorkflowInRegistry(string workflowId, string definitionId)
+ {
+ _workflowRegistry[workflowId] = new()
+ {
+ Identity = new(definitionId, 1, workflowId)
+ };
+ }
+
+ private async Task SetupTestScenarioAsync((WorkflowDefinition[] Workflows, StoredTrigger[] Triggers) testData)
+ {
+ await _services.PopulateRegistriesAsync();
+ await SaveWorkflowsAndTriggersAsync(testData.Workflows, testData.Triggers);
+ }
+
+ private async Task DeleteTriggersAsync()
+ {
+ await _triggerIndexer.DeleteTriggersAsync(new());
+ }
+
+ private async Task GetRemainingTriggerIdsAsync()
+ {
+ var remainingTriggers = await _triggerStore.FindManyAsync(new());
+ return remainingTriggers.Select(t => t.Id).OrderBy(id => id).ToArray();
+ }
+}
\ No newline at end of file
diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs
index 5752968c3..11ac147a6 100644
--- a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs
+++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledCronTaskTests.cs
@@ -103,10 +103,16 @@ public class ScheduledCronTaskTests : IDisposable
SetupCronParser(DefaultNow, DefaultNow.AddMinutes(5)); // First: delay=0, Second: proper future time
// Act
- CreateScheduledTask();
+ var task = CreateScheduledTask();
- // Assert - Should call GetNextOccurrence twice (once for initial delay=0, once for retry)
- _cronParser.Received(2).GetNextOccurrence(DefaultCronExpression);
+ // Dispose immediately to prevent timer from firing
+ ((IDisposable)task).Dispose();
+
+ // Assert - Should call GetNextOccurrence at least twice (initial + retry)
+ // May be called more if timer fires before disposal in rare race conditions
+ _cronParser.Received().GetNextOccurrence(DefaultCronExpression);
+ var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence));
+ Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}");
}
[Fact]
@@ -117,10 +123,16 @@ public class ScheduledCronTaskTests : IDisposable
SetupCronParser(DefaultNow.AddMinutes(-1), DefaultNow.AddMinutes(5)); // First: past, Second: future
// Act
- CreateScheduledTask();
+ var task = CreateScheduledTask();
- // Assert - Should call GetNextOccurrence twice
- _cronParser.Received(2).GetNextOccurrence(DefaultCronExpression);
+ // Dispose immediately to prevent timer from firing
+ ((IDisposable)task).Dispose();
+ Thread.Sleep(5); // Brief wait to ensure disposal completes
+
+ // Assert - Should call GetNextOccurrence at least twice
+ _cronParser.Received().GetNextOccurrence(DefaultCronExpression);
+ var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence));
+ Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}");
}
[Fact]
@@ -132,10 +144,17 @@ public class ScheduledCronTaskTests : IDisposable
SetupCronParser(DefaultNow); // Both calls return exactly now (delay = 0)
// Act - This should not crash and should set up a timer with minimum delay
- CreateScheduledTask();
+ var task = CreateScheduledTask();
- // Assert - Should call GetNextOccurrence twice (initial + retry) and log warning once (on final attempt)
- _cronParser.Received(2).GetNextOccurrence(DefaultCronExpression);
+ // Dispose immediately to prevent timer from firing and recursing
+ ((IDisposable)task).Dispose();
+ Thread.Sleep(5); // Brief wait to ensure disposal completes
+
+ // Assert - Should call GetNextOccurrence at least twice (initial + retry)
+ // May be called more if timer fires before disposal and triggers Schedule() again
+ _cronParser.Received().GetNextOccurrence(DefaultCronExpression);
+ var calls = _cronParser.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(_cronParser.GetNextOccurrence));
+ Assert.True(calls >= 2, $"Expected at least 2 calls to GetNextOccurrence, but got {calls}");
AssertWarningLogged();
}
@@ -148,7 +167,10 @@ public class ScheduledCronTaskTests : IDisposable
SetupCronParser(DefaultNow.AddMilliseconds(-100), DefaultNow.AddMilliseconds(-50));
// Act - Should handle negative delay gracefully
- CreateScheduledTask();
+ var task = CreateScheduledTask();
+
+ // Dispose immediately to prevent timer from firing
+ ((IDisposable)task).Dispose();
// Assert - Should log a warning and still set up timer
AssertWarningLogged();
@@ -182,8 +204,6 @@ public class ScheduledCronTaskTests : IDisposable
{
((IDisposable)task).Dispose();
}
- // Small delay to ensure any timer callbacks have completed
- Thread.Sleep(10);
_serviceProvider.Dispose();
}
}
diff --git a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs
index 5fc93d315..0d702c406 100644
--- a/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs
+++ b/test/unit/Elsa.Scheduling.UnitTests/ScheduledTasks/ScheduledRecurringTaskTests.cs
@@ -96,10 +96,16 @@ public class ScheduledRecurringTaskTests : IDisposable
var startAt = DefaultNow; // First: delay=0
// Act
- CreateScheduledTask(startAt: startAt);
+ var task = CreateScheduledTask(startAt: startAt);
- // Assert - System clock should be called twice (once for initial delay=0, once for retry)
- _ = _systemClock.Received(2).UtcNow;
+ // Dispose immediately to prevent timer from firing
+ ((IDisposable)task).Dispose();
+
+ // Assert - System clock should be called at least twice (initial + retry)
+ // May be called more if timer fires before disposal in rare race conditions
+ _ = _systemClock.Received().UtcNow;
+ var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow");
+ Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}");
}
[Fact]
@@ -110,10 +116,16 @@ public class ScheduledRecurringTaskTests : IDisposable
var startAt = DefaultNow.AddMinutes(-1); // Past time
// Act
- CreateScheduledTask(startAt: startAt);
+ var task = CreateScheduledTask(startAt: startAt);
- // Assert - System clock should be called twice
- _ = _systemClock.Received(2).UtcNow;
+ // Dispose immediately to prevent timer from firing
+ ((IDisposable)task).Dispose();
+
+ // Assert - System clock should be called at least twice (initial + retry)
+ // May be called more if timer fires before disposal in rare race conditions
+ _ = _systemClock.Received().UtcNow;
+ var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow");
+ Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}");
}
[Fact]
@@ -125,10 +137,17 @@ public class ScheduledRecurringTaskTests : IDisposable
var startAt = DefaultNow; // Both calls return exactly now (delay = 0)
// Act - This should not crash and should set up a timer with minimum delay
- CreateScheduledTask(startAt: startAt);
+ var task = CreateScheduledTask(startAt: startAt);
- // Assert - Should call UtcNow twice (initial + retry) and log warning
- _ = _systemClock.Received(2).UtcNow;
+ // Dispose immediately to prevent timer from firing and recursing
+ ((IDisposable)task).Dispose();
+ Thread.Sleep(5); // Brief wait to ensure disposal completes
+
+ // Assert - Should call UtcNow at least twice (initial + retry)
+ // May be called more if timer fires before disposal
+ _ = _systemClock.Received().UtcNow;
+ var calls = _systemClock.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "get_UtcNow");
+ Assert.True(calls >= 2, $"Expected at least 2 calls to UtcNow, but got {calls}");
AssertWarningLogged();
}
@@ -141,7 +160,10 @@ public class ScheduledRecurringTaskTests : IDisposable
var startAt = DefaultNow.AddMilliseconds(-100); // Negative delay
// Act - Should handle negative delay gracefully
- CreateScheduledTask(startAt: startAt);
+ var task = CreateScheduledTask(startAt: startAt);
+
+ // Dispose immediately to prevent timer from firing
+ ((IDisposable)task).Dispose();
// Assert - Should log a warning and still set up timer
AssertWarningLogged();
@@ -170,8 +192,6 @@ public class ScheduledRecurringTaskTests : IDisposable
{
((IDisposable)task).Dispose();
}
- // Small delay to ensure any timer callbacks have completed
- Thread.Sleep(10);
_serviceProvider.Dispose();
}
}
From f0f09e2bce04c4e9c970d6a5e85e0739380c81fe Mon Sep 17 00:00:00 2001
From: Matt
Date: Sat, 22 Nov 2025 01:16:44 +0000
Subject: [PATCH 11/33] Update package versions and restructure props file
Updated `MicrosoftVersion` and `ResilienceVersion` to `10.0.0`.
Restructured `` elements for better organization.
Added conditional `` entries for `net8.0`, `net9.0`,
and `net10.0` target frameworks to specify appropriate
`Microsoft.EntityFrameworkCore` package versions.
Preserved existing package versions and configurations.
---
Directory.Packages.props | 369 ++++++++++++++++++++-------------------
1 file changed, 189 insertions(+), 180 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4029049ab..23eb221dd 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,182 +1,191 @@
-
- true
- false
-
-
- 9.0.11
- 9.10.0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ true
+ false
+
+
+ 10.0.0
+ 10.0.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
From b0d93d347b6bebd4bc4172bb80f177ec0681a5db Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Mon, 24 Nov 2025 14:37:06 +0100
Subject: [PATCH 12/33] Add unit and integration tests for `DownloadHttpFile`
activity (#7094)
* Add unit and integration tests for `DownloadHttpFile` activity
- Developed comprehensive unit tests for the `DownloadHttpFile` activity validating method execution (GET, POST), URL handling, headers, and status codes.
- Added integration tests to verify functionality like authorization headers, filename extraction, and response stream handling.
- Updated `IntegrationTests` project references to include the required `Elsa.Http` module.
* Refactor `DownloadHttpFileTests` to simplify test setup and improve reusability
- Consolidated test initialization logic into `RunActivityAsync` for cleaner and reusable test setup.
- Updated existing tests to use `RunActivityAsync`, reducing boilerplate code.
- Unified filename extraction tests into a parameterized theory to improve test coverage and clarity.
- Simplified helper handlers with expression-bodied members for readability.
* Add comments to clarify HttpResponseMessage disposal responsibility in `DownloadHttpFileTests`.
* Ensure `HttpResponseMessage.Content` is always set to prevent null reference issues in `DownloadHttpFileTests`.
* Refactor `DownloadHttpFileTests` to ensure `HttpResponseMessage.Content` is always initialized and simplify content assignment logic.
* Update `DownloadHttpFileTests` to set an empty `HttpResponseMessage.Content` for testing `HasContent` behavior
* Add `GitHubActionsTestLogger` package to test projects and refactor `Directory.Packages.props`
- Updated unit and integration test projects to include `GitHubActionsTestLogger` for improved test reporting in CI.
- Refactored `Directory.Packages.props` to add conditional dependencies for .NET 8, 9, and 10 compatibility.
* Remove `GitHubActionsTestLogger` package from test projects.
* Add project reference for unit tests and update `DownloadHttpFileTests`
- Included `Elsa.Activities.UnitTests` project reference in `IntegrationTests` to reuse helpers.
- Removed redundant `TestHttpMessageHandler` by utilizing shared helper from `UnitTests`.
* Refactor `DownloadHttpFileTests` to remove unused methods and simplify imports
* Update target framework to .NET 10 and upgrade `Nuke.Components` package to v10.0.0
---
.github/workflows/packages.yml | 1 +
Directory.Packages.props | 361 +++++++++---------
build/_build.csproj | 9 +-
.../Elsa.Activities.IntegrationTests.csproj | 4 +-
.../Http/DownloadHttpFileTests.cs | 224 +++++++++++
.../Elsa.Alterations.IntegrationTests.csproj | 18 +-
.../Elsa.Common.IntegrationTests.csproj | 6 +-
.../Elsa.Workflows.IntegrationTests.csproj | 1 +
.../Elsa.Workflows.PerformanceTests.csproj | 2 -
.../Http/DownloadHttpFileTests.cs | 332 ++++++++++++++++
10 files changed, 747 insertions(+), 211 deletions(-)
create mode 100644 test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs
create mode 100644 test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
index b9801c2f0..760b28067 100644
--- a/.github/workflows/packages.yml
+++ b/.github/workflows/packages.yml
@@ -36,6 +36,7 @@ jobs:
dotnet-version: |
8.0.x
9.0.x
+ 10.0.x
- name: Cache NuGet packages
uses: actions/cache@v4
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 23eb221dd..01970e991 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,191 +1,174 @@
-
- true
- false
-
-
- 10.0.0
- 10.0.0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build/_build.csproj b/build/_build.csproj
index c004aa8d9..bd32549c9 100644
--- a/build/_build.csproj
+++ b/build/_build.csproj
@@ -2,7 +2,7 @@
Exe
- net9.0
+ net10.0
CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006
..
@@ -19,12 +19,7 @@
-
-
-
-
-
-
+
diff --git a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj
index 511e3c7ff..5f9082087 100644
--- a/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj
+++ b/test/integration/Elsa.Activities.IntegrationTests/Elsa.Activities.IntegrationTests.csproj
@@ -8,7 +8,9 @@
+
+
-
+
diff --git a/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs
new file mode 100644
index 000000000..5c5fc3cfb
--- /dev/null
+++ b/test/integration/Elsa.Activities.IntegrationTests/Http/DownloadHttpFileTests.cs
@@ -0,0 +1,224 @@
+using System.Net;
+using Elsa.Activities.UnitTests.Http.Helpers;
+using Elsa.Extensions;
+using Elsa.Http;
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using Elsa.Workflows.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Elsa.Activities.IntegrationTests.Http;
+
+public class DownloadHttpFileTests(ITestOutputHelper testOutputHelper)
+{
+ [Fact(DisplayName = "DownloadHttpFile downloads file successfully")]
+ public async Task DownloadsFile_Successfully()
+ {
+ // Arrange
+ var fileContent = "Test file content"u8.ToArray();
+ var handler = CreateFileResponseHandler(fileContent, "document.pdf", "application/pdf");
+
+ // Act
+ var (workflowResult, activity) = await RunActivityAsync("https://example.com/file.pdf", handler);
+
+ // Assert
+ var file = workflowResult.GetActivityOutput(activity);
+ Assert.NotNull(file);
+ Assert.Equal("document.pdf", file.Filename);
+ Assert.Equal("application/pdf", file.ContentType);
+
+ var stream = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.ResponseContentStream));
+ Assert.NotNull(stream);
+ using var reader = new StreamReader(stream);
+ var content = await reader.ReadToEndAsync();
+ Assert.Equal("Test file content", content);
+ }
+
+ [Fact(DisplayName = "DownloadHttpFile handles POST requests")]
+ public async Task HandlesPostRequest()
+ {
+ // Arrange
+ var fileContent = "Response content"u8.ToArray();
+ var handler = CreateFileResponseHandler(fileContent);
+
+ // Act
+ var (workflowResult, activity) = await RunActivityAsync(
+ "https://api.example.com/download",
+ handler,
+ method: "POST",
+ requestContent: new { id = 123 },
+ requestContentType: "application/json");
+
+ // Assert
+ var statusCode = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.StatusCode));
+ Assert.Equal(200, statusCode);
+
+ var file = workflowResult.GetActivityOutput(activity);
+ Assert.NotNull(file);
+ }
+
+ [Fact(DisplayName = "DownloadHttpFile includes authorization header")]
+ public async Task IncludesAuthorizationHeader()
+ {
+ // Arrange
+ var requestCapture = new HttpRequestMessage?[1];
+ var fileContent = "Authorized content"u8.ToArray();
+ var handler = CreateCapturingHandler(fileContent, requestCapture);
+
+ // Act
+ await RunActivityAsync(
+ "https://example.com/secure/file.pdf",
+ handler,
+ authorization: "Bearer test-token");
+
+ // Assert
+ var capturedRequest = requestCapture[0];
+ Assert.NotNull(capturedRequest);
+ Assert.NotNull(capturedRequest.Headers.Authorization);
+ Assert.Equal("Bearer test-token", capturedRequest.Headers.Authorization.ToString());
+ }
+
+ [Theory(DisplayName = "DownloadHttpFile extracts filename correctly")]
+ [InlineData("https://example.com/generate-report", "annual-report.xlsx", "annual-report.xlsx")] // From Content-Disposition
+ [InlineData("https://example.com/downloads/image.png", null, "image.png")] // From URL
+ public async Task ExtractsFilename(string url, string? contentDispositionFilename, string expectedFilename)
+ {
+ // Arrange
+ var fileContent = "File data"u8.ToArray();
+ var handler = CreateFileResponseHandler(fileContent, contentDispositionFilename);
+
+ // Act
+ var (workflowResult, activity) = await RunActivityAsync(url, handler);
+
+ // Assert
+ var file = workflowResult.GetActivityOutput(activity);
+ Assert.NotNull(file);
+ Assert.Equal(expectedFilename, file.Filename);
+ }
+
+ [Theory(DisplayName = "DownloadHttpFile handles various status codes")]
+ [InlineData(200)]
+ [InlineData(201)]
+ [InlineData(404)]
+ public async Task HandlesStatusCodes(int statusCode)
+ {
+ // Arrange
+ var handler = CreateFileResponseHandler(statusCode: (HttpStatusCode)statusCode);
+
+ // Act
+ var (workflowResult, activity) = await RunActivityAsync(
+ "https://example.com/file.pdf",
+ handler,
+ expectedStatusCodes: [statusCode]);
+
+ // Assert
+ var actualStatusCode = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.StatusCode));
+ Assert.Equal(statusCode, actualStatusCode);
+ }
+
+ [Fact(DisplayName = "DownloadHttpFile sets response headers")]
+ public async Task SetsResponseHeaders()
+ {
+ // Arrange
+ var additionalHeaders = new Dictionary
+ {
+ { "X-Rate-Limit", "100" },
+ { "X-Request-Id", "abc123" }
+ };
+ var handler = CreateFileResponseHandler(additionalHeaders: additionalHeaders);
+
+ // Act
+ var (workflowResult, activity) = await RunActivityAsync("https://api.example.com/download", handler);
+
+ // Assert
+ var responseHeaders = workflowResult.GetActivityOutput(activity, nameof(DownloadHttpFile.ResponseHeaders));
+ Assert.NotNull(responseHeaders);
+ Assert.Contains(responseHeaders.Keys, k => k.Equals("X-Rate-Limit", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(responseHeaders.Keys, k => k.Equals("X-Request-Id", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async Task<(RunWorkflowResult Result, DownloadHttpFile Activity)> RunActivityAsync(
+ string url,
+ HttpMessageHandler handler,
+ string method = "GET",
+ object? requestContent = null,
+ string? requestContentType = null,
+ string? authorization = null,
+ int[]? expectedStatusCodes = null)
+ {
+ var fixture = CreateFixture(handler);
+ var activity = new DownloadHttpFile
+ {
+ Url = new(new Uri(url)),
+ Method = new(method),
+ ExpectedStatusCodes = new(expectedStatusCodes ?? [200])
+ };
+
+ if (requestContent != null)
+ activity.RequestContent = new(requestContent);
+
+ if (requestContentType != null)
+ activity.RequestContentType = new(requestContentType);
+
+ if (authorization != null)
+ activity.Authorization = new(authorization);
+
+ var result = await fixture.RunActivityAsync(activity);
+ return (result, activity);
+ }
+
+ private WorkflowTestFixture CreateFixture(HttpMessageHandler handler) =>
+ new WorkflowTestFixture(testOutputHelper)
+ .ConfigureElsa(elsa => elsa.UseHttp(http =>
+ {
+ http.HttpClientBuilder = builder => builder.ConfigurePrimaryHttpMessageHandler(() => handler);
+ }));
+
+ private static HttpMessageHandler CreateFileResponseHandler(
+ byte[]? content = null,
+ string? filename = null,
+ string? contentType = null,
+ HttpStatusCode statusCode = HttpStatusCode.OK,
+ Dictionary? additionalHeaders = null) =>
+ new TestHttpMessageHandler((_, _) =>
+ {
+ // Response ownership is transferred to the caller (HttpClient), which will dispose it
+ var response = new HttpResponseMessage(statusCode);
+
+ // Always set Content (even if empty) because DownloadHttpFile accesses Content.Headers
+ var actualContent = content ?? (statusCode == HttpStatusCode.OK ? "Default file content"u8.ToArray() : []);
+ var actualContentType = contentType ?? "application/octet-stream";
+ response.Content = new ByteArrayContent(actualContent);
+ response.Content.Headers.ContentType = new(actualContentType);
+
+ if (filename != null)
+ {
+ response.Content.Headers.ContentDisposition = new("attachment")
+ {
+ FileName = filename
+ };
+ }
+
+ if (additionalHeaders != null)
+ foreach (var header in additionalHeaders)
+ response.Headers.Add(header.Key, header.Value);
+
+ return Task.FromResult(response);
+ });
+
+ private static HttpMessageHandler CreateCapturingHandler(byte[] content, HttpRequestMessage?[] capture) =>
+ new TestHttpMessageHandler((request, _) =>
+ {
+ capture[0] = request;
+
+ // Response ownership is transferred to the caller (HttpClient), which will dispose it
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(content)
+ };
+ response.Content.Headers.ContentType = new("application/octet-stream");
+
+ return Task.FromResult(response);
+ });
+}
diff --git a/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj b/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj
index c93acbf44..e749c8d4a 100644
--- a/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj
+++ b/test/integration/Elsa.Alterations.IntegrationTests/Elsa.Alterations.IntegrationTests.csproj
@@ -5,18 +5,18 @@
-
-
-
+
+
+
-
- Always
-
-
- Always
-
+
+ Always
+
+
+ Always
+
diff --git a/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj
index cc3bc2079..3cc54f7ff 100644
--- a/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj
+++ b/test/integration/Elsa.Common.IntegrationTests/Elsa.Common.IntegrationTests.csproj
@@ -6,8 +6,8 @@
-
-
+
+
-
+
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
index 9fa6481ce..43f34a790 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Elsa.Workflows.IntegrationTests.csproj
@@ -91,4 +91,5 @@
Always
+
diff --git a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj
index 57ec54ffe..9f0c772af 100644
--- a/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj
+++ b/test/performance/Elsa.Workflows.PerformanceTests/Elsa.Workflows.PerformanceTests.csproj
@@ -1,7 +1,5 @@
-
-
diff --git a/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs b/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs
new file mode 100644
index 000000000..301c5e1ee
--- /dev/null
+++ b/test/unit/Elsa.Activities.UnitTests/Http/DownloadHttpFileTests.cs
@@ -0,0 +1,332 @@
+using System.Net;
+using Elsa.Activities.UnitTests.Http.Helpers;
+using Elsa.Extensions;
+using Elsa.Http;
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using static Elsa.Activities.UnitTests.Http.Helpers.SendHttpRequestTestHelpers;
+
+namespace Elsa.Activities.UnitTests.Http;
+
+public class DownloadHttpFileTests
+{
+ [Theory]
+ [InlineData("GET", "https://example.com/file.pdf")]
+ [InlineData("POST", "https://api.example.com/download")]
+ [InlineData("PUT", "https://api.example.com/update-file")]
+ public async Task ExecuteAsync_SendsRequest_WithCorrectMethodAndUrl(string method, string url)
+ {
+ // Arrange
+ var expectedUrl = new Uri(url);
+ var expectedMethod = new HttpMethod(method);
+ var requestCapture = new RequestCapture();
+ var responseHandler = CreateFileResponseHandler(requestCapture: requestCapture);
+ var activity = CreateActivity(expectedUrl, method);
+
+ // Act
+ await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ Assert.NotNull(requestCapture.CapturedRequest);
+ Assert.Equal(expectedMethod, requestCapture.CapturedRequest.Method);
+ Assert.Equal(expectedUrl, requestCapture.CapturedRequest.RequestUri);
+ }
+
+ [Theory]
+ [InlineData("Bearer token123")]
+ [InlineData("Basic YWRtaW46cGFzcw==")]
+ public async Task ExecuteAsync_AddsAuthorizationHeader_WhenProvided(string authorizationHeader)
+ {
+ // Arrange
+ var requestCapture = new RequestCapture();
+ var responseHandler = CreateFileResponseHandler(requestCapture: requestCapture);
+ var activity = CreateActivity(new("https://example.com/file.pdf"), authorization: authorizationHeader);
+
+ // Act
+ await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ Assert.NotNull(requestCapture.CapturedRequest);
+ Assert.NotNull(requestCapture.CapturedRequest.Headers.Authorization);
+ Assert.Equal(authorizationHeader, requestCapture.CapturedRequest.Headers.Authorization.ToString());
+ }
+
+ [Theory]
+ [InlineData(200)]
+ [InlineData(201)]
+ [InlineData(404)]
+ public async Task ExecuteAsync_SetsStatusCodeOutput(int statusCode)
+ {
+ // Arrange
+ var responseHandler = CreateFileResponseHandler(statusCode: (HttpStatusCode)statusCode);
+ var activity = CreateActivity(new("https://example.com/file.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var actualStatusCode = context.GetActivityOutput(() => activity.StatusCode);
+ Assert.Equal(statusCode, actualStatusCode);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SetsFileOutput_WithCorrectFilename()
+ {
+ // Arrange
+ const string expectedFilename = "document.pdf";
+ var responseHandler = CreateFileResponseHandler(filename: expectedFilename);
+ var activity = CreateActivity(new("https://example.com/file.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var file = context.GetActivityOutput(() => activity.Result) as HttpFile;
+ Assert.NotNull(file);
+ Assert.Equal(expectedFilename, file.Filename);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SetsFileOutput_WithCorrectContentType()
+ {
+ // Arrange
+ const string expectedContentType = "application/pdf";
+ var responseHandler = CreateFileResponseHandler(contentType: expectedContentType);
+ var activity = CreateActivity(new("https://example.com/file.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var file = context.GetActivityOutput(() => activity.Result) as HttpFile;
+ Assert.NotNull(file);
+ Assert.Equal(expectedContentType, file.ContentType);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SetsResponseContentStream_FromFile()
+ {
+ // Arrange
+ var fileContent = "Test file content"u8.ToArray();
+ var responseHandler = CreateFileResponseHandler(content: fileContent);
+ var activity = CreateActivity(new("https://example.com/file.txt"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var stream = context.GetActivityOutput(() => activity.ResponseContentStream) as Stream;
+ Assert.NotNull(stream);
+
+ using var reader = new StreamReader(stream);
+ var actualContent = await reader.ReadToEndAsync();
+ Assert.Equal("Test file content", actualContent);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SetsResponseHeaders()
+ {
+ // Arrange
+ var expectedHeaders = new Dictionary
+ {
+ { "X-Custom-Header", "CustomValue" },
+ { "X-Request-Id", "12345" }
+ };
+ var responseHandler = CreateFileResponseHandler(additionalHeaders: expectedHeaders);
+ var activity = CreateActivity(new("https://example.com/file.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var responseHeaders = context.GetActivityOutput(() => activity.ResponseHeaders);
+ Assert.NotNull(responseHeaders);
+ var httpHeaders = Assert.IsType(responseHeaders);
+ Assert.True(httpHeaders.Count > 0);
+ Assert.Contains(httpHeaders.Keys, k => k.Equals("X-Custom-Header", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(httpHeaders.Keys, k => k.Equals("X-Request-Id", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_CompletesSuccessfully()
+ {
+ // Arrange
+ var responseHandler = CreateFileResponseHandler();
+ var activity = CreateActivity(new("https://example.com/file.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ Assert.Equal(ActivityStatus.Completed, context.Status);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_HandlesHttpRequestException()
+ {
+ // Arrange
+ var responseHandler = CreateExceptionHandler("Connection failed");
+ var activity = CreateActivity(new("https://example.com/file.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ Assert.Equal(ActivityStatus.Completed, context.Status);
+ Assert.True(context.JournalData.ContainsKey("Error"));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_HandlesTaskCanceledException()
+ {
+ // Arrange
+ var responseHandler = CreateExceptionHandler("Request timed out");
+ var activity = CreateActivity(new("https://example.com/file.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ Assert.Equal(ActivityStatus.Completed, context.Status);
+ Assert.True(context.JournalData.ContainsKey("Cancelled"));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ReturnsNull_WhenResponseHasNoContent()
+ {
+ // Arrange
+ var responseHandler = CreateEmptyResponseHandler();
+ var activity = CreateActivity(new("https://example.com/empty"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var file = context.GetActivityOutput(() => activity.Result);
+ Assert.Null(file);
+
+ var stream = context.GetActivityOutput(() => activity.ResponseContentStream);
+ Assert.Null(stream);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ExtractsFilename_FromContentDisposition()
+ {
+ // Arrange
+ const string expectedFilename = "report.xlsx";
+ var responseHandler = CreateFileResponseHandler(filename: expectedFilename);
+ var activity = CreateActivity(new("https://example.com/download"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var file = context.GetActivityOutput(() => activity.Result) as HttpFile;
+ Assert.NotNull(file);
+ Assert.Equal(expectedFilename, file.Filename);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ExtractsFilename_FromUrlSegment_WhenNoContentDisposition()
+ {
+ // Arrange
+ var responseHandler = CreateFileResponseHandler(filename: null); // No Content-Disposition
+ var activity = CreateActivity(new("https://example.com/files/document.pdf"));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var file = context.GetActivityOutput(() => activity.Result) as HttpFile;
+ Assert.NotNull(file);
+ Assert.Equal("document.pdf", file.Filename);
+ }
+
+ [Theory]
+ [InlineData("https://example.com", "/")]
+ [InlineData("https://example.com/", "/")]
+ [InlineData("https://example.com/download", "download")]
+ public async Task ExecuteAsync_ExtractsFilename_FromUrlWhenNoContentDisposition(string url, string expectedFilename)
+ {
+ // Arrange
+ var responseHandler = CreateFileResponseHandler(filename: null);
+ var activity = CreateActivity(new Uri(url));
+
+ // Act
+ var context = await ExecuteAsync(activity, responseHandler);
+
+ // Assert
+ var file = context.GetActivityOutput(() => activity.Result) as HttpFile;
+ Assert.NotNull(file);
+ Assert.Equal(expectedFilename, file.Filename);
+ }
+
+ private static DownloadHttpFile CreateActivity(
+ Uri url,
+ string method = "GET",
+ string? authorization = null) =>
+ new()
+ {
+ Url = new(url),
+ Method = new(method),
+ Authorization = authorization != null ? new(authorization) : null!,
+ ExpectedStatusCodes = new([200])
+ };
+
+ private static Task ExecuteAsync(
+ DownloadHttpFile activity,
+ Func> responseHandler) =>
+ new ActivityTestFixture(activity).WithHttpServices(responseHandler).ExecuteAsync();
+
+ private static Func> CreateFileResponseHandler(
+ HttpStatusCode statusCode = HttpStatusCode.OK,
+ byte[]? content = null,
+ string? filename = null,
+ string? contentType = null,
+ Dictionary? additionalHeaders = null,
+ RequestCapture? requestCapture = null)
+ {
+ return (request, _) =>
+ {
+ requestCapture?.CapturedRequest = request;
+
+ var response = new HttpResponseMessage(statusCode);
+
+ // Always set Content (even if empty) because DownloadHttpFile accesses Content.Headers
+ var actualContent = content ?? (statusCode == HttpStatusCode.OK ? "Default file content"u8.ToArray() : []);
+ var actualContentType = contentType ?? "application/octet-stream";
+ response.Content = new ByteArrayContent(actualContent);
+ response.Content.Headers.ContentType = new(actualContentType);
+
+ if (filename != null)
+ {
+ response.Content.Headers.ContentDisposition = new("attachment")
+ {
+ FileName = filename
+ };
+ }
+
+ if (additionalHeaders != null)
+ {
+ foreach (var header in additionalHeaders)
+ {
+ response.Headers.Add(header.Key, header.Value);
+ }
+ }
+
+ return Task.FromResult(response);
+ };
+ }
+
+ private static Func> CreateEmptyResponseHandler()
+ {
+ return (_, _) =>
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.OK);
+ // Set empty content (ContentLength = 0) to test HasContent returning false
+ response.Content = new ByteArrayContent(Array.Empty());
+ response.Content.Headers.ContentLength = 0;
+ return Task.FromResult(response);
+ };
+ }
+}
From f020c5e9d3623343bd1b4290e57d252a6fccf101 Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Mon, 24 Nov 2025 14:43:42 +0100
Subject: [PATCH 13/33] Adds integration tests for core activities (#7100)
* Add unit and integration tests for `DownloadHttpFile` activity
- Developed comprehensive unit tests for the `DownloadHttpFile` activity validating method execution (GET, POST), URL handling, headers, and status codes.
- Added integration tests to verify functionality like authorization headers, filename extraction, and response stream handling.
- Updated `IntegrationTests` project references to include the required `Elsa.Http` module.
* Refactor `DownloadHttpFileTests` to simplify test setup and improve reusability
- Consolidated test initialization logic into `RunActivityAsync` for cleaner and reusable test setup.
- Updated existing tests to use `RunActivityAsync`, reducing boilerplate code.
- Unified filename extraction tests into a parameterized theory to improve test coverage and clarity.
- Simplified helper handlers with expression-bodied members for readability.
* Add comments to clarify HttpResponseMessage disposal responsibility in `DownloadHttpFileTests`.
* Ensure `HttpResponseMessage.Content` is always set to prevent null reference issues in `DownloadHttpFileTests`.
* Refactor `DownloadHttpFileTests` to ensure `HttpResponseMessage.Content` is always initialized and simplify content assignment logic.
* Update `DownloadHttpFileTests` to set an empty `HttpResponseMessage.Content` for testing `HasContent` behavior
* Add `GitHubActionsTestLogger` package to test projects and refactor `Directory.Packages.props`
- Updated unit and integration test projects to include `GitHubActionsTestLogger` for improved test reporting in CI.
- Refactored `Directory.Packages.props` to add conditional dependencies for .NET 8, 9, and 10 compatibility.
* Remove `GitHubActionsTestLogger` package from test projects.
* Add project reference for unit tests and update `DownloadHttpFileTests`
- Included `Elsa.Activities.UnitTests` project reference in `IntegrationTests` to reuse helpers.
- Removed redundant `TestHttpMessageHandler` by utilizing shared helper from `UnitTests`.
* Refactor `DownloadHttpFileTests` to remove unused methods and simplify imports
* Update target framework to .NET 10 and upgrade `Nuke.Components` package to v10.0.0
* Add integration tests for smoke testing all core workflow activities
- Introduced `ActivitiesSmokeTests` to validate basic functionality of core workflow activities, including control flow and data manipulation.
- Added comprehensive `ActivitiesSmokeTestWorkflow` to test activities such as `Start`, `Finish`, `If`, `Switch`, `For`, `While`, `ForEach`, `SetVariable`, and `SetOutput`.
* Refactor `ActivitiesSmokeTestWorkflow` to reorder and clarify activity test cases
* Update test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../SmokeTests/ActivitiesSmokeTestWorkflow.cs | 209 ++++++++++++++++++
.../SmokeTests/ActivitiesSmokeTests.cs | 109 +++++++++
2 files changed, 318 insertions(+)
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs
create mode 100644 test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs
new file mode 100644
index 000000000..1bda08cf9
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTestWorkflow.cs
@@ -0,0 +1,209 @@
+using Elsa.Workflows.Activities;
+using Elsa.Workflows.Management.Activities.SetOutput;
+using Elsa.Workflows.Memory;
+using Elsa.Workflows.Models;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.SmokeTests;
+
+///
+/// Comprehensive smoke test workflow that exercises basic control flow and data manipulation activities.
+/// Tests: Start, Sequence, Break, Complete, If, Switch, While, For, ForEach,
+/// WriteLine, SetName, SetVariable<T>, SetVariable (untyped), SetOutput
+///
+public class ActivitiesSmokeTestWorkflow : WorkflowBase
+{
+ protected override void Build(IWorkflowBuilder workflow)
+ {
+ // Variables for testing
+ var counter = new Variable("Counter", 0);
+ var name = new Variable("Name", "Initial");
+ var result = new Variable("Result", "");
+ var items = new Variable>("Items", ["A", "B", "C"]);
+ var currentItem = new Variable("CurrentItem", "");
+ var loopCounter = new Variable("LoopCounter", 0);
+ var untypedVar = new Variable("UntypedVar", null);
+ var switchValue = new Variable("SwitchValue", 2);
+
+ workflow.WithVariables(counter, name, result, items, currentItem, loopCounter, untypedVar, switchValue);
+
+ workflow.Root = new Sequence
+ {
+ Activities =
+ {
+ // Test Start activity
+ new Start(),
+
+ // Test SetName
+ new SetName
+ {
+ Value = new("SmokeTestWorkflow")
+ },
+
+ // Test WriteLine
+ new WriteLine(context => "=== Smoke Test Started ==="),
+
+ // Test SetVariable
+ new SetVariable
+ {
+ Variable = name,
+ Value = new("Updated Name")
+ },
+ new WriteLine(context => $"Name: {name.Get(context)}"),
+
+ // Test SetVariable (untyped)
+ new SetVariable
+ {
+ Variable = untypedVar,
+ Value = new("Untyped value")
+ },
+ new WriteLine(context => $"Untyped: {untypedVar.Get(context)}"),
+
+ // Test If activity (condition true)
+ new If(() => true)
+ {
+ Then = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine("If branch: True path executed"),
+ new SetVariable { Variable = counter, Value = new(10) }
+ }
+ },
+ Else = new WriteLine("If branch: False path (should not execute)")
+ },
+
+ // Test Switch activity
+ new Switch
+ {
+ Cases =
+ {
+ new(
+ "Case1",
+ context => ValueTask.FromResult(switchValue.Get(context) == 1),
+ new WriteLine("Switch: Case 1 (should not execute)")
+ ),
+ new(
+ "Case2",
+ context => ValueTask.FromResult(switchValue.Get(context) == 2),
+ new Sequence
+ {
+ Activities =
+ {
+ new WriteLine("Switch: Case 2 executed"),
+ new SetVariable { Variable = result, Value = new("Switch-2") }
+ }
+ }
+ ),
+ new(
+ "Case3",
+ context => ValueTask.FromResult(switchValue.Get(context) == 3),
+ new WriteLine("Switch: Case 3 (should not execute)")
+ )
+ },
+ Default = new WriteLine("Switch: Default (should not execute)")
+ },
+
+ // Test For loop with Break
+ new Sequence
+ {
+ Activities =
+ {
+ new WriteLine("For loop: Starting"),
+ new For
+ {
+ Start = new(0),
+ End = new(100),
+ Step = new(1),
+ Body = new Sequence
+ {
+ Activities =
+ {
+ new SetVariable { Variable = loopCounter, Value = new(context => loopCounter.Get(context) + 1) },
+ // Break after 3 iterations
+ new If(context => loopCounter.Get(context) >= 3)
+ {
+ Then = new Break()
+ }
+ }
+ }
+ },
+ new WriteLine(context => $"For loop: Completed with {loopCounter.Get(context)} iterations")
+ }
+ },
+
+ // Test While loop with Break
+ new Sequence
+ {
+ Activities =
+ {
+ new SetVariable { Variable = loopCounter, Value = new(0) },
+ new WriteLine("While loop: Starting"),
+ new While(() => true)
+ {
+ Body = new Sequence
+ {
+ Activities =
+ {
+ new SetVariable { Variable = loopCounter, Value = new(context => loopCounter.Get(context) + 1) },
+ new WriteLine(context => $"While loop: Iteration {loopCounter.Get(context)}"),
+ // Break after 3 iterations
+ new If(context => loopCounter.Get(context) >= 3)
+ {
+ Then = new Break()
+ }
+ }
+ }
+ },
+ new WriteLine(context => $"While loop: Completed with {loopCounter.Get(context)} iterations")
+ }
+ },
+
+ // Test ForEach with Break
+ new Sequence
+ {
+ Activities =
+ {
+ new SetVariable { Variable = loopCounter, Value = new(0) },
+ new WriteLine("ForEach loop: Starting"),
+ new ForEach
+ {
+ Items = new(items),
+ CurrentValue = new(currentItem),
+ Body = new Sequence
+ {
+ Activities =
+ {
+ new SetVariable { Variable = loopCounter, Value = new(context => loopCounter.Get(context) + 1) },
+ new WriteLine(context => $"ForEach: Item '{currentItem.Get(context)}'"),
+ // Break after processing 2 items (A, B)
+ new If(context => loopCounter.Get(context) >= 2)
+ {
+ Then = new Break()
+ }
+ }
+ }
+ },
+ new WriteLine(context => $"ForEach loop: Completed with {loopCounter.Get(context)} items processed")
+ }
+ },
+
+ // Test SetOutput
+ new SetOutput
+ {
+ OutputName = new("FinalResult"),
+ OutputValue = new(context => $"Counter={counter.Get(context)}, Name={name.Get(context)}, Result={result.Get(context)}")
+ },
+
+ // Test Finish and End activities
+ new Finish(),
+ new End(),
+
+ // Test Complete activity (ends workflow immediately)
+ new Complete(),
+
+ // This should not execute due to Complete
+ new WriteLine("After Complete (should not execute)")
+ }
+ };
+ }
+}
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs
new file mode 100644
index 000000000..16d27936a
--- /dev/null
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/SmokeTests/ActivitiesSmokeTests.cs
@@ -0,0 +1,109 @@
+using Elsa.Testing.Shared;
+using Elsa.Workflows.Models;
+using Xunit.Abstractions;
+
+namespace Elsa.Workflows.IntegrationTests.Scenarios.SmokeTests;
+
+///
+/// Smoke tests that verify basic functionality of core workflow activities.
+///
+public class ActivitiesSmokeTests(ITestOutputHelper testOutputHelper)
+{
+ private readonly WorkflowTestFixture _fixture = new(testOutputHelper);
+
+ [Fact(DisplayName = "Smoke test executes all core activities successfully")]
+ public async Task SmokeTest_ExecutesAllActivities_Successfully()
+ {
+ // Act
+ var (result, _) = await RunWorkflowAndCaptureOutput();
+
+ // Assert - Workflow completed successfully
+ Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status);
+ Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
+
+ // Verify outputs were set correctly
+ var outputs = result.WorkflowState.Output;
+ Assert.NotNull(outputs);
+ Assert.True(outputs.TryGetValue("FinalResult", out var finalResultObj));
+
+ var finalResult = finalResultObj?.ToString();
+ Assert.NotNull(finalResult);
+ Assert.Contains("Counter=10", finalResult);
+ Assert.Contains("Name=Updated Name", finalResult);
+ Assert.Contains("Result=Switch-2", finalResult);
+ }
+
+ [Fact(DisplayName = "Smoke test verifies all activities were executed")]
+ public async Task SmokeTest_VerifiesActivityExecution()
+ {
+ // Act
+ var (_, lines) = await RunWorkflowAndCaptureOutput();
+
+ // Assert - Verify key activities executed by checking WriteLine outputs
+ Assert.Contains(lines, line => line.Contains("=== Smoke Test Started ==="));
+ Assert.Contains(lines, line => line.Contains("Name: Updated Name"));
+ Assert.Contains(lines, line => line.Contains("Untyped: Untyped value"));
+ Assert.Contains(lines, line => line.Contains("If branch: True path executed"));
+ Assert.Contains(lines, line => line.Contains("Switch: Case 2 executed"));
+ Assert.Contains(lines, line => line.Contains("For loop: Completed with 3 iterations"));
+ Assert.Contains(lines, line => line.Contains("While loop: Completed with 3 iterations"));
+ Assert.Contains(lines, line => line.Contains("ForEach loop: Completed with 2 items processed"));
+
+ // Verify activities after Complete did NOT execute
+ Assert.DoesNotContain(lines, line => line.Contains("After Complete (should not execute)"));
+ }
+
+ [Fact(DisplayName = "Break activity works correctly in different loop contexts")]
+ public async Task SmokeTest_BreakActivity_WorksInDifferentLoops()
+ {
+ // Act
+ var (_, lines) = await RunWorkflowAndCaptureOutput();
+
+ // Assert - Verify Break worked in For loop (stopped at 3 iterations, not 100)
+ Assert.Contains(lines, line => line.Contains("For loop: Completed with 3 iterations"));
+
+ // Verify Break worked in While loop (stopped at 3 iterations, didn't run infinitely)
+ Assert.Contains(lines, line => line.Contains("While loop: Iteration 1"));
+ Assert.Contains(lines, line => line.Contains("While loop: Iteration 2"));
+ Assert.Contains(lines, line => line.Contains("While loop: Iteration 3"));
+ Assert.Contains(lines, line => line.Contains("While loop: Completed with 3 iterations"));
+
+ // Verify Break worked in ForEach (stopped after 2 items: A, B, not C)
+ Assert.Contains(lines, line => line.Contains("ForEach: Item 'A'"));
+ Assert.Contains(lines, line => line.Contains("ForEach: Item 'B'"));
+ Assert.DoesNotContain(lines, line => line.Contains("ForEach: Item 'C'"));
+ Assert.Contains(lines, line => line.Contains("ForEach loop: Completed with 2 items processed"));
+ }
+
+ [Fact(DisplayName = "Switch activity executes correct case")]
+ public async Task SmokeTest_SwitchActivity_ExecutesCorrectCase()
+ {
+ // Act
+ var (_, lines) = await RunWorkflowAndCaptureOutput();
+
+ // Assert - Only Case 2 should execute
+ Assert.DoesNotContain(lines, line => line.Contains("Switch: Case 1 (should not execute)"));
+ Assert.Contains(lines, line => line.Contains("Switch: Case 2 executed"));
+ Assert.DoesNotContain(lines, line => line.Contains("Switch: Case 3 (should not execute)"));
+ Assert.DoesNotContain(lines, line => line.Contains("Switch: Default (should not execute)"));
+ }
+
+ [Fact(DisplayName = "If activity executes correct branch")]
+ public async Task SmokeTest_IfActivity_ExecutesCorrectBranch()
+ {
+ // Act
+ var (_, lines) = await RunWorkflowAndCaptureOutput();
+
+ // Assert - Only Then branch should execute
+ Assert.Contains(lines, line => line.Contains("If branch: True path executed"));
+ Assert.DoesNotContain(lines, line => line.Contains("If branch: False path (should not execute)"));
+ }
+
+ private async Task<(RunWorkflowResult Result, List Lines)> RunWorkflowAndCaptureOutput()
+ {
+ var workflow = new ActivitiesSmokeTestWorkflow();
+ var result = await _fixture.RunWorkflowAsync(workflow);
+ var lines = _fixture.CapturingTextWriter.Lines.ToList();
+ return (result, lines);
+ }
+}
From 0837930ec1f542c4b7a16ea356f3acd4262527df Mon Sep 17 00:00:00 2001
From: Sipke Schoorstra
Date: Tue, 25 Nov 2025 14:53:18 +0100
Subject: [PATCH 14/33] Refactors extension methods to instance methods (#7089)
* Refactor workflow instance deletion to use `IWorkflowRuntime` for enhanced coordination and separation of concerns.
* Remove `EnumerableTypeConverter` and update related usages for serialization.
- Deleted the `EnumerableTypeConverter` class and its JSON serialization logic.
- Removed associated type descriptor attribute in `DefaultFormattersFeature`.
- Updated `ObjectFormatter` to handle collection serialization directly with JSON.
* Remove `EnumerableTypeConverter` tests and consolidate serialization logic into `ObjectFormatter`.
- Deleted `EnumerableTypeConverterTests` as the related functionality was removed.
- Added comprehensive tests in `ObjectFormatterTests` to handle serialization of collections and arrays with JSON.
* Add integration tests for `TriggerIndexer` to handle workflows with failing materialization
- Introduced comprehensive test scenarios verifying `DeleteTriggersAsync` behavior when workflows fail to load or partially succeed.
- Enhanced error handling in `TriggerIndexer` to skip failed workflows while ensuring remaining workflows are processed.
* Add exception handling in `TriggerIndexer.DeleteTriggersAsync` and integration tests
- Enhanced `DeleteTriggersAsync` with exception handling to skip failed workflows while processing others.
- Logged warnings for failed workflows without halting execution.
- Added comprehensive integration tests to verify behavior across success, failure, and mixed scenarios.
- Refactored tests for improved clarity, maintainability, and consistency.
* Add exception handling for `ResumeWorkflowTask` to skip deleted workflow instances
- Enhanced `ResumeWorkflowTask.ExecuteAsync` to handle `WorkflowInstanceNotFoundException` gracefully when a scheduled workflow instance is missing.
- Logged warnings for skipped executions to improve observability.
- Ensured remaining workflows and scheduled tasks are processed seamlessly without disruption.
* Add thread safety to `LocalScheduler` to prevent race conditions during concurrent scheduling
- Introduced a `lock` object to synchronize access to internal dictionaries.
- Resolved `IndexOutOfRangeException` caused by concurrent modifications during startup.
- Ensured thread-safe operations in `ScheduleAsync`, `ClearScheduleAsync`, and related methods.
- Improved reliability and stability of scheduling under concurrent workloads.
* Improve exception handling, thread safety, and workflow instance deletion
- Added exception handling in `TriggerIndexer.DeleteTriggersAsync` to skip failed workflows while continuing processing.
- Enhanced `ResumeWorkflowTask` to handle missing workflow instances gracefully and log warnings.
- Introduced thread synchronization in `LocalScheduler` with `lock` to prevent concurrent access issues.
- Implemented and refactored tests to ensure behavior consistency and improve maintainability.
- Added component tests for workflow deletion scenarios, covering running, completed, and non-existent workflows.
* Add component tests for workflow instance deletion and refactor bulk delete logic
- Added comprehensive component tests for workflow instance deletion scenarios (running, completed, bulk, and non-existent instances).
- Refactored `BulkDelete` API to use `IWorkflowInstanceManager` for proper cleanup of related records (execution logs, activity executions, bookmarks).
* Add integration tests and fakes for `TriggerIndexer` to verify behavior with failing and successful workflows
- Introduced `FailingMaterializer` and `WorkingMaterializer` for simulating failing and successful workflow materializations.
- Added `TriggerDeletionTestScenario`, `TriggerTestDataBuilder`, and related test data classes to define comprehensive test cases.
- Updated `DeleteTriggersAsync` tests with scenarios for materialization failures and mixed success.
- Improved test coverage and maintainability with reusable test data builders and utilities.
* Refactor `ActivityExecutionContextExtensions` to use instance methods for improved readability and encapsulation
* Refactor extension methods to use instance methods for improved encapsulation and readability in core workflow modules
* Update src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionMiddlewareExtensions.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionMiddlewareExtensions.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor `ActivityExecutionContextExtensions` to improve encapsulation and simplify methods
- Converted extension methods to instance methods for better readability and maintainability.
- Introduced helper methods to encapsulate logic for common operations like detecting unconsumed tokens, running instances, scheduled works, and faulted children.
- Improved reusability and cohesion of workflow-related logic.
* Change access modifiers in `ActivityExecutionContextExtensions` to improve accessibility and reusability of workflow logic
* Update src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor `ActivityExecutionContextExtensions` for improved readability by simplifying conditional expression
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../ActivityExecutionContextExtensions.cs | 53 +-
.../ActivityExecutionContextExtensions.cs | 193 ++--
.../Extensions/ActivityExtensions.cs | 67 +-
.../Extensions/ConnectionsExtensions.cs | 51 +-
.../ActivityDescriptorExtensions.cs | 97 +-
...cutionContextExtensions.InputEvaluation.cs | 227 ++---
...yExecutionContextExtensions.InputOutput.cs | 47 +-
.../ActivityExecutionContextExtensions.cs | 768 ++++++++--------
.../Extensions/ActivityExtensions.cs | 373 ++++----
.../Extensions/ActivityPropertyExtensions.cs | 142 +--
.../Extensions/ActivityRegistryExtensions.cs | 27 +-
...roundActivityExecutionContextExtensions.cs | 131 +--
.../BehaviorCollectionExtensions.cs | 13 +-
.../Extensions/BookmarkExtensions.cs | 31 +-
...aviorActivityExecutionContextExtensions.cs | 21 +-
.../Extensions/DictionaryExtensions.cs | 109 ++-
.../ExpressionEvaluatorExtensions.cs | 27 +-
.../ExpressionExecutionContextExtensions.cs | 837 +++++++++---------
.../Extensions/InputExtensions.cs | 81 +-
.../MemoryBlockReferenceExtensions.cs | 86 +-
.../Extensions/ObjectExtensions.cs | 33 +-
.../Extensions/OutputExtensions.cs | 86 +-
.../Extensions/ServiceCollectionExtensions.cs | 17 +-
.../Extensions/VariableExtensions.cs | 104 ++-
.../WorkflowExecutionContextExtensions.cs | 241 ++---
.../Extensions/WorkflowExtensions.cs | 29 +-
.../ActivityExecutionMiddlewareExtensions.cs | 56 +-
.../WorkflowExecutionMiddlewareExtensions.cs | 108 ++-
28 files changed, 2085 insertions(+), 1970 deletions(-)
diff --git a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
index 3f9d03d9d..b4d221f85 100644
--- a/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
+++ b/src/common/Elsa.Testing.Shared/ActivityExecutionContextExtensions.cs
@@ -7,35 +7,36 @@ namespace Elsa.Testing.Shared;
///
public static class ActivityExecutionContextExtensions
{
- ///
- /// Determines whether the specified activity is scheduled in the workflow execution context.
- ///
/// The activity execution context instance used for checking the scheduled activity.
- /// The activity to check for scheduling.
- /// True if the specified activity is scheduled; otherwise, false.
- public static bool HasScheduledActivity(this ActivityExecutionContext activityExecutionContext, IActivity activity)
+ extension(ActivityExecutionContext activityExecutionContext)
{
- return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
- }
+ ///
+ /// Determines whether the specified activity is scheduled in the workflow execution context.
+ ///
+ /// The activity to check for scheduling.
+ /// True if the specified activity is scheduled; otherwise, false.
+ public bool HasScheduledActivity(IActivity activity)
+ {
+ return activityExecutionContext.WorkflowExecutionContext.Scheduler.Find(x => x.Activity == activity) != null;
+ }
- ///
- /// Retrieves the collection of outcomes recorded in the execution context's journal data.
- ///
- /// The activity execution context from which to retrieve the outcomes.
- /// A collection of outcome strings, or an empty collection if no outcomes are present.
- public static IEnumerable GetOutcomes(this ActivityExecutionContext activityExecutionContext)
- {
- return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : [];
- }
+ ///
+ /// Retrieves the collection of outcomes recorded in the execution context's journal data.
+ ///
+ /// A collection of outcome strings, or an empty collection if no outcomes are present.
+ public IEnumerable GetOutcomes()
+ {
+ return activityExecutionContext.JournalData.TryGetValue("Outcomes", out var outcomes) && outcomes is string[] arr ? arr : [];
+ }
- ///
- /// Determines whether the specified outcome exists in the current activity execution context.
- ///
- /// The activity execution context to check for the outcome.
- /// The outcome to verify within the execution context.
- /// True if the specified outcome exists; otherwise, false.
- public static bool HasOutcome(this ActivityExecutionContext activityExecutionContext, string outcome)
- {
- return activityExecutionContext.GetOutcomes().Contains(outcome);
+ ///
+ /// Determines whether the specified outcome exists in the current activity execution context.
+ ///
+ /// The outcome to verify within the execution context.
+ /// True if the specified outcome exists; otherwise, false.
+ public bool HasOutcome(string outcome)
+ {
+ return activityExecutionContext.GetOutcomes().Contains(outcome);
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs
index 317a54abb..9495a7105 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExecutionContextExtensions.cs
@@ -7,100 +7,129 @@ public static class ActivityExecutionContextExtensions
{
private const string GraphTransientProperty = "FlowGraph";
- public static IActivity? GetStartActivity(this Activities.Flowchart flowchart, string? triggerActivityId)
+ extension(Activities.Flowchart flowchart)
{
- var activities = flowchart.Activities;
-
- // If there's a trigger that triggered this workflow, use that.
- var triggerActivity = triggerActivityId != null ? activities.FirstOrDefault(x => x.Id == triggerActivityId) : null;
-
- if (triggerActivity != null)
- return triggerActivity;
-
- // If an explicit Start activity was provided, use that.
- if (flowchart.Start != null)
- return flowchart.Start;
-
- // If there is a Start activity on the flowchart, use that.
- var startActivity = activities.FirstOrDefault(x => x is Start);
-
- if (startActivity != null)
- return startActivity;
-
- // If there's an activity marked as "Can Start Workflow", use that.
- var canStartWorkflowActivity = activities.FirstOrDefault(x => x.GetCanStartWorkflow());
-
- if (canStartWorkflowActivity != null)
- return canStartWorkflowActivity;
-
- // If there is a single activity that has no inbound connections, use that.
- var root = flowchart.GetRootActivity();
-
- if (root != null)
- return root;
-
- // If no start activity found, return the first activity.
- return activities.FirstOrDefault();
- }
-
- ///
- /// Checks if there is any pending work for the flowchart.
- ///
- internal static bool HasPendingWork(this ActivityExecutionContext context)
- {
- var flowchart = (Activities.Flowchart)context.Activity;
- var workflowExecutionContext = context.WorkflowExecutionContext;
- var activityIds = flowchart.Activities.Select(x => x.Id).ToList();
- var children = context.Children;
- var hasRunningActivityInstances = children.Where(x => activityIds.Contains(x.Activity.Id)).Any(x => x.Status == ActivityStatus.Running);
- var hasUnconsumedTokens = flowchart.GetTokenList(context).Any(x => x is { Consumed: false, Blocked: false });
- var hasFaulted = context.HasFaultedChildren();
-
- var hasPendingWork = workflowExecutionContext.Scheduler.List().Any(workItem =>
+ public IActivity? GetStartActivity(string? triggerActivityId)
{
- var ownerInstanceId = workItem.Owner?.Id;
+ return flowchart.GetTriggerActivity(triggerActivityId)
+ ?? flowchart.Start
+ ?? flowchart.GetExplicitStartActivity()
+ ?? flowchart.GetCanStartWorkflowActivity()
+ ?? flowchart.GetRootActivity()
+ ?? flowchart.Activities.FirstOrDefault();
+ }
- if (ownerInstanceId == null)
- return false;
+ ///
+ /// Gets the activity that was triggered by the specified trigger activity ID.
+ ///
+ public IActivity? GetTriggerActivity(string? triggerActivityId)
+ {
+ return triggerActivityId == null
+ ? null :
+ flowchart.Activities.FirstOrDefault(x => x.Id == triggerActivityId);
+ }
- if (ownerInstanceId == context.Id)
- return true;
+ ///
+ /// Gets the explicit Start activity from the flowchart.
+ ///
+ public IActivity? GetExplicitStartActivity()
+ {
+ return flowchart.Activities.FirstOrDefault(x => x is Start);
+ }
- var ownerContext = context.WorkflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId);
- var ancestors = ownerContext.GetAncestors().ToList();
-
- return ancestors.Any(x => x == context);
- });
-
- return hasRunningActivityInstances || hasPendingWork || hasUnconsumedTokens || hasFaulted;
+ ///
+ /// Gets the first activity marked as "Can Start Workflow".
+ ///
+ public IActivity? GetCanStartWorkflowActivity()
+ {
+ return flowchart.Activities.FirstOrDefault(x => x.GetCanStartWorkflow());
+ }
}
- internal static bool HasFaultedChildren(this ActivityExecutionContext context)
+ extension(ActivityExecutionContext context)
{
- return context.Children.Any(x => x.Status == ActivityStatus.Faulted);
- }
+ ///
+ /// Checks if there is any pending work for the flowchart.
+ ///
+ public bool HasPendingWork()
+ {
+ return context.HasRunningActivityInstances()
+ || context.HasScheduledWork()
+ || context.HasUnconsumedTokens()
+ || context.HasFaultedChildren();
+ }
- internal static FlowGraph GetFlowGraph(this ActivityExecutionContext context)
- {
- // Store in TransientProperties so FlowChart is not persisted in WorkflowState
- var flowchart = (Activities.Flowchart)context.Activity;
- var startActivity = flowchart.GetStartActivity(context.WorkflowExecutionContext.TriggerActivityId);
- return context.TransientProperties.GetOrAdd(GraphTransientProperty, () => new FlowGraph(flowchart.Connections, startActivity));
- }
-
- internal static async Task CancelInboundAncestorsAsync(this ActivityExecutionContext flowchartContext, IActivity activity)
- {
- if(flowchartContext.Activity is not Activities.Flowchart)
- throw new InvalidOperationException("Activity context is not a flowchart.");
+ public FlowGraph GetFlowGraph()
+ {
+ // Store in TransientProperties so FlowChart is not persisted in WorkflowState
+ var flowchart = (Activities.Flowchart)context.Activity;
+ var startActivity = flowchart.GetStartActivity(context.WorkflowExecutionContext.TriggerActivityId);
+ return context.TransientProperties.GetOrAdd(GraphTransientProperty, () => new FlowGraph(flowchart.Connections, startActivity));
+ }
+
+ public async Task CancelInboundAncestorsAsync(IActivity activity)
+ {
+ if(context.Activity is not Activities.Flowchart)
+ throw new InvalidOperationException("Activity context is not a flowchart.");
- var flowGraph = flowchartContext.GetFlowGraph();
- var ancestorActivities = flowGraph.GetAncestorActivities(activity);
- var inboundActivityExecutionContexts = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == flowchartContext).ToList();
+ var flowGraph = context.GetFlowGraph();
+ var ancestorActivities = flowGraph.GetAncestorActivities(activity);
+ var inboundActivityExecutionContexts = context.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == context).ToList();
- // Cancel each ancestor activity.
- foreach (var activityExecutionContext in inboundActivityExecutionContexts)
+ // Cancel each ancestor activity.
+ foreach (var activityExecutionContext in inboundActivityExecutionContexts)
+ {
+ await activityExecutionContext.CancelActivityAsync();
+ }
+ }
+
+ ///
+ /// Checks if the flowchart has any unconsumed tokens.
+ ///
+ public bool HasUnconsumedTokens()
{
- await activityExecutionContext.CancelActivityAsync();
+ var flowchart = (Activities.Flowchart)context.Activity;
+ return flowchart.GetTokenList(context).Any(x => x is { Consumed: false, Blocked: false });
+ }
+
+ ///
+ /// Checks if the flowchart has any running activity instances.
+ ///
+ public bool HasRunningActivityInstances()
+ {
+ var flowchart = (Activities.Flowchart)context.Activity;
+ var activityIds = flowchart.Activities.Select(x => x.Id).ToList();
+ var children = context.Children;
+ return children.Where(x => activityIds.Contains(x.Activity.Id)).Any(x => x.Status == ActivityStatus.Running);
+ }
+
+ ///
+ /// Checks if the flowchart has any scheduled work items.
+ ///
+ public bool HasScheduledWork()
+ {
+ var workflowExecutionContext = context.WorkflowExecutionContext;
+
+ return workflowExecutionContext.Scheduler.List().Any(workItem =>
+ {
+ var ownerInstanceId = workItem.Owner?.Id;
+
+ if (ownerInstanceId == null)
+ return false;
+
+ if (ownerInstanceId == context.Id)
+ return true;
+
+ var ownerContext = context.WorkflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId);
+ var ancestors = ownerContext.GetAncestors().ToList();
+
+ return ancestors.Any(x => x == context);
+ });
+ }
+
+ public bool HasFaultedChildren()
+ {
+ return context.Children.Any(x => x.Status == ActivityStatus.Faulted);
}
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs
index 176126183..e21f83c1e 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ActivityExtensions.cs
@@ -6,43 +6,46 @@ namespace Elsa.Workflows.Activities.Flowchart.Extensions;
public static class ActivityExtensions
{
- public static MergeMode? GetMergeMode(this IActivity activity)
+ extension(IActivity activity)
{
- if (!activity.CustomProperties.TryGetValue("mergeMode", out var value))
- return null;
-
- // Handle both string and enum values for backwards compatibility
- return value switch
+ public MergeMode? GetMergeMode()
{
- MergeMode mode => mode,
- string str when Enum.TryParse(str, true, out var mode) => mode,
- _ => null
- };
- }
+ if (!activity.CustomProperties.TryGetValue("mergeMode", out var value))
+ return null;
- public static void SetMergeMode(this IActivity activity, MergeMode? value)
- {
- if (value == null)
- activity.CustomProperties.Remove("mergeMode");
- else
- activity.CustomProperties["mergeMode"] = value.ToString()!;
- }
-
- public static async Task GetMergeModeAsync(this IActivity activity, ActivityExecutionContext context)
- {
- if (activity.Type != "Elsa.FlowJoin")
- {
- return activity.GetMergeMode();
+ // Handle both string and enum values for backwards compatibility
+ return value switch
+ {
+ MergeMode mode => mode,
+ string str when Enum.TryParse(str, true, out var mode) => mode,
+ _ => null
+ };
}
-
- // Handle deprecated FlowJoin activity by evaluating its JoinMode property and mapping it to the appropriate MergeMode equivalent.
- var joinActivityExecutionContext = await context.WorkflowExecutionContext.CreateActivityExecutionContextAsync(activity);
- var joinMode = await joinActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode);
- return joinMode switch
+ public void SetMergeMode(MergeMode? value)
{
- FlowJoinMode.WaitAny => MergeMode.Race,
- _ => MergeMode.Converge
- };
+ if (value == null)
+ activity.CustomProperties.Remove("mergeMode");
+ else
+ activity.CustomProperties["mergeMode"] = value.ToString()!;
+ }
+
+ public async Task GetMergeModeAsync(ActivityExecutionContext context)
+ {
+ if (activity.Type != "Elsa.FlowJoin")
+ {
+ return activity.GetMergeMode();
+ }
+
+ // Handle deprecated FlowJoin activity by evaluating its JoinMode property and mapping it to the appropriate MergeMode equivalent.
+ var joinActivityExecutionContext = await context.WorkflowExecutionContext.CreateActivityExecutionContextAsync(activity);
+ var joinMode = await joinActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode);
+
+ return joinMode switch
+ {
+ FlowJoinMode.WaitAny => MergeMode.Race,
+ _ => MergeMode.Converge
+ };
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs
index 46ff61b7f..2e3ca8aa0 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Extensions/ConnectionsExtensions.cs
@@ -7,33 +7,36 @@ namespace Elsa.Workflows.Activities.Flowchart.Extensions;
///
public static class ConnectionsExtensions
{
- ///
- /// Returns all inbound connections of the specified activity.
- ///
- public static IEnumerable InboundConnections(this ICollection connections, IActivity activity) => connections.Where(x => x.Target.Activity == activity).Distinct().ToList();
+ extension(ICollection connections)
+ {
+ ///
+ /// Returns all inbound connections of the specified activity.
+ ///
+ public IEnumerable InboundConnections(IActivity activity) => connections.Where(x => x.Target.Activity == activity).Distinct().ToList();
- ///
- /// Returns all inbound activities of the specified activity.
- ///
- public static IEnumerable InboundActivities(this ICollection connections, IActivity activity) => connections.InboundConnections(activity).Select(x => x.Source.Activity);
+ ///
+ /// Returns all inbound activities of the specified activity.
+ ///
+ public IEnumerable InboundActivities(IActivity activity) => connections.InboundConnections(activity).Select(x => x.Source.Activity);
- ///
- /// Returns all outbound connections of the specified activity.
- ///
- public static IEnumerable OutboundConnections(this ICollection connections, IActivity activity) => connections.Where(x => x.Source.Activity == activity).Distinct().ToList();
+ ///
+ /// Returns all outbound connections of the specified activity.
+ ///
+ public IEnumerable OutboundConnections(IActivity activity) => connections.Where(x => x.Source.Activity == activity).Distinct().ToList();
- ///
- /// Returns all outbound connections of the specified activity matching the specified outcomes.
- ///
- public static IEnumerable OutboundConnections(this ICollection connections, IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port));
+ ///
+ /// Returns all outbound connections of the specified activity matching the specified outcomes.
+ ///
+ public IEnumerable OutboundConnections(IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port));
- ///
- /// Returns all outbound activities of the specified activity.
- ///
- public static IEnumerable OutboundActivities(this ICollection connections, IActivity activity) => connections.OutboundConnections(activity).Select(x => x.Source.Activity);
+ ///
+ /// Returns all outbound activities of the specified activity.
+ ///
+ public IEnumerable OutboundActivities(IActivity activity) => connections.OutboundConnections(activity).Select(x => x.Source.Activity);
- ///
- /// Returns all outbound activities of the specified activity matching the specified outcomes
- ///
- public static IEnumerable OutboundActivities(this ICollection connections, IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port)).Select(x => x.Source.Activity);
+ ///
+ /// Returns all outbound activities of the specified activity matching the specified outcomes
+ ///
+ public IEnumerable OutboundActivities(IActivity activity, Outcomes outcomes) => connections.OutboundConnections(activity).Where(c => outcomes.Names.Contains(c.Source.Port)).Select(x => x.Source.Activity);
+ }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs
index a3d942346..e78e92ba0 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityDescriptorExtensions.cs
@@ -9,54 +9,57 @@ namespace Elsa.Extensions;
///
public static class ActivityDescriptorExtensions
{
- ///
- /// Returns the specified attribute from the activity descriptor.
- ///
- public static T? GetAttribute(this ActivityDescriptor activityDescriptor) where T : Attribute
+ extension(ActivityDescriptor activityDescriptor)
{
- return (T?)activityDescriptor.Attributes.FirstOrDefault(x => x.GetType() == typeof(T));
- }
+ ///
+ /// Returns the specified attribute from the activity descriptor.
+ ///
+ public T? GetAttribute() where T : Attribute
+ {
+ return (T?)activityDescriptor.Attributes.FirstOrDefault(x => x.GetType() == typeof(T));
+ }
- ///
- /// Returns each wrapped input from the specified activity.
- ///
- public static IEnumerable GetWrappedInputPropertyDescriptors(this ActivityDescriptor activityDescriptor, IActivity activity)
- {
- return activityDescriptor.Inputs.Where(x => x.IsWrapped);
- }
-
- ///
- /// Returns each naked input from the specified activity.
- ///
- public static IEnumerable GetNakedInputPropertyDescriptors(this ActivityDescriptor activityDescriptor, IActivity activity)
- {
- return activityDescriptor.Inputs.Where(x => !x.IsWrapped);
- }
-
- ///
- /// Returns each input from the specified activity.
- ///
- public static IDictionary GetWrappedInputProperties(this ActivityDescriptor activityDescriptor, IActivity activity)
- {
- var wrappedInputDescriptors = activityDescriptor.Inputs.Where(x => x.IsWrapped).ToList();
- var inputLookup = wrappedInputDescriptors.ToDictionary(x => x.Name, x => (Input?)x.ValueGetter(activity));
- return inputLookup;
- }
-
- ///
- /// Returns each input descriptor from the specified activity.
- ///
- public static InputDescriptor? GetWrappedInputPropertyDescriptor(this ActivityDescriptor activityDescriptor, IActivity activity, string name)
- {
- return activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped);
- }
-
- ///
- /// Returns each input from the specified activity.
- ///
- public static Input? GetWrappedInputProperty(this ActivityDescriptor activityDescriptor, IActivity activity, string name)
- {
- var inputDescriptor = activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped);
- return inputDescriptor?.ValueGetter(activity) as Input;
+ ///
+ /// Returns each wrapped input from the specified activity.
+ ///
+ public IEnumerable GetWrappedInputPropertyDescriptors(IActivity activity)
+ {
+ return activityDescriptor.Inputs.Where(x => x.IsWrapped);
+ }
+
+ ///
+ /// Returns each naked input from the specified activity.
+ ///
+ public IEnumerable GetNakedInputPropertyDescriptors(IActivity activity)
+ {
+ return activityDescriptor.Inputs.Where(x => !x.IsWrapped);
+ }
+
+ ///
+ /// Returns each input from the specified activity.
+ ///
+ public IDictionary GetWrappedInputProperties(IActivity activity)
+ {
+ var wrappedInputDescriptors = activityDescriptor.Inputs.Where(x => x.IsWrapped).ToList();
+ var inputLookup = wrappedInputDescriptors.ToDictionary(x => x.Name, x => (Input?)x.ValueGetter(activity));
+ return inputLookup;
+ }
+
+ ///
+ /// Returns each input descriptor from the specified activity.
+ ///
+ public InputDescriptor? GetWrappedInputPropertyDescriptor(IActivity activity, string name)
+ {
+ return activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped);
+ }
+
+ ///
+ /// Returns each input from the specified activity.
+ ///
+ public Input? GetWrappedInputProperty(IActivity activity, string name)
+ {
+ var inputDescriptor = activityDescriptor.Inputs.FirstOrDefault(x => x.Name == name && x.IsWrapped);
+ return inputDescriptor?.ValueGetter(activity) as Input;
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs
index aee9509a3..4bdbd64e6 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs
@@ -11,133 +11,136 @@ namespace Elsa.Extensions;
public static partial class ActivityExecutionContextExtensions
{
- ///
- /// Evaluates each input property of the activity.
- ///
- public static async Task EvaluateInputPropertiesAsync(this ActivityExecutionContext context)
- {
- var activityDescriptor = context.ActivityDescriptor;
- var inputDescriptors = activityDescriptor.Inputs.Where(x => x.AutoEvaluate).ToList();
-
- // Evaluate inputs.
- foreach (var inputDescriptor in inputDescriptors)
- await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor);
-
- context.SetHasEvaluatedProperties();
- }
-
- ///
- /// Evaluates the specified input property of the activity.
- ///
- public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, Expression>> propertyExpression)
- {
- var inputName = propertyExpression.GetProperty()!.Name;
- var input = await EvaluateInputPropertyAsync(context, inputName);
- return input.ConvertTo();
- }
-
- ///
- /// Evaluates a specific input property of the activity.
- ///
- public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, string inputName)
- {
- var activity = context.Activity;
- var activityRegistryLookup = context.GetRequiredService();
- var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type) ?? throw new Exception("Activity descriptor not found");
- var inputDescriptor = activityDescriptor.GetWrappedInputPropertyDescriptor(activity, inputName);
-
- if (inputDescriptor == null)
- throw new Exception($"No input with name {inputName} could be found");
-
- return await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor);
- }
-
- ///
- /// Evaluates the specified input and sets the result in the activity execution context's memory space.
- ///
/// The being extended.
- /// The input to evaluate.
- /// The type of the input.
- /// The evaluated value.
- public static async Task EvaluateAsync(this ActivityExecutionContext context, Input input)
+ extension(ActivityExecutionContext context)
{
- var evaluator = context.GetRequiredService();
- var memoryBlockReference = input.MemoryBlockReference();
- var value = await evaluator.EvaluateAsync(input, context.ExpressionExecutionContext);
- memoryBlockReference.Set(context, value);
- return value;
- }
-
- private static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor)
- {
- try
+ ///
+ /// Evaluates each input property of the activity.
+ ///
+ public async Task EvaluateInputPropertiesAsync()
{
- return await EvaluateInputPropertyCoreAsync(context, activityDescriptor, inputDescriptor);
- }
- catch (Exception e)
- {
- throw new InputEvaluationException(inputDescriptor.Name, $"Failed to evaluate activity input '{inputDescriptor.Name}'", e);
- }
- }
-
- private static async Task EvaluateInputPropertyCoreAsync(this ActivityExecutionContext context, ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor)
- {
- var activity = context.Activity;
- var defaultValue = inputDescriptor.DefaultValue;
- var value = defaultValue;
- var input = inputDescriptor.ValueGetter(activity);
- var identityGenerator = context.GetRequiredService();
+ var activityDescriptor = context.ActivityDescriptor;
+ var inputDescriptors = activityDescriptor.Inputs.Where(x => x.AutoEvaluate).ToList();
- if (inputDescriptor.IsWrapped)
- {
- var wrappedInput = (Input?)input;
+ // Evaluate inputs.
+ foreach (var inputDescriptor in inputDescriptors)
+ await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor);
- if (defaultValue != null && wrappedInput == null)
+ context.SetHasEvaluatedProperties();
+ }
+
+ ///
+ /// Evaluates the specified input property of the activity.
+ ///
+ public async Task EvaluateInputPropertyAsync(Expression>> propertyExpression)
+ {
+ var inputName = propertyExpression.GetProperty()!.Name;
+ var input = await EvaluateInputPropertyAsync(context, inputName);
+ return input.ConvertTo();
+ }
+
+ ///
+ /// Evaluates a specific input property of the activity.
+ ///
+ public async Task EvaluateInputPropertyAsync(string inputName)
+ {
+ var activity = context.Activity;
+ var activityRegistryLookup = context.GetRequiredService();
+ var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type) ?? throw new Exception("Activity descriptor not found");
+ var inputDescriptor = activityDescriptor.GetWrappedInputPropertyDescriptor(activity, inputName);
+
+ if (inputDescriptor == null)
+ throw new Exception($"No input with name {inputName} could be found");
+
+ return await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor);
+ }
+
+ ///
+ /// Evaluates the specified input and sets the result in the activity execution context's memory space.
+ ///
+ /// The input to evaluate.
+ /// The type of the input.
+ /// The evaluated value.
+ public async Task EvaluateAsync(Input input)
+ {
+ var evaluator = context.GetRequiredService();
+ var memoryBlockReference = input.MemoryBlockReference();
+ var value = await evaluator.EvaluateAsync(input, context.ExpressionExecutionContext);
+ memoryBlockReference.Set(context, value);
+ return value;
+ }
+
+ private async Task EvaluateInputPropertyAsync(ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor)
+ {
+ try
{
- var typedInput = typeof(Input<>).MakeGenericType(inputDescriptor.Type);
- var valueExpression = new Literal(defaultValue)
+ return await EvaluateInputPropertyCoreAsync(context, activityDescriptor, inputDescriptor);
+ }
+ catch (Exception e)
+ {
+ throw new InputEvaluationException(inputDescriptor.Name, $"Failed to evaluate activity input '{inputDescriptor.Name}'", e);
+ }
+ }
+
+ private async Task EvaluateInputPropertyCoreAsync(ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor)
+ {
+ var activity = context.Activity;
+ var defaultValue = inputDescriptor.DefaultValue;
+ var value = defaultValue;
+ var input = inputDescriptor.ValueGetter(activity);
+ var identityGenerator = context.GetRequiredService();
+
+ if (inputDescriptor.IsWrapped)
+ {
+ var wrappedInput = (Input?)input;
+
+ if (defaultValue != null && wrappedInput == null)
{
- Id = identityGenerator.GenerateId(),
- };
- wrappedInput = (Input)Activator.CreateInstance(typedInput, valueExpression)!;
- inputDescriptor.ValueSetter(activity, wrappedInput);
+ var typedInput = typeof(Input<>).MakeGenericType(inputDescriptor.Type);
+ var valueExpression = new Literal(defaultValue)
+ {
+ Id = identityGenerator.GenerateId(),
+ };
+ wrappedInput = (Input)Activator.CreateInstance(typedInput, valueExpression)!;
+ inputDescriptor.ValueSetter(activity, wrappedInput);
+ }
+ else
+ {
+ var expressionEvaluator = context.GetRequiredService();
+ var expressionExecutionContext = context.ExpressionExecutionContext;
+ var inputEvaluatorType = inputDescriptor.EvaluatorType ?? typeof(DefaultActivityInputEvaluator);
+
+ if (wrappedInput?.Expression != null)
+ {
+ var inputEvaluator = (IActivityInputEvaluator)context.GetRequiredService(inputEvaluatorType);
+ var inputEvaluatorContext = new ActivityInputEvaluatorContext(context, expressionExecutionContext, inputDescriptor, wrappedInput, expressionEvaluator);
+ value = await inputEvaluator.EvaluateAsync(inputEvaluatorContext);
+ }
+ }
+
+ var memoryReference = wrappedInput?.MemoryBlockReference();
+
+ if (memoryReference != null)
+ {
+ // When input is created from an activity provider, there may be no memory block reference ID.
+ if (memoryReference.Id == null!)
+ memoryReference.Id = $"{activity.NodeId}.{inputDescriptor.Name}"; // Construct a deterministic ID.
+
+ // Declare the input memory block in the current context.
+ context.ExpressionExecutionContext.Set(memoryReference, value!);
+ }
}
else
{
- var expressionEvaluator = context.GetRequiredService();
- var expressionExecutionContext = context.ExpressionExecutionContext;
- var inputEvaluatorType = inputDescriptor.EvaluatorType ?? typeof(DefaultActivityInputEvaluator);
-
- if (wrappedInput?.Expression != null)
- {
- var inputEvaluator = (IActivityInputEvaluator)context.GetRequiredService(inputEvaluatorType);
- var inputEvaluatorContext = new ActivityInputEvaluatorContext(context, expressionExecutionContext, inputDescriptor, wrappedInput, expressionEvaluator);
- value = await inputEvaluator.EvaluateAsync(inputEvaluatorContext);
- }
+ value = input;
}
- var memoryReference = wrappedInput?.MemoryBlockReference();
+ await StoreInputValueAsync(context, inputDescriptor, value!);
- if (memoryReference != null)
- {
- // When input is created from an activity provider, there may be no memory block reference ID.
- if (memoryReference.Id == null!)
- memoryReference.Id = $"{activity.NodeId}.{inputDescriptor.Name}"; // Construct a deterministic ID.
-
- // Declare the input memory block in the current context.
- context.ExpressionExecutionContext.Set(memoryReference, value!);
- }
+ return value;
}
- else
- {
- value = input;
- }
-
- await StoreInputValueAsync(context, inputDescriptor, value!);
-
- return value;
}
-
+
private static Task StoreInputValueAsync(ActivityExecutionContext context, InputDescriptor inputDescriptor, object value)
{
// Store the serialized input value in the activity state.
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs
index 00c6c626e..7bb6a8cec 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputOutput.cs
@@ -5,34 +5,37 @@ namespace Elsa.Workflows;
public static class ActivityExecutionContextExtensions
{
- public static IDictionary GetInputs(this ActivityExecutionContext context)
+ extension(ActivityExecutionContext context)
{
- return context.ActivityState!;
- }
-
- public static IDictionary GetOutputs(this ActivityExecutionContext context)
- {
- var activity = context.Activity;
- var expressionExecutionContext = context.ExpressionExecutionContext;
- var activityDescriptor = context.ActivityDescriptor;
- var outputDescriptors = activityDescriptor.Outputs;
-
- var outputs = outputDescriptors.ToDictionary(x => x.Name, x =>
+ public IDictionary GetInputs()
{
- if (x.IsSerializable == false)
- return "(not serializable)";
+ return context.ActivityState!;
+ }
- var cachedValue = activity.GetOutput(expressionExecutionContext, x.Name);
+ public IDictionary GetOutputs()
+ {
+ var activity = context.Activity;
+ var expressionExecutionContext = context.ExpressionExecutionContext;
+ var activityDescriptor = context.ActivityDescriptor;
+ var outputDescriptors = activityDescriptor.Outputs;
- if (cachedValue != null)
- return cachedValue;
+ var outputs = outputDescriptors.ToDictionary(x => x.Name, x =>
+ {
+ if (x.IsSerializable == false)
+ return "(not serializable)";
- if (x.ValueGetter(activity) is Output output && context.TryGet(output.MemoryBlockReference(), out var outputValue))
- return outputValue!;
+ var cachedValue = activity.GetOutput(expressionExecutionContext, x.Name);
- return null!;
- });
+ if (cachedValue != null)
+ return cachedValue;
- return outputs;
+ if (x.ValueGetter(activity) is Output output && context.TryGet(output.MemoryBlockReference(), out var outputValue))
+ return outputValue!;
+
+ return null!;
+ });
+
+ return outputs;
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs
index 501d08122..90524fe51 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs
@@ -27,140 +27,140 @@ public static partial class ActivityExecutionContextExtensions
{
private const string ExtensionsMetadataKey = "Extensions";
- ///
- /// Attempts to get a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T.
- ///
- public static bool TryGetWorkflowInput(this ActivityExecutionContext context, string key, out T value, JsonSerializerOptions? serializerOptions = null)
+ /// The
+ extension(ActivityExecutionContext context)
{
- var wellKnownTypeRegistry = context.GetRequiredService();
-
- if (context.WorkflowInput.TryGetValue(key, out var v))
+ ///
+ /// Attempts to get a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T.
+ ///
+ public bool TryGetWorkflowInput(string key, out T value, JsonSerializerOptions? serializerOptions = null)
{
- value = v.ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!;
- return true;
+ var wellKnownTypeRegistry = context.GetRequiredService();
+
+ if (context.WorkflowInput.TryGetValue(key, out var v))
+ {
+ value = v.ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!;
+ return true;
+ }
+
+ value = default!;
+ return false;
}
- value = default!;
- return false;
- }
+ ///
+ /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T.
+ ///
+ public T GetWorkflowInput(JsonSerializerOptions? serializerOptions = null) => context.GetWorkflowInput(typeof(T).Name, serializerOptions);
- ///
- /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T.
- ///
- public static T GetWorkflowInput(this ActivityExecutionContext context, JsonSerializerOptions? serializerOptions = null) => context.GetWorkflowInput(typeof(T).Name, serializerOptions);
-
- ///
- /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T.
- ///
- public static T GetWorkflowInput(this ActivityExecutionContext context, string key, JsonSerializerOptions? serializerOptions = null)
- {
- var wellKnownTypeRegistry = context.GetRequiredService();
- return context.WorkflowInput[key].ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!;
- }
-
- ///
- /// Sets the Result property of the specified activity.
- ///
- /// The being extended.
- /// The value to set.
- /// Thrown when the specified activity does not implement .
- public static void SetResult(this ActivityExecutionContext context, object? value)
- {
- var activity = context.Activity as IActivityWithResult ?? throw new($"Cannot set result on activity {context.Activity.Id} because it does not implement {nameof(IActivityWithResult)}.");
- context.Set(activity.Result, value, "Result");
- }
-
- ///
- /// Returns true if this activity is triggered for the first time and not being resumed.
- ///
- public static bool IsTriggerOfWorkflow(this ActivityExecutionContext context) => context.WorkflowExecutionContext.TriggerActivityId == context.Activity.Id;
-
- ///
- /// Creates a workflow variable by name and optionally sets the value.
- ///
- /// The being extended.
- /// The name of the variable.
- /// The value of the variable.
- /// The type of storage driver to use for the variable.
- /// A callback to configure the memory block.
- /// The created .
- public static Variable CreateVariable(this ActivityExecutionContext context, string name, object? value, Type? storageDriverType = null, Action? configure = null)
- {
- return context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure);
- }
-
- ///
- /// Sets a workflow variable by name.
- ///
- /// The being extended.
- /// The name of the variable.
- /// The value of the variable.
- /// A callback to configure the memory block.
- /// The created .
- public static Variable SetVariable(this ActivityExecutionContext context, string name, object? value, Action? configure = null) =>
- context.ExpressionExecutionContext.SetVariable(name, value, configure);
-
- public static Variable SetDynamicVariable(this ActivityExecutionContext context, string name, T value, Action? configure = null)
- {
- // Check if a predefined variable already exists.
- var predefinedVariable = context.ExpressionExecutionContext.GetVariable(name);
-
- if (predefinedVariable != null)
+ ///
+ /// Gets a value from the input provided via . If a value was found, an attempt is made to convert it into the specified type T.
+ ///
+ public T GetWorkflowInput(string key, JsonSerializerOptions? serializerOptions = null)
{
- context.SetVariable(name, value);
- return predefinedVariable;
+ var wellKnownTypeRegistry = context.GetRequiredService();
+ return context.WorkflowInput[key].ConvertTo(new(serializerOptions, wellKnownTypeRegistry))!;
}
- // No predefined variable exists, so we will add a dynamic variable to the current container in scope.
- var container = context.FindParentWithVariableContainer();
+ ///
+ /// Sets the Result property of the specified activity.
+ ///
+ /// being extended.
+ /// The value to set.
+ /// Thrown when the specified activity does not implement .
+ public void SetResult(object? value)
+ {
+ var activity = context.Activity as IActivityWithResult ?? throw new($"Cannot set result on activity {context.Activity.Id} because it does not implement {nameof(IActivityWithResult)}.");
+ context.Set(activity.Result, value, "Result");
+ }
- if (container == null)
- throw new("No parent variable container found");
+ ///
+ /// Returns true if this activity is triggered for the first time and not being resumed.
+ ///
+ public bool IsTriggerOfWorkflow() => context.WorkflowExecutionContext.TriggerActivityId == context.Activity.Id;
- var existingVariable = container.DynamicVariables.FirstOrDefault(x => x.Name == name);
+ ///
+ /// Creates a workflow variable by name and optionally sets the value.
+ ///
+ /// The name of the variable.
+ /// The value of the variable.
+ /// The type of storage driver to use for the variable.
+ /// A callback to configure the memory block.
+ /// The created .
+ public Variable CreateVariable(string name, object? value, Type? storageDriverType = null, Action? configure = null)
+ {
+ return context.ExpressionExecutionContext.CreateVariable(name, value, storageDriverType, configure);
+ }
- if (existingVariable == null)
- container.DynamicVariables.Add(new Variable(name, value));
+ ///
+ /// Sets a workflow variable by name.
+ ///
+ /// The name of the variable.
+ /// The value of the variable.
+ /// A callback to configure the memory block.
+ /// The created .
+ public Variable SetVariable(string name, object? value, Action? configure = null) =>
+ context.ExpressionExecutionContext.SetVariable(name, value, configure);
- return context.SetVariable(name, value);
- }
+ public Variable SetDynamicVariable(string name, T value, Action? configure = null)
+ {
+ // Check if a predefined variable already exists.
+ var predefinedVariable = context.ExpressionExecutionContext.GetVariable(name);
- ///
- /// Gets a workflow variable by name.
- ///
- /// The being extended.
- /// The name of the variable.
- /// The type of the variable.
- /// The variable if found, otherwise null.
- public static T? GetVariable(this ActivityExecutionContext context, string name) => context.ExpressionExecutionContext.GetVariable(name);
+ if (predefinedVariable != null)
+ {
+ context.SetVariable(name, value);
+ return predefinedVariable;
+ }
- ///
- /// Returns a dictionary of variable keys and their values across scopes.
- ///
- public static IDictionary GetVariableValues(this ActivityExecutionContext activityExecutionContext) => activityExecutionContext.ExpressionExecutionContext.ReadAndFlattenMemoryBlocks();
+ // No predefined variable exists, so we will add a dynamic variable to the current container in scope.
+ var container = context.FindParentWithVariableContainer();
- ///
- /// Returns a set of tuples containing the activity and its descriptor for all activities with outputs.
- ///
- /// The being extended.
- public static async IAsyncEnumerable<(IActivity Activity, ActivityDescriptor ActivityDescriptor)> GetActivitiesWithOutputs(this ActivityExecutionContext activityExecutionContext)
- {
- // Get current container.
- var currentContainerNode = activityExecutionContext.FindParentWithVariableContainer()?.ActivityNode;
+ if (container == null)
+ throw new("No parent variable container found");
- if (currentContainerNode == null)
- yield break;
+ var existingVariable = container.DynamicVariables.FirstOrDefault(x => x.Name == name);
- // Get all nodes in the current container
- var workflowExecutionContext = activityExecutionContext.WorkflowExecutionContext;
- var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList();
+ if (existingVariable == null)
+ container.DynamicVariables.Add(new Variable(name, value));
- // Select activities with outputs.
- var activityRegistry = workflowExecutionContext.GetRequiredService();
- var activitiesWithOutputs = containedNodes.GetActivitiesWithOutputs(activityRegistry);
+ return context.SetVariable(name, value);
+ }
- await foreach (var (activity, activityDescriptor) in activitiesWithOutputs)
- yield return (activity, activityDescriptor);
+ ///
+ /// Gets a workflow variable by name.
+ ///
+ /// The name of the variable.
+ /// The type of the variable.
+ /// The variable if found, otherwise null.
+ public T? GetVariable(string name) => context.ExpressionExecutionContext.GetVariable(name);
+
+ ///
+ /// Returns a dictionary of variable keys and their values across scopes.
+ ///
+ public IDictionary GetVariableValues() => context.ExpressionExecutionContext.ReadAndFlattenMemoryBlocks();
+
+ ///
+ /// Returns a set of tuples containing the activity and its descriptor for all activities with outputs.
+ ///
+ public async IAsyncEnumerable<(IActivity Activity, ActivityDescriptor ActivityDescriptor)> GetActivitiesWithOutputs()
+ {
+ // Get current container.
+ var currentContainerNode = context.FindParentWithVariableContainer()?.ActivityNode;
+
+ if (currentContainerNode == null)
+ yield break;
+
+ // Get all nodes in the current container
+ var workflowExecutionContext = context.WorkflowExecutionContext;
+ var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList();
+
+ // Select activities with outputs.
+ var activityRegistry = workflowExecutionContext.GetRequiredService();
+ var activitiesWithOutputs = containedNodes.GetActivitiesWithOutputs(activityRegistry);
+
+ await foreach (var (activity, activityDescriptor) in activitiesWithOutputs)
+ yield return (activity, activityDescriptor);
+ }
}
///
@@ -177,315 +177,313 @@ public static partial class ActivityExecutionContextExtensions
}
}
- ///
- /// Returns the activity with the specified ID or name.
- ///
- public static IActivity? FindActivityByIdOrName(this ActivityExecutionContext activityExecutionContext, string idOrName)
+ /// The being extended.
+ extension(ActivityExecutionContext activityExecutionContext)
{
- // Get current container.
- var currentContainerNode = activityExecutionContext.FindParentWithVariableContainer()?.ActivityNode;
-
- if (currentContainerNode == null)
- return null;
-
- // Get all nodes in the current container
- var workflowExecutionContext = activityExecutionContext.WorkflowExecutionContext;
- var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList();
- var node = containedNodes.FirstOrDefault(x => x.Activity.Name == idOrName || x.Activity.Id == idOrName);
- return node?.Activity;
- }
-
- ///
- /// Returns the outcome name for the specified port property name.
- ///
- /// The being extended.
- /// The name of the port property.
- /// The outcome name.
- public static string GetOutcomeName(this ActivityExecutionContext context, string portPropertyName)
- {
- var owner = context.Activity;
- var ports = owner.GetType().GetProperties().Where(x => typeof(IActivity).IsAssignableFrom(x.PropertyType)).ToList();
-
- var portQuery =
- from p in ports
- where p.Name == portPropertyName
- select p;
-
- var portProperty = portQuery.First();
- return portProperty.GetCustomAttribute()?.Name ?? portProperty.Name;
- }
-
- ///
- /// Send a signal up the current hierarchy of ancestors.
- ///
- public static async ValueTask SendSignalAsync(this ActivityExecutionContext context, object signal)
- {
- var receivingContexts = new[]
+ ///
+ /// Returns the activity with the specified ID or name.
+ ///
+ public IActivity? FindActivityByIdOrName(string idOrName)
{
- context
- }.Concat(context.GetAncestors()).ToList();
- var logger = context.GetRequiredService>();
- var signalType = signal.GetType();
- var signalTypeName = signalType.Name;
+ // Get current container.
+ var currentContainerNode = activityExecutionContext.FindParentWithVariableContainer()?.ActivityNode;
- // Let all ancestors receive the signal.
- foreach (var ancestorContext in receivingContexts)
+ if (currentContainerNode == null)
+ return null;
+
+ // Get all nodes in the current container
+ var workflowExecutionContext = activityExecutionContext.WorkflowExecutionContext;
+ var containedNodes = workflowExecutionContext.Nodes.Where(x => x.Parents.Contains(currentContainerNode)).Distinct().ToList();
+ var node = containedNodes.FirstOrDefault(x => x.Activity.Name == idOrName || x.Activity.Id == idOrName);
+ return node?.Activity;
+ }
+
+ ///
+ /// Returns the outcome name for the specified port property name.
+ ///
+ /// The name of the port property.
+ /// The outcome name.
+ public string GetOutcomeName(string portPropertyName)
{
- var signalContext = new SignalContext(ancestorContext, context, context.CancellationToken);
+ var owner = activityExecutionContext.Activity;
+ var ports = owner.GetType().GetProperties().Where(x => typeof(IActivity).IsAssignableFrom(x.PropertyType)).ToList();
- if (ancestorContext.Activity is not ISignalHandler handler)
- continue;
+ var portQuery =
+ from p in ports
+ where p.Name == portPropertyName
+ select p;
- logger.LogDebug("Receiving signal {SignalType} on activity {ActivityId} of type {ActivityType}", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type);
- await handler.ReceiveSignalAsync(signal, signalContext);
+ var portProperty = portQuery.First();
+ return portProperty.GetCustomAttribute()?.Name ?? portProperty.Name;
+ }
- if (signalContext.StopPropagationRequested)
+ ///
+ /// Send a signal up the current hierarchy of ancestors.
+ ///
+ public async ValueTask SendSignalAsync(object signal)
+ {
+ var receivingContexts = new[]
{
- logger.LogDebug("Propagation of signal {SignalType} on activity {ActivityId} of type {ActivityType} was stopped", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type);
- return;
+ activityExecutionContext
+ }.Concat(activityExecutionContext.GetAncestors()).ToList();
+ var logger = activityExecutionContext.GetRequiredService>();
+ var signalType = signal.GetType();
+ var signalTypeName = signalType.Name;
+
+ // Let all ancestors receive the signal.
+ foreach (var ancestorContext in receivingContexts)
+ {
+ var signalContext = new SignalContext(ancestorContext, activityExecutionContext, activityExecutionContext.CancellationToken);
+
+ if (ancestorContext.Activity is not ISignalHandler handler)
+ continue;
+
+ logger.LogDebug("Receiving signal {SignalType} on activity {ActivityId} of type {ActivityType}", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type);
+ await handler.ReceiveSignalAsync(signal, signalContext);
+
+ if (signalContext.StopPropagationRequested)
+ {
+ logger.LogDebug("Propagation of signal {SignalType} on activity {ActivityId} of type {ActivityType} was stopped", signalTypeName, ancestorContext.Activity.Id, ancestorContext.Activity.Type);
+ return;
+ }
}
}
- }
- ///
- /// Complete the current activity. This should only be called by activities that explicitly suppress automatic-completion.
- ///
- public static async ValueTask ScheduleOutcomesAsync(this ActivityExecutionContext context, params string[] outcomes)
- {
- // Record the outcomes, if any.
- context.JournalData["Outcomes"] = outcomes;
-
- // Record the output, if any.
- var activity = context.Activity;
- var expressionExecutionContext = context.ExpressionExecutionContext;
- var activityDescriptor = context.ActivityDescriptor;
- var outputDescriptors = activityDescriptor.Outputs;
- var outputs = outputDescriptors.ToDictionary(x => x.Name, x => activity.GetOutput(expressionExecutionContext, x.Name)!);
- var serializer = context.GetRequiredService();
-
- foreach (var output in outputs)
+ ///
+ /// Complete the current activity. This should only be called by activities that explicitly suppress automatic-completion.
+ ///
+ public async ValueTask ScheduleOutcomesAsync(params string[] outcomes)
{
- var outputName = output.Key;
- var outputValue = output.Value;
+ // Record the outcomes, if any.
+ activityExecutionContext.JournalData["Outcomes"] = outcomes;
- if (outputValue == null!)
- continue;
+ // Record the output, if any.
+ var activity = activityExecutionContext.Activity;
+ var expressionExecutionContext = activityExecutionContext.ExpressionExecutionContext;
+ var activityDescriptor = activityExecutionContext.ActivityDescriptor;
+ var outputDescriptors = activityDescriptor.Outputs;
+ var outputs = outputDescriptors.ToDictionary(x => x.Name, x => activity.GetOutput(expressionExecutionContext, x.Name)!);
+ var serializer = activityExecutionContext.GetRequiredService();
- var serializedOutputValue = serializer.Serialize(outputValue);
- context.JournalData[outputName] = serializedOutputValue;
+ foreach (var output in outputs)
+ {
+ var outputName = output.Key;
+ var outputValue = output.Value;
+
+ if (outputValue == null!)
+ continue;
+
+ var serializedOutputValue = serializer.Serialize(outputValue);
+ activityExecutionContext.JournalData[outputName] = serializedOutputValue;
+ }
+
+ // Send a signal.
+ await activityExecutionContext.SendSignalAsync(new ScheduleActivityOutcomes(outcomes));
}
- // Send a signal.
- await context.SendSignalAsync(new ScheduleActivityOutcomes(outcomes));
- }
+ ///
+ /// Cancel the activity. For blocking activities, it means their bookmarks will be removed. For job activities, the background work will be cancelled.
+ ///
+ public async Task CancelActivityAsync()
+ {
+ // If the activity is not running, do nothing.
+ if (activityExecutionContext.Status != ActivityStatus.Running && activityExecutionContext.Status != ActivityStatus.Faulted)
+ return;
- ///
- /// Cancel the activity. For blocking activities, it means their bookmarks will be removed. For job activities, the background work will be cancelled.
- ///
- public static async Task CancelActivityAsync(this ActivityExecutionContext context)
- {
- // If the activity is not running, do nothing.
- if (context.Status != ActivityStatus.Running && context.Status != ActivityStatus.Faulted)
- return;
+ // Select all child contexts.
+ var childContexts = activityExecutionContext.Children.ToList();
- // Select all child contexts.
- var childContexts = context.Children.ToList();
+ foreach (var childContext in childContexts)
+ await CancelActivityAsync(childContext);
- foreach (var childContext in childContexts)
- await CancelActivityAsync(childContext);
+ var publisher = activityExecutionContext.GetRequiredService();
+ activityExecutionContext.TransitionTo(ActivityStatus.Canceled);
+ activityExecutionContext.ClearBookmarks();
+ activityExecutionContext.ClearCompletionCallbacks();
+ activityExecutionContext.WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityNodeId == activityExecutionContext.NodeId);
- var publisher = context.GetRequiredService();
- context.TransitionTo(ActivityStatus.Canceled);
- context.ClearBookmarks();
- context.ClearCompletionCallbacks();
- context.WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityNodeId == context.NodeId);
+ // Add an execution log entry.
+ activityExecutionContext.AddExecutionLogEntry("Canceled");
- // Add an execution log entry.
- context.AddExecutionLogEntry("Canceled");
+ await activityExecutionContext.SendSignalAsync(new CancelSignal());
+ await publisher.SendAsync(new ActivityCancelled(activityExecutionContext));
+ }
- await context.SendSignalAsync(new CancelSignal());
- await publisher.SendAsync(new ActivityCancelled(context));
- }
-
- ///
- /// Marks the current activity as faulted, records an exception, and transitions its status to .
- /// An incident is logged, and the fault count for the activity and its ancestor activities is incremented.
- ///
- /// The representing the execution context of the current activity.
- /// The exception to be recorded as the cause of the fault.
- public static void Fault(this ActivityExecutionContext context, Exception e)
- {
- context.Exception = e;
- context.TransitionTo(ActivityStatus.Faulted);
- var activity = context.Activity;
- var exceptionState = ExceptionState.FromException(e);
- var systemClock = context.GetRequiredService();
- var now = systemClock.UtcNow;
- var incident = new ActivityIncident(activity.Id, activity.NodeId, activity.Type, e.Message, exceptionState, now);
- context.WorkflowExecutionContext.Incidents.Add(incident);
- context.AggregateFaultCount++;
+ ///
+ /// Marks the current activity as faulted, records an exception, and transitions its status to .
+ /// An incident is logged, and the fault count for the activity and its ancestor activities is incremented.
+ ///
+ /// The exception to be recorded as the cause of the fault.
+ public void Fault(Exception e)
+ {
+ activityExecutionContext.Exception = e;
+ activityExecutionContext.TransitionTo(ActivityStatus.Faulted);
+ var activity = activityExecutionContext.Activity;
+ var exceptionState = ExceptionState.FromException(e);
+ var systemClock = activityExecutionContext.GetRequiredService();
+ var now = systemClock.UtcNow;
+ var incident = new ActivityIncident(activity.Id, activity.NodeId, activity.Type, e.Message, exceptionState, now);
+ activityExecutionContext.WorkflowExecutionContext.Incidents.Add(incident);
+ activityExecutionContext.AggregateFaultCount++;
- var ancestors = context.GetAncestors();
+ var ancestors = activityExecutionContext.GetAncestors();
- foreach (var ancestor in ancestors)
- ancestor.AggregateFaultCount++;
-
- context.TransitionTo(ActivityStatus.Faulted);
- }
-
- ///
- /// Recovers the current activity from a faulted state by resetting fault counts and transitioning the activity to the running status.
- ///
- public static void RecoverFromFault(this ActivityExecutionContext context)
- {
- context.AggregateFaultCount = 0;
-
- var ancestors = context.GetAncestors();
-
- foreach (var ancestor in ancestors)
- ancestor.AggregateFaultCount--;
-
- context.TransitionTo(ActivityStatus.Running);
- }
-
- ///
- /// Returns the first context that is associated with a .
- ///
- public static ActivityExecutionContext? FindParentWithVariableContainer(this ActivityExecutionContext context)
- {
- return context.FindParent(x => x.Activity is IVariableContainer);
- }
-
- ///
- /// Returns the first context in the hierarchy that matches the specified predicate.
- ///
- /// The context to start searching from.
- /// The predicate to match.
- /// The first context that matches the predicate or null if no match was found.
- public static ActivityExecutionContext? FindParent(this ActivityExecutionContext context, Func predicate)
- {
- var currentContext = context;
-
- while (currentContext != null)
- {
- if (predicate(currentContext))
- return currentContext;
-
- currentContext = currentContext.ParentActivityExecutionContext;
+ foreach (var ancestor in ancestors)
+ ancestor.AggregateFaultCount++;
}
- return null;
- }
-
- ///
- /// Returns a flattened list of the current context's ancestors.
- ///
- public static IEnumerable GetAncestors(this ActivityExecutionContext context)
- {
- var current = context.ParentActivityExecutionContext;
-
- while (current != null)
+ ///
+ /// Recovers the current activity from a faulted state by resetting fault counts and transitioning the activity to the running status.
+ ///
+ public void RecoverFromFault()
{
- yield return current;
- current = current.ParentActivityExecutionContext;
+ activityExecutionContext.AggregateFaultCount = 0;
+
+ var ancestors = activityExecutionContext.GetAncestors();
+
+ foreach (var ancestor in ancestors)
+ ancestor.AggregateFaultCount--;
+
+ activityExecutionContext.TransitionTo(ActivityStatus.Running);
}
- }
- ///
- /// Returns a flattened list of the current context's descendants.
- ///
- public static IEnumerable GetDescendents(this ActivityExecutionContext context)
- {
- var children = context.Children.ToList();
-
- foreach (var child in children)
+ ///
+ /// Returns the first context that is associated with a .
+ ///
+ public ActivityExecutionContext? FindParentWithVariableContainer()
{
- yield return child;
-
- foreach (var descendent in GetDescendents(child))
- yield return descendent;
+ return activityExecutionContext.FindParent(x => x.Activity is IVariableContainer);
}
- }
- ///
- /// Returns a flattened list of the current context's immediate active children.
- ///
- public static IEnumerable GetActiveChildren(this ActivityExecutionContext context)
- {
- return context.Children;
- }
-
- ///
- /// Returns a flattened list of the current context's immediate children.
- ///
- public static IEnumerable GetChildren(this ActivityExecutionContext context)
- {
- return context.Children;
- }
-
- ///
- /// Returns a flattened list of the current context's descendants.
- ///
- public static IEnumerable GetDescendants(this ActivityExecutionContext context)
- {
- var children = context.Children.ToList();
-
- foreach (var child in children)
+ ///
+ /// Returns the first context in the hierarchy that matches the specified predicate.
+ ///
+ /// The predicate to match.
+ /// The first context that matches the predicate or null if no match was found.
+ public ActivityExecutionContext? FindParent(Func predicate)
{
- yield return child;
+ var currentContext = activityExecutionContext;
- foreach (var descendant in child.GetDescendants())
- yield return descendant;
- }
- }
+ while (currentContext != null)
+ {
+ if (predicate(currentContext))
+ return currentContext;
- ///
- /// Sets extension data in the metadata. Represents specific data that is exposed generically for an activity.
- ///
- public static void SetExtensionsMetadata(this ActivityExecutionContext context, string key, object? value)
- {
- var extensionsDictionary = context.GetExtensionsMetadata() ?? new Dictionary();
+ currentContext = currentContext.ParentActivityExecutionContext;
+ }
- extensionsDictionary[key] = value;
-
- context.Metadata[ExtensionsMetadataKey] = extensionsDictionary;
- }
-
- ///
- /// Retrieves the extension data from the metadata. Represents specific data that is exposed generically for an activity.
- ///
- public static Dictionary? GetExtensionsMetadata(this ActivityExecutionContext context)
- {
- return context.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null;
- }
-
- ///
- /// Gets the output of an activity even when the context is not the same as the activity's context.
- /// This is useful when you want to get the output of a previously executed activity.
- ///
- ///
- ///
- ///
- ///
- public static object? GetActivityOutput(this ActivityExecutionContext context, Expression> property)
- {
- if (property.Body is not MemberExpression memberExpr)
- {
return null;
}
-
- var propertyName = memberExpr.Member.Name;
-
- var value = context.Get(propertyName);
-
- if (value != null)
- return value;
- var registry = context.WorkflowExecutionContext.GetActivityOutputRegister();
+ ///
+ /// Returns a flattened list of the current context's ancestors.
+ ///
+ public IEnumerable GetAncestors()
+ {
+ var current = activityExecutionContext.ParentActivityExecutionContext;
+
+ while (current != null)
+ {
+ yield return current;
+ current = current.ParentActivityExecutionContext;
+ }
+ }
+
+ ///
+ /// Returns a flattened list of the current context's descendants.
+ ///
+ public IEnumerable GetDescendents()
+ {
+ var children = activityExecutionContext.Children.ToList();
+
+ foreach (var child in children)
+ {
+ yield return child;
+
+ foreach (var descendent in GetDescendents(child))
+ yield return descendent;
+ }
+ }
+
+ ///
+ /// Returns a flattened list of the current context's immediate active children.
+ ///
+ public IEnumerable GetActiveChildren()
+ {
+ return activityExecutionContext.Children;
+ }
+
+ ///
+ /// Returns a flattened list of the current context's immediate children.
+ ///
+ public IEnumerable GetChildren()
+ {
+ return activityExecutionContext.Children;
+ }
+
+ ///
+ /// Returns a flattened list of the current context's descendants.
+ ///
+ public IEnumerable GetDescendants()
+ {
+ var children = activityExecutionContext.Children.ToList();
+
+ foreach (var child in children)
+ {
+ yield return child;
+
+ foreach (var descendant in child.GetDescendants())
+ yield return descendant;
+ }
+ }
+
+ ///
+ /// Sets extension data in the metadata. Represents specific data that is exposed generically for an activity.
+ ///
+ public void SetExtensionsMetadata(string key, object? value)
+ {
+ var extensionsDictionary = activityExecutionContext.GetExtensionsMetadata() ?? new Dictionary();
+
+ extensionsDictionary[key] = value;
+
+ activityExecutionContext.Metadata[ExtensionsMetadataKey] = extensionsDictionary;
+ }
+
+ ///
+ /// Retrieves the extension data from the metadata. Represents specific data that is exposed generically for an activity.
+ ///
+ public Dictionary? GetExtensionsMetadata()
+ {
+ return activityExecutionContext.Metadata.TryGetValue(ExtensionsMetadataKey, out var value) ? value as Dictionary : null;
+ }
+
+ ///
+ /// Gets the output of an activity even when the context is not the same as the activity's context.
+ /// This is useful when you want to get the output of a previously executed activity.
+ ///
+ ///
+ ///
+ ///
+ public object? GetActivityOutput(Expression> property)
+ {
+ if (property.Body is not MemberExpression memberExpr)
+ {
+ return null;
+ }
+
+ var propertyName = memberExpr.Member.Name;
- return registry.FindOutputByActivityInstanceId(context.Id, propertyName);
+ var value = activityExecutionContext.Get(propertyName);
+
+ if (value != null)
+ return value;
+
+ var registry = activityExecutionContext.WorkflowExecutionContext.GetActivityOutputRegister();
+
+ return registry.FindOutputByActivityInstanceId(activityExecutionContext.Id, propertyName);
+ }
+
+ public bool GetHasEvaluatedProperties() => activityExecutionContext.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value;
+ public void SetHasEvaluatedProperties() => activityExecutionContext.TransientProperties["HasEvaluatedProperties"] = true;
}
-
- public static bool GetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties.TryGetValue("HasEvaluatedProperties", out var value) && value;
- public static void SetHasEvaluatedProperties(this ActivityExecutionContext context) => context.TransientProperties["HasEvaluatedProperties"] = true;
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExtensions.cs
index b6f033699..9b3ab5c10 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExtensions.cs
@@ -15,175 +15,177 @@ namespace Elsa.Extensions;
[PublicAPI]
public static class ActivityExtensions
{
- ///
- /// Gets the input properties of the specified activity.
- ///
- public static IDictionary GetNamedInputs(this IActivity activity)
- {
- var inputProps = activity.GetInputProperties().ToList();
-
- var query =
- from inputProp in inputProps
- let inputValue = (Input?)inputProp.GetValue(activity)
- where inputValue != null
- select (inputProp, inputValue);
-
- return query.DistinctBy(x => x.inputProp.Name).ToDictionary(x => x.inputProp.Name, x => x.inputValue);
- }
-
- ///
- /// Gets the input properties of the specified activity.
- ///
- public static IEnumerable GetInputs(this IActivity activity) => GetNamedInputs(activity).Values;
-
- ///
- /// Gets the input with the specified name.
- ///
- public static Input? GetInput(this IActivity activity, string inputName) => GetNamedInputs(activity).TryGetValue(inputName, out var input) ? input : null;
-
- ///
- /// Gets the output properties of the specified activity.
- ///
- public static IEnumerable GetOutputs(this IActivity activity)
- {
- var outputProps = activity.GetType().GetProperties().Where(x => typeof(Output).IsAssignableFrom(x.PropertyType)).ToList();
-
- var query =
- from outputProp in outputProps
- let output = (Output?)outputProp.GetValue(activity)
- where output != null
- select new NamedOutput(outputProp.Name, output);
-
- return query.Select(x => x!).ToList();
- }
-
- ///
- /// Gets the output with the specified name.
- ///
/// The activity to get the output from.
- /// The activity execution context.
- /// Name of the output.
- /// The output value.
- public static object? GetOutput(this IActivity activity, ActivityExecutionContext context, string? outputName = null)
+ extension(IActivity activity)
{
- var workflowExecutionContext = context.WorkflowExecutionContext;
- var outputRegister = workflowExecutionContext.GetActivityOutputRegister();
-
- // If the provided activity execution context is the same as the current activity's execution context, we return the exact output value of the current activity execution context.
- if(context.Activity.NodeId == activity.NodeId)
- return outputRegister.FindOutputByActivityInstanceId(context.Id, outputName);
-
- // If the provided activity execution context is different from the current activity's execution context, we look for the last output value of the activity.
- return outputRegister.FindOutputByActivityId(activity.Id, outputName);
- }
-
- ///
- /// Gets the output with the specified name.
- ///
- /// The activity to get the output from.
- /// The expression execution context.
- /// Name of the output.
- /// The output value.
- public static object? GetOutput(this IActivity activity, ExpressionExecutionContext context, string? outputName = null)
- {
- var activityExecutionContext = context.GetActivityExecutionContext();
+ ///
+ /// Gets the input properties of the specified activity.
+ ///
+ public IDictionary GetNamedInputs()
+ {
+ var inputProps = activity.GetInputProperties().ToList();
- if (activityExecutionContext == null)
- return null;
+ var query =
+ from inputProp in inputProps
+ let inputValue = (Input?)inputProp.GetValue(activity)
+ where inputValue != null
+ select (inputProp, inputValue);
+
+ return query.DistinctBy(x => x.inputProp.Name).ToDictionary(x => x.inputProp.Name, x => x.inputValue);
+ }
+
+ ///
+ /// Gets the input properties of the specified activity.
+ ///
+ public IEnumerable GetInputs() => GetNamedInputs(activity).Values;
+
+ ///
+ /// Gets the input with the specified name.
+ ///
+ public Input? GetInput(string inputName) => GetNamedInputs(activity).TryGetValue(inputName, out var input) ? input : null;
+
+ ///
+ /// Gets the output properties of the specified activity.
+ ///
+ public IEnumerable GetOutputs()
+ {
+ var outputProps = activity.GetType().GetProperties().Where(x => typeof(Output).IsAssignableFrom(x.PropertyType)).ToList();
+
+ var query =
+ from outputProp in outputProps
+ let output = (Output?)outputProp.GetValue(activity)
+ where output != null
+ select new NamedOutput(outputProp.Name, output);
+
+ return query.Select(x => x!).ToList();
+ }
+
+ ///
+ /// Gets the output with the specified name.
+ ///
+ /// The activity execution context.
+ /// Name of the output.
+ /// The output value.
+ public object? GetOutput(ActivityExecutionContext context, string? outputName = null)
+ {
+ var workflowExecutionContext = context.WorkflowExecutionContext;
+ var outputRegister = workflowExecutionContext.GetActivityOutputRegister();
- return activity.GetOutput(activityExecutionContext, outputName);
+ // If the provided activity execution context is the same as the current activity's execution context, we return the exact output value of the current activity execution context.
+ if(context.Activity.NodeId == activity.NodeId)
+ return outputRegister.FindOutputByActivityInstanceId(context.Id, outputName);
+
+ // If the provided activity execution context is different from the current activity's execution context, we look for the last output value of the activity.
+ return outputRegister.FindOutputByActivityId(activity.Id, outputName);
+ }
+
+ ///
+ /// Gets the output with the specified name.
+ ///
+ /// The expression execution context.
+ /// Name of the output.
+ /// The output value.
+ public object? GetOutput(ExpressionExecutionContext context, string? outputName = null)
+ {
+ var activityExecutionContext = context.GetActivityExecutionContext();
+
+ if (activityExecutionContext == null)
+ return null;
+
+ return activity.GetOutput(activityExecutionContext, outputName);
+ }
+
+ ///
+ /// Gets the output with the specified name.
+ ///
+ /// The context.
+ /// Name of the output.
+ /// The type of the output.
+ /// The output value.
+ public T? GetOutput(ActivityExecutionContext context, string outputName)
+ {
+ var outputValue = activity.GetOutput(context, outputName);
+ return outputValue == null ? default! : outputValue.ConvertTo();
+ }
+
+ ///
+ /// Gets the output with the specified name.
+ ///
+ /// The context.
+ /// Name of the output.
+ /// The type of the output.
+ /// The output value.
+ public T? GetOutput(ExpressionExecutionContext context, string outputName)
+ {
+ return activity.GetOutput(context.GetActivityExecutionContext(), outputName);
+ }
}
- ///
- /// Gets the output with the specified name.
- ///
/// The activity.
- /// The context.
- /// Name of the output.
- /// The type of the output.
- /// The output value.
- public static T? GetOutput(this IActivity activity, ActivityExecutionContext context, string outputName)
- {
- var outputValue = activity.GetOutput(context, outputName);
- return outputValue == null ? default! : outputValue.ConvertTo();
- }
-
- ///
- /// Gets the output with the specified name.
- ///
- /// The activity.
- /// The context.
- /// Name of the output.
- /// The type of the output.
- /// The output value.
- public static T? GetOutput(this IActivity activity, ExpressionExecutionContext context, string outputName)
- {
- return activity.GetOutput(context.GetActivityExecutionContext(), outputName);
- }
-
- ///
- /// Gets the output with the specified name.
- ///
- /// The activity.
- /// The context.
- /// The output expression.
/// The type of the activity.
- /// The type of the output.
- /// The output value.
- public static T? GetOutput(this TActivity activity, ActivityExecutionContext context, Expression> outputExpression)
+ extension(TActivity activity)
{
- var outputName = outputExpression.GetPropertyName();
- return ((IActivity)activity!).GetOutput(context, outputName);
- }
-
- ///
- /// Gets the output with the specified name.
- ///
- /// The activity.
- /// The context.
- /// The output expression.
- /// The type of the activity.
- /// The type of the output.
- /// The output value.
- public static T? GetOutput(this TActivity activity, ExpressionExecutionContext context, Expression> outputExpression)
- {
- var outputName = outputExpression.GetPropertyName();
- return ((IActivity)activity!).GetOutput(context, outputName);
+ ///
+ /// Gets the output with the specified name.
+ ///
+ /// The context.
+ /// The output expression.
+ /// The type of the output.
+ /// The output value.
+ public T? GetOutput(ActivityExecutionContext context, Expression> outputExpression)
+ {
+ var outputName = outputExpression.GetPropertyName();
+ return ((IActivity)activity!).GetOutput(context, outputName);
+ }
+
+ ///
+ /// Gets the output with the specified name.
+ ///
+ /// The context.
+ /// The output expression.
+ /// The type of the output.
+ /// The output value.
+ public T? GetOutput(ExpressionExecutionContext context, Expression> outputExpression)
+ {
+ var outputName = outputExpression.GetPropertyName();
+ return ((IActivity)activity!).GetOutput(context, outputName);
+ }
}
- ///
- /// Gets the Result output of the specified activity.
- ///
/// The activity.
- /// The context.
- /// The type as which to return the output.
- /// The output value.
- public static T? GetResult(this IActivity activity, ExpressionExecutionContext context)
+ extension(IActivity activity)
{
- var value = GetResult(activity, context);
- return value == null ? default! : value.ConvertTo();
- }
-
- ///
- /// Gets the Result output of the specified activity.
- ///
- /// The activity.
- /// The context.
- /// The output value.
- public static object? GetResult(this IActivity activity, ExpressionExecutionContext context)
- {
- return activity.GetResult(context.GetActivityExecutionContext());
- }
+ ///
+ /// Gets the Result output of the specified activity.
+ ///
+ /// The context.
+ /// The type as which to return the output.
+ /// The output value.
+ public T? GetResult(ExpressionExecutionContext context)
+ {
+ var value = GetResult(activity, context);
+ return value == null ? default! : value.ConvertTo();
+ }
- ///
- /// Gets the Result output of the specified activity.
- ///
- /// The activity.
- /// The context.
- /// The output value.
- public static object? GetResult(this IActivity activity, ActivityExecutionContext context)
- {
- return activity.GetOutput(context, ActivityOutputRegister.DefaultOutputName);
+ ///
+ /// Gets the Result output of the specified activity.
+ ///
+ /// The context.
+ /// The output value.
+ public object? GetResult(ExpressionExecutionContext context)
+ {
+ return activity.GetResult(context.GetActivityExecutionContext());
+ }
+
+ ///
+ /// Gets the Result output of the specified activity.
+ ///
+ /// The context.
+ /// The output value.
+ public object? GetResult(ActivityExecutionContext context)
+ {
+ return activity.GetOutput(context, ActivityOutputRegister.DefaultOutputName);
+ }
}
///
@@ -225,40 +227,43 @@ public static class ActivityExtensions
return context.GetLastActivityResult();
}
- ///
- /// Gets the input properties of the specified activity.
- ///
- public static IEnumerable GetInputProperties(this IActivity activity) => activity.GetType().GetProperties().Where(x => typeof(Input).IsAssignableFrom(x.PropertyType)).ToList();
-
- ///
- /// Gets the method for the specified method name on the specified activity.
- ///
- public static TDelegate GetDelegate(this IActivity activity, string methodName) where TDelegate : Delegate
+ extension(IActivity activity)
{
- var activityType = activity.GetType();
- const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy;
- var resumeMethodInfo = default(MethodInfo?);
- var currentType = activityType;
+ ///
+ /// Gets the input properties of the specified activity.
+ ///
+ public IEnumerable GetInputProperties() => activity.GetType().GetProperties().Where(x => typeof(Input).IsAssignableFrom(x.PropertyType)).ToList();
- while (currentType != null && resumeMethodInfo == null)
+ ///
+ /// Gets the method for the specified method name on the specified activity.
+ ///
+ public TDelegate GetDelegate(string methodName) where TDelegate : Delegate
{
- resumeMethodInfo = currentType.GetMethod(methodName, bindingFlags);
- currentType = currentType.BaseType;
+ var activityType = activity.GetType();
+ const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy;
+ var resumeMethodInfo = default(MethodInfo?);
+ var currentType = activityType;
+
+ while (currentType != null && resumeMethodInfo == null)
+ {
+ resumeMethodInfo = currentType.GetMethod(methodName, bindingFlags);
+ currentType = currentType.BaseType;
+ }
+
+ if (resumeMethodInfo == null)
+ throw new Exception($"Can't find method name {methodName} on type {activityType} or its base type {activityType.BaseType}");
+
+ return resumeMethodInfo.IsStatic ? (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), resumeMethodInfo) : (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), activity, resumeMethodInfo);
}
- if (resumeMethodInfo == null)
- throw new Exception($"Can't find method name {methodName} on type {activityType} or its base type {activityType.BaseType}");
+ ///
+ /// Gets the Resume method for the specified activity.
+ ///
+ public ExecuteActivityDelegate GetResumeActivityDelegate(string resumeMethodName) => activity.GetDelegate(resumeMethodName);
- return resumeMethodInfo.IsStatic ? (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), resumeMethodInfo) : (TDelegate)Delegate.CreateDelegate(typeof(TDelegate), activity, resumeMethodInfo);
+ ///
+ /// Gets the Child Activity Completed method for the specified activity.
+ ///
+ public ActivityCompletionCallback GetActivityCompletionCallback(string completionMethodName) => activity.GetDelegate(completionMethodName);
}
-
- ///
- /// Gets the Resume method for the specified activity.
- ///
- public static ExecuteActivityDelegate GetResumeActivityDelegate(this IActivity driver, string resumeMethodName) => driver.GetDelegate(resumeMethodName);
-
- ///
- /// Gets the Child Activity Completed method for the specified activity.
- ///
- public static ActivityCompletionCallback GetActivityCompletionCallback(this IActivity driver, string completionMethodName) => driver.GetDelegate(completionMethodName);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs
index 938134cc1..e2d65a532 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs
@@ -13,49 +13,52 @@ public static class ActivityPropertyExtensions
private static readonly string[] SourcePropertyName = ["source", "Source"];
private static readonly string[] CommitStrategyName = ["commitStrategyName", "CommitStrategyName"];
- ///
- /// Gets a flag indicating whether this activity can be used for starting a workflow.
- /// Usually used for triggers, but also used to disambiguate between two or more starting activities and no starting activity was specified.
- ///
- public static bool GetCanStartWorkflow(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false);
-
- ///
- /// Sets a flag indicating whether this activity can be used for starting a workflow.
- ///
- public static void SetCanStartWorkflow(this IActivity activity, bool value) => activity.CustomProperties[CanStartWorkflowPropertyName[0]] = value;
-
- ///
- /// Gets a flag indicating if this activity should execute synchronously or asynchronously.
- /// By default, activities with an of , or
- /// will execute synchronously, while activities of the kind will execute asynchronously.
- ///
- public static bool? GetRunAsynchronously(this IActivity activity)
+ extension(IActivity activity)
{
- return activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, defaultValueFactory: () => null);
+ ///
+ /// Gets a flag indicating whether this activity can be used for starting a workflow.
+ /// Usually used for triggers, but also used to disambiguate between two or more starting activities and no starting activity was specified.
+ ///
+ public bool GetCanStartWorkflow() => activity.CustomProperties.GetValueOrDefault(CanStartWorkflowPropertyName, () => false);
+
+ ///
+ /// Sets a flag indicating whether this activity can be used for starting a workflow.
+ ///
+ public void SetCanStartWorkflow(bool value) => activity.CustomProperties[CanStartWorkflowPropertyName[0]] = value;
+
+ ///
+ /// Gets a flag indicating if this activity should execute synchronously or asynchronously.
+ /// By default, activities with an of , or
+ /// will execute synchronously, while activities of the kind will execute asynchronously.
+ ///
+ public bool? GetRunAsynchronously()
+ {
+ return activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, defaultValueFactory: () => null);
+ }
+
+ ///
+ /// Sets a flag indicating if this activity should execute synchronously or asynchronously.
+ /// By default, activities with an of , or
+ /// will execute synchronously, while activities of the kind will execute asynchronously.
+ ///
+ public void SetRunAsynchronously(bool? value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value ?? false;
+
+ ///
+ /// Gets the source file and line number where this activity was instantiated, if any.
+ ///
+ public string? GetSource() => activity.CustomProperties.GetValueOrDefault(SourcePropertyName, () => default(string?));
+
+ ///
+ /// Sets the source file and line number where this activity was instantiated, if any.
+ ///
+ public void SetSource(string value) => activity.CustomProperties[SourcePropertyName[0]] = value;
+
+ ///
+ /// Gets the commit state behavior for the specified activity.
+ ///
+ public string? GetCommitStrategy() => activity.CustomProperties.GetValueOrDefault(CommitStrategyName, () => null);
}
- ///
- /// Sets a flag indicating if this activity should execute synchronously or asynchronously.
- /// By default, activities with an of , or
- /// will execute synchronously, while activities of the kind will execute asynchronously.
- ///
- public static void SetRunAsynchronously(this IActivity activity, bool? value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value ?? false;
-
- ///
- /// Gets the source file and line number where this activity was instantiated, if any.
- ///
- public static string? GetSource(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(SourcePropertyName, () => default(string?));
-
- ///
- /// Sets the source file and line number where this activity was instantiated, if any.
- ///
- public static void SetSource(this IActivity activity, string value) => activity.CustomProperties[SourcePropertyName[0]] = value;
-
- ///
- /// Gets the commit state behavior for the specified activity.
- ///
- public static string? GetCommitStrategy(this IActivity activity) => activity.CustomProperties.GetValueOrDefault(CommitStrategyName, () => null);
-
///
/// Sets the commit state behavior for the specified activity.
///
@@ -68,35 +71,38 @@ public static class ActivityPropertyExtensions
return activity;
}
- ///
- /// Sets the source file and line number where this activity was instantiated, if any.
- ///
- public static void SetSource(this IActivity activity, string? sourceFile, int? lineNumber)
+ extension(IActivity activity)
{
- if (sourceFile == null || lineNumber == null)
- return;
+ ///
+ /// Sets the source file and line number where this activity was instantiated, if any.
+ ///
+ public void SetSource(string? sourceFile, int? lineNumber)
+ {
+ if (sourceFile == null || lineNumber == null)
+ return;
- var source = $"{Path.GetFileName(sourceFile)}:{lineNumber}";
- activity.SetSource(source);
+ var source = $"{Path.GetFileName(sourceFile)}:{lineNumber}";
+ activity.SetSource(source);
+ }
+
+ ///
+ /// Gets the display text for the specified activity.
+ ///
+ public string? GetDisplayText() => activity.Metadata.TryGetValue("displayText", out var value) ? value.ToString() : null;
+
+ ///
+ /// Sets the display text for the specified activity.
+ ///
+ public void SetDisplayText(string value) => activity.Metadata["displayText"] = value;
+
+ ///
+ /// Gets the description for the specified activity.
+ ///
+ public string? GetDescription() => activity.Metadata.TryGetValue("description", out var value) ? value.ToString() : null;
+
+ ///
+ /// Sets the description for the specified activity.
+ ///
+ public void SetDescription(string value) => activity.Metadata["description"] = value;
}
-
- ///
- /// Gets the display text for the specified activity.
- ///
- public static string? GetDisplayText(this IActivity activity) => activity.Metadata.TryGetValue("displayText", out var value) ? value.ToString() : null;
-
- ///
- /// Sets the display text for the specified activity.
- ///
- public static void SetDisplayText(this IActivity activity, string value) => activity.Metadata["displayText"] = value;
-
- ///
- /// Gets the description for the specified activity.
- ///
- public static string? GetDescription(this IActivity activity) => activity.Metadata.TryGetValue("description", out var value) ? value.ToString() : null;
-
- ///
- /// Sets the description for the specified activity.
- ///
- public static void SetDescription(this IActivity activity, string value) => activity.Metadata["description"] = value;
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs
index 5c467dbe0..6c26748e3 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityRegistryExtensions.cs
@@ -15,17 +15,20 @@ public static class ActivityRegistryExtensions
///
public static Task FindAsync(this IActivityRegistryLookupService activityRegistry, IActivity activity) => activityRegistry.FindAsync(activity.Type, activity.Version);
- ///
- /// Finds the activity descriptor for the specified activity type.
- ///
- public static ActivityDescriptor? Find(this IActivityRegistry activityRegistry) where T:IActivity => activityRegistry.Find(ActivityTypeNameHelper.GenerateTypeName());
-
- ///
- /// Registers the specified activity type with the registry.
- ///
/// The activity registry.
- /// The cancellation token.
- /// The type of the activity to register.
- public static async Task RegisterAsync(this IActivityRegistry activityRegistry, CancellationToken cancellationToken = default) where T : IActivity =>
- await activityRegistry.RegisterAsync(typeof(T), cancellationToken);
+ extension(IActivityRegistry activityRegistry)
+ {
+ ///
+ /// Finds the activity descriptor for the specified activity type.
+ ///
+ public ActivityDescriptor? Find() where T:IActivity => activityRegistry.Find(ActivityTypeNameHelper.GenerateTypeName());
+
+ ///
+ /// Registers the specified activity type with the registry.
+ ///
+ /// The cancellation token.
+ /// The type of the activity to register.
+ public async Task RegisterAsync(CancellationToken cancellationToken = default) where T : IActivity =>
+ await activityRegistry.RegisterAsync(typeof(T), cancellationToken);
+ }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs
index a446f237b..369685b03 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/BackgroundActivityExecutionContextExtensions.cs
@@ -11,71 +11,74 @@ public static class BackgroundActivityExecutionContextExtensions
/// A key into the activity execution context's transient properties that indicates whether the current activity is being executed in the background.
///
public static readonly object IsBackgroundExecution = new();
-
- ///
- /// Configures the activity execution context to execute the current activity in the background.
- ///
- public static void SetIsBackgroundExecution(this ActivityExecutionContext activityExecutionContext, bool value = true)
- {
- activityExecutionContext.TransientProperties[IsBackgroundExecution] = value;
- }
-
- ///
- /// Gets a value indicating whether the current activity is being executed in the background.
- ///
- public static bool GetIsBackgroundExecution(this ActivityExecutionContext activityExecutionContext)
- {
- return activityExecutionContext.TransientProperties.ContainsKey(IsBackgroundExecution);
- }
-
- ///
- /// Sets the background outcomes.
- ///
- public static void SetBackgroundOutcomes(this ActivityExecutionContext activityExecutionContext, IEnumerable outcomes)
- {
- var outcomesList = outcomes.ToList();
- activityExecutionContext.SetProperty("BackgroundOutcomes", outcomesList);
- }
-
- ///
- /// Gets the background outcomes.
- ///
- public static IEnumerable? GetBackgroundOutcomes(this ActivityExecutionContext activityExecutionContext)
- {
- return activityExecutionContext.GetProperty>("BackgroundOutcomes");
- }
-
- ///
- /// Sets the background outcomes.
- ///
- public static void SetBackgroundCompletion(this ActivityExecutionContext activityExecutionContext)
- {
- activityExecutionContext.SetProperty("BackgroundCompletion", true);
- }
-
- ///
- /// Gets the background outcomes.
- ///
- public static bool? GetBackgroundCompleted(this ActivityExecutionContext activityExecutionContext)
- {
- return activityExecutionContext.GetProperty("BackgroundCompletion");
- }
-
- ///
- /// Sets the background scheduled activities.
- ///
- public static void SetBackgroundScheduledActivities(this ActivityExecutionContext activityExecutionContext, IEnumerable scheduledActivities)
- {
- var scheduledActivitiesList = scheduledActivities.ToList();
- activityExecutionContext.SetProperty("BackgroundScheduledActivities", scheduledActivitiesList);
- }
-
- ///
- /// Gets the background scheduled activities.
- ///
+
///
- public static IEnumerable GetBackgroundScheduledActivities(this ActivityExecutionContext activityExecutionContext)
+ extension(ActivityExecutionContext activityExecutionContext)
{
- return activityExecutionContext.GetProperty>("BackgroundScheduledActivities") ?? [];
+ ///
+ /// Configures the activity execution context to execute the current activity in the background.
+ ///
+ public void SetIsBackgroundExecution(bool value = true)
+ {
+ activityExecutionContext.TransientProperties[IsBackgroundExecution] = value;
+ }
+
+ ///
+ /// Gets a value indicating whether the current activity is being executed in the background.
+ ///
+ public bool GetIsBackgroundExecution()
+ {
+ return activityExecutionContext.TransientProperties.ContainsKey(IsBackgroundExecution);
+ }
+
+ ///
+ /// Sets the background outcomes.
+ ///
+ public void SetBackgroundOutcomes(IEnumerable outcomes)
+ {
+ var outcomesList = outcomes.ToList();
+ activityExecutionContext.SetProperty("BackgroundOutcomes", outcomesList);
+ }
+
+ ///
+ /// Gets the background outcomes.
+ ///
+ public IEnumerable? GetBackgroundOutcomes()
+ {
+ return activityExecutionContext.GetProperty>("BackgroundOutcomes");
+ }
+
+ ///
+ /// Sets the background outcomes.
+ ///
+ public void SetBackgroundCompletion()
+ {
+ activityExecutionContext.SetProperty("BackgroundCompletion", true);
+ }
+
+ ///
+ /// Gets the background outcomes.
+ ///
+ public bool? GetBackgroundCompleted()
+ {
+ return activityExecutionContext.GetProperty("BackgroundCompletion");
+ }
+
+ ///
+ /// Sets the background scheduled activities.
+ ///
+ public void SetBackgroundScheduledActivities(IEnumerable scheduledActivities)
+ {
+ var scheduledActivitiesList = scheduledActivities.ToList();
+ activityExecutionContext.SetProperty("BackgroundScheduledActivities", scheduledActivitiesList);
+ }
+
+ ///
+ /// Gets the background scheduled activities.
+ ///
+ public IEnumerable GetBackgroundScheduledActivities()
+ {
+ return activityExecutionContext.GetProperty>("BackgroundScheduledActivities") ?? [];
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs
index 712d370d1..d5c3d4d35 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/BehaviorCollectionExtensions.cs
@@ -5,12 +5,15 @@ namespace Elsa.Extensions;
public static class BehaviorCollectionExtensions
{
- public static void Add(this ICollection behaviors, IActivity owner) where T : IBehavior => behaviors.Add((T)Activator.CreateInstance(typeof(T), owner)!);
-
- public static void Remove(this ICollection behaviors) where T : IBehavior
+ extension(ICollection behaviors)
{
- var behaviorsToRemove = behaviors.Where(x => x is T).ToList();
+ public void Add(IActivity owner) where T : IBehavior => behaviors.Add((T)Activator.CreateInstance(typeof(T), owner)!);
- foreach (var behavior in behaviorsToRemove) behaviors.Remove(behavior);
+ public void Remove() where T : IBehavior
+ {
+ var behaviorsToRemove = behaviors.Where(x => x is T).ToList();
+
+ foreach (var behavior in behaviorsToRemove) behaviors.Remove(behavior);
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs
index 2a7f20c7b..33cb24ca2 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/BookmarkExtensions.cs
@@ -10,23 +10,26 @@ namespace Elsa.Extensions;
///
public static class BookmarkExtensions
{
- ///
- /// Filters the specified set of bookmarks by the specified activity type.
- ///
/// The set of bookmarks to filter.
- /// The activity type to filter by.
- /// The filtered set of bookmarks.
- public static IEnumerable Filter(this IEnumerable bookmarks) where T : IActivity
+ extension(IEnumerable bookmarks)
{
- var bookmarkName = ActivityTypeNameHelper.GenerateTypeName();
- return bookmarks.Filter(bookmarkName);
+ ///
+ /// Filters the specified set of bookmarks by the specified activity type.
+ ///
+ /// The activity type to filter by.
+ /// The filtered set of bookmarks.
+ public IEnumerable Filter() where T : IActivity
+ {
+ var bookmarkName = ActivityTypeNameHelper.GenerateTypeName();
+ return bookmarks.Filter(bookmarkName);
+ }
+
+ public IEnumerable Filter(string name)
+ {
+ return bookmarks.Where(x => x.Name == name);
+ }
}
-
- public static IEnumerable Filter(this IEnumerable bookmarks, string name)
- {
- return bookmarks.Where(x => x.Name == name);
- }
-
+
///
/// Returns the Data property of the bookmark as a strongly-typed object.
///
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs
index 8fa3f170e..9dcbadbad 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/BreakBehaviorActivityExecutionContextExtensions.cs
@@ -5,14 +5,17 @@ namespace Elsa.Workflows;
///
public static class BreakBehaviorActivityExecutionContextExtensions
{
- ///
- /// Gets a value indicating whether the current activity is breaking out of a loop.
- ///
- public static bool GetIsBreaking(this ActivityExecutionContext context) => context.GetProperty("IsBreaking");
-
- ///
- /// Sets a value indicating whether the current activity is breaking out of a loop.
- ///
///
- public static void SetIsBreaking(this ActivityExecutionContext context) => context.SetProperty("IsBreaking", true);
+ extension(ActivityExecutionContext context)
+ {
+ ///
+ /// Gets a value indicating whether the current activity is breaking out of a loop.
+ ///
+ public bool GetIsBreaking() => context.GetProperty("IsBreaking");
+
+ ///
+ /// Sets a value indicating whether the current activity is breaking out of a loop.
+ ///
+ public void SetIsBreaking() => context.SetProperty("IsBreaking", true);
+ }
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs
index 9aae63257..d8bf45d96 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/DictionaryExtensions.cs
@@ -6,8 +6,12 @@ namespace Elsa.Extensions;
public static class DictionaryExtensions
{
- public static bool TryGetValue(this IDictionary dictionary, string key, out T value) => dictionary.TryGetValue(key, out value);
- public static bool TryGetValue(this IDictionary dictionary, IEnumerable keys, out T value) => dictionary.TryGetValue(keys, out value);
+ extension(IDictionary dictionary)
+ {
+ public bool TryGetValue(string key, out T value) => dictionary.TryGetValue(key, out value);
+ public bool TryGetValue(IEnumerable keys, out T value) => dictionary.TryGetValue(keys, out value);
+ }
+
public static bool TryGetValue(this IDictionary dictionary, string key, out T value) => dictionary.TryGetValue(key, out value);
public static bool TryGetValue(this IDictionary dictionary, TKey key, out T value)
@@ -22,46 +26,56 @@ public static class DictionaryExtensions
return true;
}
- public static bool TryGetValue(this IDictionary dictionary, TKey key, out T value)
+ extension(IDictionary dictionary)
{
- if (!dictionary.TryGetValue(key, out var item))
+ public bool TryGetValue(TKey key, out T value)
{
+ if (!dictionary.TryGetValue(key, out var item))
+ {
+ value = default!;
+ return false;
+ }
+
+ var result = TryConvertValue(item);
+ value = result.Success ? (T)result.Value! : default!;
+ return result.Success;
+ }
+
+ public bool TryGetValue(IEnumerable keys, out T value)
+ {
+ foreach (var key in keys)
+ {
+ if (dictionary.TryGetValue(key, out var item))
+ {
+ var result = TryConvertValue(item);
+ value = result.Success ? (T)result.Value! : default!;
+ return result.Success;
+ }
+ }
+
value = default!;
return false;
}
-
- var result = TryConvertValue(item);
- value = result.Success ? (T)result.Value! : default!;
- return result.Success;
- }
-
- public static bool TryGetValue(this IDictionary dictionary, IEnumerable keys, out T value)
- {
- foreach (var key in keys)
- {
- if (dictionary.TryGetValue(key, out var item))
- {
- var result = TryConvertValue(item);
- value = result.Success ? (T)result.Value! : default!;
- return result.Success;
- }
- }
-
- value = default!;
- return false;
}
public static T? GetValue(this IDictionary dictionary, TKey key) => ConvertValue(dictionary[key]);
public static T? GetValue(this IDictionary dictionary, string key) => ConvertValue(dictionary[key]);
public static T? GetValueOrDefault(this IDictionary dictionary, TKey key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory();
- public static T? GetValueOrDefault(this IDictionary dictionary, TKey key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory();
- public static T? GetValueOrDefault(this IDictionary dictionary, TKey key) => GetValueOrDefault(dictionary, key, () => default);
- public static T? GetValueOrDefault(this IDictionary dictionary, string key, Func defaultValueFactory) => TryGetValue(dictionary, key, out var value) ? value : defaultValueFactory();
- public static T? GetValueOrDefault(this IDictionary dictionary, IEnumerable keys, Func defaultValueFactory) => TryGetValue(dictionary, keys, out var value) ? value : defaultValueFactory();
- public static T? GetValueOrDefault