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:
@@ -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 ?? []);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?>();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user