Merge remote-tracking branch 'origin/develop/3.5.0' into develop/3.6.0

This commit is contained in:
Sipke Schoorstra 2025-08-01 22:09:48 +02:00
commit 30139dedc6
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
20 changed files with 416 additions and 70 deletions

View file

@ -0,0 +1,49 @@
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Activities.Flowchart.Attributes;
using Elsa.Workflows.Attributes;
namespace Elsa.Server.Web.Activities;
[Activity("Elsa", "Authorization", "Authorizes a flow based on the configured policies.")]
[FlowNode("Authorized", "Unauthorized", "Error")]
public class AuthorizeFlow : Activity<string>
{
protected override ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var httpContext = context.GetRequiredService<IHttpContextAccessor>().HttpContext;
if (httpContext == null)
throw new InvalidOperationException("HttpContext is not available. Ensure that the activity is executed within an HTTP request context.");
var bookmark = context.CreateBookmark(new AuthorizeStimulus(), OnResumeAsync);
var redirectUrl = context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmark.Id);
Result.Set(context, redirectUrl);
return ValueTask.CompletedTask;
}
private async ValueTask OnResumeAsync(ActivityExecutionContext context)
{
if (!context.TryGetWorkflowInput<string>("Answer", out var response))
{
await context.CompleteActivityWithOutcomesAsync("Unauthorized");
return;
}
switch (response)
{
case "Authorized":
await context.CompleteActivityWithOutcomesAsync("Authorized");
return;
case "Error":
await context.CompleteActivityWithOutcomesAsync("Error");
return;
default:
await context.CompleteActivityWithOutcomesAsync("Unauthorized");
break;
}
}
}
public record AuthorizeStimulus;

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>

View file

@ -0,0 +1,79 @@
using Elsa.Expressions.Models;
using Elsa.Http;
using Elsa.SasTokens.Contracts;
using Elsa.Workflows;
using Elsa.Workflows.Api;
using Elsa.Workflows.Runtime;
using Microsoft.Extensions.Options;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
/// Provides extension methods for working with <see cref="ExpressionExecutionContext"/> and generating bookmark trigger URLs.
/// </summary>
public static class BookmarkExecutionContextExtensions
{
public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId, TimeSpan lifetime) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, lifetime);
public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId, DateTimeOffset expiresAt) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, expiresAt);
public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId);
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="context">The expression execution context.</param>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <param name="lifetime">The lifetime of the bookmark trigger token.</param>
/// <returns>A URL that can be used to resume a bookmarked workflow.</returns>
public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, TimeSpan lifetime)
{
var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, lifetime);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="context">The expression execution context.</param>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <param name="expiresAt">The expiration date of the bookmark trigger token.</param>
/// <returns>A URL that can be used to resume a bookmarked workflow.</returns>
public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, DateTimeOffset expiresAt)
{
var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, expiresAt: expiresAt);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="context">The expression execution context.</param>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <returns>A URL that can be used to trigger an event.</returns>
public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId)
{
var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
private static string GenerateBookmarkTriggerUrlInternal(this ExpressionExecutionContext context, string token)
{
var options = context.GetRequiredService<IOptions<ApiEndpointOptions>>().Value;
var url = $"{options.RoutePrefix}/bookmarks/resume?t={token}";
var absoluteUrlProvider = context.GetRequiredService<IAbsoluteUrlProvider>();
return absoluteUrlProvider.ToAbsoluteUrl(url).ToString();
}
private static string GenerateBookmarkTriggerTokenInternal(this ExpressionExecutionContext context, string bookmarkId, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null)
{
var workflowInstanceId = context.GetWorkflowExecutionContext().Id;
var payload = new BookmarkTokenPayload(bookmarkId, workflowInstanceId);
var tokenService = context.GetRequiredService<ITokenService>();
return lifetime != null
? tokenService.CreateToken(payload, lifetime.Value)
: expiresAt != null
? tokenService.CreateToken(payload, expiresAt.Value)
: tokenService.CreateToken(payload);
}
}

View file

