diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index 7b550d6fb..e62d553ab 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Text.Json; using Elsa.Expressions.Models; using Elsa.Extensions; @@ -182,7 +182,7 @@ public class HttpEndpoint : Trigger { var path = Path.Get(context); var methods = SupportedMethods.GetOrDefault(context) ?? new List { HttpMethods.Get }; - await context.WaitForHttpRequestAsync(path, methods, OnResumeAsync); + await context.WaitForHttpRequestAsync(path, methods, OnResumeAsync, Elsa.Http.HttpStimulusNames.HttpEndpoint); } private async ValueTask OnResumeAsync(ActivityExecutionContext context) diff --git a/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs b/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs index 9257a73c1..8d6a708ce 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpointBase.cs @@ -22,8 +22,8 @@ public abstract class HttpEndpointBase : Trigger protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { - var options = GetOptions(); - await context.WaitForHttpRequestAsync(options, HttpRequestReceivedAsync); + var options = GetOptions(); + await context.WaitForHttpRequestAsync(options, HttpRequestReceivedAsync, Elsa.Http.HttpStimulusNames.HttpEndpoint); } protected override IEnumerable GetTriggerPayloads(TriggerIndexingContext context) diff --git a/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs b/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs index ca62a0782..4c56c3cd3 100644 --- a/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/HttpEndpointActivityExecutionContextExtensions.cs @@ -9,27 +9,27 @@ namespace Elsa.Http.Extensions; public static class HttpEndpointActivityExecutionContextExtensions { -public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, string method, ExecuteActivityDelegate? callback = null) +public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, string method, ExecuteActivityDelegate? callback = null, string? bookmarkName = null) { var options = new HttpEndpointOptions { Path = path, Methods = [method] }; - await WaitForHttpRequestAsync(context, options, callback); + await WaitForHttpRequestAsync(context, options, callback, bookmarkName); } -public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, IEnumerable methods, ExecuteActivityDelegate? callback = null) +public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, string path, IEnumerable methods, ExecuteActivityDelegate? callback = null, string? bookmarkName = null) { var options = new HttpEndpointOptions { Path = path, Methods = methods.ToList() }; - await WaitForHttpRequestAsync(context, options, callback); + await WaitForHttpRequestAsync(context, options, callback, bookmarkName); } - public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, HttpEndpointOptions options, ExecuteActivityDelegate? callback = null) + public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionContext context, HttpEndpointOptions options, ExecuteActivityDelegate? callback = null, string? bookmarkName = null) { var path = options.Path; if (path.Contains("//")) @@ -38,7 +38,8 @@ public static async ValueTask WaitForHttpRequestAsync(this ActivityExecutionCont var expressionExecutionContext = context.ExpressionExecutionContext; if (!context.IsTriggerOfWorkflow()) { - context.CreateBookmarks(expressionExecutionContext.GetHttpEndpointStimuli(options), includeActivityInstanceId: false, callback: callback); + var name = bookmarkName ?? Elsa.Http.HttpStimulusNames.HttpEndpoint; + context.CreateBookmarks(expressionExecutionContext.GetHttpEndpointStimuli(options), includeActivityInstanceId: false, bookmarkName: name, callback: callback); return; } diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs index c41419322..c83c5a74b 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/FlowJoin.cs @@ -1,6 +1,5 @@ using System.Runtime.CompilerServices; using Elsa.Workflows.Activities.Flowchart.Contracts; -using Elsa.Workflows.Activities.Flowchart.Extensions; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Attributes; using Elsa.Workflows.Models; @@ -27,7 +26,7 @@ public class FlowJoin : Activity, IJoinNode /// The join mode determines whether this activity should continue as soon as one inbound path comes in (Wait Any), or once all inbound paths have executed (Wait All). /// [Input( - Description = "The join mode determines whether this activity should continue as soon as one inbound path comes in (Wait Any), or once all inbound paths have executed (Wait All).", + Description = "The join mode determines whether this activity should continue as soon as one inbound path comes in (WaitAny), or once all inbound paths have executed (WaitAll). To wait for all activated inbound paths, set the mode to WaitAllActive.", DefaultValue = FlowJoinMode.WaitAny, UIHint = InputUIHints.DropDown )] @@ -40,19 +39,7 @@ public class FlowJoin : Activity, IJoinNode if (context.ParentActivityExecutionContext != null) await context.ParentActivityExecutionContext.CancelInboundAncestorsAsync(this); + // The join behavior is handled by Flowchart, so we can simply complete the activity here. await context.CompleteActivityAsync(); } - - protected override bool CanExecute(ActivityExecutionContext context) - { - if(Flowchart.UseTokenFlow) - return true; - - return context.Get(Mode) switch - { - FlowJoinMode.WaitAny => true, - FlowJoinMode.WaitAll => Flowchart.CanWaitAllProceed(context), - _ => true - }; - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs index 6c72282e7..a35408447 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.cs @@ -1,6 +1,6 @@ using System.ComponentModel; using System.Runtime.CompilerServices; -using Elsa.Workflows.Activities.Flowchart.Extensions; +using Elsa.Extensions; using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Attributes; using Elsa.Workflows.Signals; @@ -52,6 +52,379 @@ public partial class Flowchart : Container await context.ScheduleActivityAsync(startActivity, OnChildCompletedAsync); } +// BEGIN 3.5 + private IActivity? GetStartActivity(ActivityExecutionContext context) + { + // If there's a trigger that triggered this workflow, use that. + var triggerActivityId = context.WorkflowExecutionContext.TriggerActivityId; + 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 (Start != null) + return 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 = 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. + /// + private bool HasPendingWork(ActivityExecutionContext context) + { + var workflowExecutionContext = context.WorkflowExecutionContext; + + // Use HashSet for O(1) lookups + var activityIds = new HashSet(Activities.Select(x => x.Id)); + + // Short circuit evaluation - check running instances first before more expensive scheduler check + if (context.Children.Any(x => activityIds.Contains(x.Activity.Id) && x.Status == ActivityStatus.Running)) + return true; + + // Scheduler check - optimize to avoid repeated LINQ evaluations + var scheduledItems = workflowExecutionContext.Scheduler.List().ToList(); + + return scheduledItems.Any(workItem => + { + var ownerInstanceId = workItem.Owner?.Id; + + if (ownerInstanceId == null) + return false; + + if (ownerInstanceId == context.Id) + return true; + + var ownerContext = workflowExecutionContext.ActivityExecutionContexts.First(x => x.Id == ownerInstanceId); + return ownerContext.GetAncestors().Any(x => x == context); + }); + } + + private IActivity? GetRootActivity() + { + // Get the first activity that has no inbound connections. + var query = + from activity in Activities + let inboundConnections = Connections.Any(x => x.Target.Activity == activity) + where !inboundConnections + select activity; + + var rootActivity = query.FirstOrDefault(); + return rootActivity; + } + + private FlowGraph GetFlowGraph(ActivityExecutionContext context) + { + // Store in TransientProperties so FlowChart is not persisted in WorkflowState + return context.TransientProperties.GetOrAdd(GraphTransientProperty, () => new FlowGraph(Connections, GetStartActivity(context))); + } + + private FlowScope GetFlowScope(ActivityExecutionContext context) + { + return context.GetProperty(ScopeProperty, () => new FlowScope()); + } + + private async ValueTask OnChildCompletedAsync(ActivityCompletedContext context) + { + var flowchartContext = context.TargetContext; + var completedActivityContext = context.ChildContext; + var completedActivity = completedActivityContext.Activity; + var result = context.Result; + + // Determine the outcomes from the completed activity + var outcomes = result is Outcomes o ? o : Outcomes.Default; + + await ProcessChildCompletedAsync(flowchartContext, completedActivity, completedActivityContext, outcomes); + } + + private async ValueTask ProcessChildCompletedAsync(ActivityExecutionContext flowchartContext, IActivity completedActivity, ActivityExecutionContext completedActivityContext, Outcomes outcomes) + { + if (flowchartContext.Activity != this) + { + throw new("Target context activity must be this flowchart"); + } + + // If the completed activity's status is anything but "Completed", do not schedule its outbound activities. + if (completedActivityContext.Status != ActivityStatus.Completed) + { + return; + } + + // If the complete activity is a terminal node, complete the flowchart immediately. + if (completedActivity is ITerminalNode) + { + await flowchartContext.CompleteActivityAsync(); + return; + } + + // Schedule the outbound activities + var flowGraph = GetFlowGraph(flowchartContext); + var flowScope = GetFlowScope(flowchartContext); + var completedActivityExcecutedByBackwardConnection = completedActivityContext.ActivityInput.GetValueOrDefault(BackwardConnectionActivityInput); + bool hasScheduledActivity = await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, completedActivity, outcomes, OnChildCompletedAsync, completedActivityExcecutedByBackwardConnection); + + // If there are not any outbound connections, complete the flowchart activity if there is no other pending work + if (!hasScheduledActivity) + { + await CompleteIfNoPendingWorkAsync(flowchartContext); + } + } + + /// + /// Schedules outbound activities based on the flowchart's structure and execution state. + /// This method determines whether an activity should be scheduled based on visited connections, + /// forward traversal rules, and backward connections. If outcomes is Outcomes.Empty, it indicates + /// that the activity should be skipped - all outbound connections will be visited and treated as + /// not followed. + /// + /// The graph representation of the flowchart. + /// Tracks activity and connection visits. + /// The execution context of the flowchart. + /// The current activity being processed. + /// The outcomes that determine which connections were followed. + /// The callback to invoke upon activity completion. + /// Indicates if the completed activity + /// was executed due to a backward connection. + /// True if at least one activity was scheduled; otherwise, false. + private static async ValueTask MaybeScheduleOutboundActivitiesAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity activity, Outcomes outcomes, ActivityCompletionCallback completionCallback, bool completedActivityExecutedByBackwardConnection = false) + { + bool hasScheduledActivity = false; + + // Check if the activity is dangling (i.e., it is not reachable from the flowchart graph) + if (flowGraph.IsDanglingActivity(activity)) + { + throw new($"Activity {activity.Id} is not reachable from the flowchart graph. Unable to schedule it's outbound activities."); + } + + // Register the activity as visited unless it was executed due to a backward connection + if (!completedActivityExecutedByBackwardConnection) + { + flowScope.RegisterActivityVisit(activity); + } + + // Process each outbound connection from the current activity + foreach (var outboundConnection in flowGraph.GetOutboundConnections(activity)) + { + var connectionFollowed = outcomes.Names.Contains(outboundConnection.Source.Port); + flowScope.RegisterConnectionVisit(outboundConnection, connectionFollowed); + var outboundActivity = outboundConnection.Target.Activity; + + // Determine the scheduling strategy based on connection-type. + if (flowGraph.IsBackwardConnection(outboundConnection, out var backwardConnectionIsValid)) + // Backward connections are scheduled differently + hasScheduledActivity |= await MaybeScheduleBackwardConnectionActivityAsync(flowGraph, flowchartContext, outboundConnection, outboundActivity, connectionFollowed, backwardConnectionIsValid, completionCallback); + else + hasScheduledActivity |= await MaybeScheduleOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback); + } + + return hasScheduledActivity; + } + + /// + /// Schedules an outbound activity that originates from a backward connection. + /// + private static async ValueTask MaybeScheduleBackwardConnectionActivityAsync(FlowGraph flowGraph, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, bool connectionFollowed, bool backwardConnectionIsValid, ActivityCompletionCallback completionCallback) + { + if (!connectionFollowed) + { + return false; + } + + if (!backwardConnectionIsValid) + { + throw new($"Invalid backward connection: Every path from the source ('{outboundConnection.Source.Activity.Id}') must go through the target ('{outboundConnection.Target.Activity.Id}') when tracing back to the start."); + } + + var scheduleWorkOptions = new ScheduleWorkOptions + { + CompletionCallback = completionCallback, + Input = new Dictionary() { { BackwardConnectionActivityInput, true } } + }; + + await flowchartContext.ScheduleActivityAsync(outboundActivity, scheduleWorkOptions); + return true; + } + + /// + /// Determines the merge mode for a given outbound activity. If the outbound activity is a FlowJoin, it retrieves its configured + /// mode. Otherwise, it defaults to FlowJoinMode.WaitAllActive for implicit joins. + /// + private static async ValueTask GetMergeModeAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) + { + if (outboundActivity is FlowJoin) + { + var outboundActivityExecutionContext = await flowchartContext.WorkflowExecutionContext.CreateActivityExecutionContextAsync(outboundActivity); + return await outboundActivityExecutionContext.EvaluateInputPropertyAsync(x => x.Mode); + } + else + { + // Implicit join case - treat as WaitAllActive + return FlowJoinMode.WaitAllActive; + } + } + + /// + /// Schedules a join activity based on inbound connection statuses. + /// + private static async ValueTask MaybeScheduleOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + FlowJoinMode mode = await GetMergeModeAsync(flowchartContext, outboundActivity); + + return mode switch + { + FlowJoinMode.WaitAll => await MaybeScheduleWaitAllActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), + FlowJoinMode.WaitAllActive => await MaybeScheduleWaitAllActiveActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback), + FlowJoinMode.WaitAny => await MaybeScheduleWaitAnyActivityAsync(flowGraph, flowScope, flowchartContext, outboundConnection, outboundActivity, completionCallback), + _ => throw new($"Unsupported FlowJoinMode: {mode}"), + }; + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAll behavior. + /// If all inbound connections were visited, it checks if they were all followed to decide whether to schedule or skip the activity. + /// + private static async ValueTask MaybeScheduleWaitAllActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // Not all inbound connections have been visited yet; do not schedule anything yet. + return false; + + if (flowScope.AllInboundConnectionsFollowed(flowGraph, outboundActivity)) + // All inbound connections were followed; schedule the outbound activity. + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + else + // No inbound connections were followed; skip the outbound activity. + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAllActive behavior. + /// If all inbound connections have been visited, it checks if any were followed to decide whether to schedule or skip the activity. + /// + private static async ValueTask MaybeScheduleWaitAllActiveActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (!flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // Not all inbound connections have been visited yet; do not schedule anything yet. + return false; + + if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) + // At least one inbound connection was followed; schedule the outbound activity. + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + else + // No inbound connections were followed; skip the outbound activity. + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + } + + /// + /// Determines whether to schedule an activity based on the FlowJoinMode.WaitAny behavior. + /// If any inbound connection has been followed, it schedules the activity and cancels remaining inbound activities. + /// If a subsequent inbound connection is followed after the activity has been scheduled, it ignores it. + /// + private static async ValueTask MaybeScheduleWaitAnyActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, Connection outboundConnection, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + if (flowScope.ShouldIgnoreConnection(outboundConnection, outboundActivity)) + // Ignore the connection if the outbound activity has already completed (JoinAny scenario) + return false; + + if (flowchartContext.WorkflowExecutionContext.Scheduler.List().Any(workItem => workItem.Owner == flowchartContext && workItem.Activity == outboundActivity)) + // Ignore the connection if the outbound activity is already scheduled + return false; + + if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) + { + // An inbound connection has been followed; cancel remaining inbound activities + await CancelRemainingInboundActivitiesAsync(flowchartContext, outboundActivity); + + // This is the first inbound connection followed; schedule the outbound activity + return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + } + + if (flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) + // All inbound connections have been visited without any being followed; skip the outbound activity + return await SkipOutboundActivityAsync(flowGraph, flowScope, flowchartContext, outboundActivity, completionCallback); + + // No inbound connections have been followed yet; do not schedule anything yet. + return false; + } + + /// + /// Schedules the outbound activity. + /// + private static async ValueTask ScheduleOutboundActivityAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + await flowchartContext.ScheduleActivityAsync(outboundActivity, completionCallback); + return true; + } + + /// + /// Skips the outbound activity by propagating skipped connections. + /// + private static async ValueTask SkipOutboundActivityAsync(FlowGraph flowGraph, FlowScope flowScope, ActivityExecutionContext flowchartContext, IActivity outboundActivity, ActivityCompletionCallback completionCallback) + { + return await MaybeScheduleOutboundActivitiesAsync(flowGraph, flowScope, flowchartContext, outboundActivity, Outcomes.Empty, completionCallback); + } + + private static async ValueTask CancelRemainingInboundActivitiesAsync(ActivityExecutionContext flowchartContext, IActivity outboundActivity) + { + var flowchart = (Flowchart)flowchartContext.Activity; + var flowGraph = flowchart.GetFlowGraph(flowchartContext); + var ancestorActivities = flowGraph.GetAncestorActivities(outboundActivity); + var inboundActivityExecutionContexts = flowchartContext.WorkflowExecutionContext.ActivityExecutionContexts.Where(x => ancestorActivities.Contains(x.Activity) && x.ParentActivityExecutionContext == flowchartContext).ToList(); + + // Cancel each ancestor activity. + foreach (var activityExecutionContext in inboundActivityExecutionContexts) + { + await activityExecutionContext.CancelActivityAsync(); + } + } + + private async Task CompleteIfNoPendingWorkAsync(ActivityExecutionContext context) + { + var hasPendingWork = HasPendingWork(context); + + if (!hasPendingWork) + { + var hasFaultedActivities = context.Children.Any(x => x.Status == ActivityStatus.Faulted); + + if (!hasFaultedActivities) + { + await context.CompleteActivityAsync(); + } + } + } + + private async ValueTask OnScheduleOutcomesAsync(ScheduleActivityOutcomes signal, SignalContext context) + { + var flowchartContext = context.ReceiverActivityExecutionContext; + var schedulingActivityContext = context.SenderActivityExecutionContext; + var schedulingActivity = schedulingActivityContext.Activity; + var outcomes = new Outcomes(signal.Outcomes); + + await ProcessChildCompletedAsync(flowchartContext, schedulingActivity, schedulingActivityContext, outcomes); + } + +// END 3.5 + private async ValueTask OnScheduleChildActivityAsync(ScheduleChildActivity signal, SignalContext context) { var flowchartContext = context.ReceiverActivityExecutionContext; diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs index cd23b523b..7372af7c8 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowJoinMode.cs @@ -3,5 +3,6 @@ namespace Elsa.Workflows.Activities.Flowchart.Models; public enum FlowJoinMode { WaitAll, - WaitAny + WaitAllActive, + WaitAny, } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs index 742fd3222..8d0155fc1 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Models/FlowScope.cs @@ -86,7 +86,7 @@ public class FlowScope /// The flow graph containing connections. /// The activity to check. /// True if any inbound connection has been followed, otherwise false. - public bool HasFollowedInboundConnection(FlowGraph flowGraph, IActivity activity) + public bool AnyInboundConnectionsFollowed(FlowGraph flowGraph, IActivity activity) { var forwardInboundConnections = flowGraph.GetForwardInboundConnections(activity); var outboundActivityVisitCount = GetActivityVisitCount(activity); @@ -95,6 +95,21 @@ public class FlowScope && forwardInboundConnections.Any(c => GetConnectionVisitCount(c) == maxConnectionVisitCount && GetConnectionLastVisitFollowed(c)); } + /// + /// Determines whether all inbound connection to the specified activity has been followed. + /// + /// The flow graph containing connections. + /// The activity to check. + /// True if all inbound connection has been followed, otherwise false. + public bool AllInboundConnectionsFollowed(FlowGraph flowGraph, IActivity activity) + { + var forwardInboundConnections = flowGraph.GetForwardInboundConnections(activity); + var outboundActivityVisitCount = GetActivityVisitCount(activity); + var maxConnectionVisitCount = forwardInboundConnections.Max(GetConnectionVisitCount); + return maxConnectionVisitCount > outboundActivityVisitCount + && forwardInboundConnections.All(c => GetConnectionVisitCount(c) == maxConnectionVisitCount && GetConnectionLastVisitFollowed(c)); + } + /// /// Determines whether a connection should be ignored based on visit counts. /// diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index 0b2f642ad..a1bb1883d 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -371,13 +371,15 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable /// The payloads to create bookmarks for. /// An optional callback that is invoked when the bookmark is resumed. /// Whether or not the activity instance ID should be included in the bookmark payload. - public void CreateBookmarks(IEnumerable payloads, ExecuteActivityDelegate? callback = null, bool includeActivityInstanceId = true) + /// An optional name to use for the bookmark. Defaults to the activity type. + public void CreateBookmarks(IEnumerable payloads, ExecuteActivityDelegate? callback = null, bool includeActivityInstanceId = true, string? bookmarkName = null) { foreach (var payload in payloads) CreateBookmark(new() { Stimulus = payload, Callback = callback, + BookmarkName = bookmarkName, IncludeActivityInstanceId = includeActivityInstanceId }); } diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs index b3f149017..72f50335a 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs @@ -8,9 +8,7 @@ using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; -using Elsa.Workflows.Options; using Humanizer; -using Microsoft.Extensions.Options; // ReSharper disable once CheckNamespace namespace Elsa.Extensions; @@ -74,20 +72,40 @@ public static class ExpressionExecutionContextExtensions /// public bool TryGetWorkflowExecutionContext(out WorkflowExecutionContext workflowExecutionContext) => context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out workflowExecutionContext!); - /// - /// Returns the of the specified - /// - public WorkflowExecutionContext GetWorkflowExecutionContext() => (WorkflowExecutionContext)context.TransientProperties[WorkflowExecutionContextKey]; + /// + /// Returns the of the specified + /// + public static WorkflowExecutionContext GetWorkflowExecutionContext(this ExpressionExecutionContext context) + { + return context.TransientProperties.TryGetValue(WorkflowExecutionContextKey, out var value) + ? (WorkflowExecutionContext)value + : throw new InvalidOperationException("WorkflowExecutionContext not found. This value exists only on activity execution contexts."); + } - /// - /// Returns the of the specified - /// - public ActivityExecutionContext GetActivityExecutionContext() => (ActivityExecutionContext)context.TransientProperties[ActivityExecutionContextKey]; + /// + /// Returns the of the specified + /// + public static ActivityExecutionContext GetActivityExecutionContext(this ExpressionExecutionContext context) + { + return context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out var value) + ? (ActivityExecutionContext)value + : throw new InvalidOperationException("ActivityExecutionContext not found. This value exists only on activity execution contexts."); + } - /// - /// Returns the of the specified - /// - public bool TryGetActivityExecutionContext(out ActivityExecutionContext activityExecutionContext) => context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out activityExecutionContext!); + /// + /// Returns the of the specified + /// + public static bool TryGetActivityExecutionContext(this ExpressionExecutionContext context, out ActivityExecutionContext activityExecutionContext) => context.TransientProperties.TryGetValue(ActivityExecutionContextKey, out activityExecutionContext!); + + /// + /// Returns the of the specified + /// + public static IActivity GetActivity(this ExpressionExecutionContext context) + { + return context.TransientProperties.TryGetValue(ActivityKey, out var value) + ? (IActivity)value + : throw new InvalidOperationException("Activity not found. This value exists only on activity execution contexts."); + } /// /// Returns the of the specified diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 501a67276..5835d852a 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -118,6 +118,14 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) /// public Func DispatchWorkflowCommandHandler { get; set; } = sp => sp.GetRequiredService(); + /// + /// A factory that instantiates an . + /// + public Func WorkflowResumer { get; set; } = sp => sp.GetRequiredService(); + + /// + /// A factory that instantiates an . + /// public Func BookmarkQueueWorker { get; set; } = sp => sp.GetRequiredService(); /// @@ -249,7 +257,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddSingleton(BackgroundActivityScheduler) .AddSingleton() .AddSingleton() - .AddScoped() + .AddScoped(BookmarkQueueWorker) .AddScoped() .AddScoped() .AddScoped() @@ -275,7 +283,10 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) .AddScoped() .AddScoped() .AddScoped() - .AddScoped() + .AddScoped(WorkflowResumer) + .AddScoped() + .AddScoped(BookmarkQueueWorker) + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs new file mode 100644 index 000000000..a46e1aa4b --- /dev/null +++ b/test/integration/Elsa.JavaScript.IntegrationTests/GuidTests.cs @@ -0,0 +1,33 @@ +using Elsa.Expressions.Models; +using Elsa.JavaScript.Contracts; +using Elsa.Testing.Shared; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Elsa.JavaScript.IntegrationTests; + +public class GuidTests +{ + private readonly IJavaScriptEvaluator _evaluator; + private readonly IServiceProvider _serviceProvider; + + public GuidTests(ITestOutputHelper testOutputHelper) + { + _serviceProvider = new TestApplicationBuilder(testOutputHelper).Build(); + _evaluator = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task NewGuidReturnsGuid() + { + //Setup + var script = "newGuid()"; + var expressionExecutionContext = new ExpressionExecutionContext(_serviceProvider, new()); + + //Act + var result = (Guid)(await _evaluator.EvaluateAsync(script, typeof(Guid), expressionExecutionContext))!; + + //Assert + Assert.IsType(result); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs index dd25a4f73..5ddb29eb3 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FlowchartNextActivity/Tests.cs @@ -198,8 +198,9 @@ public class FlowchartNextActivityTests [Theory(DisplayName = "Flowchart with a Join activity executed multiple times")] [InlineData(FlowJoinMode.WaitAll)] + [InlineData(FlowJoinMode.WaitAllActive)] [InlineData(FlowJoinMode.WaitAny)] - public async Task WaitAnyLoopTest(FlowJoinMode joinMode) + public async Task JoinLoopTest(FlowJoinMode joinMode) { var workflow = new TestWorkflow(workflowBuilder => { @@ -272,8 +273,9 @@ public class FlowchartNextActivityTests [Theory(DisplayName = "Flowchart with a Join activity executed multiple times, bug 6479")] [InlineData(FlowJoinMode.WaitAll)] + [InlineData(FlowJoinMode.WaitAllActive)] [InlineData(FlowJoinMode.WaitAny)] - public async Task WaitLoopBug6479Test(FlowJoinMode joinMode) + public async Task JoinLoopBug6479Test(FlowJoinMode joinMode) { var workflow = new TestWorkflow(workflowBuilder => { @@ -339,4 +341,87 @@ public class FlowchartNextActivityTests "A", "A", "A", "B" }, lines); } + + [Theory(DisplayName = "Flowchart Join behaves correctly")] + [InlineData(false, FlowJoinMode.WaitAll, new[] { "A", "B", "C", "D", "F" })] // "E" is not scheduled because join has an unfollowed inbound connection + [InlineData(false, FlowJoinMode.WaitAllActive, new[] { "A", "B", "C", "D", "E", "F" })] // "E" gets scheduled by join with an unfollowed inbound connection + [InlineData(false, FlowJoinMode.WaitAny, new[] { "A", "B", "C", "D", "E", "F" })] // "E" only scheduled once + [InlineData(true, FlowJoinMode.WaitAll, new[] { "A", "B", "C", "E", "F" })] // all Join inbound connections followed, "E" gets scheduled + [InlineData(true, FlowJoinMode.WaitAllActive, new[] { "A", "B", "C", "E", "F" })] // all Join inbound connections followed, "E" gets scheduled + [InlineData(true, FlowJoinMode.WaitAny, new[] { "A", "B", "C", "E", "F" })] // "E" only scheduled once + // Start + // / | \ + // / | \ + // A B C + // / | | + // | | | + // Decision | | + // / \ | | + // | (true) | / + // | \ | / + // (false) \ | / + // | Join + // D | + // \ E + // \ / + // \ / + // \ / + // F + public async Task JoinBehavesCorrectly(bool decisionResult, FlowJoinMode joinMode, string[] expectedLines) + { + var workflow = new TestWorkflow(workflowBuilder => + { + var start = new Start() { Id = "Start" }; + var a = new WriteLine("A") { Id = "WriteLineA" }; + var b = new WriteLine("B") { Id = "WriteLineB" }; + var c = new WriteLine("C") { Id = "WriteLineC" }; + var decision = new FlowDecision() + { + Condition = new(new Literal(decisionResult)) + }; + var d = new WriteLine("D") { Id = "WriteLineD" }; + var join = new FlowJoin() + { + Mode = new(joinMode) + }; + var e = new WriteLine("E") { Id = "WriteLineE" }; + var f = new WriteLine("F") { Id = "WriteLineF" }; + + workflowBuilder.Root = new Flowchart + { + Activities = + { + start, + a, + b, + c, + decision, + d, + join, + e, + f, + }, + Connections = + { + new(start, a), + new(start, b), + new(start, c), + new(a, decision), + new(new Endpoint(decision, "True"), new Endpoint(join)), + new(new Endpoint(decision, "False"), new Endpoint(d)), + new(b, join), + new(c, join), + new(d, f), + new(join, e), + new(e, f), + } + }; + }); + + await _services.PopulateRegistriesAsync(); + var result = await _workflowRunner.RunAsync(workflow); + var lines = _capturingTextWriter.Lines.ToList(); + Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); + Assert.Equal(expectedLines, lines); + } } \ No newline at end of file