backport: merge Serdica workflow abstractions and contracts improvements

Backport generic improvements from Serdica workflow engine to StellaOps:

Abstractions:
- Add IWorkflowActorRoleResolver interface and NullWorkflowActorRoleResolver
  default implementation for server-side actor identity resolution
- Add expression-based Call overloads to WorkflowFlowBuilder (6 new methods
  accepting WorkflowExpressionDefinition for payload instead of Func<> factory)
- Fix failure handler compilation: preserve empty handlers (0 steps) as empty
  sequences instead of null, allowing "ignore failure and continue" semantics
- Add explanatory comments to WorkflowRegistrationAbstractions for JSON number
  normalization logic

Contracts:
- Add NextTasks and WorkflowState to StartWorkflowResponse so callers can
  see immediate next tasks after starting a workflow
- Add WorkflowInstanceId, NextTasks, and WorkflowState to
  WorkflowTaskCompleteResponse for richer task completion feedback

Transport: verified Transport.GraphQL, Transport.Http, Transport.Microservice,
and Transport.LegacyRabbit are engine-embedded plugins (no separate directories
to add/remove). ElkSharp library confirmed present at src/__Libraries/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 13:59:09 +03:00
parent 00b248f3d8
commit ca35f66830
6 changed files with 158 additions and 2 deletions

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Resolves the authenticated actor's identity (ID + roles) from the server-side
/// request context. Implementations may query a database, read JWT claims, or
/// fall back to client-supplied values.
/// </summary>
public interface IWorkflowActorRoleResolver
{
/// <summary>
/// Returns the actor ID for the current request.
/// Implementations should prefer server-side identity over client-supplied values.
/// </summary>
string ResolveActorId(string? clientActorId);
/// <summary>
/// Resolves user roles from a trusted source (database, identity provider).
/// Falls back to <paramref name="clientRoles"/> when no server-side context
/// is available (e.g., during signal pump processing).
/// </summary>
Task<IReadOnlyCollection<string>> ResolveActorRolesAsync(
string actorId,
IReadOnlyCollection<string>? clientRoles,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default no-op resolver that passes through client-supplied values.
/// Used when no database-backed role resolution is configured.
/// </summary>
public sealed class NullWorkflowActorRoleResolver : IWorkflowActorRoleResolver
{
public string ResolveActorId(string? clientActorId)
=> clientActorId ?? string.Empty;
public Task<IReadOnlyCollection<string>> ResolveActorRolesAsync(
string actorId,
IReadOnlyCollection<string>? clientRoles,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyCollection<string>>(clientRoles ?? []);
}

View File

@@ -179,8 +179,10 @@ public static partial class WorkflowCanonicalDefinitionCompiler
},
ResultKey = resultKey,
TimeoutSeconds = timeoutSeconds,
WhenFailure = failureHandlers?.HasFailureBranch == true ? BuildSequence(failureHandlers.WhenFailure) : null,
WhenTimeout = failureHandlers?.HasTimeoutBranch == true ? BuildSequence(failureHandlers.WhenTimeout) : null,
// Preserve empty handlers (0 steps) as empty sequences — they mean "ignore failure and continue".
// Only set to null when no handler was provided at all.
WhenFailure = failureHandlers is not null ? BuildSequence(failureHandlers.WhenFailure) : null,
WhenTimeout = failureHandlers is not null ? BuildSequence(failureHandlers.WhenTimeout) : null,
};
}

View File

@@ -726,6 +726,104 @@ public sealed class WorkflowFlowBuilder<TStartRequest>
null);
}
public WorkflowFlowBuilder<TStartRequest> Call(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
Action<WorkflowFlowBuilder<TStartRequest>> whenFailure,
Action<WorkflowFlowBuilder<TStartRequest>>? whenTimeout = null)
{
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
null,
whenFailure,
whenTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
WorkflowHandledBranchAction onFailure,
WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None)
{
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
null,
null,
null,
onFailure,
onTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call<TResponse>(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
string? resultKey = null)
{
ArgumentNullException.ThrowIfNull(payloadExpression);
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
resultKey ?? stepName,
null,
null);
}
public WorkflowFlowBuilder<TStartRequest> Call<TResponse>(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
Action<WorkflowFlowBuilder<TStartRequest>> whenFailure,
Action<WorkflowFlowBuilder<TStartRequest>>? whenTimeout = null,
string? resultKey = null)
{
ArgumentNullException.ThrowIfNull(payloadExpression);
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
resultKey,
whenFailure,
whenTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call<TResponse>(
string stepName,
Address address,
WorkflowExpressionDefinition payloadExpression,
WorkflowHandledBranchAction onFailure,
WorkflowHandledBranchAction onTimeout = WorkflowHandledBranchAction.None,
string? resultKey = null)
{
ArgumentNullException.ThrowIfNull(payloadExpression);
return AddMicroserviceCall(
stepName,
address.MicroserviceName,
address.Command,
context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context),
payloadExpression,
resultKey,
null,
null,
onFailure,
onTimeout);
}
public WorkflowFlowBuilder<TStartRequest> Call(
string stepName,
Address address,

View File

@@ -78,6 +78,9 @@ public static class WorkflowRegistrationServiceCollectionExtensions
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
// Allow floating-point JSON numbers (e.g., 201000256548.0) to deserialize into integer
// types (long?, int?). This is needed because the workflow state round-trips through
// JSON serialization which may add ".0" to integer values.
NumberHandling = global::System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
};
@@ -191,6 +194,9 @@ public static class WorkflowRegistrationServiceCollectionExtensions
return new Dictionary<string, object?>(payload, StringComparer.OrdinalIgnoreCase);
}
// Normalize decimal-valued integers (e.g., 201000256548.0 → 201000256548)
// before serialization. This is needed because the workflow state may store
// integer values as decimals after JSON round-trips.
var normalized = NormalizePayloadNumbers(payload);
var json = JsonSerializer.Serialize(normalized, SerializerOptions);
return JsonSerializer.Deserialize(json, startRequestType, SerializerOptions)

View File

@@ -16,4 +16,6 @@ public sealed record StartWorkflowResponse
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public IReadOnlyCollection<WorkflowTaskSummary> NextTasks { get; init; } = [];
public IDictionary<string, object?> WorkflowState { get; init; } = new Dictionary<string, object?>();
}

View File

@@ -87,6 +87,9 @@ public sealed record WorkflowTaskCompleteResponse
{
public required string WorkflowTaskId { get; init; }
public required bool Completed { get; init; }
public string? WorkflowInstanceId { get; init; }
public IReadOnlyCollection<WorkflowTaskSummary> NextTasks { get; init; } = [];
public IDictionary<string, object?> WorkflowState { get; init; } = new Dictionary<string, object?>();
}
public sealed record WorkflowTaskAssignRequest