@ -1,16 +1,17 @@
using Elsa.Expressions.Models;
using Elsa.Http;
using Elsa.Http.Options;
using Elsa.SasTokens.Contracts;
using Elsa.Workflows.Api;
using Elsa.Workflows.Runtime;
using Microsoft.Extensions.Options;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
/// <summary>
///
/// Provides extension methods for working with <see cref="ExpressionExecutionContext"/>.
/// </summary>
public static class ExpressionExecutionContextExtensions
public static class EventExpressionExecutionContextExtensions
{
/// <summary>
/// Generates a URL that can be used to trigger an event.
@ -52,13 +53,13 @@ public static class ExpressionExecutionContextExtensions
private static string GenerateEventTriggerUrlInternal(this ExpressionExecutionContext context, string token)
{
var options = context.GetRequiredService<IOptions<HttpActivityOptions>>().Value;
var url = $"{options.ApiRoutePrefix}/events/trigger?t={token}";
var options = context.GetRequiredService<IOptions<ApiEndpointOptions>>().Value;
var url = $"{options.RoutePrefix}/events/trigger?t={token}";
var absoluteUrlProvider = context.GetRequiredService<IAbsoluteUrlProvider>();
return absoluteUrlProvider.ToAbsoluteUrl(url).ToString();
}
private static string GenerateEventTriggerTokenInternal(this ExpressionExecutionContext context, string eventName, TimeSpan? lifetime = default, DateTimeOffset? expiresAt = default)
private static string GenerateEventTriggerTokenInternal(this ExpressionExecutionContext context, string eventName, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null)
{
var workflowInstanceId = context.GetWorkflowExecutionContext().Id;
var payload = new EventTokenPayload(eventName, workflowInstanceId);

View file

@ -15,7 +15,7 @@ public class HttpActivityOptions
/// <summary>
/// The base URL of the server. This should be set to the same value at which the Elsa Server is publicly available. It will be used when generating absolute URLs need to be generated by activities such as SendEmail.
/// </summary>
public Uri BaseUrl { get; set; } = default!;
public Uri BaseUrl { get; set; } = null!;
/// <summary>
/// The prefix used for API routes.

View file

@ -20,6 +20,9 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
private readonly ICronParser _cronParser;
private readonly IServiceScopeFactory _scopeFactory;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly SemaphoreSlim _executionSemaphore = new(1, 1);
private bool _executing;
private bool _cancellationRequested;
/// <summary>
/// Initializes a new instance of <see cref="ScheduledCronTask"/>.
@ -32,7 +35,7 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
_scopeFactory = scopeFactory;
_systemClock = systemClock;
_logger = logger;
_cancellationTokenSource = new CancellationTokenSource();
_cancellationTokenSource = new();
Schedule();
}
@ -41,6 +44,13 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
public void Cancel()
{
_timer?.Dispose();
if (_executing)
{
_cancellationRequested = true;
return;
}
_cancellationTokenSource.Cancel();
}
@ -54,7 +64,7 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
var nextOccurence = _cronParser.GetNextOccurrence(_cronExpression);
var delay = nextOccurence - now;
if (!adjusted && delay.Milliseconds <= 0)
if (!adjusted && delay <= TimeSpan.Zero)
{
adjusted = true;
continue;
@ -67,7 +77,7 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
private void TrySetupTimer(TimeSpan delay)
{
if (delay.Milliseconds <= 0)
if (delay <= TimeSpan.Zero)
return;
try
@ -82,7 +92,10 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
private void SetupTimer(TimeSpan delay)
{
_timer = new Timer(delay.TotalMilliseconds) { Enabled = true };
_timer = new(delay.TotalMilliseconds)
{
Enabled = true
};
_timer.Elapsed += async (_, _) =>
{
@ -93,8 +106,36 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
var commandSender = scope.ServiceProvider.GetRequiredService<ICommandSender>();
var cancellationToken = _cancellationTokenSource.Token;
if (!cancellationToken.IsCancellationRequested) await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken);
if (!cancellationToken.IsCancellationRequested) Schedule();
if (!cancellationToken.IsCancellationRequested)
{
try
{
var 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)
{
_logger.LogError(e, "Error executing scheduled task");
}
finally
{
_executing = false;
_executionSemaphore.Release();
}
}
if (!cancellationToken.IsCancellationRequested)
Schedule();
};
}
@ -102,5 +143,6 @@ public class ScheduledCronTask : IScheduledTask, IDisposable
{
_timer?.Dispose();
_cancellationTokenSource.Dispose();
_executionSemaphore.Dispose();
}
}

View file

@ -2,6 +2,7 @@ using Elsa.Common;
using Elsa.Mediator.Contracts;
using Elsa.Scheduling.Commands;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace Elsa.Scheduling.ScheduledTasks;
@ -14,24 +15,24 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
private readonly ITask _task;
private readonly ISystemClock _systemClock;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ScheduledRecurringTask> _logger;
private readonly TimeSpan _interval;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly SemaphoreSlim _executionSemaphore = new(1, 1);
private DateTimeOffset _startAt;
private Timer? _timer;
private bool _executing;
private bool _cancellationRequested;
/// <summary>
/// Initializes a new instance of <see cref="ScheduledRecurringTask"/>.
/// </summary>
/// <param name="task">The task to execute.</param>
/// <param name="startAt">The instant at which to start executing the task.</param>
/// <param name="interval">The interval at which to execute the task.</param>
/// <param name="systemClock">The system clock.</param>
/// <param name="scopeFactory">Scope factory to create the scope and get dependancies.</param>
public ScheduledRecurringTask(ITask task, DateTimeOffset startAt, TimeSpan interval, ISystemClock systemClock, IServiceScopeFactory scopeFactory)
public ScheduledRecurringTask(ITask task, DateTimeOffset startAt, TimeSpan interval, ISystemClock systemClock, IServiceScopeFactory scopeFactory, ILogger<ScheduledRecurringTask> logger)
{
_task = task;
_systemClock = systemClock;
_scopeFactory = scopeFactory;
_logger = logger;
_startAt = startAt;
_interval = interval;
_cancellationTokenSource = new();
@ -43,6 +44,13 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
public void Cancel()
{
_timer?.Dispose();
if (_executing)
{
_cancellationRequested = true;
return;
}
_cancellationTokenSource.Cancel();
}
@ -56,7 +64,7 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
var now = _systemClock.UtcNow;
var delay = startAt - now;
if (!adjusted && delay.Milliseconds <= 0)
if (!adjusted && delay <= TimeSpan.Zero)
{
adjusted = true;
continue;
@ -71,7 +79,10 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
{
if (delay < TimeSpan.Zero) delay = TimeSpan.FromSeconds(1);
_timer = new(delay.TotalMilliseconds) { Enabled = true };
_timer = new(delay.TotalMilliseconds)
{
Enabled = true
};
_timer.Elapsed += async (_, _) =>
{
@ -81,10 +92,35 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
using var scope = _scopeFactory.CreateScope();
var commandSender = scope.ServiceProvider.GetRequiredService<ICommandSender>();
var cancellationToken = _cancellationTokenSource.Token;
if (!cancellationToken.IsCancellationRequested) await commandSender.SendAsync(new RunScheduledTask(_task), cancellationToken);
if (!cancellationToken.IsCancellationRequested) Schedule();
if (!cancellationToken.IsCancellationRequested)
{
try
{
var 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)
{
_logger.LogError(e, "Error executing scheduled task");
}
finally
{
_executing = false;
_executionSemaphore.Release();
}
}
if (!cancellationToken.IsCancellationRequested)
Schedule();
};
}
@ -92,5 +128,6 @@ public class ScheduledRecurringTask : IScheduledTask, IDisposable
{
_cancellationTokenSource.Dispose();
_timer?.Dispose();
_executionSemaphore.Dispose();
}
}
}

View file

@ -18,7 +18,10 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
private readonly ILogger<ScheduledSpecificInstantTask> _logger;
private readonly DateTimeOffset _startAt;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly SemaphoreSlim _executionSemaphore = new(1, 1);
private Timer? _timer;
private bool _executing;
private bool _cancellationRequested;
/// <summary>
/// Initializes a new instance of <see cref="ScheduledSpecificInstantTask"/>.
@ -30,23 +33,37 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
_scopeFactory = scopeFactory;
_logger = logger;
_startAt = startAt;
_cancellationTokenSource = new CancellationTokenSource();
_cancellationTokenSource = new();
Schedule();
}
/// <inheritdoc />
public void Cancel() => _timer?.Dispose();
public void Cancel()
{
_timer?.Dispose();
if (_executing)
{
_cancellationRequested = true;
return;
}
_cancellationTokenSource.Cancel();
}
private void Schedule()
{
var now = _systemClock.UtcNow;
var delay = _startAt - now;
if (delay.Milliseconds <= 0)
if (delay <= TimeSpan.Zero)
delay = TimeSpan.FromMilliseconds(1);
_timer = new Timer(delay.TotalMilliseconds) { Enabled = true };
_timer = new(delay.TotalMilliseconds)
{
Enabled = true
};
_timer.Elapsed += async (_, _) =>
{
@ -55,19 +72,31 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
using var scope = _scopeFactory.CreateScope();
var commandSender = scope.ServiceProvider.GetRequiredService<ICommandSender>();
var cancellationToken = _cancellationTokenSource.Token;
if (!cancellationToken.IsCancellationRequested)
{
try
{
var 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)
{
_logger.LogError(e, "Error scheduled task");
_logger.LogError(e, "Error executing scheduled task");
}
finally
{
_executing = false;
_executionSemaphore.Release();
}
}
};
}
@ -76,5 +105,6 @@ public class ScheduledSpecificInstantTask : IScheduledTask, IDisposable
{
_cancellationTokenSource.Dispose();
_timer?.Dispose();
_executionSemaphore.Dispose();
}
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>

