diff --git a/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs b/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs
index 0ef11e6d8..1548daa0c 100644
--- a/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs
+++ b/src/modules/Elsa.Workflows.Core/Abstractions/Activity.cs
@@ -68,7 +68,7 @@ public abstract class Activity : IActivity, ISignalHandler
/// will execute synchronously, while activities of the kind will execute asynchronously.
///
[JsonIgnore]
- public bool RunAsynchronously
+ public bool? RunAsynchronously
{
get => this.GetRunAsynchronously();
set => this.SetRunAsynchronously(value);
diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs
index 4e0053ae4..b4658043a 100644
--- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs
+++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Serialization/FlowchartJsonConverter.cs
@@ -1,4 +1,4 @@
-using System.Text.Json;
+using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
@@ -28,6 +28,7 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK
var name = doc.RootElement.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null;
var type = doc.RootElement.TryGetProperty("type", out var typeElement) ? typeElement.GetString() : null;
var version = doc.RootElement.TryGetProperty("version", out var versionElement) ? versionElement.GetInt32() : 1;
+ var runAsynchronously = doc.RootElement.TryGetProperty("runAsynchronously", out var runAsyncElement) && runAsyncElement.GetBoolean();
var connectionsElement = doc.RootElement.TryGetProperty("connections", out var connectionsEl) ? connectionsEl : default;
var activitiesElement = doc.RootElement.TryGetProperty("activities", out var activitiesEl) ? activitiesEl : default;
@@ -60,6 +61,7 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK
NodeId = nodeId!,
Name = name,
Type = type!,
+ RunAsynchronously = runAsynchronously,
Version = version,
CustomProperties = customProperties,
Metadata = metadata,
diff --git a/src/modules/Elsa.Workflows.Core/Attributes/TaskActivityAttribute.cs b/src/modules/Elsa.Workflows.Core/Attributes/TaskActivityAttribute.cs
new file mode 100644
index 000000000..390cfd628
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Core/Attributes/TaskActivityAttribute.cs
@@ -0,0 +1,19 @@
+namespace Elsa.Workflows.Attributes;
+
+[AttributeUsage(AttributeTargets.Class)]
+public class TaskActivityAttribute : ActivityAttribute
+{
+ public TaskActivityAttribute()
+ {
+ Kind = ActivityKind.Task;
+ }
+
+ public TaskActivityAttribute(string @namespace, string? category, string? description = null, bool runAsynchronously = false)
+ : base(@namespace, category, description)
+ {
+ Kind = ActivityKind.Task;
+ RunAsynchronously = runAsynchronously;
+ }
+
+ public bool RunAsynchronously { get; set; }
+}
\ 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 ff9bee7c5..938134cc1 100644
--- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs
+++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityPropertyExtensions.cs
@@ -29,14 +29,17 @@ public static class ActivityPropertyExtensions
/// 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) => activity.CustomProperties.GetValueOrDefault(RunAsynchronouslyPropertyName, () => false);
+ public static bool? GetRunAsynchronously(this IActivity activity)
+ {
+ 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 static void SetRunAsynchronously(this IActivity activity, bool value) => activity.CustomProperties[RunAsynchronouslyPropertyName[0]] = value;
+ 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.
diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs
index 081550a0d..4dbed1dc3 100644
--- a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs
+++ b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs
@@ -2,6 +2,7 @@
using System.Text.Json.Nodes;
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
+using Elsa.Workflows.Attributes;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
using Humanizer;
@@ -34,7 +35,18 @@ public class ActivityFactory : IActivityFactory
// 7) Pull out your boolean flags from the cleaned element
var canStartWorkflow = GetBoolean(cleanedElement, "canStartWorkflow");
- var runAsynchronously = GetBoolean(cleanedElement, "runAsynchronously");
+ var runAsynchronously = GetNullableBoolean(cleanedElement, "runAsynchronously");
+ if (runAsynchronously is null)
+ {
+ if (context.ActivityDescriptor.Attributes.OfType().FirstOrDefault() is { } taskActivityAttribute)
+ {
+ runAsynchronously = taskActivityAttribute.RunAsynchronously;
+ }
+ else
+ {
+ runAsynchronously = false;
+ }
+ }
// 8) If composite, setup
if (activity is IComposite composite)
@@ -180,6 +192,11 @@ public class ActivityFactory : IActivityFactory
}
private static bool GetBoolean(JsonElement element, string propertyName)
+ {
+ return GetNullableBoolean(element, propertyName) ?? false;
+ }
+
+ private static bool? GetNullableBoolean(JsonElement element, string propertyName)
{
var propertyNames = new[] { propertyName.Camelize(), propertyName.Pascalize() };
@@ -188,13 +205,14 @@ public class ActivityFactory : IActivityFactory
if (element.TryGetProperty("customProperties", out var customPropertyElement))
{
if (customPropertyElement.TryGetProperty(name, out var canStartWorkflowElement))
- return canStartWorkflowElement.GetBoolean();
+ return (bool?)canStartWorkflowElement.GetValue();
}
- if (element.TryGetProperty(propertyName.Camelize(), out var property) && property.GetBoolean())
- return true;
+ if (element.TryGetProperty(propertyName.Camelize(), out var property)
+ && (bool?)property.GetValue() is { } propValue)
+ return propValue;
}
- return false;
+ return null;
}
}
diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs
index e744870a3..30277beb9 100644
--- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs
+++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Elsa.Extensions;
using Elsa.Mediator.Contracts;
+using Elsa.Workflows.Attributes;
using Elsa.Workflows.CommitStates;
using Elsa.Workflows.Middleware.Activities;
using Elsa.Workflows.Models;
@@ -103,7 +104,28 @@ public class BackgroundActivityInvokerMiddleware(
return !GetIsBackgroundExecution(context)
&& context.WorkflowExecutionContext.ExecuteDelegate == null
- && (kind is ActivityKind.Job || (kind == ActivityKind.Task && activity.GetRunAsynchronously()));
+ && (kind is ActivityKind.Job || GetTaskRunAsynchronously(context));
+ }
+
+ private static bool GetTaskRunAsynchronously(ActivityExecutionContext context)
+ {
+ var activity = context.Activity;
+ var activityDescriptor = context.ActivityDescriptor;
+ var kind = activityDescriptor.Kind;
+
+ if (kind is not ActivityKind.Task)
+ return false;
+
+ var runAsynchronously = activity.GetRunAsynchronously();
+
+ if (runAsynchronously is null)
+ {
+ var taskActivityAttribute = activityDescriptor.Attributes.OfType().FirstOrDefault();
+
+ return taskActivityAttribute is { RunAsynchronously: true };
+ }
+
+ return (bool)runAsynchronously;
}
private static bool GetIsBackgroundExecution(ActivityExecutionContext context) => context.TransientProperties.ContainsKey(BackgroundActivityExecutionContextExtensions.IsBackgroundExecution);
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs
index ffe94bfab..a5b37596c 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/RunAsynchronousActivityOutput/Tests.cs
@@ -14,7 +14,7 @@ public class Tests
[Theory(DisplayName = "Activity outputs captured in activity execution record")]
[InlineData(true)]
[InlineData(false)]
- public async Task ActivityOutputCaptureTest(bool runAsynchronously)
+ public async Task ActivityOutputCaptureTest(bool? runAsynchronously)
{
// Arrange
var workflow = new TestWorkflow(workflowBuilder =>
@@ -76,7 +76,7 @@ public class Tests
[Theory(DisplayName = "Activity outputs captured in activity execution record")]
[InlineData(true)]
[InlineData(false)]
- public async Task ActivityOutputCaptureParallelTest(bool runAsynchronously)
+ public async Task ActivityOutputCaptureParallelTest(bool? runAsynchronously)
{
// Arrange
var workflow = new TestWorkflow(workflowBuilder =>
diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs
index ed02ac470..e3f2c9716 100644
--- a/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs
+++ b/test/integration/Elsa.Workflows.IntegrationTests/Serialization/ContainerSerialization/Tests.cs
@@ -32,17 +32,23 @@ public class Tests
{
Id = "start",
Name = "Start",
+ RunAsynchronously = false // Manually set to false because the manual construction defaults to null,
+ // But deserialization uses the factory creation method that overwrites null values.
};
var writeLine = new WriteLine(new Input(new Expression("JavaScript", "getVariable('TextVar')")))
{
Id = "writeLine",
Name = "WriteLine",
Version = 3,
+ RunAsynchronously = false // Manually set to false because the manual construction defaults to null,
+ // But deserialization uses the factory creation method that overwrites null values.
};
var end = new End
{
Id = "end",
Name = "end",
+ RunAsynchronously = false // Manually set to false because the manual construction defaults to null,
+ // But deserialization uses the factory creation method that overwrites null values.
};
var container = new Flowchart
{
@@ -74,6 +80,7 @@ public class Tests
new(writeLine, end),
},
};
+ container.RunAsynchronously = false;
// Act
@@ -107,6 +114,8 @@ public class Tests
Id = "writeLine",
Name = "WriteLine",
CanStartWorkflow = true,
+ RunAsynchronously = false // Manually set to false because the manual construction defaults to null,
+ // But deserialization uses the factory creation method that overwrites null values.
},
},
CustomProperties = new Dictionary
@@ -120,6 +129,7 @@ public class Tests
{ "string", "str"},
}
};
+ container.RunAsynchronously = false;
// Act
@@ -153,6 +163,9 @@ public class Tests
Id = "writeLine",
Name = "WriteLine",
CanStartWorkflow = true,
+ RunAsynchronously = false // Manually set to false because the manual construction defaults to null,
+ // But deserialization uses the factory creation method that overwrites null values.
+
},
},
CustomProperties = new Dictionary
@@ -166,6 +179,7 @@ public class Tests
{ "string", "str"},
}
};
+ container.RunAsynchronously = false;
// Act