< Summary

Information
Class: Elsa.UserTasks.Services.DefaultUserTaskManager
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/DefaultUserTaskManager.cs
Line coverage
53%
Covered lines: 232
Uncovered lines: 199
Coverable lines: 431
Total lines: 601
Line coverage: 53.8%
Branch coverage
42%
Covered branches: 118
Total branches: 280
Branch coverage: 42.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ProjectAsync()53.33%473073.56%
QueryAsync()100%44100%
GetAsync()100%44100%
GetCapabilitiesAsync()0%2040%
GetEventsAsync()61.11%191885.71%
RevealFieldAsync()85.71%141491.66%
EncodeEventCursor(...)100%210%
DecodeEventCursor(...)100%210%
ClaimAsync(...)50%4490%
ReleaseAsync(...)0%620%
AssignAsync(...)0%156120%
UpdateSchedulingAsync(...)0%210140%
CompleteAsync()63.79%1375871.42%
TimeoutAsync()42.3%462669.04%
CancelAsync()0%506220%
RetryResolutionAsync()0%1190340%
MutateAsync()50%262075.86%
FindOperation(...)100%11100%
HasCandidates(...)100%88100%
HasCandidates(...)0%4260%
MaterializationKey(...)100%11100%
Hash(...)100%11100%
PayloadBytes(...)100%22100%
Failure(...)100%210%
Failure(...)100%11100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/DefaultUserTaskManager.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using System.Text.Json;
 4using Elsa.UserTasks.Contracts;
 5using Elsa.UserTasks.Models;
 6using Elsa.UserTasks.Options;
 7using Elsa.Common;
 8using Elsa.Workflows;
 9
 10namespace Elsa.UserTasks.Services;
 11
 7712public sealed class DefaultUserTaskManager(
 7713    IUserTaskRepository repository,
 7714    IUserTaskAccessPolicy accessPolicy,
 7715    IEnumerable<IUserTaskFormProvider> formProviders,
 7716    IUserTaskWorkflowResumer workflowResumer,
 7717    IUserTaskNotificationSink notificationSink,
 7718    IIdentityGenerator identityGenerator,
 7719    ISystemClock clock,
 7720    Microsoft.Extensions.Options.IOptions<UserTasksOptions> options,
 7721    IUserTaskParticipantDirectory? participantDirectory = null) : IUserTaskManager
 22{
 7723    private readonly IReadOnlyDictionary<string, IUserTaskFormProvider> _formProviders = formProviders
 7924        .ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
 7725    private readonly UserTasksOptions _options = options.Value;
 26
 27    public async Task<UserTaskProjectionResult> ProjectAsync(UserTaskMaterialization materialization, CancellationToken 
 28    {
 4329        var definition = materialization.Definition.Normalize();
 4330        if (PayloadBytes(definition.TaskData) > _options.MaximumPayloadBytes || Encoding.UTF8.GetByteCount(definition.In
 031            throw new ArgumentException("User Task protected payload exceeds the configured limit.");
 4332        var existing = await repository.FindByMaterializationKeyAsync(materialization.TenantId, MaterializationKey(mater
 4333        if (existing != null)
 034            return new UserTaskProjectionResult(existing, false);
 35
 4336        ResolvedUserTaskForm? pinnedForm = null;
 4337        var healthSeverity = (UserTaskHealthSeverity?)null;
 4338        string? healthCode = null;
 4339        string? healthMessage = null;
 4340        var snapshotMembers = materialization.SnapshotMembers.ToList();
 4341        var snapshotGroups = materialization.SnapshotGroups.ToList();
 4342        if (definition.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot)
 43        {
 044            snapshotMembers.AddRange(definition.CandidateUsers);
 045            snapshotGroups.AddRange(definition.CandidateGroups);
 046            if (participantDirectory != null)
 47            {
 048                foreach (var group in definition.CandidateGroups)
 49                {
 50                    try
 51                    {
 052                        snapshotMembers.AddRange(await participantDirectory.EnumerateGroupMembersAsync(group, cancellati
 053                    }
 054                    catch (Exception exception) when (exception is not OperationCanceledException)
 55                    {
 056                        healthSeverity = UserTaskHealthSeverity.Blocking;
 057                        healthCode = "snapshot-resolution-failed";
 058                        healthMessage = "A candidate group could not be resolved for this task.";
 059                    }
 60                }
 61            }
 062            else if (definition.CandidateGroups.Count > 0)
 63            {
 064                healthSeverity = UserTaskHealthSeverity.Blocking;
 065                healthCode = "snapshot-directory-missing";
 066                healthMessage = "A participant directory is required to snapshot candidate groups.";
 67            }
 68        }
 4369        if (definition.FormReference != null)
 70        {
 271            if (!_formProviders.TryGetValue(definition.FormReference.ProviderName, out var provider))
 72            {
 073                healthSeverity = UserTaskHealthSeverity.Blocking;
 074                healthCode = "form-provider-missing";
 075                healthMessage = "The configured form provider is not available.";
 76            }
 77            else
 78            {
 279                pinnedForm = await provider.ResolveAsync(definition.FormReference, cancellationToken);
 280                if (pinnedForm == null)
 81                {
 082                    healthSeverity = UserTaskHealthSeverity.Blocking;
 083                    healthCode = "form-resolution-failed";
 084                    healthMessage = "The configured form could not be resolved.";
 85                }
 86            }
 87        }
 88
 4389        var task = new UserTask
 4390        {
 4391            Id = materialization.TaskId ?? identityGenerator.GenerateId(),
 4392            TenantId = materialization.TenantId,
 4393            WorkflowDefinitionId = materialization.WorkflowDefinitionId,
 4394            WorkflowDefinitionName = materialization.WorkflowDefinitionName,
 4395            WorkflowDefinitionVersion = materialization.WorkflowDefinitionVersion,
 4396            WorkflowInstanceId = materialization.WorkflowInstanceId,
 4397            WorkflowInstanceReference = materialization.WorkflowInstanceReference,
 4398            ActivityInstanceId = materialization.ActivityInstanceId,
 4399            BookmarkId = materialization.BookmarkId,
 43100            MaterializationKey = MaterializationKey(materialization),
 43101            Title = definition.Title,
 43102            Summary = definition.Summary,
 43103            Reference = definition.Reference,
 43104            Tags = new HashSet<string>(definition.Tags, StringComparer.OrdinalIgnoreCase),
 43105            TaskType = definition.TaskType,
 43106            Requester = definition.Requester,
 43107            Assignee = definition.Assignee,
 43108            CandidateUsers = [..definition.CandidateUsers],
 43109            CandidateGroups = [..definition.CandidateGroups],
 43110            SnapshotMembers = [..snapshotMembers.Distinct()],
 43111            SnapshotGroups = [..snapshotGroups.Distinct()],
 43112            ExcludedUsers = [..definition.ExcludedUsers],
 43113            MembershipResolutionMode = definition.MembershipResolutionMode,
 43114            AllowManagerExclusionOverride = definition.AllowManagerExclusionOverride,
 43115            Priority = definition.Priority,
 43116            DueAt = definition.DueAt,
 43117            Instructions = definition.Instructions,
 43118            TaskData = definition.TaskData,
 43119            RequestedForm = definition.FormReference,
 43120            PinnedForm = pinnedForm,
 43121            Actions = [..definition.Actions],
 43122            InvitationDefinitions = [..definition.Invitations],
 43123            EnableTimeoutOutcome = definition.EnableTimeoutOutcome,
 43124            EnableCancellationOutcome = definition.EnableCancellationOutcome,
 43125            Status = definition.Assignee != null ? UserTaskStatus.Assigned : HasCandidates(definition, materialization) 
 43126            HealthSeverity = healthSeverity,
 43127            HealthCode = healthCode,
 43128            HealthMessage = healthMessage,
 43129            CreatedAt = materialization.CreatedAt,
 43130            UpdatedAt = materialization.CreatedAt
 43131        };
 43132        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), task.TenantId, task.Id, 1, "Created", task.Cre
 43133        await repository.AddProjectionAsync(task, cancellationToken);
 43134        var projected = await repository.FindByMaterializationKeyAsync(task.TenantId, task.MaterializationKey, cancellat
 43135        await notificationSink.PublishAsync(new UserTaskCreated(projected.TenantId, projected.Id, projected.Status, proj
 43136        return new UserTaskProjectionResult(projected, true);
 43137    }
 138
 139    public async Task<UserTaskQueryResultDto?> QueryAsync(UserTaskQuery query, UserTaskQueryScopeKind scopeKind, UserTas
 140    {
 141        // A null scope is a denial, not an absent filter. Falling through with the caller's raw query would
 142        // hand an unauthorized actor a tenant-wide page.
 6143        var scope = await accessPolicy.CreateScopeAsync(actor, scopeKind, cancellationToken);
 6144        if (scope == null)
 2145            return null;
 146
 4147        var scopedQuery = query with { TenantId = actor.Subject.TenantId, Scope = scope };
 4148        var result = await repository.QueryAsync(scopedQuery, cancellationToken);
 4149        var summaries = new List<UserTaskSummary>();
 12150        foreach (var item in result.Items)
 2151            summaries.Add(await UserTaskModelMapper.ToSummaryAsync(item, actor, accessPolicy, cancellationToken));
 4152        return new UserTaskQueryResultDto(summaries, result.NextCursor, result.TotalCount);
 6153    }
 154
 155    public async Task<UserTaskDetail?> GetAsync(string tenantId, string taskId, UserTaskActor actor, CancellationToken c
 156    {
 6157        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 6158        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadSummary, cancell
 1159            return null;
 5160        return await UserTaskModelMapper.ToDetailAsync(task, actor, accessPolicy, cancellationToken);
 6161    }
 162
 163    public async Task<UserTaskCapabilities?> GetCapabilitiesAsync(string tenantId, string taskId, UserTaskActor actor, C
 164    {
 0165        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 0166        return task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadSummary, canc
 0167            ? null
 0168            : await UserTaskModelMapper.ToCapabilitiesAsync(task, actor, accessPolicy, cancellationToken);
 0169    }
 170
 171    public async Task<UserTaskEventsResult?> GetEventsAsync(string tenantId, string taskId, string? cursor, int limit, U
 172    {
 3173        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 3174        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadSummary, cancell
 0175            return null;
 176
 177        // Audit history is protected disclosure: a candidate who has never acted on the task sees the safe
 178        // summary but not who claimed, released, or completed it. Guests never see history at all.
 3179        var canReadProtected = await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, can
 3180        if (actor.IsGuest || !canReadProtected)
 2181            return new UserTaskEventsResult([], null);
 182
 183        var ordered = task.Events.OrderBy(x => x.OccurredAt).ThenBy(x => x.Id, StringComparer.Ordinal).ToList();
 1184        var startIndex = 0;
 1185        if (!string.IsNullOrWhiteSpace(cursor))
 186        {
 0187            var index = ordered.FindIndex(x => string.Equals(x.Id, DecodeEventCursor(cursor), StringComparison.Ordinal))
 0188            startIndex = index < 0 ? ordered.Count : index + 1;
 189        }
 190
 1191        var pageSize = Math.Clamp(limit <= 0 ? 50 : limit, 1, 200);
 1192        var page = ordered.Skip(startIndex).Take(pageSize).ToArray();
 1193        var next = startIndex + page.Length < ordered.Count && page.Length > 0 ? EncodeEventCursor(page[^1].Id) : null;
 1194        return new UserTaskEventsResult(page.Select(UserTaskModelMapper.ToEventSummary).ToArray(), next);
 3195    }
 196
 197    public async Task<JsonElement?> RevealFieldAsync(string tenantId, string taskId, string fieldKey, UserTaskActor acto
 198    {
 5199        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 5200        if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cance
 1201            return null;
 202
 203        // Only a field the form provider explicitly marked revealable can be disclosed, and only one at a
 204        // time. Unmarked or unknown keys are indistinguishable from an unauthorized task.
 13205        var descriptor = task.PinnedForm?.Fields.FirstOrDefault(x => string.Equals(x.Key, fieldKey, StringComparison.Ord
 4206        if (descriptor is not { Masked: true, CanReveal: true })
 2207            return null;
 208
 2209        var value = UserTaskModelMapper.ReadFieldValue(task.TaskData, fieldKey);
 2210        if (value == null)
 0211            return null;
 212
 213        // Appended without bumping the revision: a reveal is a read, and consuming the concurrency token
 214        // here would make the caller's next command conflict for no reason.
 2215        await repository.AppendEventAsync(tenantId, taskId, new(identityGenerator.GenerateId(), tenantId, taskId, task.R
 2216            "FieldRevealed", clock.UtcNow, actor.Subject, Metadata: new Dictionary<string, object?> { ["fieldKey"] = fie
 2217        return value;
 5218    }
 219
 0220    private static string EncodeEventCursor(string eventId) => Convert.ToBase64String(Encoding.UTF8.GetBytes(eventId));
 221
 222    private static string? DecodeEventCursor(string cursor)
 223    {
 224        try
 225        {
 0226            return Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
 227        }
 0228        catch (FormatException)
 229        {
 0230            return null;
 231        }
 0232    }
 233
 234    public Task<UserTaskOperationResult> ClaimAsync(string tenantId, string taskId, UserTaskMutationRequest request, Use
 10235        MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.Claim, actor,
 10236            task =>
 10237            {
 7238                if (task.Status is not (UserTaskStatus.Available or UserTaskStatus.Unassigned))
 0239                    return "not-claimable";
 7240                task.Assignee = actor.Subject;
 7241                task.AssignedAt = clock.UtcNow;
 7242                task.Status = UserTaskStatus.Assigned;
 7243                return null;
 10244            }, cancellationToken);
 245
 246    public Task<UserTaskOperationResult> ReleaseAsync(string tenantId, string taskId, UserTaskMutationRequest request, U
 0247        MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.Release, acto
 0248            task =>
 0249            {
 0250                task.Assignee = null;
 0251                task.AssignedAt = null;
 0252                task.Status = HasCandidates(task) ? UserTaskStatus.Available : UserTaskStatus.Unassigned;
 0253                return null;
 0254            }, cancellationToken);
 255
 256    public Task<UserTaskOperationResult> AssignAsync(string tenantId, string taskId, UserTaskAssignRequest request, User
 0257        MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.Assign, actor
 0258            task =>
 0259            {
 0260                if (!string.Equals(request.Assignee.TenantId, tenantId, StringComparison.Ordinal))
 0261                    return "cross-tenant-assignee";
 0262                var excluded = task.ExcludedUsers.Any(x => x.Matches(request.Assignee));
 0263                if (excluded && (!task.AllowManagerExclusionOverride || string.IsNullOrWhiteSpace(request.Reason)))
 0264                    return "excluded-assignee";
 0265                if (task.IsTerminal)
 0266                    return "terminal";
 0267                task.Assignee = request.Assignee;
 0268                task.AssignedAt = clock.UtcNow;
 0269                task.Status = UserTaskStatus.Assigned;
 0270                return null;
 0271            }, cancellationToken, request.Reason, request.Assignee);
 272
 273    public Task<UserTaskOperationResult> UpdateSchedulingAsync(string tenantId, string taskId, UserTaskSchedulingUpdate 
 0274        MutateAsync(tenantId, taskId, request.ExpectedRevision, request.OperationId, UserTaskOperationKind.ScheduleUpdat
 0275            task =>
 0276            {
 0277                if (task.IsTerminal)
 0278                    return "terminal";
 0279                if (request.Priority is < 0 or > 100)
 0280                    return "invalid-priority";
 0281                if (request.Priority.HasValue)
 0282                    task.Priority = request.Priority.Value;
 0283                if (request.DueAt.HasValue)
 0284                    task.DueAt = request.DueAt.Value;
 0285                return null;
 0286            }, cancellationToken, requestValue: new { request.Priority, request.DueAt });
 287
 288    public async Task<UserTaskOperationResult> CompleteAsync(string tenantId, string taskId, UserTaskCompletionRequest r
 289    {
 12290        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 12291        if (task == null)
 0292            return Failure(taskId, tenantId, request.OperationId, UserTaskOperationKind.Complete, "not-found");
 12293        if (!await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.Complete, cancellationToken))
 1294            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "forbidden");
 295        // A guest link is issued for a specific outcome set. Selecting any other configured action â€” even a
 296        // valid one â€” is an authorization failure, not a validation failure.
 11297        if (actor.IsGuest && !actor.GuestAllowedActions.Contains(request.ActionKey))
 1298            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "forbidden");
 299
 10300        if (PayloadBytes(request.Data) > _options.MaximumPayloadBytes)
 0301            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "payload-too-large");
 21302        var action = task.Actions.FirstOrDefault(x => string.Equals(x.Key, request.ActionKey, StringComparison.Ordinal))
 10303        var isTimeout = string.Equals(request.ActionKey, "Timeout", StringComparison.Ordinal);
 10304        var isCancelled = string.Equals(request.ActionKey, "Cancelled", StringComparison.Ordinal);
 10305        var existingOperation = FindOperation(task, request.OperationId);
 10306        if (existingOperation != null && (isTimeout || isCancelled || action == null))
 307        {
 0308            var rawHash = Hash(UserTaskOperationKind.Complete, new { request.ExpectedRevision, request.ActionKey, reques
 0309            return existingOperation.RequestHash == rawHash
 0310                ? new UserTaskOperationResult(task, existingOperation, existingOperation.Status != UserTaskOperationStat
 0311                : Failure(task, request.OperationId, UserTaskOperationKind.Complete, "idempotency-conflict");
 312        }
 10313        if (isTimeout || isCancelled)
 0314            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "reserved-action");
 10315        if (action == null)
 0316            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "invalid-action");
 10317        if (task.RequestedForm == null && request.Data is { } data && data.ValueKind is not JsonValueKind.Null and not J
 0318            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-required");
 10319        if (task.RequestedForm != null && task.PinnedForm == null)
 0320            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-resolution-failed");
 10321        if (task.PinnedForm != null)
 322        {
 1323            if (!_formProviders.TryGetValue(task.PinnedForm.Requested.ProviderName, out var provider))
 0324                return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-provider-missing");
 1325            var normalized = await provider.ValidateAndNormalizeAsync(task.PinnedForm, request.ActionKey, request.Data ?
 1326            if (!normalized.Succeeded)
 0327                return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "form-invalid");
 1328            request = request with { Data = normalized.NormalizedData };
 1329            if (PayloadBytes(request.Data) > _options.MaximumPayloadBytes)
 0330                return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "payload-too-large");
 331        }
 332
 333        // Hash the canonical form data that will actually be persisted. This makes retries compare the
 334        // same request after provider normalization instead of comparing a raw pre-normalization shape.
 10335        var requestHash = Hash(UserTaskOperationKind.Complete, new { request.ExpectedRevision, request.ActionKey, reques
 10336        var existing = FindOperation(task, request.OperationId);
 10337        if (existing != null)
 3338            return existing.RequestHash == requestHash ? new UserTaskOperationResult(task, existing, existing.Status != 
 7339        if (task.Revision != request.ExpectedRevision)
 2340            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "revision-conflict");
 5341        if (task.IsTerminal)
 0342            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "terminal");
 5343        if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling)
 0344            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "transition-in-progress");
 345
 5346        var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, request.OperationId, Use
 5347            request.ExpectedRevision, requestHash, UserTaskOperationStatus.Accepted,
 5348            clock.UtcNow, clock.UtcNow, request.ActionKey, request.Data);
 5349        task.Status = UserTaskStatus.Completing;
 5350        task.CompletionActionKey = request.ActionKey;
 5351        task.CompletionData = request.Data;
 5352        task.CompletedBy = actor.Subject;
 5353        task.Operations.Add(operation);
 5354        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, "Completi
 355        try
 356        {
 5357            await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken);
 5358        }
 0359        catch (UserTaskRevisionConflictException)
 360        {
 0361            return Failure(task, request.OperationId, UserTaskOperationKind.Complete, "revision-conflict");
 362        }
 5363        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task;
 5364        await notificationSink.PublishAsync(new UserTaskCompletionAccepted(tenantId, taskId, committed.Status, committed
 5365        await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, 
 5366        await workflowResumer.ResumeAsync(committed, new UserTaskStimulus(tenantId, taskId, request.OperationId, request
 5367        return new UserTaskOperationResult(committed, operation, true);
 12368    }
 369
 370    /// <summary>
 371    /// Reserves the timeout outcome for the due-date worker. This deliberately does not flow through
 372    /// <see cref="CompleteAsync"/>: reserved outcomes are system transitions and must not be callable
 373    /// as worker-selected actions.
 374    /// </summary>
 375    public async Task<UserTaskOperationResult> TimeoutAsync(string tenantId, string taskId, int expectedRevision, DateTi
 376    {
 1377        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 1378        if (task == null)
 0379            return Failure(taskId, tenantId, $"timeout:{taskId}:{expectedRevision}", UserTaskOperationKind.Timeout, "not
 380
 1381        var operationId = $"timeout:{task.Id}:{expectedRevision}";
 1382        var hash = Hash(UserTaskOperationKind.Timeout, new { expectedRevision, dueAt = task.DueAt });
 1383        var existing = FindOperation(task, operationId);
 1384        if (existing != null)
 385        {
 0386            if (existing.RequestHash != hash)
 0387                return Failure(task, operationId, UserTaskOperationKind.Timeout, "idempotency-conflict");
 0388            if (existing.Status != UserTaskOperationStatus.Accepted)
 0389                return new UserTaskOperationResult(task, existing, existing.Status == UserTaskOperationStatus.Completed)
 390
 391            // A previous delivery attempt may have failed after the accepted operation was committed.
 392            // Retrying the same bookmark stimulus is safe and lets reconciliation repair that gap.
 0393            await workflowResumer.ResumeAsync(task, new UserTaskStimulus(tenantId, taskId, operationId, "Timeout", null,
 0394            return new UserTaskOperationResult(task, existing, true);
 395        }
 396
 1397        if (task.IsTerminal)
 0398            return Failure(task, operationId, UserTaskOperationKind.Timeout, "terminal");
 1399        if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling)
 0400            return Failure(task, operationId, UserTaskOperationKind.Timeout, "transition-in-progress");
 1401        if (!task.EnableTimeoutOutcome || task.DueAt is not { } dueAt || dueAt > now)
 0402            return Failure(task, operationId, UserTaskOperationKind.Timeout, "timeout-not-due");
 1403        if (task.Revision != expectedRevision)
 0404            return Failure(task, operationId, UserTaskOperationKind.Timeout, "revision-conflict");
 405
 1406        var operation = new UserTaskOperation(
 1407            identityGenerator.GenerateId(), tenantId, taskId, operationId, UserTaskOperationKind.Timeout,
 1408            expectedRevision, hash, UserTaskOperationStatus.Accepted, clock.UtcNow, clock.UtcNow,
 1409            ActionKey: "Timeout");
 1410        task.IsOverdue = true;
 1411        task.Status = UserTaskStatus.TimingOut;
 1412        task.CompletionActionKey = "Timeout";
 1413        task.Operations.Add(operation);
 1414        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1,
 1415            "TimeoutRequested", clock.UtcNow, OperationId: operationId));
 416        try
 417        {
 1418            await repository.SaveAsync(task, expectedRevision, cancellationToken);
 1419        }
 0420        catch (UserTaskRevisionConflictException)
 421        {
 0422            return Failure(task, operationId, UserTaskOperationKind.Timeout, "revision-conflict");
 423        }
 424
 1425        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task;
 1426        await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, 
 1427        await notificationSink.PublishAsync(new UserTaskOverdue(tenantId, taskId, committed.Status, committed.Revision),
 1428        await workflowResumer.ResumeAsync(committed, new UserTaskStimulus(tenantId, taskId, operationId, "Timeout", null
 1429        var committedOperation = FindOperation(committed, operationId) ?? operation;
 1430        return new UserTaskOperationResult(committed, committedOperation, true);
 1431    }
 432
 433    public async Task<UserTaskOperationResult> CancelAsync(string tenantId, string taskId, UserTaskCancelRequest request
 434    {
 0435        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 0436        if (task == null)
 0437            return Failure(taskId, tenantId, request.OperationId, UserTaskOperationKind.Cancel, "not-found");
 0438        if (!await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.Cancel, cancellationToken))
 0439            return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "forbidden");
 0440        if (string.IsNullOrWhiteSpace(request.Reason))
 0441            return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "reason-required");
 0442        if (!task.EnableCancellationOutcome)
 0443            return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "cancellation-disabled");
 0444        var hash = Hash(UserTaskOperationKind.Cancel, new { request.ExpectedRevision, request.Reason });
 0445        var existing = FindOperation(task, request.OperationId);
 0446        if (existing != null)
 0447            return existing.RequestHash == hash ? new UserTaskOperationResult(task, existing, existing.Status != UserTas
 0448        if (task.IsTerminal)
 0449            return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "terminal");
 0450        if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling)
 0451            return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "transition-in-progress");
 0452        if (task.Revision != request.ExpectedRevision)
 0453            return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "revision-conflict");
 0454        var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, request.OperationId, Use
 0455            request.ExpectedRevision, hash, UserTaskOperationStatus.Accepted, clock.UtcNow, clock.UtcNow, ActionKey: "Ca
 0456        task.Status = UserTaskStatus.Cancelling;
 0457        task.CompletionActionKey = "Cancelled";
 0458        task.CompletedBy = actor.Subject;
 0459        task.Operations.Add(operation);
 0460        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, "Cancella
 461        try
 462        {
 0463            await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken);
 0464        }
 0465        catch (UserTaskRevisionConflictException)
 466        {
 0467            return Failure(task, request.OperationId, UserTaskOperationKind.Cancel, "revision-conflict");
 468        }
 0469        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task;
 0470        await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, 
 0471        await workflowResumer.ResumeAsync(committed, new UserTaskStimulus(tenantId, taskId, request.OperationId, "Cancel
 0472        return new UserTaskOperationResult(committed, operation, true);
 0473    }
 474
 475    public async Task<UserTaskOperationResult> RetryResolutionAsync(string tenantId, string taskId, UserTaskMutationRequ
 476    {
 0477        var operationId = request.OperationId ?? identityGenerator.GenerateId();
 0478        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 0479        if (task == null)
 0480            return Failure(taskId, tenantId, operationId, UserTaskOperationKind.RetryResolution, "not-found");
 0481        if (!await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.RetryResolution, cancellationToken))
 0482            return Failure(task, operationId, UserTaskOperationKind.RetryResolution, "forbidden");
 0483        var hash = Hash(UserTaskOperationKind.RetryResolution, new { request.ExpectedRevision });
 0484        var existing = FindOperation(task, operationId);
 0485        if (existing != null)
 0486            return existing.RequestHash == hash ? new UserTaskOperationResult(task, existing, existing.Status != UserTas
 0487        if (task.Revision != request.ExpectedRevision)
 0488            return Failure(task, operationId, UserTaskOperationKind.RetryResolution, "revision-conflict");
 489
 0490        if (task.RequestedForm != null)
 491        {
 0492            task.PinnedForm = _formProviders.TryGetValue(task.RequestedForm.ProviderName, out var provider)
 0493                ? await provider.ResolveAsync(task.RequestedForm, cancellationToken)
 0494                : null;
 495        }
 0496        UserTaskHealthSeverity? healthSeverity = task.RequestedForm != null && task.PinnedForm == null ? UserTaskHealthS
 0497        var healthCode = healthSeverity == UserTaskHealthSeverity.Blocking ? "form-resolution-failed" : null;
 0498        var healthMessage = healthSeverity == UserTaskHealthSeverity.Blocking ? "The configured form could not be resolv
 0499        if (task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot && task.CandidateGroups.Count > 0
 500        {
 0501            var snapshotMembers = task.CandidateUsers.ToList();
 0502            var snapshotGroups = task.CandidateGroups.ToList();
 0503            if (participantDirectory == null)
 504            {
 0505                healthSeverity = UserTaskHealthSeverity.Blocking;
 0506                healthCode = "snapshot-directory-missing";
 0507                healthMessage = "A participant directory is required to snapshot candidate groups.";
 508            }
 509            else
 510            {
 0511                foreach (var group in task.CandidateGroups)
 512                {
 513                    try
 514                    {
 0515                        snapshotMembers.AddRange(await participantDirectory.EnumerateGroupMembersAsync(group, cancellati
 0516                    }
 0517                    catch (Exception exception) when (exception is not OperationCanceledException)
 518                    {
 0519                        healthSeverity = UserTaskHealthSeverity.Blocking;
 0520                        healthCode = "snapshot-resolution-failed";
 0521                        healthMessage = "A candidate group could not be resolved for this task.";
 0522                    }
 523                }
 524            }
 0525            task.SnapshotMembers = [..snapshotMembers.Distinct()];
 0526            task.SnapshotGroups = [..snapshotGroups.Distinct()];
 0527        }
 0528        task.HealthSeverity = healthSeverity;
 0529        task.HealthCode = healthCode;
 0530        task.HealthMessage = healthMessage;
 0531        var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, operationId, UserTaskOpe
 0532            request.ExpectedRevision, hash, UserTaskOperationStatus.Completed, clock.UtcNow, clock.UtcNow);
 0533        task.Operations.Add(operation);
 0534        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, task.Revision + 1, "Resoluti
 535        try
 536        {
 0537            await repository.SaveAsync(task, request.ExpectedRevision, cancellationToken);
 0538        }
 0539        catch (UserTaskRevisionConflictException)
 540        {
 0541            return Failure(task, operationId, UserTaskOperationKind.RetryResolution, "revision-conflict");
 542        }
 0543        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task;
 0544        await notificationSink.PublishAsync(new UserTaskHealthChanged(tenantId, taskId, committed.Status, committed.Revi
 0545        return new UserTaskOperationResult(committed, operation, true);
 0546    }
 547
 548    private async Task<UserTaskOperationResult> MutateAsync(string tenantId, string taskId, int expectedRevision, string
 549        UserTaskOperationKind kind, UserTaskActor actor, UserTaskAccessOperation access, Func<UserTask, string?> mutatio
 550    {
 10551        var operationId = requestedOperationId ?? identityGenerator.GenerateId();
 10552        var task = await repository.GetAsync(tenantId, taskId, cancellationToken);
 10553        if (task == null)
 0554            return Failure(taskId, tenantId, operationId, kind, "not-found");
 10555        if (!await accessPolicy.AuthorizeAsync(task, actor, access, cancellationToken))
 1556            return Failure(task, operationId, kind, "forbidden");
 9557        var hash = Hash(kind, new { expectedRevision, reason, requestValue, actor.Subject });
 9558        var existing = FindOperation(task, operationId);
 9559        if (existing != null)
 0560            return existing.RequestHash == hash ? new UserTaskOperationResult(task, existing, existing.Status == UserTas
 9561        if (task.Revision != expectedRevision)
 2562            return Failure(task, operationId, kind, "revision-conflict");
 7563        if (task.Status is UserTaskStatus.Completing or UserTaskStatus.TimingOut or UserTaskStatus.Cancelling)
 0564            return Failure(task, operationId, kind, "transition-in-progress");
 7565        var reasonCode = mutation(task);
 7566        if (reasonCode != null)
 0567            return Failure(task, operationId, kind, reasonCode);
 7568        var operation = new UserTaskOperation(identityGenerator.GenerateId(), tenantId, taskId, operationId, kind, expec
 7569            hash, UserTaskOperationStatus.Completed, clock.UtcNow, clock.UtcNow);
 7570        task.Operations.Add(operation);
 7571        task.Events.Add(new UserTaskEvent(identityGenerator.GenerateId(), tenantId, taskId, expectedRevision + 1, kind.T
 572        try
 573        {
 7574            await repository.SaveAsync(task, expectedRevision, cancellationToken);
 7575        }
 0576        catch (UserTaskRevisionConflictException)
 577        {
 0578            return Failure(task, operationId, kind, "revision-conflict");
 579        }
 7580        var committed = await repository.GetAsync(tenantId, taskId, cancellationToken) ?? task;
 7581        await notificationSink.PublishAsync(new UserTaskChanged(tenantId, taskId, committed.Status, committed.Revision, 
 7582        return new UserTaskOperationResult(committed, operation, true);
 10583    }
 584
 60585    private static UserTaskOperation? FindOperation(UserTask task, string operationId) => task.Operations.FirstOrDefault
 43586    private static bool HasCandidates(UserTaskDefinitionSnapshot definition, UserTaskMaterialization materialization) =>
 0587    private static bool HasCandidates(UserTask task) => task.CandidateUsers.Count > 0 || task.CandidateGroups.Count > 0 
 86588    private static string MaterializationKey(UserTaskMaterialization materialization) => string.Join("/", materializatio
 20589    private static string Hash(UserTaskOperationKind kind, object value) => Convert.ToHexString(SHA256.HashData(Encoding
 54590    private static int PayloadBytes(JsonElement? value) => value is { } element ? JsonSerializer.SerializeToUtf8Bytes(el
 591
 592    private UserTaskOperationResult Failure(string taskId, string tenantId, string operationId, UserTaskOperationKind ki
 0593        Failure(new UserTask { Id = taskId, TenantId = tenantId }, operationId, kind, code);
 594
 595    private static UserTaskOperationResult Failure(UserTask task, string operationId, UserTaskOperationKind kind, string
 596    {
 8597        var operation = new UserTaskOperation(Guid.NewGuid().ToString("N"), task.TenantId, task.Id, operationId, kind, t
 8598            "", UserTaskOperationStatus.Failed, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ErrorCode: code);
 8599        return new UserTaskOperationResult(task, operation, false, code);
 600    }
 601}

Methods/Properties

.ctor(Elsa.UserTasks.Contracts.IUserTaskRepository,Elsa.UserTasks.Contracts.IUserTaskAccessPolicy,System.Collections.Generic.IEnumerable`1<Elsa.UserTasks.Contracts.IUserTaskFormProvider>,Elsa.UserTasks.Contracts.IUserTaskWorkflowResumer,Elsa.UserTasks.Contracts.IUserTaskNotificationSink,Elsa.Workflows.IIdentityGenerator,Elsa.Common.ISystemClock,Microsoft.Extensions.Options.IOptions`1<Elsa.UserTasks.Options.UserTasksOptions>,Elsa.UserTasks.Contracts.IUserTaskParticipantDirectory)
ProjectAsync()
QueryAsync()
GetAsync()
GetCapabilitiesAsync()
GetEventsAsync()
RevealFieldAsync()
EncodeEventCursor(System.String)
DecodeEventCursor(System.String)
ClaimAsync(System.String,System.String,Elsa.UserTasks.Models.UserTaskMutationRequest,Elsa.UserTasks.Models.UserTaskActor,System.Threading.CancellationToken)
ReleaseAsync(System.String,System.String,Elsa.UserTasks.Models.UserTaskMutationRequest,Elsa.UserTasks.Models.UserTaskActor,System.Threading.CancellationToken)
AssignAsync(System.String,System.String,Elsa.UserTasks.Models.UserTaskAssignRequest,Elsa.UserTasks.Models.UserTaskActor,System.Threading.CancellationToken)
UpdateSchedulingAsync(System.String,System.String,Elsa.UserTasks.Models.UserTaskSchedulingUpdate,Elsa.UserTasks.Models.UserTaskActor,System.Threading.CancellationToken)
CompleteAsync()
TimeoutAsync()
CancelAsync()
RetryResolutionAsync()
MutateAsync()
FindOperation(Elsa.UserTasks.Models.UserTask,System.String)
HasCandidates(Elsa.UserTasks.Models.UserTaskDefinitionSnapshot,Elsa.UserTasks.Models.UserTaskMaterialization)
HasCandidates(Elsa.UserTasks.Models.UserTask)
MaterializationKey(Elsa.UserTasks.Models.UserTaskMaterialization)
Hash(Elsa.UserTasks.Models.UserTaskOperationKind,System.Object)
PayloadBytes(System.Nullable`1<System.Text.Json.JsonElement>)
Failure(System.String,System.String,System.String,Elsa.UserTasks.Models.UserTaskOperationKind,System.String)
Failure(Elsa.UserTasks.Models.UserTask,System.String,Elsa.UserTasks.Models.UserTaskOperationKind,System.String)