View file

@ -0,0 +1,89 @@
using Elsa.Abstractions;
using Elsa.SasTokens.Contracts;
using Elsa.Workflows.Runtime;
using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
namespace Elsa.Workflows.Api.Endpoints.Bookmarks.Resume;
/// <summary>
/// Resumes a bookmarked workflow instance with the bookmark ID specified in the provided SAS token.
/// </summary>
[PublicAPI]
internal class Resume(ITokenService tokenService, IBookmarkQueue bookmarkQueue, IPayloadSerializer serializer) : ElsaEndpoint<Request>
{
/// <inheritdoc />
public override void Configure()
{
Routes("/bookmarks/resume");
Verbs(Http.GET, Http.POST);
AllowAnonymous();
}
/// <inheritdoc />
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
var token = Query<string>("t")!;
if (!tokenService.TryDecryptToken<BookmarkTokenPayload>(token, out var payload))
AddError("Invalid token.");
var input = HttpContext.Request.Method == HttpMethods.Post ? request.Input : GetInputFromQueryString();
if (ValidationFailed)
{
await SendErrorsAsync(cancellation: cancellationToken);
return;
}
await ResumeBookmarkedWorkflowAsync(payload, input, cancellationToken);
if (!HttpContext.Response.HasStarted)
await SendOkAsync(cancellationToken);
}
private IDictionary<string, object>? GetInputFromQueryString()
{
var inputJson = Query<string?>("in", false);
if (string.IsNullOrWhiteSpace(inputJson))
return null;
try
{
return serializer.Deserialize<IDictionary<string, object>>(inputJson);
}
catch
{
AddError("Invalid input format. Expected a valid JSON string.");
return null;
}
}
private async Task ResumeBookmarkedWorkflowAsync(BookmarkTokenPayload tokenPayload, IDictionary<string, object>? input, CancellationToken cancellationToken)
{
var bookmarkId = tokenPayload.BookmarkId;
var workflowInstanceId = tokenPayload.WorkflowInstanceId;
var item = new NewBookmarkQueueItem
{
BookmarkId = bookmarkId,
WorkflowInstanceId = workflowInstanceId,
Options = new()
{
Input = input
}
};
await bookmarkQueue.EnqueueAsync(item, cancellationToken);
}
}
/// <summary>
/// The request model for the Resume endpoint.
/// </summary>
internal class Request
{
/// <summary>
/// The input to provide to the workflow when resuming.
/// </summary>
public IDictionary<string, object>? Input { get; set; }
}

