diff --git a/src/Workflow/StellaOps.Workflow.slnx b/src/Workflow/StellaOps.Workflow.slnx index 67e2f4912..008e298d5 100644 --- a/src/Workflow/StellaOps.Workflow.slnx +++ b/src/Workflow/StellaOps.Workflow.slnx @@ -14,6 +14,11 @@ + + + + + diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowActorRoleResolver.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowActorRoleResolver.cs new file mode 100644 index 000000000..7a9fd0f77 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/IWorkflowActorRoleResolver.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// 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. +/// +public interface IWorkflowActorRoleResolver +{ + /// + /// Returns the actor ID for the current request. + /// Implementations should prefer server-side identity over client-supplied values. + /// + string ResolveActorId(string? clientActorId); + + /// + /// Resolves user roles from a trusted source (database, identity provider). + /// Falls back to when no server-side context + /// is available (e.g., during signal pump processing). + /// + Task> ResolveActorRolesAsync( + string actorId, + IReadOnlyCollection? clientRoles, + CancellationToken cancellationToken = default); +} + +/// +/// Default no-op resolver that passes through client-supplied values. +/// Used when no database-backed role resolution is configured. +/// +public sealed class NullWorkflowActorRoleResolver : IWorkflowActorRoleResolver +{ + public string ResolveActorId(string? clientActorId) + => clientActorId ?? string.Empty; + + public Task> ResolveActorRolesAsync( + string actorId, + IReadOnlyCollection? clientRoles, + CancellationToken cancellationToken = default) + => Task.FromResult>(clientRoles ?? []); +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs index a39bde064..915e74b1e 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalDefinitionCompiler.Helpers.cs @@ -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, }; } diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs index fabb7c215..7e53dbb07 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowCanonicalExpressionRuntime.cs @@ -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(), }; diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs index 7d373c17f..8ba98212b 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowDeclarativeAbstractions.cs @@ -726,6 +726,104 @@ public sealed class WorkflowFlowBuilder null); } + public WorkflowFlowBuilder Call( + string stepName, + Address address, + WorkflowExpressionDefinition payloadExpression, + Action> whenFailure, + Action>? whenTimeout = null) + { + return AddMicroserviceCall( + stepName, + address.MicroserviceName, + address.Command, + context => WorkflowCanonicalExpressionRuntime.Evaluate(payloadExpression, context), + payloadExpression, + null, + whenFailure, + whenTimeout); + } + + public WorkflowFlowBuilder 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 Call( + 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 Call( + string stepName, + Address address, + WorkflowExpressionDefinition payloadExpression, + Action> whenFailure, + Action>? 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 Call( + 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 Call( string stepName, Address address, diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowExecutionActorContext.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowExecutionActorContext.cs new file mode 100644 index 000000000..871ce857f --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowExecutionActorContext.cs @@ -0,0 +1,35 @@ +using System.Threading; + +namespace StellaOps.Workflow.Abstractions; + +/// +/// 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. +/// +public sealed class WorkflowExecutionActorContext +{ + private static readonly AsyncLocal CurrentActorId = new(); + private static readonly AsyncLocal CurrentCallerUserId = new(); + + /// + /// The actor ID from the task operation request (e.g., numeric user account ID). + /// Used for role resolution and task assignment. + /// + public string? ActorId + { + get => CurrentActorId.Value; + set => CurrentActorId.Value = value; + } + + /// + /// The authenticated caller's user ID extracted from the HTTP request context. + /// Used by transport implementations for backend service calls. + /// + public string? CallerUserId + { + get => CurrentCallerUserId.Value; + set => CurrentCallerUserId.Value = value; + } +} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs index 770d41846..e0d8cd899 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowRegistrationAbstractions.cs @@ -78,6 +78,10 @@ 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, }; public static IServiceCollection AddWorkflowRegistration( @@ -190,7 +194,11 @@ public static class WorkflowRegistrationServiceCollectionExtensions return new Dictionary(payload, StringComparer.OrdinalIgnoreCase); } - var json = JsonSerializer.Serialize(payload, SerializerOptions); + // 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) ?? throw new InvalidOperationException( $"Unable to bind workflow payload to '{startRequestType.FullName}'."); @@ -291,5 +299,27 @@ public static class WorkflowRegistrationServiceCollectionExtensions }; } + private static IDictionary NormalizePayloadNumbers(IDictionary payload) + { + var result = new Dictionary(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 dict => NormalizePayloadNumbers(dict), + object[] arr => arr.Select(NormalizeValue).ToArray(), + _ => value, + }; + } + private sealed record WorkflowBusinessReferencePartProperty(PropertyInfo Property, string PartName); } diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs index 8c4d3716e..bf9270c83 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowStartContracts.cs @@ -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 NextTasks { get; init; } = []; + public IDictionary WorkflowState { get; init; } = new Dictionary(); } diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs index 0df8f01ac..06e080f81 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Contracts/WorkflowTaskContracts.cs @@ -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 NextTasks { get; init; } = []; + public IDictionary WorkflowState { get; init; } = new Dictionary(); } public sealed record WorkflowTaskAssignRequest diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs index c39a268a7..792a310dd 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.Oracle/OracleWorkflowDataStoreExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.EntityFrameworkCore; using StellaOps.Workflow.Abstractions; @@ -10,10 +11,27 @@ public static class OracleWorkflowDataStoreExtensions public static IServiceCollection AddWorkflowOracleDataStore( this IServiceCollection services, IConfiguration configuration) { - // Register WorkflowDbContext with Oracle provider - // Register OracleWorkflowRuntimeStateStore - // Register OracleWorkflowHostedJobLockService - // Register EF-based projection/retention stores + services.AddWorkflowModule("workflow-store.oracle", "1.0.0"); + services.AddSingleton( + new WorkflowBackendRegistrationMarker(WorkflowBackendNames.Oracle)); + + if (!string.Equals(configuration.GetWorkflowBackendProvider(), WorkflowBackendNames.Oracle, StringComparison.OrdinalIgnoreCase)) + { + return services; + } + + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("WorkflowOracle") + ?? configuration.GetConnectionString("Default"); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + options.UseOracle(connectionString); + } + }); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + return services; } } diff --git a/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Mongo/ServiceRegistrator.cs b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Mongo/ServiceRegistrator.cs new file mode 100644 index 000000000..024b1e762 --- /dev/null +++ b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Mongo/ServiceRegistrator.cs @@ -0,0 +1,16 @@ +using StellaOps.DependencyInjection; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.DataStore.MongoDB; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace StellaOps.Workflow.Plugin.WorkflowStore.Mongo; + +public sealed class ServiceRegistrator : IDependencyInjectionRoutine +{ + public IServiceCollection Register(IServiceCollection services, IConfiguration configuration) + { + return services.AddWorkflowMongoDataStore(configuration); + } +} diff --git a/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Mongo/StellaOps.Workflow.Plugin.WorkflowStore.Mongo.csproj b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Mongo/StellaOps.Workflow.Plugin.WorkflowStore.Mongo.csproj new file mode 100644 index 000000000..84a3614d3 --- /dev/null +++ b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Mongo/StellaOps.Workflow.Plugin.WorkflowStore.Mongo.csproj @@ -0,0 +1,37 @@ + + + net10.0 + false + enable + true + enable + false + true + $([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','StellaOps.Workflow.WebService','PluginBinaries','$(MSBuildProjectName)')) + + + + + + + + + + + + + + + false + runtime + + + false + runtime + + + false + runtime + + + diff --git a/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Oracle/ServiceRegistrator.cs b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Oracle/ServiceRegistrator.cs new file mode 100644 index 000000000..0e29b71ff --- /dev/null +++ b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Oracle/ServiceRegistrator.cs @@ -0,0 +1,45 @@ +using StellaOps.DependencyInjection; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.DataStore.Oracle; +using StellaOps.Workflow.Signaling.OracleAq; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Workflow.Plugin.WorkflowStore.Oracle; + +public sealed class ServiceRegistrator : IDependencyInjectionRoutine +{ + public IServiceCollection Register(IServiceCollection services, IConfiguration configuration) + { + services.AddWorkflowOracleDataStore(configuration); + + if (!string.Equals(configuration.GetWorkflowBackendProvider(), WorkflowBackendNames.Oracle, StringComparison.OrdinalIgnoreCase)) + { + return services; + } + + var useNativeSignalDriver = string.Equals( + configuration.GetWorkflowSignalDriverProvider(), + WorkflowSignalDriverNames.Native, + StringComparison.OrdinalIgnoreCase); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Scoped()); + if (useNativeSignalDriver) + { + services.Replace(ServiceDescriptor.Scoped(sp => sp.GetRequiredService())); + } + + services.AddSingleton( + new WorkflowSignalDriverRegistrationMarker(WorkflowSignalDriverNames.Native)); + + return services; + } +} diff --git a/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Oracle/StellaOps.Workflow.Plugin.WorkflowStore.Oracle.csproj b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Oracle/StellaOps.Workflow.Plugin.WorkflowStore.Oracle.csproj new file mode 100644 index 000000000..4ae46bed2 --- /dev/null +++ b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Oracle/StellaOps.Workflow.Plugin.WorkflowStore.Oracle.csproj @@ -0,0 +1,45 @@ + + + net10.0 + false + enable + true + enable + false + true + $([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','StellaOps.Workflow.WebService','PluginBinaries','$(MSBuildProjectName)')) + + + + + + + + + + + + + + + false + runtime + + + false + runtime + + + false + runtime + + + false + runtime + + + false + runtime + + + diff --git a/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Postgres/ServiceRegistrator.cs b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Postgres/ServiceRegistrator.cs new file mode 100644 index 000000000..f852fc5b0 --- /dev/null +++ b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Postgres/ServiceRegistrator.cs @@ -0,0 +1,16 @@ +using StellaOps.DependencyInjection; +using StellaOps.Workflow.Abstractions; +using StellaOps.Workflow.DataStore.PostgreSQL; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace StellaOps.Workflow.Plugin.WorkflowStore.Postgres; + +public sealed class ServiceRegistrator : IDependencyInjectionRoutine +{ + public IServiceCollection Register(IServiceCollection services, IConfiguration configuration) + { + return services.AddWorkflowPostgresDataStore(configuration); + } +} diff --git a/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Postgres/StellaOps.Workflow.Plugin.WorkflowStore.Postgres.csproj b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Postgres/StellaOps.Workflow.Plugin.WorkflowStore.Postgres.csproj new file mode 100644 index 000000000..8810d0ddc --- /dev/null +++ b/src/Workflow/__Plugins/StellaOps.Workflow.Plugin.WorkflowStore.Postgres/StellaOps.Workflow.Plugin.WorkflowStore.Postgres.csproj @@ -0,0 +1,37 @@ + + + net10.0 + false + enable + true + enable + false + true + $([System.IO.Path]::Combine($(MSBuildProjectDirectory),'..','..','StellaOps.Workflow.WebService','PluginBinaries','$(MSBuildProjectName)')) + + + + + + + + + + + + + + + false + runtime + + + false + runtime + + + false + runtime + + +