fix: backport engine fixes from Serdica integration

1. Handle duplicate JSON property names in ToRuntimeValue — GroupBy
   before ToDictionary prevents crash on case-insensitive duplicates
2. Normalize decimal-valued integers in sub-workflow payloads —
   recursive NormalizePayloadNumbers converts 201000256548.0 to long
3. Add WorkflowExecutionActorContext — AsyncLocal propagation of
   actor identity through OnComplete execution chains

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 13:48:27 +03:00
parent 80c33d3c59
commit 00b248f3d8
3 changed files with 66 additions and 5 deletions

View File

@@ -480,10 +480,12 @@ public static class WorkflowCanonicalExpressionRuntime
JsonValueKind.Number when element.TryGetInt64(out var int64Value) => int64Value,
JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => decimalValue,
JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue,
JsonValueKind.Object => element.EnumerateObject().ToDictionary(
x => x.Name,
x => ToRuntimeValue(x.Value),
StringComparer.OrdinalIgnoreCase),
JsonValueKind.Object => element.EnumerateObject()
.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => ToRuntimeValue(g.Last().Value),
StringComparer.OrdinalIgnoreCase),
JsonValueKind.Array => element.EnumerateArray().Select(ToRuntimeValue).ToArray(),
_ => element.ToString(),
};

View File

@@ -0,0 +1,35 @@
using System.Threading;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Propagates the authenticated actor ID through async workflow execution chains
/// (e.g., OnComplete -> Call -> transport) where the original request context
/// is not available. Implementations set ActorId and CallerUserId during
/// task-complete/start-workflow processing.
/// </summary>
public sealed class WorkflowExecutionActorContext
{
private static readonly AsyncLocal<string?> CurrentActorId = new();
private static readonly AsyncLocal<string?> CurrentCallerUserId = new();
/// <summary>
/// The actor ID from the task operation request (e.g., numeric user account ID).
/// Used for role resolution and task assignment.
/// </summary>
public string? ActorId
{
get => CurrentActorId.Value;
set => CurrentActorId.Value = value;
}
/// <summary>
/// The authenticated caller's user ID extracted from the HTTP request context.
/// Used by transport implementations for backend service calls.
/// </summary>
public string? CallerUserId
{
get => CurrentCallerUserId.Value;
set => CurrentCallerUserId.Value = value;
}
}

View File

@@ -78,6 +78,7 @@ public static class WorkflowRegistrationServiceCollectionExtensions
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
NumberHandling = global::System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
};
public static IServiceCollection AddWorkflowRegistration<TWorkflow>(
@@ -190,7 +191,8 @@ public static class WorkflowRegistrationServiceCollectionExtensions
return new Dictionary<string, object?>(payload, StringComparer.OrdinalIgnoreCase);
}
var json = JsonSerializer.Serialize(payload, SerializerOptions);
var normalized = NormalizePayloadNumbers(payload);
var json = JsonSerializer.Serialize(normalized, SerializerOptions);
return JsonSerializer.Deserialize(json, startRequestType, SerializerOptions)
?? throw new InvalidOperationException(
$"Unable to bind workflow payload to '{startRequestType.FullName}'.");
@@ -291,5 +293,27 @@ public static class WorkflowRegistrationServiceCollectionExtensions
};
}
private static IDictionary<string, object?> NormalizePayloadNumbers(IDictionary<string, object?> payload)
{
var result = new Dictionary<string, object?>(payload.Count, StringComparer.OrdinalIgnoreCase);
foreach (var kvp in payload)
{
result[kvp.Key] = NormalizeValue(kvp.Value);
}
return result;
}
private static object? NormalizeValue(object? value)
{
return value switch
{
decimal d when d == Math.Truncate(d) && d >= long.MinValue && d <= long.MaxValue => (long)d,
double d when d == Math.Truncate(d) && d >= long.MinValue && d <= long.MaxValue => (long)d,
IDictionary<string, object?> dict => NormalizePayloadNumbers(dict),
object[] arr => arr.Select(NormalizeValue).ToArray(),
_ => value,
};
}
private sealed record WorkflowBusinessReferencePartProperty(PropertyInfo Property, string PartName);
}