View file

@ -1,5 +1,4 @@
using Elsa.Abstractions;
using Elsa.Http;
using Elsa.SasTokens.Contracts;
using Elsa.Workflows.Runtime;
using JetBrains.Annotations;

View file

@ -2,7 +2,6 @@ using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Http.Features;
using Elsa.SasTokens.Features;
using Elsa.Workflows.Api.Constants;
using Elsa.Workflows.Api.Requirements;
@ -21,15 +20,9 @@ namespace Elsa.Workflows.Api.Features;
[DependsOn(typeof(WorkflowInstancesFeature))]
[DependsOn(typeof(WorkflowManagementFeature))]
[DependsOn(typeof(WorkflowRuntimeFeature))]
[DependsOn(typeof(HttpFeature))]
[DependsOn(typeof(SasTokensFeature))]
public class WorkflowsApiFeature : FeatureBase
public class WorkflowsApiFeature(IModule module) : FeatureBase(module)
{
/// <inheritdoc />
public WorkflowsApiFeature(IModule module) : base(module)
{
}
/// <inheritdoc />
public override void Configure()
{
@ -43,7 +36,6 @@ public class WorkflowsApiFeature : FeatureBase
Module.AddFastEndpointsFromModule();
Services.AddScoped<IWorkflowDefinitionLinker, StaticWorkflowDefinitionLinker>();
Services.AddScoped<IAuthorizationHandler, NotReadOnlyRequirementHandler>();
Services.Configure<AuthorizationOptions>(options =>
{

View file

@ -4,8 +4,8 @@ namespace Elsa.Workflows.Activities.Flowchart.Models;
/// Represents a list of outcomes that can be send when completing an activity. This information is used by <see cref="Activities.Flowchart"/>.
/// </summary>
/// <param name="Names">A list of outcome names.</param>
public record Outcomes(params string[] Names)
{
public static readonly Outcomes Default = new([null!, "Done"]);
public static readonly Outcomes Empty = new();
}
public record Outcomes(params string[] Names)
{
public static readonly Outcomes Default = new(null!, "Done");
public static readonly Outcomes Empty = new();
}

View file

@ -2,11 +2,11 @@ namespace Elsa.Workflows;
public class DefaultWorkflowInstanceVariableReader(IVariablePersistenceManager variablePersistenceManager) : IWorkflowInstanceVariableReader
{
public async Task<IEnumerable<ResolvedVariable>> GetVariables(WorkflowExecutionContext workflowExecutionContext, IEnumerable<string>? excludeTags = default, CancellationToken cancellationToken = default)
public async Task<IEnumerable<ResolvedVariable>> GetVariables(WorkflowExecutionContext workflowExecutionContext, IEnumerable<string>? excludeTags = null, CancellationToken cancellationToken = default)
{
var workflow = workflowExecutionContext.Workflow;
var workflowVariables = workflow.Variables;
var rootWorkflowActivityExecutionContext = workflowExecutionContext.ActivityExecutionContexts.FirstOrDefault(x => x.ParentActivityExecutionContext == null);
var rootWorkflowActivityExecutionContext = workflowExecutionContext.ActivityExecutionContexts.FirstOrDefault(x => x.Activity == workflow);
if (rootWorkflowActivityExecutionContext == null)
return [];
@ -17,7 +17,7 @@ public class DefaultWorkflowInstanceVariableReader(IVariablePersistenceManager v
foreach (var workflowVariable in workflowVariables)
{
var value = workflowVariable.Get(rootWorkflowActivityExecutionContext.ExpressionExecutionContext);
resolvedVariables.Add(new ResolvedVariable(workflowVariable, value));
resolvedVariables.Add(new(workflowVariable, value));
}
return resolvedVariables;

View file

@ -2,11 +2,12 @@ using Elsa.Extensions;
using Elsa.Workflows.Models;
using Elsa.Workflows.Services;
using Elsa.Workflows.State;
using Microsoft.Extensions.Logging;
namespace Elsa.Workflows;
/// <inheritdoc />
public class WorkflowStateExtractor : IWorkflowStateExtractor
public class WorkflowStateExtractor(ILogger<WorkflowStateExtractor> logger) : IWorkflowStateExtractor
{
/// <inheritdoc />
public WorkflowState Extract(WorkflowExecutionContext workflowExecutionContext)
@ -101,7 +102,7 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor
workflowExecutionContext.Properties[property.Key] = property.Value;
}
private static async Task ApplyActivityExecutionContextsAsync(WorkflowState state, WorkflowExecutionContext workflowExecutionContext)
private async Task ApplyActivityExecutionContextsAsync(WorkflowState state, WorkflowExecutionContext workflowExecutionContext)
{
var activityExecutionContexts = (await Task.WhenAll(state.ActivityExecutionContexts.Select(async item => await CreateActivityExecutionContextAsync(item)))).Where(x => x != null).Select(x => x!).ToList();
@ -110,10 +111,14 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor
// Reconstruct hierarchy.
foreach (var contextState in state.ActivityExecutionContexts.Where(x => !string.IsNullOrWhiteSpace(x.ParentContextId)))
{
if (lookup.ContainsKey(contextState.ParentContextId))
var parentContextId = contextState.ParentContextId;
if (parentContextId == null || !lookup.TryGetValue(parentContextId, out var parentContext))
{
var parentContext = lookup[contextState.ParentContextId!];
var contextId = contextState.Id;
logger.LogWarning("Parent context with ID '{ParentContextId}' not found for context with ID '{ContextId}'.", parentContextId, contextState.Id);
continue; // Skip if parent context is not found.
}
var contextId = contextState.Id;
if (lookup.TryGetValue(contextId, out var context))
{

View file

@ -16,17 +16,26 @@ public static class ActivityExecutionContextRecordExtensions
context.TransientProperties[ActivityExecutionRecordKey] = record;
}
public static ActivityExecutionRecord? GetCapturedActivityExecutionRecord(this ActivityExecutionContext context)
{
return context.TransientProperties.TryGetValue(ActivityExecutionRecordKey, out var record) ? (ActivityExecutionRecord?)record : null;
}
public static async Task<ActivityExecutionRecord> GetOrMapCapturedActivityExecutionRecordAsync(this ActivityExecutionContext context)
{
if(context.TransientProperties.TryGetValue(ActivityExecutionRecordKey, out var record))
return (ActivityExecutionRecord)record;
var mapper = context.GetRequiredService<IActivityExecutionMapper>();
return await mapper.MapAsync(context);
var record = await mapper.MapAsync(context);
if (context.TransientProperties.TryGetValue(ActivityExecutionRecordKey, out var capturedRecord))
{
var serializedSnapshot = ((ActivityExecutionRecord)capturedRecord).SerializedSnapshot!;
// Take the existing serialized snapshot.
record.SerializedSnapshot = serializedSnapshot;
// Update the serialized snapshot with the current record's properties.
// This will reflect the latest state of the activity execution context without losing the existing serialized snapshot representing e.g., variable values at the time of the record capture.
serializedSnapshot.HasBookmarks = record.HasBookmarks;
serializedSnapshot.Status = record.Status;
serializedSnapshot.AggregateFaultCount = record.AggregateFaultCount;
serializedSnapshot.CompletedAt = record.CompletedAt;
}
return record;
}
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Workflows.Runtime;
/// <summary>
/// Represents the payload for a bookmark token, including the bookmark identifier and the associated workflow instance identifier.
/// </summary>
public record BookmarkTokenPayload(string BookmarkId, string WorkflowInstanceId);

View file

@ -1,4 +1,4 @@
namespace Elsa.Http;
namespace Elsa.Workflows.Runtime;
/// <summary>
/// Represents the payload of an event, serialized as a secured token.

View file

@ -1,5 +1,7 @@
using Elsa.Common.Multitenancy;
using Elsa.Mediator;
using Elsa.Mediator.Contracts;
using Elsa.Tenants.Mediator;
using Elsa.Workflows.Runtime.Commands;
using Elsa.Workflows.Runtime.Requests;
using Elsa.Workflows.Runtime.Responses;
@ -9,13 +11,18 @@ namespace Elsa.Workflows.Runtime;
/// <summary>
/// A simple implementation that queues the specified request for delivering stimuli on a non-durable background worker.
/// </summary>
public class BackgroundStimulusDispatcher(ICommandSender commandSender) : IStimulusDispatcher
public class BackgroundStimulusDispatcher(ICommandSender commandSender, ITenantAccessor tenantAccessor) : IStimulusDispatcher
{
/// <inheritdoc />
public async Task<DispatchStimulusResponse> SendAsync(DispatchStimulusRequest request, CancellationToken cancellationToken = default)
{
var command = new DispatchStimulusCommand(request);
await commandSender.SendAsync(command, CommandStrategy.Background, cancellationToken);
await commandSender.SendAsync(command, CommandStrategy.Background, CreateHeaders(), cancellationToken);
return DispatchStimulusResponse.Empty;
}
}
private IDictionary<object, object> CreateHeaders()
{
return TenantHeaders.CreateHeaders(tenantAccessor.Tenant?.Id);
}
}

View file

@ -143,6 +143,7 @@ public class LocalWorkflowClient(
SubStatus = workflowState.SubStatus,
Incidents = workflowState.Incidents,
Output = request.IncludeWorkflowOutput ? new Dictionary<string, object>(workflowState.Output) : null
Bookmarks = workflowState.Bookmarks
};
}