Add StellaOps.Workflow engine: 14 libraries, WebService, 8 test projects

Extract product-agnostic workflow engine from Ablera.Serdica.Workflow into
standalone StellaOps.Workflow.* libraries targeting net10.0.

Libraries (14):
- Contracts, Abstractions (compiler, decompiler, expression runtime)
- Engine (execution, signaling, scheduling, projections, hosted services)
- ElkSharp (generic graph layout algorithm)
- Renderer.ElkSharp, Renderer.ElkJs, Renderer.Msagl, Renderer.Svg
- Signaling.Redis, Signaling.OracleAq
- DataStore.MongoDB, DataStore.PostgreSQL, DataStore.Oracle

WebService: ASP.NET Core Minimal API with 22 endpoints

Tests (8 projects, 109 tests pass):
- Engine.Tests (105 pass), WebService.Tests (4 E2E pass)
- Renderer.Tests, DataStore.MongoDB/Oracle/PostgreSQL.Tests
- Signaling.Redis.Tests, IntegrationTests.Shared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-20 19:14:44 +02:00
parent e56f9a114a
commit f5b5f24d95
422 changed files with 85428 additions and 0 deletions

View File

@@ -0,0 +1,436 @@
using System;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using StellaOps.Workflow.Engine.Exceptions;
using StellaOps.Workflow.Engine.Services;
namespace StellaOps.Workflow.WebService.Endpoints;
internal static class WorkflowEndpoints
{
public static RouteGroupBuilder MapWorkflowEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/workflow");
// ────────────────────────────────────────────────────
// Workflow runtime
// ────────────────────────────────────────────────────
group.MapPost("/start", StartWorkflow);
group.MapGet("/instances/{id}", GetInstance);
group.MapGet("/instances", GetInstances);
group.MapGet("/tasks/{id}", GetTask);
group.MapGet("/tasks", GetTasks);
group.MapPost("/tasks/{id}/complete", CompleteTask);
group.MapPost("/tasks/{id}/assign", AssignTask);
group.MapPost("/tasks/{id}/release", ReleaseTask);
group.MapPost("/signals/raise", RaiseSignal);
// ────────────────────────────────────────────────────
// Definition query (in-memory catalog)
// ────────────────────────────────────────────────────
group.MapGet("/definitions", GetDefinitions);
// ────────────────────────────────────────────────────
// Definition deployment (store-backed)
// ────────────────────────────────────────────────────
group.MapGet("/definitions/{id}", GetDefinitionById);
group.MapPost("/definitions/import", ImportDefinition);
group.MapPost("/definitions/export", ExportDefinition);
group.MapGet("/definitions/{name}/versions", GetDefinitionVersions);
group.MapPost("/definitions/{name}/activate", ActivateDefinition);
// ────────────────────────────────────────────────────
// Diagrams
// ────────────────────────────────────────────────────
group.MapGet("/diagrams/{name}", GetDiagram);
// ────────────────────────────────────────────────────
// Canonical schema & validation
// ────────────────────────────────────────────────────
group.MapGet("/canonical/schema", GetCanonicalSchema);
group.MapPost("/canonical/validate", ValidateCanonical);
// ────────────────────────────────────────────────────
// Operational: functions, metadata, retention
// ────────────────────────────────────────────────────
group.MapGet("/functions", GetFunctionCatalog);
group.MapGet("/metadata", GetServiceMetadata);
group.MapPost("/retention/run", RunRetention);
// ────────────────────────────────────────────────────
// Signals: dead letters & pump telemetry
// ────────────────────────────────────────────────────
group.MapGet("/signals/dead-letters", GetDeadLetters);
group.MapPost("/signals/dead-letters/replay", ReplayDeadLetters);
group.MapGet("/signals/pump/stats", GetSignalPumpStats);
return group;
}
// ════════════════════════════════════════════════════════
// Runtime handlers
// ════════════════════════════════════════════════════════
private static async Task<IResult> StartWorkflow(
StartWorkflowRequest request,
WorkflowRuntimeService runtimeService,
CancellationToken cancellationToken)
{
try
{
var response = await runtimeService.StartWorkflowAsync(request, cancellationToken);
return Results.Ok(response);
}
catch (BaseResultException ex)
{
return Results.BadRequest(new { error = ex.MessageId, message = ex.Message });
}
}
private static async Task<IResult> GetInstance(
string id,
WorkflowRuntimeService runtimeService,
string? actorId,
CancellationToken cancellationToken)
{
var response = await runtimeService.GetInstanceAsync(new WorkflowInstanceGetRequest
{
WorkflowInstanceId = id,
ActorId = actorId,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> GetInstances(
WorkflowRuntimeService runtimeService,
string? workflowName,
string? workflowVersion,
string? workflowInstanceId,
string? businessReferenceKey,
string? status,
bool? includeDetails,
CancellationToken cancellationToken)
{
var response = await runtimeService.GetInstancesAsync(new WorkflowInstancesGetRequest
{
WorkflowName = workflowName,
WorkflowVersion = workflowVersion,
WorkflowInstanceId = workflowInstanceId,
BusinessReferenceKey = businessReferenceKey,
Status = status,
IncludeDetails = includeDetails ?? false,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> GetTask(
string id,
WorkflowRuntimeService runtimeService,
string? actorId,
CancellationToken cancellationToken)
{
var response = await runtimeService.GetTaskAsync(new WorkflowTaskGetRequest
{
WorkflowTaskId = id,
ActorId = actorId,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> GetTasks(
WorkflowRuntimeService runtimeService,
string? workflowName,
string? workflowVersion,
string? workflowInstanceId,
string? businessReferenceKey,
string? assignee,
string? status,
string? actorId,
CancellationToken cancellationToken)
{
var response = await runtimeService.GetTasksAsync(new WorkflowTasksGetRequest
{
WorkflowName = workflowName,
WorkflowVersion = workflowVersion,
WorkflowInstanceId = workflowInstanceId,
BusinessReferenceKey = businessReferenceKey,
Assignee = assignee,
Status = status,
ActorId = actorId,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> CompleteTask(
string id,
WorkflowTaskCompleteRequest request,
WorkflowRuntimeService runtimeService,
CancellationToken cancellationToken)
{
var response = await runtimeService.CompleteTaskAsync(request with
{
WorkflowTaskId = id,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> AssignTask(
string id,
WorkflowTaskAssignRequest request,
WorkflowRuntimeService runtimeService,
CancellationToken cancellationToken)
{
var response = await runtimeService.AssignTaskAsync(request with
{
WorkflowTaskId = id,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> ReleaseTask(
string id,
WorkflowTaskReleaseRequest request,
WorkflowRuntimeService runtimeService,
CancellationToken cancellationToken)
{
var response = await runtimeService.ReleaseTaskAsync(request with
{
WorkflowTaskId = id,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> RaiseSignal(
WorkflowSignalRaiseRequest request,
WorkflowRuntimeService runtimeService,
CancellationToken cancellationToken)
{
var response = await runtimeService.RaiseExternalSignalAsync(request, cancellationToken);
return Results.Ok(response);
}
// ════════════════════════════════════════════════════════
// Definition query handlers (in-memory catalog)
// ════════════════════════════════════════════════════════
private static IResult GetDefinitions(
IWorkflowDefinitionCatalog definitionCatalog,
string? workflowName,
string? workflowVersion)
{
var definitions = definitionCatalog.GetDefinitions().AsEnumerable();
if (!string.IsNullOrWhiteSpace(workflowName))
{
definitions = definitions.Where(x =>
string.Equals(x.WorkflowName, workflowName, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(workflowVersion))
{
definitions = definitions.Where(x =>
string.Equals(x.WorkflowVersion, workflowVersion, StringComparison.OrdinalIgnoreCase));
}
return Results.Ok(new WorkflowDefinitionGetResponse
{
Definitions = definitions.ToArray(),
});
}
// ════════════════════════════════════════════════════════
// Definition deployment handlers (store-backed)
// ════════════════════════════════════════════════════════
private static async Task<IResult> GetDefinitionById(
string id,
WorkflowDefinitionDeploymentService deploymentService,
bool? includeRendering,
CancellationToken cancellationToken)
{
var response = await deploymentService.GetDefinitionByIdAsync(new WorkflowDefinitionByIdRequest
{
WorkflowName = id,
IncludeRendering = includeRendering ?? false,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> ImportDefinition(
WorkflowDefinitionImportRequest request,
WorkflowDefinitionDeploymentService deploymentService,
CancellationToken cancellationToken)
{
var response = await deploymentService.ImportAsync(request, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> ExportDefinition(
WorkflowDefinitionExportRequest request,
WorkflowDefinitionDeploymentService deploymentService,
CancellationToken cancellationToken)
{
var response = await deploymentService.ExportAsync(request, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> GetDefinitionVersions(
string name,
WorkflowDefinitionDeploymentService deploymentService,
CancellationToken cancellationToken)
{
var response = await deploymentService.GetVersionsAsync(new WorkflowDefinitionVersionsGetRequest
{
WorkflowName = name,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> ActivateDefinition(
string name,
WorkflowDefinitionActivateRequest request,
WorkflowDefinitionDeploymentService deploymentService,
CancellationToken cancellationToken)
{
var response = await deploymentService.ActivateAsync(request with
{
WorkflowName = name,
}, cancellationToken);
return Results.Ok(response);
}
// ════════════════════════════════════════════════════════
// Diagram handlers
// ════════════════════════════════════════════════════════
private static async Task<IResult> GetDiagram(
string name,
WorkflowDiagramService diagramService,
string? workflowVersion,
string? workflowInstanceId,
string? layoutProvider,
string? layoutEffort,
int? layoutOrderingIterations,
int? layoutPlacementIterations,
CancellationToken cancellationToken)
{
var response = await diagramService.GetDiagramAsync(new WorkflowDiagramGetRequest
{
WorkflowName = name,
WorkflowVersion = workflowVersion,
WorkflowInstanceId = workflowInstanceId,
LayoutProvider = layoutProvider,
LayoutEffort = layoutEffort,
LayoutOrderingIterations = layoutOrderingIterations,
LayoutPlacementIterations = layoutPlacementIterations,
}, cancellationToken);
return Results.Ok(response);
}
// ════════════════════════════════════════════════════════
// Canonical schema & validation handlers
// ════════════════════════════════════════════════════════
private static IResult GetCanonicalSchema(
WorkflowCanonicalDefinitionService canonicalService)
{
var response = canonicalService.GetSchema();
return Results.Ok(response);
}
private static IResult ValidateCanonical(
WorkflowCanonicalValidateRequest request,
WorkflowCanonicalDefinitionService canonicalService)
{
var response = canonicalService.Validate(request);
return Results.Ok(response);
}
// ════════════════════════════════════════════════════════
// Operational handlers
// ════════════════════════════════════════════════════════
private static IResult GetFunctionCatalog(
WorkflowFunctionCatalogService functionCatalogService)
{
var response = functionCatalogService.GetCatalog();
return Results.Ok(response);
}
private static IResult GetServiceMetadata()
{
var response = new WorkflowServiceMetadataGetResponse
{
Metadata = new WorkflowServiceMetadata
{
ServiceName = "StellaOps.Workflow.WebService",
DiagramProvider = "ElkSharp",
SupportsDefinitionInspection = true,
SupportsInstanceInspection = true,
SupportsCanonicalSchemaInspection = true,
SupportsCanonicalImportValidation = true,
},
};
return Results.Ok(response);
}
private static async Task<IResult> RunRetention(
WorkflowRetentionRunRequest request,
WorkflowRetentionService retentionService,
CancellationToken cancellationToken)
{
var result = await retentionService.RunAsync(request.ReferenceUtc, cancellationToken);
var response = new WorkflowRetentionRunResponse
{
ExecutedOnUtc = DateTime.UtcNow,
StaleInstancesMarked = result.StaleInstancesMarked,
StaleTasksMarked = result.StaleTasksMarked,
PurgedInstances = result.PurgedInstances,
PurgedTasks = result.PurgedTasks,
PurgedTaskEvents = result.PurgedTaskEvents,
PurgedRuntimeStates = result.PurgedRuntimeStates,
};
return Results.Ok(response);
}
// ════════════════════════════════════════════════════════
// Signal dead-letter & pump telemetry handlers
// ════════════════════════════════════════════════════════
private static async Task<IResult> GetDeadLetters(
WorkflowSignalDeadLetterService deadLetterService,
string? signalId,
string? workflowInstanceId,
string? signalType,
int? maxMessages,
bool? includeRawPayload,
CancellationToken cancellationToken)
{
var response = await deadLetterService.GetMessagesAsync(new WorkflowSignalDeadLettersGetRequest
{
SignalId = signalId,
WorkflowInstanceId = workflowInstanceId,
SignalType = signalType,
MaxMessages = maxMessages ?? 50,
IncludeRawPayload = includeRawPayload ?? false,
}, cancellationToken);
return Results.Ok(response);
}
private static async Task<IResult> ReplayDeadLetters(
WorkflowSignalDeadLetterReplayRequest request,
WorkflowSignalDeadLetterService deadLetterService,
CancellationToken cancellationToken)
{
var response = await deadLetterService.ReplayAsync(request, cancellationToken);
return Results.Ok(response);
}
private static IResult GetSignalPumpStats(
WorkflowSignalPumpTelemetryService telemetryService)
{
var response = telemetryService.GetStats();
return Results.Ok(response);
}
}

View File

@@ -0,0 +1,35 @@
using StellaOps.Workflow.DataStore.MongoDB;
using StellaOps.Workflow.Engine.Authorization;
using StellaOps.Workflow.Engine.Services;
using StellaOps.Workflow.Signaling.Redis;
using StellaOps.Workflow.WebService.Endpoints;
var builder = WebApplication.CreateBuilder(args);
// Core workflow engine services (runtime, definitions, signals, rendering, etc.)
builder.Services.AddWorkflowEngineCoreServices(builder.Configuration);
// Workflow engine hosted services (signal pump, retention background jobs)
builder.Services.AddWorkflowEngineHostedServices();
// Authorization service (required by WorkflowRuntimeService)
builder.Services.AddScoped<WorkflowTaskAuthorizationService>();
// MongoDB data store (projection store, runtime state, signals, dead letters, etc.)
builder.Services.AddWorkflowMongoDataStore(builder.Configuration);
// Redis signaling driver (wake notifications across instances)
builder.Services.AddWorkflowRedisSignaling(builder.Configuration);
// Rendering layout engines can be registered here when available:
// builder.Services.AddWorkflowElkSharpRenderer();
// builder.Services.AddWorkflowSvgRenderer();
var app = builder.Build();
// Map all workflow API endpoints under /api/workflow
app.MapWorkflowEndpoints();
await app.RunAsync();
public partial class Program { }

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Workflow.Host": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:49940;http://localhost:49941"
}
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Engine/StellaOps.Workflow.Engine.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Abstractions/StellaOps.Workflow.Abstractions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Contracts/StellaOps.Workflow.Contracts.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Workflow.DataStore.Oracle/StellaOps.Workflow.DataStore.Oracle.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Workflow.DataStore.MongoDB/StellaOps.Workflow.DataStore.MongoDB.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Renderer.Svg/StellaOps.Workflow.Renderer.Svg.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Workflow.Renderer.ElkSharp/StellaOps.Workflow.Renderer.ElkSharp.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" }
]
},
"WorkflowBackend": {
"Provider": "Mongo",
"Mongo": {
"ConnectionStringName": "WorkflowMongo",
"DatabaseName": "stellaops_workflow"
}
},
"WorkflowSignalDriver": {
"Provider": "Native"
},
"ConnectionStrings": {
"WorkflowMongo": "mongodb://localhost:27017"
}
}

View File

@@ -0,0 +1,28 @@
<Solution>
<Folder Name="/__Libraries/">
<Project Path="__Libraries/StellaOps.Workflow.Abstractions/StellaOps.Workflow.Abstractions.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Contracts/StellaOps.Workflow.Contracts.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.DataStore.MongoDB/StellaOps.Workflow.DataStore.MongoDB.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.DataStore.Oracle/StellaOps.Workflow.DataStore.Oracle.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/StellaOps.Workflow.DataStore.PostgreSQL.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Engine/StellaOps.Workflow.Engine.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Renderer.ElkJs/StellaOps.Workflow.Renderer.ElkJs.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Renderer.ElkSharp/StellaOps.Workflow.Renderer.ElkSharp.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Renderer.Msagl/StellaOps.Workflow.Renderer.Msagl.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Renderer.Svg/StellaOps.Workflow.Renderer.Svg.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Signaling.OracleAq/StellaOps.Workflow.Signaling.OracleAq.csproj" />
<Project Path="__Libraries/StellaOps.Workflow.Signaling.Redis/StellaOps.Workflow.Signaling.Redis.csproj" />
</Folder>
<Project Path="../__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.csproj" />
<Project Path="StellaOps.Workflow.WebService/StellaOps.Workflow.WebService.csproj" />
<Folder Name="/__Tests/">
<Project Path="__Tests/StellaOps.Workflow.Engine.Tests/StellaOps.Workflow.Engine.Tests.csproj" />
<Project Path="__Tests/StellaOps.Workflow.WebService.Tests/StellaOps.Workflow.WebService.Tests.csproj" />
<Project Path="__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj" />
<Project Path="__Tests/StellaOps.Workflow.DataStore.MongoDB.Tests/StellaOps.Workflow.DataStore.MongoDB.Tests.csproj" />
<Project Path="__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests.csproj" />
<Project Path="__Tests/StellaOps.Workflow.Signaling.Redis.Tests/StellaOps.Workflow.Signaling.Redis.Tests.csproj" />
<Project Path="__Tests/StellaOps.Workflow.DataStore.Oracle.Tests/StellaOps.Workflow.DataStore.Oracle.Tests.csproj" />
<Project Path="__Tests/StellaOps.Workflow.IntegrationTests.Shared/StellaOps.Workflow.IntegrationTests.Shared.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Canonical workflow definition operations: retrieving the JSON schema and
/// validating canonical definition payloads.
/// </summary>
public interface IWorkflowCanonicalDefinitionApi
{
Task<WorkflowCanonicalSchemaGetResponse> GetSchemaAsync(
CancellationToken cancellationToken = default);
Task<WorkflowCanonicalValidateResponse> ValidateAsync(
WorkflowCanonicalValidateRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,37 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow definition deployment operations: export, import, activate, version listing,
/// single definition lookup, and rendering.
/// </summary>
public interface IWorkflowDefinitionDeploymentApi
{
Task<WorkflowDefinitionExportResponse> ExportAsync(
WorkflowDefinitionExportRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowDefinitionImportResponse> ImportAsync(
WorkflowDefinitionImportRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowDefinitionActivateResponse> ActivateAsync(
WorkflowDefinitionActivateRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowDefinitionVersionsGetResponse> GetVersionsAsync(
WorkflowDefinitionVersionsGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowDefinitionByIdResponse> GetDefinitionByIdAsync(
WorkflowDefinitionByIdRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowRenderingResponse> RenderAsync(
WorkflowRenderingRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow definition query operations: listing registered workflow definitions.
/// </summary>
public interface IWorkflowDefinitionQueryApi
{
Task<WorkflowDefinitionGetResponse> GetDefinitionsAsync(
WorkflowDefinitionGetRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow diagram operations: retrieving the visual graph representation of a workflow.
/// </summary>
public interface IWorkflowDiagramApi
{
Task<WorkflowDiagramGetResponse> GetDiagramAsync(
WorkflowDiagramGetRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow function catalog operations: retrieving the canonical function catalog
/// and installed workflow modules.
/// </summary>
public interface IWorkflowFunctionCatalogApi
{
Task<WorkflowFunctionCatalogGetResponse> GetCatalogAsync(
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow retention operations: running cleanup and purge of stale workflow data.
/// </summary>
public interface IWorkflowRetentionApi
{
Task<WorkflowRetentionRunResponse> RunRetentionAsync(
WorkflowRetentionRunRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,49 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Core workflow runtime operations: starting workflows, querying instances and tasks,
/// completing/assigning/releasing tasks, and raising external signals.
/// </summary>
public interface IWorkflowRuntimeApi
{
Task<StartWorkflowResponse> StartWorkflowAsync(
StartWorkflowRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowInstanceGetResponse> GetInstanceAsync(
WorkflowInstanceGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowInstancesGetResponse> GetInstancesAsync(
WorkflowInstancesGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowTaskGetResponse> GetTaskAsync(
WorkflowTaskGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowTasksGetResponse> GetTasksAsync(
WorkflowTasksGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowTaskCompleteResponse> CompleteTaskAsync(
WorkflowTaskCompleteRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowTaskAssignResponse> AssignTaskAsync(
WorkflowTaskAssignRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowTaskReleaseResponse> ReleaseTaskAsync(
WorkflowTaskReleaseRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowSignalRaiseResponse> RaiseExternalSignalAsync(
WorkflowSignalRaiseRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow service metadata operations: retrieving operational metadata about
/// the running workflow service instance.
/// </summary>
public interface IWorkflowServiceMetadataApi
{
Task<WorkflowServiceMetadataGetResponse> GetMetadataAsync(
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow signal dead-letter operations: listing and replaying dead-lettered signals.
/// </summary>
public interface IWorkflowSignalDeadLetterApi
{
Task<WorkflowSignalDeadLettersGetResponse> GetMessagesAsync(
WorkflowSignalDeadLettersGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowSignalDeadLetterReplayResponse> ReplayAsync(
WorkflowSignalDeadLetterReplayRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Workflow signal pump telemetry operations: retrieving in-process statistics
/// for the workflow signal pump.
/// </summary>
public interface IWorkflowSignalPumpTelemetryApi
{
Task<WorkflowSignalPumpStatsGetResponse> GetStatsAsync(
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="NJsonSchema" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Workflow.Contracts\StellaOps.Workflow.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Abstractions;
public enum WorkflowTaskAction
{
AssignSelf,
AssignOther,
AssignRoles,
Release,
Complete,
}
public sealed record WorkflowAssignmentPermissionContext
{
public required WorkflowTaskAction Action { get; init; }
public required string ActorId { get; init; }
public string? CurrentAssignee { get; init; }
public string? TargetUserId { get; init; }
public IReadOnlyCollection<string> TargetRoles { get; init; } = [];
public IReadOnlyCollection<string> ActorRoles { get; init; } = [];
public IReadOnlyCollection<string> EffectiveRoles { get; init; } = [];
}
public sealed record WorkflowAssignmentPermissionDecision
{
public required bool Allowed { get; init; }
public string? Reason { get; init; }
}
public interface IWorkflowAssignmentPermissionEvaluator
{
WorkflowAssignmentPermissionDecision Evaluate(WorkflowAssignmentPermissionContext context);
}

View File

@@ -0,0 +1,18 @@
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowBackendConfigurationExtensions
{
public static string GetWorkflowBackendProvider(this IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var providerName = configuration.GetSection(WorkflowBackendOptions.SectionName)[nameof(WorkflowBackendOptions.Provider)];
return string.IsNullOrWhiteSpace(providerName)
? WorkflowBackendNames.Oracle
: providerName;
}
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowBackendRegistrationMarker
{
string BackendName { get; }
}
public sealed record WorkflowBackendRegistrationMarker(string BackendName) : IWorkflowBackendRegistrationMarker;

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowBackendNames
{
public const string Oracle = "Oracle";
public const string Postgres = "Postgres";
public const string Mongo = "Mongo";
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Workflow.Abstractions;
public sealed class WorkflowBackendOptions
{
public const string SectionName = "WorkflowBackend";
public string Provider { get; set; } = WorkflowBackendNames.Oracle;
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Json;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowBusinessReferenceExtensions
{
public static WorkflowBusinessReference? NormalizeBusinessReference(WorkflowBusinessReference? businessReference)
{
if (businessReference is null)
{
return null;
}
var normalizedParts = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var part in businessReference.Parts)
{
if (!string.IsNullOrWhiteSpace(part.Key))
{
normalizedParts[part.Key] = NormalizePartValue(part.Value);
}
}
var key = string.IsNullOrWhiteSpace(businessReference.Key)
? BuildCanonicalBusinessReferenceKey(normalizedParts)
: ConvertBusinessReferenceValueToString(businessReference.Key);
if (string.IsNullOrWhiteSpace(key) && normalizedParts.Count == 0)
{
return null;
}
return new WorkflowBusinessReference
{
Key = key,
Parts = normalizedParts,
};
}
public static string? BuildCanonicalBusinessReferenceKey(IDictionary<string, object?> parts)
{
var normalizedParts = parts
.Where(x => !string.IsNullOrWhiteSpace(x.Key))
.Select(x => new KeyValuePair<string, string?>(
x.Key,
ConvertBusinessReferenceValueToString(x.Value)))
.Where(x => !string.IsNullOrWhiteSpace(x.Value))
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToArray();
if (normalizedParts.Length == 0)
{
return null;
}
var builder = new StringBuilder();
for (var index = 0; index < normalizedParts.Length; index++)
{
if (index > 0)
{
builder.Append('|');
}
builder.Append(Uri.EscapeDataString(normalizedParts[index].Key));
builder.Append('=');
builder.Append(Uri.EscapeDataString(normalizedParts[index].Value!));
}
return builder.ToString();
}
public static bool MatchesBusinessReferenceFilter(
this WorkflowBusinessReference? businessReference,
string? key,
IDictionary<string, object?> parts)
{
var normalizedReference = NormalizeBusinessReference(businessReference);
if (!string.IsNullOrWhiteSpace(key)
&& !string.Equals(normalizedReference?.Key, key, StringComparison.Ordinal))
{
return false;
}
if (parts.Count == 0)
{
return true;
}
if (normalizedReference?.Parts is null || normalizedReference.Parts.Count == 0)
{
return false;
}
foreach (var filterPart in parts)
{
if (!normalizedReference.Parts.TryGetValue(filterPart.Key, out var referenceValue))
{
return false;
}
if (!string.Equals(
ConvertBusinessReferenceValueToString(referenceValue),
ConvertBusinessReferenceValueToString(filterPart.Value),
StringComparison.Ordinal))
{
return false;
}
}
return true;
}
public static IDictionary<string, object?> ToObjectDictionary(this WorkflowBusinessReference? businessReference)
{
var normalizedReference = NormalizeBusinessReference(businessReference);
if (normalizedReference is null)
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["key"] = normalizedReference.Key,
["parts"] = new Dictionary<string, object?>(normalizedReference.Parts, StringComparer.OrdinalIgnoreCase),
};
}
public static WorkflowBusinessReference? ToBusinessReference(this IDictionary<string, object?> value)
{
if (value.Count == 0)
{
return null;
}
value.TryGetValue("key", out var key);
value.TryGetValue("parts", out var parts);
return NormalizeBusinessReference(new WorkflowBusinessReference
{
Key = ConvertBusinessReferenceValueToString(key),
Parts = ConvertToPartsDictionary(parts),
});
}
public static string? ConvertBusinessReferenceValueToString(object? value)
{
return value switch
{
null => null,
string text => text,
JsonElement element when element.ValueKind == JsonValueKind.String => element.GetString(),
JsonElement element when element.ValueKind == JsonValueKind.Number => element.ToString(),
JsonElement element when element.ValueKind == JsonValueKind.True => bool.TrueString,
JsonElement element when element.ValueKind == JsonValueKind.False => bool.FalseString,
JsonElement element when element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined => null,
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString(),
};
}
private static object? NormalizePartValue(object? value)
{
return value is JsonElement element
? JsonSerializer.Deserialize<object?>(element.GetRawText())
: value;
}
private static IDictionary<string, object?> ConvertToPartsDictionary(object? value)
{
return value switch
{
IDictionary<string, object?> dictionary => new Dictionary<string, object?>(dictionary, StringComparer.OrdinalIgnoreCase),
JsonElement element when element.ValueKind == JsonValueKind.Object =>
JsonSerializer.Deserialize<Dictionary<string, object?>>(element.GetRawText())
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase),
_ => new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase),
};
}
}

View File

@@ -0,0 +1,984 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Workflow.Contracts;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Reverse compiler: converts a <see cref="WorkflowCanonicalDefinition"/> back to C# fluent DSL source code
/// using Roslyn <see cref="SyntaxFactory"/> for guaranteed syntactic correctness and consistent formatting.
/// Used for round-trip verification (compile -> decompile -> re-compile -> compare).
/// </summary>
public static class WorkflowCanonicalDecompiler
{
/// <summary>
/// Decompiles a canonical definition to syntactically valid, formatted C# source code.
/// Uses Roslyn SyntaxFactory to build an AST, then NormalizeWhitespace() for formatting.
/// </summary>
public static string Decompile(WorkflowCanonicalDefinition definition)
{
ArgumentNullException.ThrowIfNull(definition);
var requestClassName = SafeIdentifier(definition.WorkflowName) + "Request";
var requestClass = BuildRequestClass(requestClassName, definition.StartRequest);
var classDecl = BuildClassDeclaration(definition, requestClassName);
var compilationUnit = CompilationUnit()
.AddUsings(
UsingDirective(ParseName("System")),
UsingDirective(ParseName("System.Collections.Generic")),
UsingDirective(ParseName("System.Text.Json")),
UsingDirective(ParseName("StellaOps.Workflow.Abstractions")),
UsingDirective(ParseName("StellaOps.Workflow.Contracts")))
.AddMembers(requestClass, classDecl)
.NormalizeWhitespace();
var source = compilationUnit.ToFullString().Replace("DynamicWorkflowRequest", requestClassName);
return FormatFluentChains(source);
}
/// <summary>
/// Reconstructs a <see cref="WorkflowCanonicalDefinition"/> from the declaration model itself.
/// Performs a deep clone for round-trip fidelity testing.
/// </summary>
public static WorkflowCanonicalDefinition Reconstruct(WorkflowCanonicalDefinition definition)
{
ArgumentNullException.ThrowIfNull(definition);
return new WorkflowCanonicalDefinition
{
SchemaVersion = definition.SchemaVersion,
WorkflowName = definition.WorkflowName,
WorkflowVersion = definition.WorkflowVersion,
DisplayName = definition.DisplayName,
StartRequest = definition.StartRequest,
WorkflowRoles = definition.WorkflowRoles.ToArray(),
BusinessReference = ReconstructBusinessReference(definition.BusinessReference),
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = ReconstructExpression(definition.Start.InitializeStateExpression),
InitialTaskName = definition.Start.InitialTaskName,
InitialSequence = ReconstructSequence(definition.Start.InitialSequence),
},
Tasks = definition.Tasks.Select(ReconstructTask).ToArray(),
RequiredModules = definition.RequiredModules.ToArray(),
RequiredCapabilities = definition.RequiredCapabilities.ToArray(),
};
}
// ═══════════════════════════════════════════════════════════
// ROSLYN AST: Class structure
// ═══════════════════════════════════════════════════════════
private static ClassDeclarationSyntax BuildRequestClass(
string className,
WorkflowRequestContractDeclaration? startRequest)
{
var members = new List<MemberDeclarationSyntax>();
if (startRequest?.Schema is { } schema
&& schema.TryGetValue("properties", out var propsObj))
{
var propEntries = ExtractPropertyEntries(propsObj);
foreach (var (propName, jsonType) in propEntries)
{
var clrType = jsonType switch
{
"string" => "string",
"number" => "long",
"boolean" => "bool",
"array" => "object[]",
_ => "object",
};
var isNullable = clrType is "string" or "object" or "object[]";
var typeName = isNullable ? clrType + "?" : clrType;
// PascalCase the property name
var pascalName = char.ToUpperInvariant(propName[0]) + propName[1..];
var prop = PropertyDeclaration(ParseTypeName(typeName), pascalName)
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.AddAccessorListAccessors(
AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken)));
members.Add(prop);
}
}
// If no schema, add a generic payload property
if (members.Count == 0)
{
members.Add(PropertyDeclaration(ParseTypeName("IDictionary<string, object?>"), "Payload")
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.AddAccessorListAccessors(
AccessorDeclaration(SyntaxKind.GetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
AccessorDeclaration(SyntaxKind.SetAccessorDeclaration).WithSemicolonToken(Token(SyntaxKind.SemicolonToken))));
}
return ClassDeclaration(className)
.AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
.WithLeadingTrivia(Comment($"/// <summary>Start request model for the workflow.</summary>"))
.AddMembers(members.ToArray());
}
private static ClassDeclarationSyntax BuildClassDeclaration(WorkflowCanonicalDefinition def, string requestClassName)
{
var className = SafeIdentifier(def.WorkflowName) + "Workflow";
var members = new List<MemberDeclarationSyntax>
{
BuildPropertyArrow("WorkflowName", Str(def.WorkflowName)),
BuildPropertyArrow("WorkflowVersion", Str(def.WorkflowVersion)),
BuildPropertyArrow("DisplayName", Str(def.DisplayName)),
BuildRolesProperty(def.WorkflowRoles),
};
// Spec property
var specInit = BuildSpecInitializer(def);
members.Add(PropertyDeclaration(ParseTypeName("WorkflowSpec<DynamicWorkflowRequest>"), "Spec")
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.AddAccessorListAccessors(
AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken)))
.WithInitializer(EqualsValueClause(specInit))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken)));
// Tasks property
members.Add(BuildPropertyArrow("Tasks",
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName("Spec"), IdentifierName("TaskDescriptors")),
"IReadOnlyCollection<WorkflowTaskDescriptor>"));
return ClassDeclaration(className)
.AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
.AddBaseListTypes(SimpleBaseType(ParseTypeName("IDeclarativeWorkflow<DynamicWorkflowRequest>")))
.AddMembers(members.ToArray());
}
private static ExpressionSyntax BuildSpecInitializer(WorkflowCanonicalDefinition def)
{
ExpressionSyntax chain = InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName("WorkflowSpec"),
GenericName("For").AddTypeArgumentListArguments(ParseTypeName("DynamicWorkflowRequest"))),
IdentifierName("Invoke")))
.WithArgumentList(ArgumentList());
// Simplify: WorkflowSpec.For<DynamicWorkflowRequest>()
chain = InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName("WorkflowSpec"),
GenericName("For").AddTypeArgumentListArguments(ParseTypeName("DynamicWorkflowRequest"))));
// .InitializeState(expr)
chain = Invoke(chain, "InitializeState", EmitExpression(def.Start.InitializeStateExpression));
// .AddTask(...) for each task
foreach (var task in def.Tasks)
{
chain = Invoke(chain, "AddTask", EmitTaskDefinition(task));
}
// .StartWith(...)
if (!string.IsNullOrWhiteSpace(def.Start.InitialTaskName))
{
chain = Invoke(chain, "StartWith",
SimpleLambdaExpression(Parameter(Identifier("flow")),
EmitFlowChain(IdentifierName("flow"), def.Start.InitialSequence, def.BusinessReference)));
}
else if (def.Start.InitialSequence.Steps.Count > 0)
{
chain = Invoke(chain, "StartWith",
SimpleLambdaExpression(Parameter(Identifier("flow")),
EmitFlowChain(IdentifierName("flow"), def.Start.InitialSequence, def.BusinessReference)));
}
// .Build()
chain = Invoke(chain, "Build");
return chain;
}
// ═══════════════════════════════════════════════════════════
// ROSLYN AST: Flow chain (step sequences)
// ═══════════════════════════════════════════════════════════
private static ExpressionSyntax EmitFlowChain(
ExpressionSyntax target,
WorkflowStepSequenceDeclaration sequence,
WorkflowBusinessReferenceDeclaration? businessRef = null)
{
var chain = target;
if (businessRef is not null)
{
chain = Invoke(chain, "SetBusinessReference", EmitBusinessReference(businessRef));
}
foreach (var step in sequence.Steps)
{
chain = EmitStep(chain, step);
}
return chain;
}
private static ExpressionSyntax EmitStep(ExpressionSyntax chain, WorkflowStepDeclaration step)
{
return step switch
{
WorkflowSetStateStepDeclaration s => Invoke(chain,
s.OnlyIfPresent ? "SetIfHasValue" : "Set",
Str(s.StateKey), EmitExpression(s.ValueExpression)),
WorkflowAssignBusinessReferenceStepDeclaration s =>
Invoke(chain, "SetBusinessReference", EmitBusinessReference(s.BusinessReference)),
WorkflowTransportCallStepDeclaration s => EmitTransportCall(chain, s),
WorkflowDecisionStepDeclaration s => EmitDecision(chain, s),
WorkflowActivateTaskStepDeclaration s => s.RuntimeRolesExpression is not null
? Invoke(chain, "ActivateTask", Str(s.TaskName), EmitExpression(s.RuntimeRolesExpression))
: Invoke(chain, "ActivateTask", Str(s.TaskName)),
WorkflowContinueWithWorkflowStepDeclaration s =>
Invoke(chain, "ContinueWith", Str(s.StepName), EmitWorkflowInvocation(s.Invocation)),
WorkflowSubWorkflowStepDeclaration s =>
Invoke(chain, "SubWorkflow", Str(s.StepName), EmitWorkflowInvocation(s.Invocation)),
WorkflowRepeatStepDeclaration s => EmitRepeat(chain, s),
WorkflowTimerStepDeclaration s =>
Invoke(chain, "Wait", Str(s.StepName), EmitExpression(s.DelayExpression)),
WorkflowExternalSignalStepDeclaration s => EmitExternalSignal(chain, s),
WorkflowForkStepDeclaration s => EmitFork(chain, s),
WorkflowCompleteStepDeclaration => Invoke(chain, "Complete"),
_ => chain,
};
}
private static ExpressionSyntax EmitTransportCall(ExpressionSyntax chain, WorkflowTransportCallStepDeclaration call)
{
var args = new List<ArgumentSyntax>
{
Argument(Str(call.StepName)),
Argument(EmitAddress(call.Invocation.Address)),
Argument(call.Invocation.PayloadExpression is not null
? EmitExpression(call.Invocation.PayloadExpression)
: Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null))),
};
if (call.WhenFailure is { Steps.Count: > 0 })
{
args.Add(Argument(
SimpleLambdaExpression(Parameter(Identifier("fail")),
EmitFlowChain(IdentifierName("fail"), call.WhenFailure)))
.WithNameColon(NameColon("whenFailure")));
}
if (call.WhenTimeout is { Steps.Count: > 0 })
{
args.Add(Argument(
SimpleLambdaExpression(Parameter(Identifier("timeout")),
EmitFlowChain(IdentifierName("timeout"), call.WhenTimeout)))
.WithNameColon(NameColon("whenTimeout")));
}
if (!string.IsNullOrWhiteSpace(call.ResultKey))
{
args.Add(Argument(Str(call.ResultKey)).WithNameColon(NameColon("resultKey")));
}
if (call.TimeoutSeconds.HasValue)
{
args.Add(Argument(Num(call.TimeoutSeconds.Value)).WithNameColon(NameColon("timeoutSeconds")));
}
// When resultKey is set, we need Call<object> to satisfy the generic overload
SimpleNameSyntax callName = !string.IsNullOrWhiteSpace(call.ResultKey)
? GenericName("Call").AddTypeArgumentListArguments(ParseTypeName("object"))
: IdentifierName("Call");
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, callName),
ArgumentList(SeparatedList(args)));
}
private static ExpressionSyntax EmitDecision(ExpressionSyntax chain, WorkflowDecisionStepDeclaration decision)
{
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("WhenExpression")),
ArgumentList(SeparatedList(new[]
{
Argument(Str(decision.DecisionName)),
Argument(EmitExpression(decision.ConditionExpression)),
Argument(EmitLambdaForSequence("whenTrue", decision.WhenTrue)),
Argument(EmitLambdaForSequence("whenElse", decision.WhenElse)),
})));
}
private static ExpressionSyntax EmitRepeat(ExpressionSyntax chain, WorkflowRepeatStepDeclaration repeat)
{
var args = new List<ArgumentSyntax>
{
Argument(Str(repeat.StepName)),
Argument(EmitExpression(repeat.MaxIterationsExpression)),
};
if (!string.IsNullOrWhiteSpace(repeat.IterationStateKey))
{
args.Add(Argument(Str(repeat.IterationStateKey)));
}
if (repeat.ContinueWhileExpression is not null)
{
args.Add(Argument(EmitExpression(repeat.ContinueWhileExpression)));
}
args.Add(Argument(
SimpleLambdaExpression(Parameter(Identifier("body")),
EmitFlowChain(IdentifierName("body"), repeat.Body))));
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("Repeat")),
ArgumentList(SeparatedList(args)));
}
private static ExpressionSyntax EmitFork(ExpressionSyntax chain, WorkflowForkStepDeclaration fork)
{
var args = new List<ArgumentSyntax> { Argument(Str(fork.StepName)) };
var i = 0;
foreach (var branch in fork.Branches)
{
args.Add(Argument(EmitLambdaForSequence($"branch{++i}", branch)));
}
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("Fork")),
ArgumentList(SeparatedList(args)));
}
private static ExpressionSyntax EmitExternalSignal(ExpressionSyntax chain, WorkflowExternalSignalStepDeclaration signal)
{
var args = new List<ArgumentSyntax>
{
Argument(Str(signal.StepName)),
Argument(EmitExpression(signal.SignalNameExpression)),
};
if (!string.IsNullOrWhiteSpace(signal.ResultKey))
{
args.Add(Argument(Str(signal.ResultKey)).WithNameColon(NameColon("resultKey")));
}
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("WaitForSignal")),
ArgumentList(SeparatedList(args)));
}
// ═══════════════════════════════════════════════════════════
// ROSLYN AST: Expressions
// ═══════════════════════════════════════════════════════════
private static ExpressionSyntax EmitExpression(WorkflowExpressionDefinition expr)
{
return expr switch
{
WorkflowNullExpressionDefinition =>
Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null)),
WorkflowStringExpressionDefinition s =>
Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.String), Str(s.Value)),
WorkflowNumberExpressionDefinition n =>
Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Number),
LiteralExpression(SyntaxKind.NumericLiteralExpression,
Literal(n.Value, decimal.TryParse(n.Value, out var d) ? (long)d : 0))),
WorkflowBooleanExpressionDefinition b =>
Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Bool),
LiteralExpression(b.Value ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression)),
WorkflowPathExpressionDefinition p =>
Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Path), Str(p.Path)),
WorkflowObjectExpressionDefinition o => EmitObjectExpression(o),
WorkflowArrayExpressionDefinition a => EmitArrayExpression(a),
WorkflowFunctionExpressionDefinition f => EmitFunctionExpression(f),
WorkflowGroupExpressionDefinition g => EmitExpression(g.Expression),
WorkflowUnaryExpressionDefinition u =>
Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Not), EmitExpression(u.Operand)),
WorkflowBinaryExpressionDefinition b => EmitBinaryExpression(b),
_ => Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null)),
};
}
private static ExpressionSyntax EmitObjectExpression(WorkflowObjectExpressionDefinition obj)
{
if (obj.Properties.Count == 0)
{
return Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Obj));
}
var args = obj.Properties.Select(p =>
Argument(InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Prop))),
ArgumentList(SeparatedList(new[]
{
Argument(Str(p.Name)),
Argument(EmitExpression(p.Expression)),
}))))).ToArray();
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Obj))),
ArgumentList(SeparatedList(args)));
}
private static ExpressionSyntax EmitArrayExpression(WorkflowArrayExpressionDefinition arr)
{
if (arr.Items.Count == 0)
{
return Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Array));
}
var args = arr.Items.Select(i => Argument(EmitExpression(i))).ToArray();
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Array))),
ArgumentList(SeparatedList(args)));
}
private static ExpressionSyntax EmitFunctionExpression(WorkflowFunctionExpressionDefinition func)
{
var args = new List<ArgumentSyntax> { Argument(Str(func.FunctionName)) };
args.AddRange(func.Arguments.Select(a => Argument(EmitExpression(a))));
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Func))),
ArgumentList(SeparatedList(args)));
}
private static ExpressionSyntax EmitBinaryExpression(WorkflowBinaryExpressionDefinition binary)
{
var method = binary.Operator switch
{
"eq" => "Eq", "ne" => "Ne", "gt" => "Gt", "gte" => "Gte",
"lt" => "Lt", "lte" => "Lte", "and" => "And", "or" => "Or",
_ => null,
};
if (method is not null)
{
return Invoke(IdentifierName(nameof(WorkflowExpr)), method,
EmitExpression(binary.Left), EmitExpression(binary.Right));
}
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(nameof(WorkflowExpr)), IdentifierName(nameof(WorkflowExpr.Binary))),
ArgumentList(SeparatedList(new[]
{
Argument(Str(binary.Operator)),
Argument(EmitExpression(binary.Left)),
Argument(EmitExpression(binary.Right)),
})));
}
// ═══════════════════════════════════════════════════════════
// ROSLYN AST: Addresses
// ═══════════════════════════════════════════════════════════
private static ExpressionSyntax EmitAddress(WorkflowTransportAddressDeclaration address)
{
return address switch
{
WorkflowLegacyRabbitAddressDeclaration a => a.Mode == WorkflowLegacyRabbitMode.Envelope
? New(nameof(LegacyRabbitAddress), Str(a.Command))
: New(nameof(LegacyRabbitAddress), Str(a.Command),
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(nameof(WorkflowLegacyRabbitMode)), IdentifierName(a.Mode.ToString()))),
WorkflowMicroserviceAddressDeclaration a =>
New(nameof(Address), Str(a.MicroserviceName), Str(a.Command)),
WorkflowRabbitAddressDeclaration a =>
New(nameof(Address), Str(a.Exchange), Str(a.RoutingKey)),
WorkflowHttpAddressDeclaration a => string.Equals(a.Method, "POST", StringComparison.OrdinalIgnoreCase)
? New(nameof(HttpAddress), Str(a.Target), Str(a.Path))
: New(nameof(HttpAddress), Str(a.Target), Str(a.Path), Str(a.Method)),
WorkflowGraphqlAddressDeclaration a => string.IsNullOrWhiteSpace(a.OperationName)
? New(nameof(GraphqlAddress), Str(a.Target), Str(a.Query))
: New(nameof(GraphqlAddress), Str(a.Target), Str(a.Query), Str(a.OperationName)),
_ => Invoke(IdentifierName(nameof(WorkflowExpr)), nameof(WorkflowExpr.Null)),
};
}
// ═══════════════════════════════════════════════════════════
// ROSLYN AST: Tasks, business ref, workflow invocation
// ═══════════════════════════════════════════════════════════
private static ExpressionSyntax EmitTaskDefinition(WorkflowTaskDeclaration task)
{
ExpressionSyntax chain = InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName("WorkflowHumanTask"),
GenericName("For").AddTypeArgumentListArguments(ParseTypeName("DynamicWorkflowRequest"))),
ArgumentList(SeparatedList(new[]
{
Argument(Str(task.TaskName)),
Argument(Str(task.TaskType)),
Argument(Str("default")),
})));
if (task.TaskRoles.Count > 0)
{
chain = InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("WithRoles")),
ArgumentList(SeparatedList(task.TaskRoles.Select(r => Argument(Str(r))))));
}
chain = Invoke(chain, "WithPayload", EmitExpression(task.PayloadExpression));
if (task.OnComplete.Steps.Count > 0)
{
chain = InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, chain, IdentifierName("OnComplete")),
ArgumentList(SingletonSeparatedList(Argument(
SimpleLambdaExpression(Parameter(Identifier("flow")),
EmitFlowChain(IdentifierName("flow"), task.OnComplete))))));
}
else
{
chain = Invoke(chain, "Build");
}
return chain;
}
private static ExpressionSyntax EmitBusinessReference(WorkflowBusinessReferenceDeclaration businessRef)
{
var props = new List<ExpressionSyntax>();
if (businessRef.KeyExpression is not null)
{
props.Add(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression,
IdentifierName("KeyExpression"), EmitExpression(businessRef.KeyExpression)));
}
if (businessRef.Parts.Count > 0)
{
var partElements = businessRef.Parts.Select(p =>
(ExpressionSyntax)ObjectCreationExpression(ParseTypeName(nameof(WorkflowNamedExpressionDefinition)))
.WithArgumentList(ArgumentList())
.WithInitializer(InitializerExpression(SyntaxKind.ObjectInitializerExpression,
SeparatedList<ExpressionSyntax>(new[]
{
AssignmentExpression(SyntaxKind.SimpleAssignmentExpression,
IdentifierName("Name"), Str(p.Name)),
AssignmentExpression(SyntaxKind.SimpleAssignmentExpression,
IdentifierName("Expression"), EmitExpression(p.Expression)),
})))).ToArray();
props.Add(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression,
IdentifierName("Parts"),
ArrayCreationExpression(
ArrayType(ParseTypeName($"{nameof(WorkflowNamedExpressionDefinition)}[]")))
.WithInitializer(InitializerExpression(SyntaxKind.ArrayInitializerExpression,
SeparatedList(partElements)))));
}
return ObjectCreationExpression(ParseTypeName(nameof(WorkflowBusinessReferenceDeclaration)))
.WithArgumentList(ArgumentList())
.WithInitializer(InitializerExpression(SyntaxKind.ObjectInitializerExpression,
SeparatedList(props)));
}
private static ExpressionSyntax EmitWorkflowInvocation(WorkflowWorkflowInvocationDeclaration invocation)
{
var props = new List<ExpressionSyntax>
{
AssignmentExpression(SyntaxKind.SimpleAssignmentExpression,
IdentifierName("WorkflowNameExpression"), EmitExpression(invocation.WorkflowNameExpression)),
};
if (invocation.PayloadExpression is not null)
{
props.Add(AssignmentExpression(SyntaxKind.SimpleAssignmentExpression,
IdentifierName("PayloadExpression"), EmitExpression(invocation.PayloadExpression)));
}
return ObjectCreationExpression(ParseTypeName(nameof(WorkflowWorkflowInvocationDeclaration)))
.WithArgumentList(ArgumentList())
.WithInitializer(InitializerExpression(SyntaxKind.ObjectInitializerExpression,
SeparatedList(props)));
}
// ═══════════════════════════════════════════════════════════
// ROSLYN AST: Helpers
// ═══════════════════════════════════════════════════════════
private static PropertyDeclarationSyntax BuildPropertyArrow(string name, ExpressionSyntax value, string? type = null)
{
return PropertyDeclaration(ParseTypeName(type ?? "string"), name)
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.WithExpressionBody(ArrowExpressionClause(value))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken));
}
private static PropertyDeclarationSyntax BuildRolesProperty(IReadOnlyCollection<string> roles)
{
ExpressionSyntax value = roles.Count > 0
? CollectionExpression(SeparatedList(
roles.Select(r => (CollectionElementSyntax)ExpressionElement(Str(r)))))
: CollectionExpression();
return PropertyDeclaration(ParseTypeName("IReadOnlyCollection<string>"), "WorkflowRoles")
.AddModifiers(Token(SyntaxKind.PublicKeyword))
.WithExpressionBody(ArrowExpressionClause(value))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken));
}
private static ExpressionSyntax Str(string value)
{
return LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(value));
}
private static ExpressionSyntax Num(int value)
{
return LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(value));
}
private static InvocationExpressionSyntax Invoke(ExpressionSyntax target, string method, params ExpressionSyntax[] args)
{
return InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, target, IdentifierName(method)),
ArgumentList(SeparatedList(args.Select(Argument))));
}
/// <summary>
/// Emits a lambda for a step sequence. If the sequence is empty, emits _ => { } (block body)
/// instead of a bare expression lambda which would be invalid as a statement.
/// </summary>
private static LambdaExpressionSyntax EmitLambdaForSequence(string paramName, WorkflowStepSequenceDeclaration sequence)
{
if (sequence.Steps.Count == 0)
{
return SimpleLambdaExpression(Parameter(Identifier("_")), Block());
}
return SimpleLambdaExpression(Parameter(Identifier(paramName)),
EmitFlowChain(IdentifierName(paramName), sequence));
}
private static ObjectCreationExpressionSyntax New(string className, params ExpressionSyntax[] args)
{
return ObjectCreationExpression(ParseTypeName(className))
.WithArgumentList(ArgumentList(SeparatedList(args.Select(Argument))));
}
/// <summary>
/// Post-processes Roslyn output to break long lines with proper indentation.
/// Handles fluent chains, WorkflowExpr.Object/Array/Func arguments, and Prop calls.
/// </summary>
private static string FormatFluentChains(string source)
{
var lines = source.Split('\n');
var result = new global::System.Text.StringBuilder(source.Length * 2);
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd('\r');
if (line.Length > 100)
{
try
{
var indent = line.Length - line.TrimStart().Length;
var formatted = FormatLongLine(line.TrimStart(), indent);
result.AppendLine(formatted);
}
catch
{
// If formatting fails, keep the original line
result.AppendLine(line);
}
}
else
{
result.AppendLine(line);
}
}
return result.ToString().TrimEnd() + Environment.NewLine;
}
private static string FormatLongLine(string line, int baseIndent)
{
var sb = new global::System.Text.StringBuilder(line.Length * 3);
var indentStr = new string(' ', baseIndent);
var depth = 0;
var col = baseIndent;
sb.Append(indentStr);
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (ch == '(')
{
sb.Append(ch);
depth++;
col++;
// Break after "(" if followed by a long argument list
if (ShouldBreakAfterOpen(line, i))
{
sb.AppendLine();
var inner = new string(' ', baseIndent + depth * 4);
sb.Append(inner);
col = inner.Length;
}
}
else if (ch == ')')
{
depth--;
// Break before ")" if we broke after the matching "("
if (i > 0 && col > 100 && depth >= 0)
{
// Only add newline if previous char is not already a newline
var prev = sb.Length > 0 ? sb[sb.Length - 1] : ' ';
if (prev != '\n')
{
sb.AppendLine();
sb.Append(new string(' ', baseIndent + depth * 4));
}
}
sb.Append(ch);
col++;
// Break after ")." for fluent chains at low depth
if (depth <= 1 && i + 1 < line.Length && line[i + 1] == '.')
{
sb.AppendLine();
sb.Append(new string(' ', baseIndent + depth * 4));
col = baseIndent + depth * 4;
}
}
else if (ch == ',' && depth >= 1)
{
sb.Append(ch);
col++;
// Break after comma in argument lists when line is getting long
if (col > 80 && i + 1 < line.Length && line[i + 1] == ' ')
{
sb.AppendLine();
var inner = new string(' ', baseIndent + depth * 4);
sb.Append(inner);
col = inner.Length;
// Skip the space after comma since we're adding a newline
if (i + 1 < line.Length && line[i + 1] == ' ')
{
i++;
}
}
}
else
{
sb.Append(ch);
col++;
}
}
return sb.ToString();
}
private static bool ShouldBreakAfterOpen(string line, int openPos)
{
// Count chars until matching close paren
var depth = 1;
var len = 0;
for (var j = openPos + 1; j < line.Length && depth > 0; j++)
{
if (line[j] == '(') depth++;
else if (line[j] == ')') depth--;
len++;
}
// Break if the content between parens is long
return len > 60;
}
private static IEnumerable<(string Name, string JsonType)> ExtractPropertyEntries(object? propsObj)
{
// Handle direct dictionary (from compiler)
if (propsObj is IDictionary<string, object?> dict)
{
foreach (var (key, value) in dict)
{
var jsonType = "object";
if (value is IDictionary<string, object?> propSchema
&& propSchema.TryGetValue("type", out var typeVal))
{
jsonType = typeVal?.ToString() ?? "object";
}
yield return (key, jsonType);
}
yield break;
}
// Handle JsonElement (after JSON round-trip deserialization)
if (propsObj is global::System.Text.Json.JsonElement jsonElement
&& jsonElement.ValueKind == global::System.Text.Json.JsonValueKind.Object)
{
foreach (var prop in jsonElement.EnumerateObject())
{
var jsonType = "object";
if (prop.Value.ValueKind == global::System.Text.Json.JsonValueKind.Object
&& prop.Value.TryGetProperty("type", out var typeProp)
&& typeProp.ValueKind == global::System.Text.Json.JsonValueKind.String)
{
jsonType = typeProp.GetString() ?? "object";
}
yield return (prop.Name, jsonType);
}
}
}
private static string SafeIdentifier(string name)
{
return new string(name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray());
}
// ═══════════════════════════════════════════════════════════
// RECONSTRUCTION (Definition → Definition deep clone)
// ═══════════════════════════════════════════════════════════
private static WorkflowBusinessReferenceDeclaration? ReconstructBusinessReference(
WorkflowBusinessReferenceDeclaration? source)
{
if (source is null) return null;
return new WorkflowBusinessReferenceDeclaration
{
KeyExpression = source.KeyExpression is not null ? ReconstructExpression(source.KeyExpression) : null,
Parts = source.Parts.Select(p => new WorkflowNamedExpressionDefinition
{
Name = p.Name, Expression = ReconstructExpression(p.Expression),
}).ToArray(),
};
}
private static WorkflowTaskDeclaration ReconstructTask(WorkflowTaskDeclaration s) => new()
{
TaskName = s.TaskName, TaskType = s.TaskType,
RouteExpression = ReconstructExpression(s.RouteExpression),
PayloadExpression = ReconstructExpression(s.PayloadExpression),
TaskRoles = s.TaskRoles.ToArray(), OnComplete = ReconstructSequence(s.OnComplete),
};
private static WorkflowStepSequenceDeclaration ReconstructSequence(WorkflowStepSequenceDeclaration s) => new()
{
Steps = s.Steps.Select(ReconstructStep).ToArray(),
};
private static WorkflowStepDeclaration ReconstructStep(WorkflowStepDeclaration step) => step switch
{
WorkflowSetStateStepDeclaration s => new WorkflowSetStateStepDeclaration
{ StateKey = s.StateKey, ValueExpression = ReconstructExpression(s.ValueExpression), OnlyIfPresent = s.OnlyIfPresent },
WorkflowAssignBusinessReferenceStepDeclaration s => new WorkflowAssignBusinessReferenceStepDeclaration
{ BusinessReference = ReconstructBusinessReference(s.BusinessReference)! },
WorkflowTransportCallStepDeclaration s => new WorkflowTransportCallStepDeclaration
{
StepName = s.StepName, ResultKey = s.ResultKey, TimeoutSeconds = s.TimeoutSeconds,
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = s.Invocation.Address,
PayloadExpression = s.Invocation.PayloadExpression is not null ? ReconstructExpression(s.Invocation.PayloadExpression) : null,
},
WhenFailure = s.WhenFailure is not null ? ReconstructSequence(s.WhenFailure) : null,
WhenTimeout = s.WhenTimeout is not null ? ReconstructSequence(s.WhenTimeout) : null,
},
WorkflowDecisionStepDeclaration s => new WorkflowDecisionStepDeclaration
{
DecisionName = s.DecisionName, ConditionExpression = ReconstructExpression(s.ConditionExpression),
WhenTrue = ReconstructSequence(s.WhenTrue), WhenElse = ReconstructSequence(s.WhenElse),
},
WorkflowActivateTaskStepDeclaration s => new WorkflowActivateTaskStepDeclaration
{
TaskName = s.TaskName, TimeoutSeconds = s.TimeoutSeconds,
RuntimeRolesExpression = s.RuntimeRolesExpression is not null ? ReconstructExpression(s.RuntimeRolesExpression) : null,
},
WorkflowContinueWithWorkflowStepDeclaration s => new WorkflowContinueWithWorkflowStepDeclaration
{ StepName = s.StepName, Invocation = ReconstructWorkflowInvocation(s.Invocation) },
WorkflowSubWorkflowStepDeclaration s => new WorkflowSubWorkflowStepDeclaration
{ StepName = s.StepName, Invocation = ReconstructWorkflowInvocation(s.Invocation), ResultKey = s.ResultKey },
WorkflowRepeatStepDeclaration s => new WorkflowRepeatStepDeclaration
{
StepName = s.StepName, MaxIterationsExpression = ReconstructExpression(s.MaxIterationsExpression),
IterationStateKey = s.IterationStateKey, Body = ReconstructSequence(s.Body),
ContinueWhileExpression = s.ContinueWhileExpression is not null ? ReconstructExpression(s.ContinueWhileExpression) : null,
},
WorkflowTimerStepDeclaration s => new WorkflowTimerStepDeclaration
{ StepName = s.StepName, DelayExpression = ReconstructExpression(s.DelayExpression) },
WorkflowExternalSignalStepDeclaration s => new WorkflowExternalSignalStepDeclaration
{ StepName = s.StepName, SignalNameExpression = ReconstructExpression(s.SignalNameExpression), ResultKey = s.ResultKey },
WorkflowForkStepDeclaration s => new WorkflowForkStepDeclaration
{ StepName = s.StepName, Branches = s.Branches.Select(ReconstructSequence).ToArray() },
WorkflowCompleteStepDeclaration => new WorkflowCompleteStepDeclaration(),
_ => step,
};
private static WorkflowWorkflowInvocationDeclaration ReconstructWorkflowInvocation(WorkflowWorkflowInvocationDeclaration s) => new()
{
WorkflowNameExpression = ReconstructExpression(s.WorkflowNameExpression),
WorkflowVersionExpression = s.WorkflowVersionExpression is not null ? ReconstructExpression(s.WorkflowVersionExpression) : null,
PayloadExpression = s.PayloadExpression is not null ? ReconstructExpression(s.PayloadExpression) : null,
BusinessReference = ReconstructBusinessReference(s.BusinessReference),
};
private static WorkflowExpressionDefinition ReconstructExpression(WorkflowExpressionDefinition expr) => expr switch
{
WorkflowNullExpressionDefinition => new WorkflowNullExpressionDefinition(),
WorkflowStringExpressionDefinition s => new WorkflowStringExpressionDefinition { Value = s.Value },
WorkflowNumberExpressionDefinition n => new WorkflowNumberExpressionDefinition { Value = n.Value },
WorkflowBooleanExpressionDefinition b => new WorkflowBooleanExpressionDefinition { Value = b.Value },
WorkflowPathExpressionDefinition p => new WorkflowPathExpressionDefinition { Path = p.Path },
WorkflowObjectExpressionDefinition o => new WorkflowObjectExpressionDefinition
{
Properties = o.Properties.Select(p => new WorkflowNamedExpressionDefinition
{ Name = p.Name, Expression = ReconstructExpression(p.Expression) }).ToArray(),
},
WorkflowArrayExpressionDefinition a => new WorkflowArrayExpressionDefinition
{ Items = a.Items.Select(ReconstructExpression).ToArray() },
WorkflowFunctionExpressionDefinition f => new WorkflowFunctionExpressionDefinition
{ FunctionName = f.FunctionName, Arguments = f.Arguments.Select(ReconstructExpression).ToArray() },
WorkflowGroupExpressionDefinition g => new WorkflowGroupExpressionDefinition
{ Expression = ReconstructExpression(g.Expression) },
WorkflowUnaryExpressionDefinition u => new WorkflowUnaryExpressionDefinition
{ Operator = u.Operator, Operand = ReconstructExpression(u.Operand) },
WorkflowBinaryExpressionDefinition b => new WorkflowBinaryExpressionDefinition
{ Operator = b.Operator, Left = ReconstructExpression(b.Left), Right = ReconstructExpression(b.Right) },
_ => expr,
};
}

View File

@@ -0,0 +1,533 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public static partial class WorkflowCanonicalDefinitionCompiler
{
private static WorkflowTaskDeclaration BuildTask<TStartRequest>(
WorkflowHumanTaskDefinition<TStartRequest> task)
where TStartRequest : class
{
return new WorkflowTaskDeclaration
{
TaskName = task.TaskName,
TaskType = task.Descriptor.TaskType,
RouteExpression = task.RouteExpression ?? WorkflowExpr.String(task.Descriptor.Route),
PayloadExpression = task.PayloadExpression ?? WorkflowExpr.Null(),
TaskRoles = task.Descriptor.TaskRoles.ToArray(),
OnComplete = BuildSequence(task.OnComplete),
};
}
private static WorkflowStepSequenceDeclaration BuildSequence<TStartRequest>(
WorkflowStepSequence<TStartRequest> sequence)
where TStartRequest : class
{
return new WorkflowStepSequenceDeclaration
{
Steps = sequence.Steps.Select(BuildStep).ToArray(),
};
}
private static WorkflowStepDeclaration BuildStep<TStartRequest>(
WorkflowStepDefinition<TStartRequest> step)
where TStartRequest : class
{
return step switch
{
WorkflowStateAssignmentStepDefinition<TStartRequest> assignment => new WorkflowSetStateStepDeclaration
{
StateKey = assignment.Key,
ValueExpression = assignment.ValueExpression ?? WorkflowExpr.Null(),
OnlyIfPresent = assignment.OnlyWhenHasValue,
},
WorkflowBusinessReferenceAssignmentStepDefinition<TStartRequest> businessReferenceAssignment =>
new WorkflowAssignBusinessReferenceStepDeclaration
{
BusinessReference = businessReferenceAssignment.BusinessReferenceDeclaration
?? new WorkflowBusinessReferenceDeclaration(),
},
WorkflowMicroserviceCallStepDefinition<TStartRequest> microserviceCall => BuildTransportCall(
microserviceCall.StepName,
new WorkflowMicroserviceAddressDeclaration
{
MicroserviceName = microserviceCall.MicroserviceName,
Command = microserviceCall.Command,
},
microserviceCall.PayloadExpression,
microserviceCall.ResultKey,
microserviceCall.FailureHandlers,
microserviceCall.TimeoutSeconds),
WorkflowLegacyRabbitCallStepDefinition<TStartRequest> legacyRabbitCall => BuildTransportCall(
legacyRabbitCall.StepName,
new WorkflowLegacyRabbitAddressDeclaration
{
Command = legacyRabbitCall.Command,
Mode = legacyRabbitCall.Mode,
},
legacyRabbitCall.PayloadExpression,
legacyRabbitCall.ResultKey,
legacyRabbitCall.FailureHandlers,
legacyRabbitCall.TimeoutSeconds),
WorkflowGraphqlCallStepDefinition<TStartRequest> graphqlCall => BuildTransportCall(
graphqlCall.StepName,
new WorkflowGraphqlAddressDeclaration
{
Target = graphqlCall.Target,
Query = graphqlCall.Query,
OperationName = graphqlCall.OperationName,
},
graphqlCall.VariablesExpression,
graphqlCall.ResultKey,
graphqlCall.FailureHandlers,
graphqlCall.TimeoutSeconds),
WorkflowHttpCallStepDefinition<TStartRequest> httpCall => BuildTransportCall(
httpCall.StepName,
new WorkflowHttpAddressDeclaration
{
Target = httpCall.Target,
Path = httpCall.Path,
Method = httpCall.Method,
},
httpCall.PayloadExpression,
httpCall.ResultKey,
httpCall.FailureHandlers,
httpCall.TimeoutSeconds),
WorkflowDecisionStepDefinition<TStartRequest> decisionStep => new WorkflowDecisionStepDeclaration
{
DecisionName = decisionStep.Condition.DisplayName,
ConditionExpression = decisionStep.Condition.CanonicalExpression ?? WorkflowExpr.Null(),
WhenTrue = BuildSequence(decisionStep.WhenTrue),
WhenElse = BuildSequence(decisionStep.WhenFalse),
},
WorkflowConditionalStepDefinition<TStartRequest> conditionalStep => new WorkflowDecisionStepDeclaration
{
DecisionName = conditionalStep.Condition.DisplayName,
ConditionExpression = conditionalStep.Condition.CanonicalExpression ?? WorkflowExpr.Null(),
WhenTrue = BuildSequence(conditionalStep.WhenTrue),
WhenElse = BuildSequence(conditionalStep.WhenElse),
},
WorkflowActivateTaskStepDefinition<TStartRequest> activateTask => new WorkflowActivateTaskStepDeclaration
{
TaskName = activateTask.TaskName,
RuntimeRolesExpression = activateTask.RuntimeRolesExpression,
},
WorkflowContinueWithStepDefinition<TStartRequest> continueWithStep => new WorkflowContinueWithWorkflowStepDeclaration
{
StepName = continueWithStep.StepName,
Invocation = continueWithStep.InvocationDeclaration ?? CreateEmptyInvocation(),
},
WorkflowSubWorkflowStepDefinition<TStartRequest> subWorkflowStep => new WorkflowSubWorkflowStepDeclaration
{
StepName = subWorkflowStep.StepName,
Invocation = subWorkflowStep.InvocationDeclaration ?? CreateEmptyInvocation(),
ResultKey = subWorkflowStep.ResultKey,
},
WorkflowRepeatStepDefinition<TStartRequest> repeatStep => new WorkflowRepeatStepDeclaration
{
StepName = repeatStep.StepName,
MaxIterationsExpression = repeatStep.MaxIterationsExpression ?? WorkflowExpr.Number(1),
IterationStateKey = repeatStep.IterationStateKey,
ContinueWhileExpression = repeatStep.ContinueWhileExpression,
Body = BuildSequence(repeatStep.Body),
},
WorkflowTimerStepDefinition<TStartRequest> timerStep => new WorkflowTimerStepDeclaration
{
StepName = timerStep.StepName,
DelayExpression = timerStep.DelayExpression ?? WorkflowExpr.Null(),
},
WorkflowExternalSignalStepDefinition<TStartRequest> externalSignalStep => new WorkflowExternalSignalStepDeclaration
{
StepName = externalSignalStep.StepName,
SignalNameExpression = externalSignalStep.SignalNameExpression ?? WorkflowExpr.Null(),
ResultKey = externalSignalStep.ResultKey,
},
WorkflowForkStepDefinition<TStartRequest> forkStep => new WorkflowForkStepDeclaration
{
StepName = forkStep.StepName,
Branches = forkStep.Branches.Select(BuildSequence).ToArray(),
},
WorkflowCompleteStepDefinition<TStartRequest> => new WorkflowCompleteStepDeclaration(),
_ => throw new InvalidOperationException(
$"Workflow step '{step.GetType().FullName}' cannot be converted to a canonical declaration."),
};
}
private static WorkflowTransportCallStepDeclaration BuildTransportCall<TStartRequest>(
string stepName,
WorkflowTransportAddressDeclaration address,
WorkflowExpressionDefinition? payloadExpression,
string? resultKey,
WorkflowFailureHandlers<TStartRequest>? failureHandlers,
int? timeoutSeconds = null)
where TStartRequest : class
{
return new WorkflowTransportCallStepDeclaration
{
StepName = stepName,
Invocation = new WorkflowTransportInvocationDeclaration
{
Address = address,
PayloadExpression = payloadExpression,
},
ResultKey = resultKey,
TimeoutSeconds = timeoutSeconds,
WhenFailure = failureHandlers?.HasFailureBranch == true ? BuildSequence(failureHandlers.WhenFailure) : null,
WhenTimeout = failureHandlers?.HasTimeoutBranch == true ? BuildSequence(failureHandlers.WhenTimeout) : null,
};
}
private static WorkflowWorkflowInvocationDeclaration CreateEmptyInvocation()
{
return new WorkflowWorkflowInvocationDeclaration
{
WorkflowNameExpression = WorkflowExpr.String(string.Empty),
};
}
private static WorkflowBusinessReferenceDeclaration? BuildStartRequestBusinessReference(Type startRequestType)
{
var keyProperty = startRequestType
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.SingleOrDefault(property => property.GetCustomAttribute<WorkflowBusinessIdAttribute>() is not null);
var partProperties = startRequestType
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Select(property => new
{
Property = property,
Attribute = property.GetCustomAttribute<WorkflowBusinessReferencePartAttribute>(),
})
.Where(x => x.Attribute is not null)
.Select(x => new WorkflowNamedExpressionDefinition
{
Name = x.Attribute!.PartName ?? x.Property.Name,
Expression = WorkflowExpr.Path($"start.{ResolveJsonPropertyName(x.Property)}"),
})
.ToArray();
if (keyProperty is null && partProperties.Length == 0)
{
return null;
}
return new WorkflowBusinessReferenceDeclaration
{
KeyExpression = keyProperty is null ? null : WorkflowExpr.Path($"start.{ResolveJsonPropertyName(keyProperty)}"),
Parts = partProperties,
};
}
private static string ResolveJsonPropertyName(PropertyInfo property)
{
return property.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? JsonNamingPolicy.CamelCase.ConvertName(property.Name);
}
private static WorkflowRequiredModuleDeclaration[] InferRequiredModules(
WorkflowCanonicalDefinition definition,
IWorkflowFunctionCatalog? functionCatalog)
{
var modules = new Dictionary<string, WorkflowRequiredModuleDeclaration>(StringComparer.OrdinalIgnoreCase)
{
["workflow.dsl.core"] = new() { ModuleName = "workflow.dsl.core" },
};
VisitDefinition(definition, expression =>
{
if (expression is not WorkflowFunctionExpressionDefinition functionExpression)
{
return;
}
if (functionCatalog?.TryGetFunction(functionExpression.FunctionName, out var functionDescriptor) == true)
{
modules[functionDescriptor.ModuleName] = new WorkflowRequiredModuleDeclaration
{
ModuleName = functionDescriptor.ModuleName,
VersionExpression = $">={functionDescriptor.ModuleVersion}",
};
return;
}
if (ExpressionUsesFunction(expression))
{
modules["workflow.functions.core"] = new WorkflowRequiredModuleDeclaration
{
ModuleName = "workflow.functions.core",
};
}
});
foreach (var address in EnumerateAddresses(definition))
{
var moduleName = address switch
{
WorkflowMicroserviceAddressDeclaration => "transport.microservice",
WorkflowRabbitAddressDeclaration => "transport.rabbit",
WorkflowLegacyRabbitAddressDeclaration => "transport.legacy-rabbit",
WorkflowGraphqlAddressDeclaration => "transport.graphql",
WorkflowHttpAddressDeclaration => "transport.http",
_ => null,
};
if (!string.IsNullOrWhiteSpace(moduleName))
{
modules[moduleName] = new WorkflowRequiredModuleDeclaration
{
ModuleName = moduleName,
};
}
}
return modules.Values.OrderBy(x => x.ModuleName, StringComparer.OrdinalIgnoreCase).ToArray();
}
private static IEnumerable<WorkflowTransportAddressDeclaration> EnumerateAddresses(
WorkflowCanonicalDefinition definition)
{
foreach (var address in EnumerateAddresses(definition.Start.InitialSequence))
{
yield return address;
}
foreach (var task in definition.Tasks)
{
foreach (var address in EnumerateAddresses(task.OnComplete))
{
yield return address;
}
}
}
private static IEnumerable<WorkflowTransportAddressDeclaration> EnumerateAddresses(
WorkflowStepSequenceDeclaration sequence)
{
foreach (var step in sequence.Steps)
{
switch (step)
{
case WorkflowTransportCallStepDeclaration transportCall:
yield return transportCall.Invocation.Address;
if (transportCall.WhenFailure is not null)
{
foreach (var nestedAddress in EnumerateAddresses(transportCall.WhenFailure))
{
yield return nestedAddress;
}
}
if (transportCall.WhenTimeout is not null)
{
foreach (var nestedAddress in EnumerateAddresses(transportCall.WhenTimeout))
{
yield return nestedAddress;
}
}
break;
case WorkflowDecisionStepDeclaration decisionStep:
foreach (var nestedAddress in EnumerateAddresses(decisionStep.WhenTrue))
{
yield return nestedAddress;
}
foreach (var nestedAddress in EnumerateAddresses(decisionStep.WhenElse))
{
yield return nestedAddress;
}
break;
case WorkflowRepeatStepDeclaration repeatStep:
foreach (var nestedAddress in EnumerateAddresses(repeatStep.Body))
{
yield return nestedAddress;
}
break;
case WorkflowForkStepDeclaration forkStep:
foreach (var branch in forkStep.Branches)
{
foreach (var nestedAddress in EnumerateAddresses(branch))
{
yield return nestedAddress;
}
}
break;
}
}
}
private static void VisitDefinition(
WorkflowCanonicalDefinition definition,
Action<WorkflowExpressionDefinition> visitor)
{
VisitExpression(definition.BusinessReference?.KeyExpression, visitor);
foreach (var part in definition.BusinessReference?.Parts ?? [])
{
VisitExpression(part.Expression, visitor);
}
VisitExpression(definition.Start.InitializeStateExpression, visitor);
VisitSequence(definition.Start.InitialSequence, visitor);
foreach (var task in definition.Tasks)
{
VisitExpression(task.RouteExpression, visitor);
VisitExpression(task.PayloadExpression, visitor);
VisitSequence(task.OnComplete, visitor);
}
}
private static void VisitSequence(
WorkflowStepSequenceDeclaration sequence,
Action<WorkflowExpressionDefinition> visitor)
{
foreach (var step in sequence.Steps)
{
switch (step)
{
case WorkflowSetStateStepDeclaration setStateStep:
VisitExpression(setStateStep.ValueExpression, visitor);
break;
case WorkflowAssignBusinessReferenceStepDeclaration businessReferenceStep:
VisitExpression(businessReferenceStep.BusinessReference.KeyExpression, visitor);
foreach (var part in businessReferenceStep.BusinessReference.Parts)
{
VisitExpression(part.Expression, visitor);
}
break;
case WorkflowTransportCallStepDeclaration transportCall:
VisitExpression(transportCall.Invocation.PayloadExpression, visitor);
if (transportCall.WhenFailure is not null)
{
VisitSequence(transportCall.WhenFailure, visitor);
}
if (transportCall.WhenTimeout is not null)
{
VisitSequence(transportCall.WhenTimeout, visitor);
}
break;
case WorkflowDecisionStepDeclaration decisionStep:
VisitExpression(decisionStep.ConditionExpression, visitor);
VisitSequence(decisionStep.WhenTrue, visitor);
VisitSequence(decisionStep.WhenElse, visitor);
break;
case WorkflowActivateTaskStepDeclaration activateTaskStep:
VisitExpression(activateTaskStep.RuntimeRolesExpression, visitor);
break;
case WorkflowContinueWithWorkflowStepDeclaration continueWithStep:
VisitInvocation(continueWithStep.Invocation, visitor);
break;
case WorkflowSubWorkflowStepDeclaration subWorkflowStep:
VisitInvocation(subWorkflowStep.Invocation, visitor);
break;
case WorkflowRepeatStepDeclaration repeatStep:
VisitExpression(repeatStep.MaxIterationsExpression, visitor);
VisitExpression(repeatStep.ContinueWhileExpression, visitor);
VisitSequence(repeatStep.Body, visitor);
break;
case WorkflowTimerStepDeclaration timerStep:
VisitExpression(timerStep.DelayExpression, visitor);
break;
case WorkflowExternalSignalStepDeclaration externalSignalStep:
VisitExpression(externalSignalStep.SignalNameExpression, visitor);
break;
case WorkflowForkStepDeclaration forkStep:
foreach (var branch in forkStep.Branches)
{
VisitSequence(branch, visitor);
}
break;
}
}
}
private static void VisitInvocation(
WorkflowWorkflowInvocationDeclaration invocation,
Action<WorkflowExpressionDefinition> visitor)
{
VisitExpression(invocation.WorkflowNameExpression, visitor);
VisitExpression(invocation.WorkflowVersionExpression, visitor);
VisitExpression(invocation.PayloadExpression, visitor);
VisitExpression(invocation.BusinessReference?.KeyExpression, visitor);
foreach (var part in invocation.BusinessReference?.Parts ?? [])
{
VisitExpression(part.Expression, visitor);
}
}
private static void VisitExpression(
WorkflowExpressionDefinition? expression,
Action<WorkflowExpressionDefinition> visitor)
{
if (expression is null)
{
return;
}
visitor(expression);
switch (expression)
{
case WorkflowObjectExpressionDefinition objectExpression:
foreach (var property in objectExpression.Properties)
{
VisitExpression(property.Expression, visitor);
}
break;
case WorkflowArrayExpressionDefinition arrayExpression:
foreach (var item in arrayExpression.Items)
{
VisitExpression(item, visitor);
}
break;
case WorkflowFunctionExpressionDefinition functionExpression:
foreach (var argument in functionExpression.Arguments)
{
VisitExpression(argument, visitor);
}
break;
case WorkflowGroupExpressionDefinition groupExpression:
VisitExpression(groupExpression.Expression, visitor);
break;
case WorkflowUnaryExpressionDefinition unaryExpression:
VisitExpression(unaryExpression.Operand, visitor);
break;
case WorkflowBinaryExpressionDefinition binaryExpression:
VisitExpression(binaryExpression.Left, visitor);
VisitExpression(binaryExpression.Right, visitor);
break;
}
}
private static bool ExpressionUsesFunction(WorkflowExpressionDefinition expression)
{
return expression switch
{
WorkflowFunctionExpressionDefinition => true,
WorkflowObjectExpressionDefinition objectExpression => objectExpression.Properties.Any(property => ExpressionUsesFunction(property.Expression)),
WorkflowArrayExpressionDefinition arrayExpression => arrayExpression.Items.Any(ExpressionUsesFunction),
WorkflowGroupExpressionDefinition groupExpression => ExpressionUsesFunction(groupExpression.Expression),
WorkflowUnaryExpressionDefinition unaryExpression => ExpressionUsesFunction(unaryExpression.Operand),
WorkflowBinaryExpressionDefinition binaryExpression =>
ExpressionUsesFunction(binaryExpression.Left) || ExpressionUsesFunction(binaryExpression.Right),
_ => false,
};
}
}

View File

@@ -0,0 +1,398 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed record WorkflowCanonicalizationDiagnostic
{
public required string Code { get; init; }
public required string Path { get; init; }
public required string Message { get; init; }
}
public sealed record WorkflowCanonicalCompilationResult
{
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public WorkflowCanonicalDefinition? Definition { get; init; }
public IReadOnlyCollection<WorkflowCanonicalizationDiagnostic> Diagnostics { get; init; } = [];
public bool Succeeded => Definition is not null && Diagnostics.Count == 0;
}
public static partial class WorkflowCanonicalDefinitionCompiler
{
public static WorkflowCanonicalCompilationResult Compile<TStartRequest>(
IDeclarativeWorkflow<TStartRequest> workflow,
IWorkflowFunctionCatalog? functionCatalog = null)
where TStartRequest : class
{
ArgumentNullException.ThrowIfNull(workflow);
var diagnostics = new List<WorkflowCanonicalizationDiagnostic>();
AnalyzeWorkflow(workflow, diagnostics);
return new WorkflowCanonicalCompilationResult
{
WorkflowName = workflow.WorkflowName,
WorkflowVersion = workflow.WorkflowVersion,
Definition = diagnostics.Count == 0 ? BuildDefinition(workflow, functionCatalog) : null,
Diagnostics = diagnostics,
};
}
private static WorkflowCanonicalDefinition BuildDefinition<TStartRequest>(
IDeclarativeWorkflow<TStartRequest> workflow,
IWorkflowFunctionCatalog? functionCatalog)
where TStartRequest : class
{
var definition = new WorkflowCanonicalDefinition
{
WorkflowName = workflow.WorkflowName,
WorkflowVersion = workflow.WorkflowVersion,
DisplayName = workflow.DisplayName,
StartRequest = BuildStartRequestContract<TStartRequest>(),
WorkflowRoles = workflow.WorkflowRoles.ToArray(),
BusinessReference = BuildStartRequestBusinessReference(typeof(TStartRequest)),
Start = new WorkflowStartDeclaration
{
InitializeStateExpression = workflow.Spec.InitializeStateExpression ?? WorkflowExpr.Null(),
InitialTaskName = workflow.Spec.InitialTaskName,
InitialSequence = BuildSequence(workflow.Spec.InitialSequence),
},
Tasks = workflow.Spec.TasksByName.Values.Select(BuildTask).ToArray(),
};
return definition with
{
RequiredModules = InferRequiredModules(definition, functionCatalog),
};
}
private static void AnalyzeWorkflow<TStartRequest>(
IDeclarativeWorkflow<TStartRequest> workflow,
List<WorkflowCanonicalizationDiagnostic> diagnostics)
where TStartRequest : class
{
if (workflow.Spec.InitializeStateExpression is null)
{
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD001",
Path = "$.start.initializeStateExpression",
Message = $"Workflow '{workflow.WorkflowName}' initializes state through a CLR delegate. This must become a declaration expression.",
});
}
if (!string.IsNullOrWhiteSpace(workflow.Spec.InitialTaskName))
{
AnalyzeTask(workflow.Spec.GetRequiredTask(workflow.Spec.InitialTaskName), diagnostics);
}
else
{
AnalyzeSequence(workflow.Spec.InitialSequence, "$.start.initialSequence", diagnostics);
}
foreach (var task in workflow.Spec.TasksByName.Values)
{
AnalyzeTask(task, diagnostics);
AnalyzeSequence(task.OnComplete, $"$.tasks['{task.TaskName}'].onComplete", diagnostics);
}
}
private static void AnalyzeTask<TStartRequest>(
WorkflowHumanTaskDefinition<TStartRequest> task,
List<WorkflowCanonicalizationDiagnostic> diagnostics)
where TStartRequest : class
{
if (task.RouteExpression is null)
{
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD002",
Path = $"$.tasks['{task.TaskName}'].routeExpression",
Message = $"Workflow task '{task.TaskName}' resolves its route through a CLR delegate. This must become a declaration expression.",
});
}
if (task.PayloadExpression is null)
{
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD002",
Path = $"$.tasks['{task.TaskName}'].payloadExpression",
Message = $"Workflow task '{task.TaskName}' builds its payload through a CLR delegate. This must become a declaration expression.",
});
}
}
private static void AnalyzeSequence<TStartRequest>(
WorkflowStepSequence<TStartRequest> sequence,
string path,
List<WorkflowCanonicalizationDiagnostic> diagnostics)
where TStartRequest : class
{
var steps = sequence.Steps.ToArray();
for (var index = 0; index < steps.Length; index++)
{
var step = steps[index];
var stepPath = $"{path}.steps[{index}]";
switch (step)
{
case WorkflowStateAssignmentStepDefinition<TStartRequest> assignmentStep when assignmentStep.ValueExpression is null:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD010",
Path = stepPath,
Message = "State assignment value is currently provided through a CLR delegate. This must become a declaration expression.",
});
break;
case WorkflowStateAssignmentStepDefinition<TStartRequest>:
break;
case WorkflowBusinessReferenceAssignmentStepDefinition<TStartRequest> businessReferenceStep
when businessReferenceStep.BusinessReferenceDeclaration is null:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD011",
Path = stepPath,
Message = "Business reference assignment is currently provided through a CLR delegate. This must become a declaration expression.",
});
break;
case WorkflowBusinessReferenceAssignmentStepDefinition<TStartRequest>:
break;
case WorkflowMicroserviceCallStepDefinition<TStartRequest> microserviceCall
when microserviceCall.PayloadExpression is null:
case WorkflowLegacyRabbitCallStepDefinition<TStartRequest> legacyRabbitCall
when legacyRabbitCall.PayloadExpression is null:
case WorkflowGraphqlCallStepDefinition<TStartRequest> graphqlCall
when graphqlCall.VariablesExpression is null:
case WorkflowHttpCallStepDefinition<TStartRequest> httpCall
when httpCall.PayloadExpression is null:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD012",
Path = stepPath,
Message = "Transport call payload is currently provided through a CLR delegate. This must become a declaration expression.",
});
AnalyzeFailureHandlers(stepPath, step, diagnostics);
break;
case WorkflowMicroserviceCallStepDefinition<TStartRequest>:
case WorkflowLegacyRabbitCallStepDefinition<TStartRequest>:
case WorkflowGraphqlCallStepDefinition<TStartRequest>:
case WorkflowHttpCallStepDefinition<TStartRequest>:
AnalyzeFailureHandlers(stepPath, step, diagnostics);
break;
case WorkflowDecisionStepDefinition<TStartRequest> decisionStep:
AnalyzeSequence(decisionStep.WhenTrue, $"{stepPath}.whenTrue", diagnostics);
AnalyzeSequence(decisionStep.WhenFalse, $"{stepPath}.whenFalse", diagnostics);
break;
case WorkflowConditionalStepDefinition<TStartRequest> conditionalStep:
if (conditionalStep.Condition.CanonicalExpression is null)
{
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD013",
Path = $"{stepPath}.condition",
Message = "WhenExpression currently contains executable CLR logic. This must become a declaration expression tree.",
});
}
AnalyzeSequence(conditionalStep.WhenTrue, $"{stepPath}.whenTrue", diagnostics);
AnalyzeSequence(conditionalStep.WhenElse, $"{stepPath}.whenElse", diagnostics);
break;
case WorkflowContinueWithStepDefinition<TStartRequest> continueWithStep
when continueWithStep.InvocationDeclaration is null:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD014",
Path = stepPath,
Message = "ContinueWith currently builds workflow invocation payload and business reference through CLR delegates. This must become a declaration invocation.",
});
break;
case WorkflowContinueWithStepDefinition<TStartRequest>:
break;
case WorkflowSubWorkflowStepDefinition<TStartRequest> subWorkflowStep
when subWorkflowStep.InvocationDeclaration is null:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD015",
Path = stepPath,
Message = "SubWorkflow currently builds workflow invocation payload and business reference through CLR delegates. This must become a declaration invocation.",
});
break;
case WorkflowSubWorkflowStepDefinition<TStartRequest>:
break;
case WorkflowRepeatStepDefinition<TStartRequest> repeatStep:
if (repeatStep.MaxIterationsExpression is null)
{
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD018",
Path = $"{stepPath}.maxIterationsExpression",
Message = "Repeat max-iterations is currently provided through a CLR delegate. This must become a declaration expression.",
});
}
if (repeatStep.ContinueWhileExpression is null)
{
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD019",
Path = $"{stepPath}.continueWhileExpression",
Message = "Repeat continue-while condition is currently provided through a CLR delegate. This must become a declaration expression.",
});
}
AnalyzeSequence(repeatStep.Body, $"{stepPath}.body", diagnostics);
break;
case WorkflowInlineStepDefinition<TStartRequest>:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD016",
Path = stepPath,
Message = "Inline/Run step contains imperative executable code. This must be replaced by a first-class declaration primitive.",
});
AnalyzeFailureHandlers(stepPath, step, diagnostics);
break;
case WorkflowTimerStepDefinition<TStartRequest> timerStep
when timerStep.DelayExpression is null:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD017",
Path = stepPath,
Message = "Timer delay is currently provided through a CLR delegate. This must become a declaration expression.",
});
break;
case WorkflowTimerStepDefinition<TStartRequest>:
break;
case WorkflowExternalSignalStepDefinition<TStartRequest> externalSignalStep
when externalSignalStep.SignalNameExpression is null:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD020",
Path = $"{stepPath}.signalNameExpression",
Message = "External signal name is currently provided through a CLR delegate. This must become a declaration expression.",
});
break;
case WorkflowExternalSignalStepDefinition<TStartRequest>:
break;
case WorkflowForkStepDefinition<TStartRequest> forkStep:
for (var branchIndex = 0; branchIndex < forkStep.Branches.Count; branchIndex++)
{
AnalyzeSequence(forkStep.Branches.ElementAt(branchIndex), $"{stepPath}.branches[{branchIndex}]", diagnostics);
}
break;
case WorkflowActivateTaskStepDefinition<TStartRequest>:
case WorkflowCompleteStepDefinition<TStartRequest>:
break;
default:
diagnostics.Add(new WorkflowCanonicalizationDiagnostic
{
Code = "WFCD099",
Path = stepPath,
Message = $"Unknown workflow step type '{step.GetType().FullName}' encountered during canonicalization analysis.",
});
break;
}
}
}
private static void AnalyzeFailureHandlers<TStartRequest>(
string stepPath,
WorkflowStepDefinition<TStartRequest> step,
List<WorkflowCanonicalizationDiagnostic> diagnostics)
where TStartRequest : class
{
WorkflowFailureHandlers<TStartRequest>? failureHandlers = step switch
{
WorkflowMicroserviceCallStepDefinition<TStartRequest> call => call.FailureHandlers,
WorkflowLegacyRabbitCallStepDefinition<TStartRequest> call => call.FailureHandlers,
WorkflowGraphqlCallStepDefinition<TStartRequest> call => call.FailureHandlers,
WorkflowHttpCallStepDefinition<TStartRequest> call => call.FailureHandlers,
WorkflowInlineStepDefinition<TStartRequest> inline => inline.FailureHandlers,
_ => null,
};
if (failureHandlers is null)
{
return;
}
AnalyzeSequence(failureHandlers.WhenFailure, $"{stepPath}.whenFailure", diagnostics);
AnalyzeSequence(failureHandlers.WhenTimeout, $"{stepPath}.whenTimeout", diagnostics);
}
private static WorkflowRequestContractDeclaration BuildStartRequestContract<TStartRequest>()
where TStartRequest : class
{
var type = typeof(TStartRequest);
var schema = new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = BuildPropertiesSchema(type),
};
return new WorkflowRequestContractDeclaration
{
ContractName = type.FullName ?? type.Name,
Schema = schema,
AllowAdditionalProperties = true,
};
}
private static Dictionary<string, object?> BuildPropertiesSchema(Type type)
{
var properties = new Dictionary<string, object?>();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var jsonType = GetJsonSchemaType(prop.PropertyType);
var propSchema = new Dictionary<string, object?> { ["type"] = jsonType };
if (prop.PropertyType.IsEnum)
{
propSchema["enum"] = Enum.GetNames(prop.PropertyType);
}
if (jsonType == "array")
{
var elementType = prop.PropertyType.IsArray
? prop.PropertyType.GetElementType()
: prop.PropertyType.GetGenericArguments().FirstOrDefault();
if (elementType is not null)
{
propSchema["items"] = new Dictionary<string, object?> { ["type"] = GetJsonSchemaType(elementType) };
}
}
// Use camelCase property name to match JSON serialization convention
var name = char.ToLowerInvariant(prop.Name[0]) + prop.Name[1..];
properties[name] = propSchema;
}
return properties;
}
private static string GetJsonSchemaType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
if (underlying == typeof(string)) return "string";
if (underlying == typeof(bool)) return "boolean";
if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short)
|| underlying == typeof(decimal) || underlying == typeof(double) || underlying == typeof(float)) return "number";
if (underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset)) return "string";
if (underlying.IsEnum) return "string";
if (underlying.IsArray || (underlying.IsGenericType &&
typeof(global::System.Collections.IEnumerable).IsAssignableFrom(underlying))) return "array";
if (underlying == typeof(JsonElement) || underlying == typeof(object)) return "object";
return "object";
}
}

View File

@@ -0,0 +1,68 @@
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed record WorkflowCanonicalEvaluationContext
{
public JsonElement? Start { get; init; }
public JsonElement? State { get; init; }
public JsonElement? Payload { get; init; }
public JsonElement? Result { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public IWorkflowFunctionRuntime? FunctionRuntime { get; init; }
public static WorkflowCanonicalEvaluationContext From<TStartRequest>(
WorkflowSpecExecutionContext<TStartRequest> context)
where TStartRequest : class
{
return new WorkflowCanonicalEvaluationContext
{
Start = context.StartRequest is null ? null : context.StartRequest.AsJsonElement(),
State = context.WorkflowState.AsJsonElement(),
Payload = context.Payload.AsJsonElement(),
Result = context.ResultValues.AsJsonElement(),
BusinessReference = context.BusinessReference,
FunctionRuntime = context.FunctionRuntime,
};
}
public static WorkflowCanonicalEvaluationContext ForStartRequest<TStartRequest>(
TStartRequest startRequest,
IWorkflowFunctionRuntime? functionRuntime = null)
{
return new WorkflowCanonicalEvaluationContext
{
Start = startRequest is null ? null : startRequest.AsJsonElement(),
FunctionRuntime = functionRuntime,
};
}
public static WorkflowCanonicalEvaluationContext ForResult(
JsonElement result,
IWorkflowFunctionRuntime? functionRuntime = null)
{
return new WorkflowCanonicalEvaluationContext
{
Result = result.Clone(),
FunctionRuntime = functionRuntime,
};
}
public static WorkflowCanonicalEvaluationContext ForState(
IReadOnlyDictionary<string, JsonElement> state,
IReadOnlyDictionary<string, JsonElement>? results = null,
WorkflowBusinessReference? businessReference = null,
IWorkflowFunctionRuntime? functionRuntime = null)
{
return new WorkflowCanonicalEvaluationContext
{
State = state.AsJsonElement(),
Result = results?.AsJsonElement(),
BusinessReference = businessReference,
FunctionRuntime = functionRuntime,
};
}
}

View File

@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowExpr
{
public static WorkflowNamedExpressionDefinition Prop(string name, WorkflowExpressionDefinition expression)
{
return new WorkflowNamedExpressionDefinition
{
Name = name,
Expression = expression ?? throw new ArgumentNullException(nameof(expression)),
};
}
public static WorkflowNullExpressionDefinition Null()
{
return new WorkflowNullExpressionDefinition();
}
public static WorkflowExpressionDefinition FromValue(object? value)
{
return value switch
{
null => Null(),
WorkflowExpressionDefinition expression => expression,
JsonElement element => FromJsonElement(element),
_ => FromJsonElement(value.AsJsonElement()),
};
}
public static WorkflowStringExpressionDefinition String(string value)
{
return new WorkflowStringExpressionDefinition
{
Value = value,
};
}
public static WorkflowBooleanExpressionDefinition Bool(bool value)
{
return new WorkflowBooleanExpressionDefinition
{
Value = value,
};
}
public static WorkflowNumberExpressionDefinition Number(long value)
{
return new WorkflowNumberExpressionDefinition
{
Value = value.ToString(CultureInfo.InvariantCulture),
};
}
public static WorkflowNumberExpressionDefinition Number(decimal value)
{
return new WorkflowNumberExpressionDefinition
{
Value = value.ToString(CultureInfo.InvariantCulture),
};
}
public static WorkflowPathExpressionDefinition Path(string path)
{
return new WorkflowPathExpressionDefinition
{
Path = path,
};
}
public static WorkflowObjectExpressionDefinition Obj(params WorkflowNamedExpressionDefinition[] properties)
{
return new WorkflowObjectExpressionDefinition
{
Properties = properties,
};
}
public static WorkflowObjectExpressionDefinition Obj(IEnumerable<WorkflowNamedExpressionDefinition> properties)
{
return new WorkflowObjectExpressionDefinition
{
Properties = properties is WorkflowNamedExpressionDefinition[] array ? array : [.. properties],
};
}
public static WorkflowArrayExpressionDefinition Array(params WorkflowExpressionDefinition[] items)
{
return new WorkflowArrayExpressionDefinition
{
Items = items,
};
}
public static WorkflowArrayExpressionDefinition Array(IEnumerable<WorkflowExpressionDefinition> items)
{
return new WorkflowArrayExpressionDefinition
{
Items = items is WorkflowExpressionDefinition[] array ? array : [.. items],
};
}
public static WorkflowFunctionExpressionDefinition Func(string functionName, params WorkflowExpressionDefinition[] arguments)
{
return new WorkflowFunctionExpressionDefinition
{
FunctionName = functionName,
Arguments = arguments,
};
}
public static WorkflowGroupExpressionDefinition Group(WorkflowExpressionDefinition expression)
{
return new WorkflowGroupExpressionDefinition
{
Expression = expression ?? throw new ArgumentNullException(nameof(expression)),
};
}
public static WorkflowUnaryExpressionDefinition Not(WorkflowExpressionDefinition operand)
{
return new WorkflowUnaryExpressionDefinition
{
Operator = "not",
Operand = operand,
};
}
public static WorkflowBinaryExpressionDefinition Eq(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("eq", left, right);
}
public static WorkflowBinaryExpressionDefinition Ne(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("ne", left, right);
}
public static WorkflowBinaryExpressionDefinition And(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("and", left, right);
}
public static WorkflowBinaryExpressionDefinition Or(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("or", left, right);
}
public static WorkflowBinaryExpressionDefinition Gt(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("gt", left, right);
}
public static WorkflowBinaryExpressionDefinition Gte(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("gte", left, right);
}
public static WorkflowBinaryExpressionDefinition Lt(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("lt", left, right);
}
public static WorkflowBinaryExpressionDefinition Lte(WorkflowExpressionDefinition left, WorkflowExpressionDefinition right)
{
return Binary("lte", left, right);
}
public static WorkflowBinaryExpressionDefinition Binary(
string @operator,
WorkflowExpressionDefinition left,
WorkflowExpressionDefinition right)
{
return new WorkflowBinaryExpressionDefinition
{
Operator = @operator,
Left = left,
Right = right,
};
}
private static WorkflowExpressionDefinition FromJsonElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Undefined or JsonValueKind.Null => Null(),
JsonValueKind.String => String(element.GetString() ?? string.Empty),
JsonValueKind.True => Bool(true),
JsonValueKind.False => Bool(false),
JsonValueKind.Number when element.TryGetInt64(out var int64Value) => Number(int64Value),
JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => Number(decimalValue),
JsonValueKind.Number => new WorkflowNumberExpressionDefinition
{
Value = element.GetRawText(),
},
JsonValueKind.Object => Obj(BuildObjectProperties(element)),
JsonValueKind.Array => Array(BuildArrayItems(element)),
_ => String(element.ToString()),
};
}
private static IEnumerable<WorkflowNamedExpressionDefinition> BuildObjectProperties(JsonElement element)
{
foreach (var property in element.EnumerateObject())
{
yield return Prop(property.Name, FromJsonElement(property.Value));
}
}
private static IEnumerable<WorkflowExpressionDefinition> BuildArrayItems(JsonElement element)
{
foreach (var item in element.EnumerateArray())
{
yield return FromJsonElement(item);
}
}
}

View File

@@ -0,0 +1,637 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowCanonicalExpressionRuntime
{
public static object? Evaluate<TStartRequest>(
WorkflowExpressionDefinition expression,
WorkflowSpecExecutionContext<TStartRequest> context)
where TStartRequest : class
{
return Evaluate(expression, WorkflowCanonicalEvaluationContext.From(context));
}
public static object? Evaluate(
WorkflowExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
ArgumentNullException.ThrowIfNull(expression);
ArgumentNullException.ThrowIfNull(context);
return expression switch
{
WorkflowNullExpressionDefinition => null,
WorkflowStringExpressionDefinition stringExpression => stringExpression.Value,
WorkflowNumberExpressionDefinition numberExpression => ParseNumber(numberExpression.Value),
WorkflowBooleanExpressionDefinition booleanExpression => booleanExpression.Value,
WorkflowPathExpressionDefinition pathExpression => ResolvePath(pathExpression.Path, context),
WorkflowObjectExpressionDefinition objectExpression => EvaluateObject(objectExpression, context),
WorkflowArrayExpressionDefinition arrayExpression => EvaluateArray(arrayExpression, context),
WorkflowFunctionExpressionDefinition functionExpression => EvaluateFunction(functionExpression, context),
WorkflowGroupExpressionDefinition groupExpression => Evaluate(groupExpression.Expression, context),
WorkflowUnaryExpressionDefinition unaryExpression => EvaluateUnary(unaryExpression, context),
WorkflowBinaryExpressionDefinition binaryExpression => EvaluateBinary(binaryExpression, context),
_ => throw new InvalidOperationException(
$"Workflow expression type '{expression.GetType().FullName}' is not supported by the canonical runtime."),
};
}
public static WorkflowBusinessReference EvaluateBusinessReference<TStartRequest>(
WorkflowBusinessReferenceDeclaration declaration,
WorkflowSpecExecutionContext<TStartRequest> context)
where TStartRequest : class
{
return EvaluateBusinessReference(declaration, WorkflowCanonicalEvaluationContext.From(context));
}
public static WorkflowBusinessReference EvaluateBusinessReference(
WorkflowBusinessReferenceDeclaration declaration,
WorkflowCanonicalEvaluationContext context)
{
ArgumentNullException.ThrowIfNull(declaration);
ArgumentNullException.ThrowIfNull(context);
var parts = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var part in declaration.Parts)
{
parts[part.Name] = Evaluate(part.Expression, context);
}
var key = Evaluate(declaration.KeyExpression ?? WorkflowExpr.Null(), context)?.ToString();
return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference
{
Key = key,
Parts = parts,
}) ?? new WorkflowBusinessReference();
}
private static Dictionary<string, object?> EvaluateObject(
WorkflowObjectExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var property in expression.Properties)
{
result[property.Name] = Evaluate(property.Expression, context);
}
return result;
}
private static object?[] EvaluateArray(
WorkflowArrayExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
return [.. expression.Items.Select(x => Evaluate(x, context))];
}
private static object? EvaluateFunction(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
var builtInResult = expression.FunctionName switch
{
"coalesce" => EvaluateCoalesce(expression, context),
"concat" => EvaluateConcat(expression, context),
"add" => EvaluateAdd(expression, context),
"first" => EvaluateFirst(expression, context),
"if" => EvaluateIf(expression, context),
"isNullOrWhiteSpace" => EvaluateIsNullOrWhiteSpace(expression, context),
"length" => EvaluateLength(expression, context),
"mergeObjects" => EvaluateMergeObjects(expression, context),
"upper" => EvaluateUpper(expression, context),
"selectManyPath" => EvaluateSelectManyPath(expression, context),
"findPath" => EvaluateFindPath(expression, context),
_ => WorkflowCanonicalFunctionSentinel.Value,
};
if (!ReferenceEquals(builtInResult, WorkflowCanonicalFunctionSentinel.Value))
{
return builtInResult;
}
var arguments = expression.Arguments.Select(argument => Evaluate(argument, context)).ToArray();
if (context.FunctionRuntime?.TryEvaluate(expression.FunctionName, arguments, context, out var pluginResult) == true)
{
return pluginResult;
}
throw new InvalidOperationException(
$"Workflow function '{expression.FunctionName}' is not supported by the canonical runtime.");
}
private static object EvaluateUnary(
WorkflowUnaryExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
return expression.Operator switch
{
"not" => !ToBoolean(Evaluate(expression.Operand, context)),
_ => throw new InvalidOperationException(
$"Workflow unary operator '{expression.Operator}' is not supported by the canonical runtime."),
};
}
private static object EvaluateBinary(
WorkflowBinaryExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
return expression.Operator switch
{
"eq" => AreEqual(Evaluate(expression.Left, context), Evaluate(expression.Right, context)),
"ne" => !AreEqual(Evaluate(expression.Left, context), Evaluate(expression.Right, context)),
"and" => ToBoolean(Evaluate(expression.Left, context)) && ToBoolean(Evaluate(expression.Right, context)),
"or" => ToBoolean(Evaluate(expression.Left, context)) || ToBoolean(Evaluate(expression.Right, context)),
"gt" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) > 0,
"gte" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) >= 0,
"lt" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) < 0,
"lte" => CompareNumbers(Evaluate(expression.Left, context), Evaluate(expression.Right, context)) <= 0,
_ => throw new InvalidOperationException(
$"Workflow binary operator '{expression.Operator}' is not supported by the canonical runtime."),
};
}
private static object? EvaluateCoalesce(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
foreach (var argument in expression.Arguments)
{
var value = Evaluate(argument, context);
if (!IsMissing(value))
{
return value;
}
}
return null;
}
private static object EvaluateConcat(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count == 0)
{
throw new InvalidOperationException("Workflow function 'concat' requires at least one argument.");
}
return string.Concat(expression.Arguments.Select(argument => Evaluate(argument, context)?.ToString() ?? string.Empty));
}
private static object EvaluateAdd(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count < 2)
{
throw new InvalidOperationException("Workflow function 'add' requires at least two arguments.");
}
var total = 0m;
var hasFractionalPart = false;
foreach (var argument in expression.Arguments)
{
var value = Evaluate(argument, context);
if (!TryConvertToDecimal(value, out var numericValue))
{
throw new InvalidOperationException("Workflow function 'add' requires numeric arguments.");
}
total += numericValue;
hasFractionalPart |= decimal.Truncate(numericValue) != numericValue;
}
if (!hasFractionalPart
&& decimal.Truncate(total) == total
&& total >= long.MinValue
&& total <= long.MaxValue)
{
return decimal.ToInt64(total);
}
return total;
}
private static object? EvaluateFirst(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count != 1)
{
throw new InvalidOperationException("Workflow function 'first' requires one argument.");
}
var value = Evaluate(expression.Arguments.First(), context);
return value switch
{
null => null,
JsonElement element when element.ValueKind == JsonValueKind.Array =>
element.EnumerateArray().Select(ToRuntimeValue).FirstOrDefault(),
IEnumerable<object?> enumerable when value is not string => enumerable.FirstOrDefault(),
_ => value,
};
}
private static object? EvaluateIf(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count < 3)
{
throw new InvalidOperationException("Workflow function 'if' requires three arguments.");
}
var arguments = expression.Arguments.ToArray();
return ToBoolean(Evaluate(arguments[0], context))
? Evaluate(arguments[1], context)
: Evaluate(arguments[2], context);
}
private static object EvaluateIsNullOrWhiteSpace(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count != 1)
{
throw new InvalidOperationException("Workflow function 'isNullOrWhiteSpace' requires one argument.");
}
var value = Evaluate(expression.Arguments.First(), context)?.ToString();
return string.IsNullOrWhiteSpace(value);
}
private static object EvaluateLength(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count != 1)
{
throw new InvalidOperationException("Workflow function 'length' requires one argument.");
}
var value = Evaluate(expression.Arguments.First(), context);
return value switch
{
null => 0L,
string stringValue => stringValue.Length,
Array array => array.LongLength,
IReadOnlyCollection<object?> readOnlyCollection => readOnlyCollection.Count,
ICollection<object?> collection => collection.Count,
IReadOnlyCollection<KeyValuePair<string, object?>> readOnlyDictionary => readOnlyDictionary.Count,
ICollection<KeyValuePair<string, object?>> dictionary => dictionary.Count,
IEnumerable<object?> enumerable => enumerable.LongCount(),
IEnumerable<KeyValuePair<string, object?>> keyValueEnumerable => keyValueEnumerable.LongCount(),
_ => 0L,
};
}
private static object EvaluateMergeObjects(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count == 0)
{
throw new InvalidOperationException("Workflow function 'mergeObjects' requires at least one argument.");
}
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var argument in expression.Arguments)
{
var value = Evaluate(argument, context);
if (IsMissing(value))
{
continue;
}
foreach (var entry in value.AsWorkflowObjectDictionary())
{
result[entry.Key] = entry.Value;
}
}
return result;
}
private static object? EvaluateUpper(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count != 1)
{
throw new InvalidOperationException("Workflow function 'upper' requires one argument.");
}
var value = Evaluate(expression.Arguments.First(), context)?.ToString();
return value?.ToUpperInvariant();
}
private static object? EvaluateSelectManyPath(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count != 2)
{
throw new InvalidOperationException("Workflow function 'selectManyPath' requires two arguments.");
}
var sourceValues = Evaluate(expression.Arguments.ElementAt(0), context);
var relativePath = Evaluate(expression.Arguments.ElementAt(1), context)?.ToString();
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new InvalidOperationException("Workflow function 'selectManyPath' requires a relative path string.");
}
var results = new List<object?>();
foreach (var item in EnumerateRuntimeArray(sourceValues))
{
var nestedValue = ResolveRelativePath(item, relativePath);
if (nestedValue is null)
{
continue;
}
if (nestedValue is object?[] nestedArray)
{
results.AddRange(nestedArray);
continue;
}
if (nestedValue is IEnumerable<object?> enumerable && nestedValue is not string)
{
results.AddRange(enumerable);
continue;
}
results.Add(nestedValue);
}
return results.ToArray();
}
private static object? EvaluateFindPath(
WorkflowFunctionExpressionDefinition expression,
WorkflowCanonicalEvaluationContext context)
{
if (expression.Arguments.Count != 2)
{
throw new InvalidOperationException("Workflow function 'findPath' requires two arguments.");
}
var sourceValues = Evaluate(expression.Arguments.ElementAt(0), context);
var relativePath = Evaluate(expression.Arguments.ElementAt(1), context)?.ToString();
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new InvalidOperationException("Workflow function 'findPath' requires a relative path string.");
}
foreach (var item in EnumerateRuntimeArray(sourceValues))
{
var nestedValue = ResolveRelativePath(item, relativePath);
if (!IsMissing(nestedValue))
{
return nestedValue;
}
}
return null;
}
private static object? ResolvePath(
string path,
WorkflowCanonicalEvaluationContext context)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
return null;
}
var rootElement = segments[0] switch
{
"start" => context.Start,
"state" => context.State,
"payload" => context.Payload,
"result" => context.Result,
"businessReference" => context.BusinessReference is null ? default(JsonElement?) : context.BusinessReference.AsJsonElement(),
_ => default(JsonElement?),
};
if (rootElement is null)
{
return null;
}
var current = rootElement.Value;
for (var index = 1; index < segments.Length; index++)
{
if (!TryResolveSegment(current, segments[index], out current))
{
return null;
}
}
return ToRuntimeValue(current);
}
private static bool TryResolveSegment(JsonElement current, string segment, out JsonElement next)
{
if (current.ValueKind == JsonValueKind.Object
&& current.TryGetPropertyIgnoreCase(segment, out next))
{
return true;
}
if (current.ValueKind == JsonValueKind.Array
&& int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out var index))
{
var length = current.GetArrayLength();
if (index >= 0 && index < length)
{
next = current[index].Clone();
return true;
}
}
next = default;
return false;
}
private static object? ToRuntimeValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Undefined or JsonValueKind.Null => null,
JsonValueKind.String => element.GetString(),
JsonValueKind.True => true,
JsonValueKind.False => false,
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.Array => element.EnumerateArray().Select(ToRuntimeValue).ToArray(),
_ => element.ToString(),
};
}
private static object ParseNumber(string value)
{
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var int64Value))
{
return int64Value;
}
if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue))
{
return decimalValue;
}
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
return doubleValue;
}
throw new InvalidOperationException($"Workflow numeric value '{value}' is not valid.");
}
private static bool AreEqual(object? left, object? right)
{
if (left is null || right is null)
{
return left is null && right is null;
}
if (TryConvertToDecimal(left, out var leftDecimal)
&& TryConvertToDecimal(right, out var rightDecimal))
{
return leftDecimal == rightDecimal;
}
return string.Equals(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase);
}
private static bool TryConvertToDecimal(object? value, out decimal result)
{
switch (value)
{
case null:
result = default;
return false;
case decimal decimalValue:
result = decimalValue;
return true;
case byte or short or int or long:
result = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
return true;
case sbyte or ushort or uint or ulong:
result = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
return true;
case float or double:
result = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
return true;
case string stringValue when decimal.TryParse(
stringValue,
NumberStyles.Any,
CultureInfo.InvariantCulture,
out var parsedValue):
result = parsedValue;
return true;
default:
result = default;
return false;
}
}
private static bool ToBoolean(object? value)
{
return value switch
{
null => false,
bool booleanValue => booleanValue,
string stringValue when bool.TryParse(stringValue, out var parsedBoolean) => parsedBoolean,
string stringValue when decimal.TryParse(
stringValue,
NumberStyles.Any,
CultureInfo.InvariantCulture,
out var numericBoolean) => numericBoolean != 0,
byte or short or int or long => Convert.ToInt64(value, CultureInfo.InvariantCulture) != 0,
sbyte or ushort or uint or ulong => Convert.ToUInt64(value, CultureInfo.InvariantCulture) != 0,
float or double or decimal => Convert.ToDecimal(value, CultureInfo.InvariantCulture) != 0,
_ => true,
};
}
private static bool IsMissing(object? value)
{
return value switch
{
null => true,
JsonElement element when element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined => true,
_ => false,
};
}
private static int CompareNumbers(object? left, object? right)
{
if (!TryConvertToDecimal(left, out var leftDecimal)
|| !TryConvertToDecimal(right, out var rightDecimal))
{
throw new InvalidOperationException("Workflow numeric comparison requires numeric operands.");
}
return leftDecimal.CompareTo(rightDecimal);
}
private static IEnumerable<object?> EnumerateRuntimeArray(object? value)
{
return value switch
{
null => [],
JsonElement element when element.ValueKind == JsonValueKind.Array =>
element.EnumerateArray().Select(ToRuntimeValue),
object?[] array => array,
IEnumerable<object?> enumerable when value is not string => enumerable,
_ => [],
};
}
private static object? ResolveRelativePath(object? source, string relativePath)
{
if (source is null || string.IsNullOrWhiteSpace(relativePath))
{
return null;
}
var current = source.AsJsonElement();
var segments = relativePath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var segment in segments)
{
if (!TryResolveSegment(current, segment, out current))
{
return null;
}
}
return ToRuntimeValue(current);
}
private static class WorkflowCanonicalFunctionSentinel
{
public static readonly object Value = new();
}
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed record WorkflowCanonicalImportValidationResult
{
public WorkflowCanonicalDefinition? Definition { get; init; }
public IReadOnlyCollection<WorkflowCanonicalValidationError> SchemaErrors { get; init; } = [];
public IReadOnlyCollection<WorkflowCanonicalValidationError> SemanticErrors { get; init; } = [];
public IReadOnlyCollection<WorkflowCanonicalValidationError> ModuleErrors { get; init; } = [];
public bool Succeeded => SchemaErrors.Count == 0 && SemanticErrors.Count == 0 && ModuleErrors.Count == 0 && Definition is not null;
}
public static class WorkflowCanonicalImportValidator
{
public static WorkflowCanonicalImportValidationResult Validate(
string json,
IReadOnlyCollection<WorkflowInstalledModule>? installedModules = null,
IWorkflowFunctionCatalog? functionCatalog = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
try
{
using var _ = JsonDocument.Parse(json);
}
catch (JsonException exception)
{
return new WorkflowCanonicalImportValidationResult
{
SchemaErrors =
[
new WorkflowCanonicalValidationError
{
Code = "WFSCHEMA000",
Path = "$",
Message = $"Workflow canonical JSON is not valid JSON: {exception.Message}",
},
],
};
}
WorkflowCanonicalDefinition definition;
try
{
definition = WorkflowCanonicalJsonSerializer.Deserialize(json);
}
catch (JsonException exception)
{
return new WorkflowCanonicalImportValidationResult
{
SchemaErrors =
[
new WorkflowCanonicalValidationError
{
Code = "WFSCHEMA002",
Path = "$",
Message = $"Workflow canonical JSON could not be deserialized: {exception.Message}",
},
],
};
}
var semanticErrors = WorkflowCanonicalDefinitionValidator.Validate(definition, functionCatalog).Errors;
var moduleErrors = installedModules is null
? Array.Empty<WorkflowCanonicalValidationError>()
: ValidateInstalledModules(definition.RequiredModules, installedModules);
return new WorkflowCanonicalImportValidationResult
{
Definition = definition,
SchemaErrors = [],
SemanticErrors = semanticErrors,
ModuleErrors = moduleErrors,
};
}
private static WorkflowCanonicalValidationError[] ValidateInstalledModules(
IReadOnlyCollection<WorkflowRequiredModuleDeclaration> requiredModules,
IReadOnlyCollection<WorkflowInstalledModule> installedModules)
{
var errors = new List<WorkflowCanonicalValidationError>();
for (var index = 0; index < requiredModules.Count; index++)
{
var requiredModule = requiredModules.ElementAt(index);
var modulePath = $"$.requiredModules[{index}]";
var matches = installedModules
.Where(module => string.Equals(module.ModuleName, requiredModule.ModuleName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (matches.Length == 0)
{
if (!requiredModule.Optional)
{
errors.Add(new WorkflowCanonicalValidationError
{
Code = "WFIMP010",
Path = modulePath,
Message = $"Required workflow module '{requiredModule.ModuleName}' is not installed.",
});
}
continue;
}
if (!WorkflowModuleVersionExpression.TryParse(requiredModule.VersionExpression, out var requirement, out var parseError))
{
errors.Add(new WorkflowCanonicalValidationError
{
Code = "WFIMP011",
Path = $"{modulePath}.versionExpression",
Message = parseError ?? $"Required workflow module version expression '{requiredModule.VersionExpression}' is invalid.",
});
continue;
}
if (matches.Any(match => requirement.IsSatisfiedBy(match.Version)))
{
continue;
}
errors.Add(new WorkflowCanonicalValidationError
{
Code = "WFIMP012",
Path = modulePath,
Message = $"Installed versions for workflow module '{requiredModule.ModuleName}' do not satisfy '{requiredModule.VersionExpression}'.",
});
}
return [.. errors];
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Workflow.Contracts;
using NJsonSchema;
using NJsonSchema.Generation;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowCanonicalJsonSchema
{
private static readonly Lazy<JsonSchema> Schema = new(CreateSchema);
public static JsonSchema DefinitionSchema => Schema.Value;
public static string GetSchemaJson()
{
return Schema.Value.ToJson();
}
public static IReadOnlyCollection<WorkflowCanonicalValidationError> ValidateJson(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
try
{
return [.. Schema.Value
.Validate(json)
.Select(error => new WorkflowCanonicalValidationError
{
Code = "WFSCHEMA001",
Path = string.IsNullOrWhiteSpace(error.Path) ? "$" : error.Path!,
Message = error.ToString(),
})];
}
catch (Exception exception)
{
return
[
new WorkflowCanonicalValidationError
{
Code = "WFSCHEMA000",
Path = "$",
Message = $"Workflow canonical JSON schema validation failed: {exception.Message}",
},
];
}
}
private static JsonSchema CreateSchema()
{
var schemaSettings = new SystemTextJsonSchemaGeneratorSettings
{
GenerateAbstractProperties = true,
AllowReferencesWithProperties = true,
SerializerOptions = WorkflowCanonicalJsonSerializer.Options,
};
return JsonSchema.FromType<WorkflowCanonicalDefinition>(schemaSettings);
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed class WorkflowCanonicalTemplateBindings
{
private readonly Dictionary<string, string> values = new(StringComparer.Ordinal);
public WorkflowCanonicalTemplateBindings AddString(string token, string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(token);
values[token] = WorkflowCanonicalJsonSerializer.SerializeFragment(value);
return this;
}
public WorkflowCanonicalTemplateBindings AddJson<TValue>(string token, TValue value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(token);
values[token] = WorkflowCanonicalJsonSerializer.SerializeFragment(value);
return this;
}
public IReadOnlyDictionary<string, string> Build()
{
return values;
}
}
public static class WorkflowCanonicalTemplateLoader
{
public static WorkflowCanonicalDefinition LoadEmbeddedDefinition(
Assembly assembly,
string resourceName,
IReadOnlyDictionary<string, string> bindings)
{
ArgumentNullException.ThrowIfNull(assembly);
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentNullException.ThrowIfNull(bindings);
var template = LoadEmbeddedText(assembly, resourceName);
var rendered = Render(template, bindings);
return WorkflowCanonicalJsonSerializer.Deserialize(rendered);
}
public static TValue LoadEmbeddedFragment<TValue>(
Assembly assembly,
string resourceName,
IReadOnlyDictionary<string, string> bindings)
{
ArgumentNullException.ThrowIfNull(assembly);
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentNullException.ThrowIfNull(bindings);
var template = LoadEmbeddedText(assembly, resourceName);
var rendered = Render(template, bindings);
return WorkflowCanonicalJsonSerializer.DeserializeFragment<TValue>(rendered);
}
public static string LoadEmbeddedText(Assembly assembly, string resourceName)
{
ArgumentNullException.ThrowIfNull(assembly);
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new InvalidOperationException($"Embedded workflow template resource '{resourceName}' was not found in assembly '{assembly.FullName}'.");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
public static string Render(string template, IReadOnlyDictionary<string, string> bindings)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(bindings);
var rendered = template;
foreach (var binding in bindings)
{
rendered = rendered.Replace($"{{{{{binding.Key}}}}}", binding.Value, StringComparison.Ordinal);
}
return rendered;
}
}

View File

@@ -0,0 +1,23 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Computes a deterministic content hash for canonical workflow definitions.
/// Used to detect whether a definition has changed across imports.
/// </summary>
public static class WorkflowContentHasher
{
/// <summary>
/// Computes a SHA-256 hash of the canonical definition JSON.
/// The JSON is normalized to UTF-8 bytes before hashing.
/// Returns a lowercase hex string (64 characters).
/// </summary>
public static string ComputeHash(string canonicalDefinitionJson)
{
var bytes = Encoding.UTF8.GetBytes(canonicalDefinitionJson);
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
}

View File

@@ -0,0 +1,290 @@
using System.Collections.Generic;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed class WorkflowCoreFunctionProvider : IWorkflowFunctionProvider
{
private const string CoreModuleName = "workflow.functions.core";
private const string CoreModuleVersion = "1.0.0";
public IReadOnlyCollection<WorkflowFunctionDescriptor> GetFunctions()
{
return
[
Create(
"coalesce",
"Returns the first argument that is not null or missing.",
new WorkflowFunctionReturnDescriptor
{
Type = "any",
Description = "The first non-null argument value.",
},
new WorkflowFunctionExample
{
Expression = "coalesce(state.customerId, payload.customerId, start.customerId)",
Description = "Falls back across state, payload, and start values.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "values",
Type = "any",
Required = true,
Variadic = true,
Description = "One or more candidate values evaluated from left to right.",
}),
Create(
"concat",
"Concatenates the textual representation of all provided arguments.",
new WorkflowFunctionReturnDescriptor
{
Type = "string",
Description = "A single string built from all argument values.",
},
new WorkflowFunctionExample
{
Expression = "concat(\"Treaty #\", start.treatyNo)",
Description = "Builds a task description from a literal prefix and a workflow value.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "values",
Type = "any",
Required = true,
Variadic = true,
Description = "One or more values converted to text and concatenated from left to right.",
}),
Create(
"add",
"Adds the numeric representation of all provided arguments.",
new WorkflowFunctionReturnDescriptor
{
Type = "number",
Description = "The arithmetic sum of the provided numeric arguments.",
},
new WorkflowFunctionExample
{
Expression = "add(coalesce(state.agentPollAttempt, 0), 1)",
Description = "Increments a workflow counter without dropping into imperative CLR code.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "values",
Type = "number",
Required = true,
Variadic = true,
Description = "Two or more numeric values summed from left to right.",
}),
Create(
"first",
"Returns the first item from an array-like value. Non-array inputs are returned unchanged.",
new WorkflowFunctionReturnDescriptor
{
Type = "any",
Description = "The first array element, or the original value when it is not an array.",
},
new WorkflowFunctionExample
{
Expression = "first(state.objectData)",
Description = "Uses the first object-data entry when state stores an array of object payloads.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "value",
Type = "any",
Required = true,
Description = "The array-like or scalar value to inspect.",
}),
Create(
"if",
"Evaluates a boolean condition and returns either the second or third argument.",
new WorkflowFunctionReturnDescriptor
{
Type = "any",
Description = "The value selected by the condition.",
},
new WorkflowFunctionExample
{
Expression = "if(state.shouldLookupCustomer, payload.customerId, null)",
Description = "Returns a customer identifier only when lookup is required.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "condition",
Type = "boolean",
Required = true,
Description = "The condition to evaluate.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "whenTrue",
Type = "any",
Required = true,
Description = "Returned when the condition is true.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "whenFalse",
Type = "any",
Required = true,
Description = "Returned when the condition is false.",
}),
Create(
"isNullOrWhiteSpace",
"Returns true when the argument is null, missing, or only whitespace.",
new WorkflowFunctionReturnDescriptor
{
Type = "boolean",
Description = "True when the input is null or whitespace.",
},
new WorkflowFunctionExample
{
Expression = "isNullOrWhiteSpace(state.integrationCustomerId)",
Description = "Checks whether an integration customer identifier is missing.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "value",
Type = "any",
Required = true,
Description = "The value to inspect.",
}),
Create(
"length",
"Returns the length of a string, array, or object-like collection. Missing values return zero.",
new WorkflowFunctionReturnDescriptor
{
Type = "number",
Description = "The computed length of the provided value.",
},
new WorkflowFunctionExample
{
Expression = "length(result.documentsStatus)",
Description = "Counts generated documents in a batch-print response.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "value",
Type = "any",
Required = true,
Description = "The string, array, or collection to measure.",
}),
Create(
"mergeObjects",
"Merges one or more object-like values into a single object. Later arguments override earlier keys.",
new WorkflowFunctionReturnDescriptor
{
Type = "object",
Description = "A merged object containing all contributed properties.",
},
new WorkflowFunctionExample
{
Expression = "mergeObjects(first(state.objectData), { polObjectId: state.polObjectId })",
Description = "Adds or overrides fields on a workflow object payload without imperative code.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "objects",
Type = "object",
Required = true,
Variadic = true,
Description = "One or more object-like values merged from left to right.",
}),
Create(
"upper",
"Returns the upper-case invariant representation of a string value.",
new WorkflowFunctionReturnDescriptor
{
Type = "string",
Description = "The upper-case transformed string, or null when the input is missing.",
},
new WorkflowFunctionExample
{
Expression = "upper(result.productInfo.lob)",
Description = "Normalizes a returned line-of-business code.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "value",
Type = "any",
Required = true,
Description = "The value to normalize to upper-case text.",
}),
Create(
"selectManyPath",
"Projects every item from an array through a relative path and flattens the results.",
new WorkflowFunctionReturnDescriptor
{
Type = "array",
Description = "A flattened array of projected values.",
},
new WorkflowFunctionExample
{
Expression = "selectManyPath(result.objects, \"objectValues\")",
Description = "Flattens nested object values from a result payload.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "source",
Type = "array",
Required = true,
Description = "The array to project.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "relativePath",
Type = "string",
Required = true,
Description = "The relative path to resolve for each item.",
}),
Create(
"findPath",
"Returns the first non-missing value found when projecting array items through a relative path.",
new WorkflowFunctionReturnDescriptor
{
Type = "any",
Description = "The first non-missing projected value, or null.",
},
new WorkflowFunctionExample
{
Expression = "findPath(result.customers, \"srCustId\")",
Description = "Extracts the first customer identifier from a lookup result.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "source",
Type = "array",
Required = true,
Description = "The array to inspect.",
},
new WorkflowFunctionArgumentDescriptor
{
Name = "relativePath",
Type = "string",
Required = true,
Description = "The relative path to resolve for each item.",
}),
];
}
private static WorkflowFunctionDescriptor Create(
string functionName,
string summary,
WorkflowFunctionReturnDescriptor returnDescriptor,
WorkflowFunctionExample example,
params WorkflowFunctionArgumentDescriptor[] arguments)
{
return new WorkflowFunctionDescriptor
{
FunctionName = functionName,
ModuleName = CoreModuleName,
ModuleVersion = CoreModuleVersion,
Summary = summary,
Deterministic = true,
Arguments = arguments,
Return = returnDescriptor,
Examples = [example],
};
}
}

View File

@@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed class WorkflowInlineStepServices(
IWorkflowMicroserviceTransport microserviceTransport,
IWorkflowLegacyRabbitTransport legacyRabbitTransport,
IWorkflowGraphqlTransport graphqlTransport,
IWorkflowHttpTransport httpTransport)
{
public IWorkflowMicroserviceTransport MicroserviceTransport { get; } = microserviceTransport;
public IWorkflowLegacyRabbitTransport LegacyRabbitTransport { get; } = legacyRabbitTransport;
public IWorkflowGraphqlTransport GraphqlTransport { get; } = graphqlTransport;
public IWorkflowHttpTransport HttpTransport { get; } = httpTransport;
public Task<WorkflowMicroserviceResponse> CallAsync(
Address address,
object? payload,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(address);
return MicroserviceTransport.ExecuteAsync(new WorkflowMicroserviceRequest
{
MicroserviceName = address.MicroserviceName,
Command = address.Command,
Payload = payload,
}, cancellationToken);
}
public Task<WorkflowMicroserviceResponse> CallAsync(
LegacyRabbitAddress address,
object? payload,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(address);
return LegacyRabbitTransport.ExecuteAsync(new WorkflowLegacyRabbitRequest
{
Command = address.Command,
Mode = address.Mode,
Payload = payload,
}, cancellationToken);
}
public Task<WorkflowGraphqlResponse> QueryAsync(
GraphqlAddress address,
object? variables,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(address);
return GraphqlTransport.ExecuteAsync(new WorkflowGraphqlRequest
{
Target = address.Target,
Query = address.Query,
OperationName = address.OperationName,
Variables = variables.AsWorkflowObjectDictionary(),
}, cancellationToken);
}
public Task<WorkflowHttpResponse> CallAsync(
HttpAddress address,
object? payload,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(address);
return HttpTransport.ExecuteAsync(new WorkflowHttpRequest
{
Target = address.Target,
Method = address.Method,
Path = address.Path,
Payload = payload,
}, cancellationToken);
}
}
public abstract class WorkflowConditionDefinition<TStartRequest>
where TStartRequest : class
{
protected WorkflowConditionDefinition(string displayName)
{
DisplayName = displayName;
}
public string DisplayName { get; }
public virtual WorkflowExpressionDefinition? CanonicalExpression => null;
public abstract bool Evaluate(WorkflowSpecExecutionContext<TStartRequest> context);
}
public sealed class WorkflowValueConditionDefinition<TStartRequest> : WorkflowConditionDefinition<TStartRequest>
where TStartRequest : class
{
public WorkflowValueConditionDefinition(
WorkflowValueSource source,
string key,
object? expectedValue,
string displayName)
: base(displayName)
{
Source = source;
Key = key;
ExpectedValue = expectedValue;
}
public WorkflowValueSource Source { get; }
public string Key { get; }
public object? ExpectedValue { get; }
public override WorkflowExpressionDefinition CanonicalExpression => WorkflowExpr.Eq(
Source == WorkflowValueSource.Payload
? WorkflowExpr.Path($"payload.{Key}")
: WorkflowExpr.Path($"state.{Key}"),
WorkflowExpr.FromValue(ExpectedValue));
public override bool Evaluate(WorkflowSpecExecutionContext<TStartRequest> context)
{
return context.CompareValue(Source, Key, ExpectedValue);
}
}
public sealed class WorkflowExpressionConditionDefinition<TStartRequest> : WorkflowConditionDefinition<TStartRequest>
where TStartRequest : class
{
private readonly Func<WorkflowSpecExecutionContext<TStartRequest>, bool> evaluator;
private readonly WorkflowExpressionDefinition? canonicalExpression;
public WorkflowExpressionConditionDefinition(
string displayName,
Func<WorkflowSpecExecutionContext<TStartRequest>, bool> evaluator,
WorkflowExpressionDefinition? canonicalExpression = null)
: base(displayName)
{
this.evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
this.canonicalExpression = canonicalExpression;
}
public override WorkflowExpressionDefinition? CanonicalExpression => canonicalExpression;
public override bool Evaluate(WorkflowSpecExecutionContext<TStartRequest> context)
{
return evaluator(context);
}
}
public sealed record WorkflowConditionalStepDefinition<TStartRequest>(
WorkflowConditionDefinition<TStartRequest> Condition,
WorkflowStepSequence<TStartRequest> WhenTrue,
WorkflowStepSequence<TStartRequest> WhenElse)
: WorkflowStepDefinition<TStartRequest>(Condition.DisplayName)
where TStartRequest : class;
public sealed record WorkflowContinueWithStepDefinition<TStartRequest>(
string StepName,
Func<WorkflowSpecExecutionContext<TStartRequest>, StartWorkflowRequest> StartWorkflowRequestFactory)
: WorkflowStepDefinition<TStartRequest>(StepName)
where TStartRequest : class
{
public WorkflowWorkflowInvocationDeclaration? InvocationDeclaration { get; init; }
}
public sealed record WorkflowSubWorkflowStepDefinition<TStartRequest>(
string StepName,
Func<WorkflowSpecExecutionContext<TStartRequest>, StartWorkflowRequest> StartWorkflowRequestFactory,
string? ResultKey)
: WorkflowStepDefinition<TStartRequest>(StepName)
where TStartRequest : class
{
public WorkflowWorkflowInvocationDeclaration? InvocationDeclaration { get; init; }
}
public sealed record WorkflowInlineStepDefinition<TStartRequest>(
string StepName,
Func<WorkflowSpecExecutionContext<TStartRequest>, WorkflowInlineStepServices, CancellationToken, Task> ExecuteAsync,
WorkflowFailureHandlers<TStartRequest>? FailureHandlers = null)
: WorkflowStepDefinition<TStartRequest>(StepName)
where TStartRequest : class;
public sealed record WorkflowRepeatStepDefinition<TStartRequest>(
string StepName,
Func<WorkflowSpecExecutionContext<TStartRequest>, int> MaxIterationsFactory,
WorkflowStepSequence<TStartRequest> Body,
Func<WorkflowSpecExecutionContext<TStartRequest>, bool>? ContinueWhileEvaluator = null)
: WorkflowStepDefinition<TStartRequest>(StepName)
where TStartRequest : class
{
public WorkflowExpressionDefinition? MaxIterationsExpression { get; init; }
public string? IterationStateKey { get; init; }
public WorkflowExpressionDefinition? ContinueWhileExpression { get; init; }
}
public sealed record WorkflowTimerStepDefinition<TStartRequest>(
string StepName,
Func<WorkflowSpecExecutionContext<TStartRequest>, TimeSpan> DelayFactory)
: WorkflowStepDefinition<TStartRequest>(StepName)
where TStartRequest : class
{
public WorkflowExpressionDefinition? DelayExpression { get; init; }
}
public sealed record WorkflowExternalSignalStepDefinition<TStartRequest>(
string StepName,
Func<WorkflowSpecExecutionContext<TStartRequest>, string> SignalNameFactory,
string? ResultKey)
: WorkflowStepDefinition<TStartRequest>(StepName)
where TStartRequest : class
{
public WorkflowExpressionDefinition? SignalNameExpression { get; init; }
}
public sealed record WorkflowForkStepDefinition<TStartRequest>(
string StepName,
IReadOnlyCollection<WorkflowStepSequence<TStartRequest>> Branches)
: WorkflowStepDefinition<TStartRequest>(StepName)
where TStartRequest : class;

View File

@@ -0,0 +1,9 @@
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowDefinitionCatalog
{
IReadOnlyCollection<WorkflowDefinitionDescriptor> GetDefinitions();
WorkflowDefinitionDescriptor? GetDefinition(string workflowName, string? workflowVersion = null);
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Persistent store for versioned canonical workflow definitions.
/// Implementations exist for MongoDB, Oracle, and Postgres via backend plugins.
/// </summary>
public interface IWorkflowDefinitionStore
{
/// <summary>
/// Gets the active definition for a workflow name.
/// Returns null if no definition is stored for this workflow.
/// </summary>
Task<WorkflowDefinitionRecord?> GetActiveAsync(string workflowName, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific version of a workflow definition.
/// Version may include build metadata (e.g., "1.0.0+2").
/// </summary>
Task<WorkflowDefinitionRecord?> GetAsync(string workflowName, string version, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all versions of a workflow definition, ordered by creation date descending.
/// </summary>
Task<IReadOnlyCollection<WorkflowDefinitionRecord>> GetVersionsAsync(string workflowName, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all active definitions across all workflow names.
/// </summary>
Task<IReadOnlyCollection<WorkflowDefinitionRecord>> GetAllActiveAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Inserts or updates a definition record.
/// </summary>
Task UpsertAsync(WorkflowDefinitionRecord record, CancellationToken cancellationToken = default);
/// <summary>
/// Sets a specific version as active for a workflow name.
/// Deactivates all other versions of the same workflow.
/// </summary>
Task ActivateAsync(string workflowName, string version, CancellationToken cancellationToken = default);
/// <summary>
/// Finds any definition record matching the given content hash, regardless of version.
/// Used to detect duplicate imports.
/// </summary>
Task<WorkflowDefinitionRecord?> FindByHashAsync(string workflowName, string contentHash, CancellationToken cancellationToken = default);
}
/// <summary>
/// A versioned, persisted canonical workflow definition record.
/// </summary>
public sealed record WorkflowDefinitionRecord
{
/// <summary>Workflow name (e.g., "ApproveApplication").</summary>
public required string WorkflowName { get; init; }
/// <summary>
/// Full version including build metadata (e.g., "1.0.0+2").
/// Build metadata is appended when the same base version is imported with different content.
/// </summary>
public required string WorkflowVersion { get; init; }
/// <summary>Base semantic version without build metadata (e.g., "1.0.0").</summary>
public required string BaseVersion { get; init; }
/// <summary>Build iteration counter. 0 for the first import of a base version.</summary>
public int BuildIteration { get; init; }
/// <summary>SHA-256 hash of the canonical definition JSON (lowercase hex, 64 chars).</summary>
public required string ContentHash { get; init; }
/// <summary>The full canonical definition as serialized JSON.</summary>
public required string CanonicalDefinitionJson { get; init; }
/// <summary>Human-readable workflow name from the definition.</summary>
public string? DisplayName { get; init; }
/// <summary>Whether this version is the active version for the workflow name.</summary>
public bool IsActive { get; init; }
/// <summary>Optional SVG diagram rendering.</summary>
public byte[]? RenderingSvg { get; init; }
/// <summary>Optional render graph JSON (for UI rendering).</summary>
public byte[]? RenderingJson { get; init; }
/// <summary>Optional PNG screenshot.</summary>
public byte[]? RenderingPng { get; init; }
/// <summary>When this record was created.</summary>
public DateTime CreatedOnUtc { get; init; }
/// <summary>When this version was last activated (set as active).</summary>
public DateTime? ActivatedOnUtc { get; init; }
/// <summary>User or system identifier that performed the import.</summary>
public string? ImportedBy { get; init; }
/// <summary>
/// Resolved dependency versions snapshot. Populated at instance start time.
/// Maps referenced workflow names to their active versions at the time of resolution.
/// </summary>
public IReadOnlyDictionary<string, string>? ResolvedDependencies { get; init; }
}

View File

@@ -0,0 +1,211 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed record WorkflowExecutionTaskPlan
{
public string? WorkflowName { get; init; }
public string? WorkflowVersion { get; init; }
public IReadOnlyCollection<string> WorkflowRoles { get; init; } = [];
public required string TaskName { get; init; }
public required string TaskType { get; init; }
public required string Route { get; init; }
public IReadOnlyCollection<string> TaskRoles { get; init; } = [];
public IReadOnlyCollection<string> RuntimeRoles { get; init; } = [];
public IReadOnlyDictionary<string, JsonElement> Payload { get; init; } = new Dictionary<string, JsonElement>();
/// <summary>
/// Optional timeout for the task in seconds. Used to compute <see cref="WorkflowTaskSummary.DeadlineUtc"/>.
/// </summary>
public int? TimeoutSeconds { get; init; }
}
public sealed record WorkflowContinuationPlan
{
public required StartWorkflowRequest Request { get; init; }
public DateTime? DueAtUtc { get; init; }
}
public sealed record WorkflowPendingSignalPlan
{
public required string SignalType { get; init; }
public required string WaitingToken { get; init; }
public bool AutoDispatch { get; init; } = true;
public DateTime? DueAtUtc { get; init; }
public IReadOnlyDictionary<string, JsonElement> Payload { get; init; } = new Dictionary<string, JsonElement>();
public IReadOnlyDictionary<string, JsonElement> ResumeState { get; init; } = new Dictionary<string, JsonElement>();
}
public sealed record WorkflowStartExecutionContext
{
public required WorkflowRegistration Registration { get; init; }
public required WorkflowDefinitionDescriptor Definition { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public object? StartRequest { get; init; }
public IReadOnlyDictionary<string, JsonElement> Payload { get; init; } = new Dictionary<string, JsonElement>();
public TStartRequest GetRequiredStartRequest<TStartRequest>()
where TStartRequest : class
{
return StartRequest as TStartRequest
?? throw new InvalidOperationException(
$"Workflow start request is not available as '{typeof(TStartRequest).FullName}'.");
}
}
public sealed record WorkflowTaskExecutionContext
{
public required WorkflowRegistration Registration { get; init; }
public required WorkflowDefinitionDescriptor Definition { get; init; }
public required string WorkflowInstanceId { get; init; }
public string? RuntimeInstanceId { get; init; }
public string? RuntimeStateJson { get; init; }
public required WorkflowTaskSummary CurrentTask { get; init; }
public IReadOnlyDictionary<string, JsonElement> WorkflowState { get; init; } = new Dictionary<string, JsonElement>();
public IReadOnlyDictionary<string, JsonElement> Payload { get; init; } = new Dictionary<string, JsonElement>();
}
public sealed record WorkflowSignalExecutionContext
{
public required WorkflowRegistration Registration { get; init; }
public required WorkflowDefinitionDescriptor Definition { get; init; }
public required WorkflowRuntimeStateRecord RuntimeState { get; init; }
public required WorkflowSignalEnvelope Signal { get; init; }
}
public sealed record WorkflowStartExecutionPlan
{
public string InstanceStatus { get; init; } = "Open";
public WorkflowBusinessReference? BusinessReference { get; init; }
public IReadOnlyDictionary<string, JsonElement> WorkflowState { get; init; } = new Dictionary<string, JsonElement>();
public IReadOnlyCollection<WorkflowExecutionTaskPlan> Tasks { get; init; } = [];
public IReadOnlyCollection<WorkflowPendingSignalPlan> PendingSignals { get; init; } = [];
public IReadOnlyCollection<WorkflowContinuationPlan> Continuations { get; init; } = [];
}
public sealed record WorkflowTaskCompletionPlan
{
public string InstanceStatus { get; init; } = "Completed";
public WorkflowBusinessReference? BusinessReference { get; init; }
public IReadOnlyDictionary<string, JsonElement> WorkflowState { get; init; } = new Dictionary<string, JsonElement>();
public IReadOnlyCollection<WorkflowExecutionTaskPlan> NextTasks { get; init; } = [];
public IReadOnlyCollection<WorkflowPendingSignalPlan> PendingSignals { get; init; } = [];
public IReadOnlyCollection<WorkflowContinuationPlan> Continuations { get; init; } = [];
}
public sealed record WorkflowRuntimeExecutionResult
{
public bool Ignored { get; init; }
public required string RuntimeProvider { get; init; }
public string? RuntimeInstanceId { get; init; }
public string? RuntimeStatus { get; init; }
public required string InstanceStatus { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public IReadOnlyDictionary<string, JsonElement> WorkflowState { get; init; } = new Dictionary<string, JsonElement>();
public object? RuntimeState { get; init; }
public IReadOnlyCollection<WorkflowExecutionTaskPlan> Tasks { get; init; } = [];
public IReadOnlyCollection<WorkflowPendingSignalPlan> PendingSignals { get; init; } = [];
public IReadOnlyCollection<WorkflowContinuationPlan> Continuations { get; init; } = [];
}
public sealed record WorkflowRuntimeStateRecord
{
public required string WorkflowInstanceId { get; init; }
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public long Version { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public string RuntimeProvider { get; init; } = WorkflowRuntimeProviderNames.Engine;
public required string RuntimeInstanceId { get; init; }
public string RuntimeStatus { get; init; } = "Open";
public string StateJson { get; init; } = "{}";
public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow;
public DateTime? CompletedOnUtc { get; init; }
public DateTime? StaleAfterUtc { get; init; }
public DateTime? PurgeAfterUtc { get; init; }
public DateTime LastUpdatedOnUtc { get; init; } = DateTime.UtcNow;
}
public interface IWorkflowExecutionHandler
{
Task<WorkflowStartExecutionPlan> StartAsync(
WorkflowStartExecutionContext context,
CancellationToken cancellationToken = default);
Task<WorkflowTaskCompletionPlan> CompleteTaskAsync(
WorkflowTaskExecutionContext context,
CancellationToken cancellationToken = default);
}
public interface IWorkflowSignalResumableExecutionHandler
{
Task<WorkflowTaskCompletionPlan> ResumeSignalAsync(
WorkflowSignalResumeContext context,
CancellationToken cancellationToken = default);
}
public sealed record WorkflowSignalResumeContext
{
public WorkflowBusinessReference? BusinessReference { get; init; }
public required WorkflowSignalEnvelope Signal { get; init; }
public required IReadOnlyDictionary<string, JsonElement> WorkflowState { get; init; }
public IReadOnlyDictionary<string, JsonElement> ResumeState { get; init; } = new Dictionary<string, JsonElement>();
public IReadOnlyCollection<WorkflowContinuationPlan> Continuations { get; init; } = [];
}
public interface IWorkflowExecutionHandlerCatalog
{
IWorkflowExecutionHandler? GetHandler(string workflowName, string workflowVersion);
}
public interface IWorkflowRuntimeOrchestrator
{
Task<WorkflowRuntimeExecutionResult> StartAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowBusinessReference? businessReference,
StartWorkflowRequest request,
object startRequest,
CancellationToken cancellationToken = default);
Task<WorkflowRuntimeExecutionResult> CompleteAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowTaskExecutionContext context,
CancellationToken cancellationToken = default);
Task<WorkflowRuntimeExecutionResult> ResumeAsync(
WorkflowRegistration registration,
WorkflowDefinitionDescriptor definition,
WorkflowSignalExecutionContext context,
CancellationToken cancellationToken = default);
}
public interface IWorkflowRuntimeStateStore
{
Task UpsertAsync(
WorkflowRuntimeStateRecord state,
CancellationToken cancellationToken = default);
Task<WorkflowRuntimeStateRecord?> GetAsync(
string workflowInstanceId,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<WorkflowRuntimeStateRecord>> GetManyAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default);
Task<int> MarkStaleAsync(
IReadOnlyCollection<string> workflowInstanceIds,
DateTime updatedOnUtc,
CancellationToken cancellationToken = default);
Task<int> DeleteAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Workflow.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowFunctionProvider
{
IReadOnlyCollection<WorkflowFunctionDescriptor> GetFunctions();
}
public interface IWorkflowFunctionCatalog
{
IReadOnlyCollection<WorkflowFunctionDescriptor> GetFunctions();
bool TryGetFunction(string functionName, out WorkflowFunctionDescriptor descriptor);
}
public sealed class WorkflowFunctionCatalog : IWorkflowFunctionCatalog
{
private readonly IReadOnlyCollection<WorkflowFunctionDescriptor> functions;
private readonly IReadOnlyDictionary<string, WorkflowFunctionDescriptor> functionsByName;
public WorkflowFunctionCatalog(IEnumerable<IWorkflowFunctionProvider> providers)
{
ArgumentNullException.ThrowIfNull(providers);
var loadedFunctions = providers
.SelectMany(x => x.GetFunctions())
.Where(x => x is not null)
.ToArray();
var dictionary = new Dictionary<string, WorkflowFunctionDescriptor>(StringComparer.OrdinalIgnoreCase);
foreach (var function in loadedFunctions)
{
Register(dictionary, function.FunctionName, function);
foreach (var alias in function.Aliases.Where(x => !string.IsNullOrWhiteSpace(x)))
{
Register(dictionary, alias, function);
}
}
functions = loadedFunctions
.DistinctBy(x => x.FunctionName, StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x.ModuleName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.FunctionName, StringComparer.OrdinalIgnoreCase)
.ToArray();
functionsByName = dictionary;
}
public IReadOnlyCollection<WorkflowFunctionDescriptor> GetFunctions()
{
return functions;
}
public bool TryGetFunction(string functionName, out WorkflowFunctionDescriptor descriptor)
{
return functionsByName.TryGetValue(functionName, out descriptor!);
}
private static void Register(
IDictionary<string, WorkflowFunctionDescriptor> dictionary,
string key,
WorkflowFunctionDescriptor descriptor)
{
if (string.IsNullOrWhiteSpace(key))
{
return;
}
if (dictionary.TryGetValue(key, out var existing))
{
throw new InvalidOperationException(
$"Workflow function '{key}' is registered more than once by modules '{existing.ModuleName}' and '{descriptor.ModuleName}'.");
}
dictionary[key] = descriptor;
}
}
public static class WorkflowFunctionCatalogServiceCollectionExtensions
{
public static IServiceCollection AddWorkflowFunctionCatalog(this IServiceCollection services)
{
services.TryAddSingleton<IWorkflowFunctionCatalog, WorkflowFunctionCatalog>();
services.TryAddSingleton<IWorkflowFunctionRuntime, WorkflowFunctionRuntime>();
return services;
}
public static IServiceCollection AddWorkflowFunctionProvider<TProvider>(this IServiceCollection services)
where TProvider : class, IWorkflowFunctionProvider
{
services.AddWorkflowFunctionCatalog();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowFunctionProvider, TProvider>());
if (typeof(IWorkflowFunctionRuntimeProvider).IsAssignableFrom(typeof(TProvider)))
{
services.TryAddEnumerable(ServiceDescriptor.Singleton(
typeof(IWorkflowFunctionRuntimeProvider),
typeof(TProvider)));
}
return services;
}
}

View File

@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowFunctionRuntimeProvider
{
bool TryEvaluate(
string functionName,
IReadOnlyCollection<object?> arguments,
WorkflowCanonicalEvaluationContext context,
out object? result);
}
public interface IWorkflowFunctionRuntime
{
bool TryEvaluate(
string functionName,
IReadOnlyCollection<object?> arguments,
WorkflowCanonicalEvaluationContext context,
out object? result);
}
internal sealed class WorkflowFunctionRuntime(IEnumerable<IWorkflowFunctionRuntimeProvider> providers)
: IWorkflowFunctionRuntime
{
private readonly IWorkflowFunctionRuntimeProvider[] providers = providers.ToArray();
public bool TryEvaluate(
string functionName,
IReadOnlyCollection<object?> arguments,
WorkflowCanonicalEvaluationContext context,
out object? result)
{
ArgumentException.ThrowIfNullOrWhiteSpace(functionName);
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(context);
foreach (var provider in providers)
{
if (provider.TryEvaluate(functionName, arguments, context, out result))
{
return true;
}
}
result = null;
return false;
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowHostedJobLockService
{
Task<bool> TryAcquireAsync(
string lockName,
string lockOwner,
DateTime acquiredOnUtc,
TimeSpan lease,
CancellationToken cancellationToken = default);
Task ReleaseAsync(
string lockName,
string lockOwner,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,432 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
namespace StellaOps.Workflow.Abstractions;
public readonly record struct WorkflowValueDictionary(
IReadOnlyDictionary<string, JsonElement> Values,
string WorkflowName,
string MissingMessageKey)
{
public WorkflowValue this[string key]
{
get
{
if (!Values.TryGetValue(key, out var value))
{
throw new WorkflowValueNotFoundException(MissingMessageKey, key, WorkflowName);
}
return new WorkflowValue(key, WorkflowName, MissingMessageKey, value);
}
}
}
public readonly record struct WorkflowValue(
string Key,
string WorkflowName,
string MissingMessageKey,
JsonElement Value)
{
public T Get<T>()
{
if (WorkflowJsonExtensions.TryGet(Value, out T? result))
{
return result!;
}
throw new WorkflowValueNotFoundException(MissingMessageKey, Key, WorkflowName);
}
}
public static class WorkflowJsonExtensions
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
};
public static WorkflowValueDictionary WorkflowDict(
this IReadOnlyDictionary<string, JsonElement> source,
string workflowName,
string missingMessageKey)
{
return new WorkflowValueDictionary(source, workflowName, missingMessageKey);
}
public static Dictionary<string, JsonElement> CloneJson(this IReadOnlyDictionary<string, JsonElement> source)
{
var target = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var item in source)
{
target[item.Key] = item.Value.Clone();
}
return target;
}
public static Dictionary<string, JsonElement> AsWorkflowJsonDictionary(this object? value)
{
switch (value)
{
case null:
return new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
case IReadOnlyDictionary<string, JsonElement> jsonDictionary:
return jsonDictionary.CloneJson();
case IDictionary<string, JsonElement> jsonDictionary:
return new Dictionary<string, JsonElement>(jsonDictionary, StringComparer.OrdinalIgnoreCase)
.CloneJson();
}
var element = value.AsJsonElement();
if (element.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("Workflow JSON dictionary source must serialize to an object.");
}
var result = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var property in element.EnumerateObject())
{
result[property.Name] = property.Value.Clone();
}
return result;
}
public static Dictionary<string, JsonElement> Assign<T>(
this Dictionary<string, JsonElement> source,
string key,
T value)
{
source[key] = value.AsJsonElement();
return source;
}
public static Dictionary<string, JsonElement> AssignIfHasValue(
this Dictionary<string, JsonElement> source,
string key,
string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
source[key] = value.AsJsonElement();
}
return source;
}
public static T? GetOptional<T>(
this IReadOnlyDictionary<string, JsonElement> source,
string key)
{
if (!source.TryGetValue(key, out var value)
|| value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return default;
}
if (TryGet(value, out T? result))
{
return result;
}
throw new InvalidOperationException($"Unable to convert workflow value '{key}' to '{typeof(T).FullName}'.");
}
public static bool TryGetPropertyIgnoreCase(
this JsonElement element,
string propertyName,
out JsonElement value)
{
if (element.ValueKind != JsonValueKind.Object)
{
value = default;
return false;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
value = property.Value.Clone();
return true;
}
}
value = default;
return false;
}
public static T? GetOptionalProperty<T>(
this JsonElement element,
string propertyName)
{
if (!element.TryGetPropertyIgnoreCase(propertyName, out var value)
|| value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return default;
}
if (TryGet(value, out T? result))
{
return result;
}
throw new InvalidOperationException(
$"Unable to convert workflow property '{propertyName}' to '{typeof(T).FullName}'.");
}
public static T GetRequiredProperty<T>(
this JsonElement element,
string propertyName)
{
if (!element.TryGetPropertyIgnoreCase(propertyName, out var value)
|| value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
throw new InvalidOperationException($"Workflow property '{propertyName}' is required.");
}
if (TryGet(value, out T? result))
{
return result!;
}
throw new InvalidOperationException(
$"Unable to convert workflow property '{propertyName}' to '{typeof(T).FullName}'.");
}
public static JsonElement AsJsonElement<T>(this T value)
{
return value is JsonElement jsonElement
? jsonElement.Clone()
: JsonSerializer.SerializeToElement(value, SerializerOptions);
}
public static Dictionary<string, object?> AsWorkflowObjectDictionary(this object? value)
{
return value switch
{
null => new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase),
Dictionary<string, object?> dictionary => new(dictionary, StringComparer.OrdinalIgnoreCase),
IDictionary<string, object?> dictionary => new(dictionary, StringComparer.OrdinalIgnoreCase),
IReadOnlyDictionary<string, object?> dictionary => dictionary.ToDictionary(
x => x.Key,
x => x.Value,
StringComparer.OrdinalIgnoreCase),
JsonElement element when element.ValueKind == JsonValueKind.Object =>
JsonSerializer.Deserialize<Dictionary<string, object?>>(element.GetRawText(), SerializerOptions)
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase),
_ => JsonSerializer.Deserialize<Dictionary<string, object?>>(
JsonSerializer.Serialize(value, SerializerOptions),
SerializerOptions)
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase),
};
}
public static T Get<T>(this JsonElement value)
{
if (TryGet(value, out T? result))
{
return result!;
}
throw new InvalidOperationException($"Unable to convert workflow value to '{typeof(T).FullName}'.");
}
public static bool TryGet<T>(JsonElement value, out T? result)
{
if (TryGet(value, typeof(T), out var parsedValue))
{
result = (T?)parsedValue;
return true;
}
result = default;
return false;
}
public static bool TryGet(
JsonElement value,
Type targetType,
out object? result)
{
var resolvedTargetType = Nullable.GetUnderlyingType(targetType) ?? targetType;
if (value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
result = null;
return true;
}
return resolvedTargetType == typeof(JsonElement)
? TryAssign(value.Clone(), out result)
: resolvedTargetType == typeof(string)
? TryAssign(value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(), out result)
: resolvedTargetType == typeof(bool)
? TryParseBoolean(value, out result)
: resolvedTargetType == typeof(long)
? TryParseInt64(value, out result)
: resolvedTargetType == typeof(int)
? TryParseInt32(value, out result)
: resolvedTargetType == typeof(decimal)
? TryParseDecimal(value, out result)
: resolvedTargetType == typeof(double)
? TryParseDouble(value, out result)
: resolvedTargetType == typeof(Guid)
? TryParseGuid(value, out result)
: resolvedTargetType.IsEnum
? TryParseEnum(value, resolvedTargetType, out result)
: TryDeserialize(value, resolvedTargetType, out result);
}
private static bool TryAssign(object? value, out object? result)
{
result = value;
return true;
}
private static bool TryParseBoolean(JsonElement value, out object? result)
{
if (value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False)
{
result = value.GetBoolean();
return true;
}
if (value.ValueKind == JsonValueKind.String
&& bool.TryParse(value.GetString(), out var boolValue))
{
result = boolValue;
return true;
}
result = null;
return false;
}
private static bool TryParseInt64(JsonElement value, out object? result)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var longValue))
{
result = longValue;
return true;
}
if (value.ValueKind == JsonValueKind.String
&& long.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue))
{
result = parsedValue;
return true;
}
result = null;
return false;
}
private static bool TryParseInt32(JsonElement value, out object? result)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var intValue))
{
result = intValue;
return true;
}
if (value.ValueKind == JsonValueKind.String
&& int.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue))
{
result = parsedValue;
return true;
}
result = null;
return false;
}
private static bool TryParseDecimal(JsonElement value, out object? result)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var decimalValue))
{
result = decimalValue;
return true;
}
if (value.ValueKind == JsonValueKind.String
&& decimal.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue))
{
result = parsedValue;
return true;
}
result = null;
return false;
}
private static bool TryParseDouble(JsonElement value, out object? result)
{
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var doubleValue))
{
result = doubleValue;
return true;
}
if (value.ValueKind == JsonValueKind.String
&& double.TryParse(value.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue))
{
result = parsedValue;
return true;
}
result = null;
return false;
}
private static bool TryParseGuid(JsonElement value, out object? result)
{
if (value.ValueKind == JsonValueKind.String
&& Guid.TryParse(value.GetString(), out var guidValue))
{
result = guidValue;
return true;
}
result = null;
return false;
}
private static bool TryParseEnum(JsonElement value, Type enumType, out object? result)
{
if (value.ValueKind == JsonValueKind.String
&& Enum.TryParse(enumType, value.GetString(), true, out var enumValue))
{
result = enumValue;
return true;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var rawValue))
{
result = Enum.ToObject(enumType, rawValue);
return true;
}
result = null;
return false;
}
private static bool TryDeserialize(JsonElement value, Type targetType, out object? result)
{
try
{
result = JsonSerializer.Deserialize(value.GetRawText(), targetType, SerializerOptions);
return result is not null;
}
catch (JsonException)
{
result = null;
return false;
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowModuleCatalog
{
IReadOnlyCollection<WorkflowInstalledModule> GetInstalledModules();
}
internal sealed class WorkflowModuleCatalog(IEnumerable<WorkflowInstalledModule> installedModules)
: IWorkflowModuleCatalog
{
public IReadOnlyCollection<WorkflowInstalledModule> GetInstalledModules()
{
return installedModules
.Where(x => !string.IsNullOrWhiteSpace(x.ModuleName) && !string.IsNullOrWhiteSpace(x.Version))
.GroupBy(x => $"{x.ModuleName}\u0000{x.Version}".ToUpperInvariant(), StringComparer.Ordinal)
.Select(x => x.First())
.OrderBy(x => x.ModuleName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
public static class WorkflowModuleCatalogServiceCollectionExtensions
{
public static IServiceCollection AddWorkflowModuleCatalog(this IServiceCollection services)
{
services.TryAddSingleton<IWorkflowModuleCatalog, WorkflowModuleCatalog>();
return services;
}
public static IServiceCollection AddWorkflowModule(
this IServiceCollection services,
string moduleName,
string version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(moduleName);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
services.AddWorkflowModuleCatalog();
services.AddSingleton(new WorkflowInstalledModule(moduleName, version));
return services;
}
}

View File

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Workflow.Abstractions;
public enum WorkflowModuleVersionOperator
{
Equal,
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
}
public sealed record WorkflowModuleVersionRequirementClause(
WorkflowModuleVersionOperator Operator,
Version Version);
public sealed record WorkflowModuleVersionRequirement
{
public required IReadOnlyCollection<WorkflowModuleVersionRequirementClause> Clauses { get; init; }
public bool IsSatisfiedBy(string installedVersion)
{
if (!WorkflowVersioning.TryParseSemanticVersion(installedVersion, out var parsedInstalledVersion))
{
return false;
}
return Clauses.All(clause => clause.Operator switch
{
WorkflowModuleVersionOperator.Equal => parsedInstalledVersion == clause.Version,
WorkflowModuleVersionOperator.GreaterThan => parsedInstalledVersion > clause.Version,
WorkflowModuleVersionOperator.GreaterThanOrEqual => parsedInstalledVersion >= clause.Version,
WorkflowModuleVersionOperator.LessThan => parsedInstalledVersion < clause.Version,
WorkflowModuleVersionOperator.LessThanOrEqual => parsedInstalledVersion <= clause.Version,
_ => false,
});
}
}
public static class WorkflowModuleVersionExpression
{
private static readonly Regex ClauseRegex = new(
@"(?<prefix>v)?\s*(?<operator>>=|<=|>|<|=)?\s*v?(?<version>(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
public static bool TryParse(
string? expression,
out WorkflowModuleVersionRequirement requirement,
out string? error)
{
if (string.IsNullOrWhiteSpace(expression))
{
requirement = new WorkflowModuleVersionRequirement
{
Clauses = [],
};
error = "Module version expression is required.";
return false;
}
var matches = ClauseRegex.Matches(expression);
if (matches.Count == 0)
{
requirement = new WorkflowModuleVersionRequirement
{
Clauses = [],
};
error = $"Module version expression '{expression}' is not valid.";
return false;
}
var currentIndex = 0;
var clauses = new List<WorkflowModuleVersionRequirementClause>();
foreach (Match match in matches)
{
if (!HasOnlyDelimiters(expression.AsSpan(currentIndex, match.Index - currentIndex)))
{
requirement = new WorkflowModuleVersionRequirement
{
Clauses = [],
};
error = $"Module version expression '{expression}' contains unsupported syntax.";
return false;
}
var versionText = match.Groups["version"].Value;
if (!WorkflowVersioning.TryParseSemanticVersion(versionText, out var version))
{
requirement = new WorkflowModuleVersionRequirement
{
Clauses = [],
};
error = $"Module version expression '{expression}' contains invalid semantic version '{versionText}'.";
return false;
}
clauses.Add(new WorkflowModuleVersionRequirementClause(
ParseOperator(match.Groups["operator"].Value),
version));
currentIndex = match.Index + match.Length;
}
if (!HasOnlyDelimiters(expression.AsSpan(currentIndex)))
{
requirement = new WorkflowModuleVersionRequirement
{
Clauses = [],
};
error = $"Module version expression '{expression}' contains unsupported syntax.";
return false;
}
requirement = new WorkflowModuleVersionRequirement
{
Clauses = clauses,
};
error = null;
return true;
}
private static WorkflowModuleVersionOperator ParseOperator(string operatorText)
{
return operatorText switch
{
"" or "=" => WorkflowModuleVersionOperator.Equal,
">" => WorkflowModuleVersionOperator.GreaterThan,
">=" => WorkflowModuleVersionOperator.GreaterThanOrEqual,
"<" => WorkflowModuleVersionOperator.LessThan,
"<=" => WorkflowModuleVersionOperator.LessThanOrEqual,
_ => throw new InvalidOperationException($"Unsupported module version operator '{operatorText}'."),
};
}
private static bool HasOnlyDelimiters(ReadOnlySpan<char> value)
{
foreach (var character in value)
{
if (!char.IsWhiteSpace(character) && character != ',')
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public sealed record WorkflowInstalledModule(
string ModuleName,
string Version);
public sealed record WorkflowModuleRequirementValidationError(
string Code,
string Path,
string Message);
public static class WorkflowModuleVersioning
{
private static readonly Regex VersionClauseRegex = new(
@"^(?<op>>=|<=|>|<|=)?\s*(?<version>(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static IReadOnlyCollection<WorkflowModuleRequirementValidationError> ValidateRequirementSyntax(
WorkflowRequiredModuleDeclaration requirement,
string path)
{
var errors = new List<WorkflowModuleRequirementValidationError>();
if (string.IsNullOrWhiteSpace(requirement.ModuleName))
{
errors.Add(new WorkflowModuleRequirementValidationError(
"WFVAL070",
$"{path}.moduleName",
"Required module entry requires a module name."));
}
if (!TryParseVersionExpression(requirement.VersionExpression, out _))
{
errors.Add(new WorkflowModuleRequirementValidationError(
"WFVAL071",
$"{path}.versionExpression",
$"Module version expression '{requirement.VersionExpression}' is not valid."));
}
return errors;
}
public static bool Satisfies(
WorkflowInstalledModule installedModule,
WorkflowRequiredModuleDeclaration requirement)
{
if (!string.Equals(installedModule.ModuleName, requirement.ModuleName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return WorkflowVersioning.TryParseSemanticVersion(installedModule.Version, out var installedVersion)
&& TryParseVersionExpression(requirement.VersionExpression, out var clauses)
&& clauses.All(clause => clause.IsSatisfiedBy(installedVersion));
}
private static bool TryParseVersionExpression(
string? versionExpression,
out IReadOnlyCollection<WorkflowVersionClause> clauses)
{
var parts = (versionExpression ?? string.Empty)
.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
clauses = [];
return false;
}
var result = new List<WorkflowVersionClause>();
foreach (var part in parts)
{
var match = VersionClauseRegex.Match(part);
if (!match.Success
|| !WorkflowVersioning.TryParseSemanticVersion(match.Groups["version"].Value, out var version))
{
clauses = [];
return false;
}
var @operator = match.Groups["op"].Success && !string.IsNullOrWhiteSpace(match.Groups["op"].Value)
? match.Groups["op"].Value
: "=";
result.Add(new WorkflowVersionClause(@operator, version));
}
clauses = result;
return true;
}
private sealed record WorkflowVersionClause(string Operator, Version Version)
{
public bool IsSatisfiedBy(Version candidate)
{
var comparison = candidate.CompareTo(Version);
return Operator switch
{
"=" => comparison == 0,
">" => comparison > 0,
">=" => comparison >= 0,
"<" => comparison < 0,
"<=" => comparison <= 0,
_ => false,
};
}
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowMutationScope : IAsyncDisposable
{
void RegisterPostCommitAction(Func<CancellationToken, Task> action);
Task CommitAsync(CancellationToken cancellationToken = default);
}
public interface IWorkflowMutationCoordinator
{
Task<IWorkflowMutationScope> BeginAsync(CancellationToken cancellationToken = default);
}
public interface IWorkflowMutationScopeAccessor
{
IWorkflowMutationScope? Current { get; set; }
}

View File

@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowProjectionStore
{
Task<StartWorkflowResponse> CreateWorkflowAsync(
WorkflowDefinitionDescriptor definition,
WorkflowBusinessReference? businessReference,
WorkflowStartExecutionPlan executionPlan,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<WorkflowTaskSummary>> GetTasksAsync(
WorkflowTasksGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowTaskSummary?> GetTaskAsync(
string workflowTaskId,
CancellationToken cancellationToken = default);
Task<WorkflowExecutionSnapshot?> GetExecutionSnapshotAsync(
string workflowTaskId,
CancellationToken cancellationToken = default);
Task<WorkflowTaskSummary> AssignTaskAsync(
string workflowTaskId,
string actorId,
string assignee,
CancellationToken cancellationToken = default);
Task<WorkflowTaskSummary> AssignTaskRolesAsync(
string workflowTaskId,
string actorId,
IReadOnlyCollection<string> targetRoles,
CancellationToken cancellationToken = default);
Task<WorkflowTaskSummary> ReleaseTaskAsync(
string workflowTaskId,
string actorId,
CancellationToken cancellationToken = default);
Task<WorkflowTaskSummary> ApplyTaskCompletionAsync(
string workflowTaskId,
string actorId,
IDictionary<string, object?> payload,
WorkflowTaskCompletionPlan completionPlan,
WorkflowBusinessReference? businessReference,
CancellationToken cancellationToken = default);
Task ApplyRuntimeProgressAsync(
string workflowInstanceId,
WorkflowTaskCompletionPlan progressPlan,
WorkflowBusinessReference? businessReference,
CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<WorkflowInstanceSummary>> GetInstancesAsync(
WorkflowInstancesGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowInstanceSummary?> GetInstanceAsync(
string workflowInstanceId,
CancellationToken cancellationToken = default);
Task<WorkflowInstanceProjectionDetails?> GetInstanceDetailsAsync(
string workflowInstanceId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,295 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Workflow.Abstractions;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class WorkflowBusinessIdAttribute : Attribute;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class WorkflowBusinessReferencePartAttribute : Attribute
{
public WorkflowBusinessReferencePartAttribute(string? partName = null)
{
PartName = partName;
}
public string? PartName { get; }
}
public interface ISerdicaWorkflow
{
string WorkflowName { get; }
string WorkflowVersion { get; }
string DisplayName { get; }
IReadOnlyCollection<string> WorkflowRoles { get; }
IReadOnlyCollection<WorkflowTaskDescriptor> Tasks { get; }
}
public interface ISerdicaWorkflow<TStartRequest> : ISerdicaWorkflow
where TStartRequest : class;
public sealed record WorkflowRegistration
{
public required Type WorkflowType { get; init; }
public required Type StartRequestType { get; init; }
public Type? HandlerType { get; init; }
public required WorkflowDefinitionDescriptor Definition { get; init; }
public string? BusinessReferenceKeyPropertyName { get; init; }
public required Func<IDictionary<string, object?>, object> BindStartRequest { get; init; }
public required Func<object, WorkflowBusinessReference?> ExtractBusinessReference { get; init; }
}
public interface IWorkflowRegistrationCatalog
{
IReadOnlyCollection<WorkflowRegistration> GetRegistrations();
WorkflowRegistration? GetRegistration(string workflowName, string? workflowVersion = null);
}
public sealed class NoopWorkflowExecutionHandler : IWorkflowExecutionHandler
{
public Task<WorkflowStartExecutionPlan> StartAsync(
WorkflowStartExecutionContext context,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException("This handler should never be resolved.");
}
public Task<WorkflowTaskCompletionPlan> CompleteTaskAsync(
WorkflowTaskExecutionContext context,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException("This handler should never be resolved.");
}
}
public static class WorkflowRegistrationServiceCollectionExtensions
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
};
public static IServiceCollection AddWorkflowRegistration<TWorkflow>(
this IServiceCollection services)
where TWorkflow : class, ISerdicaWorkflow, new()
{
return services.AddWorkflowRegistration<TWorkflow, NoopWorkflowExecutionHandler>();
}
public static IServiceCollection AddWorkflowRegistration<TWorkflow, TRegistrationArgument>(
this IServiceCollection services)
where TWorkflow : class, ISerdicaWorkflow, new()
where TRegistrationArgument : class
{
var registrationArgumentType = typeof(TRegistrationArgument);
return typeof(IWorkflowExecutionHandler).IsAssignableFrom(registrationArgumentType)
? services.AddWorkflowRegistration(typeof(TWorkflow), ResolveStartRequestType(typeof(TWorkflow)), registrationArgumentType)
: services.AddWorkflowRegistration(
typeof(TWorkflow),
EnsureWorkflowSupportsStartRequest(typeof(TWorkflow), registrationArgumentType),
typeof(NoopWorkflowExecutionHandler));
}
public static IServiceCollection AddWorkflowRegistration<TWorkflow, TStartRequest, THandler>(
this IServiceCollection services)
where TWorkflow : class, ISerdicaWorkflow<TStartRequest>, new()
where TStartRequest : class
where THandler : class, IWorkflowExecutionHandler
{
return services.AddWorkflowRegistration(typeof(TWorkflow), typeof(TStartRequest), typeof(THandler));
}
private static IServiceCollection AddWorkflowRegistration(
this IServiceCollection services,
Type workflowType,
Type? startRequestType,
Type handlerType)
{
if (Activator.CreateInstance(workflowType) is not ISerdicaWorkflow workflow)
{
throw new InvalidOperationException(
$"Unable to create workflow '{workflowType.FullName}'.");
}
var businessReferenceKeyProperty = startRequestType is null
? null
: ResolveBusinessReferenceKeyProperty(startRequestType);
var businessReferencePartProperties = startRequestType is null
? []
: ResolveBusinessReferencePartProperties(startRequestType);
services.AddSingleton(new WorkflowRegistration
{
WorkflowType = workflowType,
StartRequestType = startRequestType ?? typeof(Dictionary<string, object?>),
HandlerType = handlerType == typeof(NoopWorkflowExecutionHandler) ? null : handlerType,
Definition = BuildDefinitionDescriptor(workflow),
BusinessReferenceKeyPropertyName = businessReferenceKeyProperty?.Name,
BindStartRequest = payload => BindStartRequest(payload, startRequestType),
ExtractBusinessReference = request => ExtractBusinessReference(
request,
businessReferenceKeyProperty,
businessReferencePartProperties),
});
if (handlerType != typeof(NoopWorkflowExecutionHandler))
{
services.AddScoped(handlerType);
}
return services;
}
private static WorkflowDefinitionDescriptor BuildDefinitionDescriptor(ISerdicaWorkflow workflow)
{
return new WorkflowDefinitionDescriptor
{
WorkflowName = workflow.WorkflowName,
WorkflowVersion = workflow.WorkflowVersion,
DisplayName = workflow.DisplayName,
WorkflowRoles = workflow.WorkflowRoles.ToArray(),
Tasks = workflow.Tasks.ToArray(),
};
}
private static Type? ResolveStartRequestType(Type workflowType)
{
return workflowType
.GetInterfaces()
.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(ISerdicaWorkflow<>))
?.GetGenericArguments()[0];
}
private static Type EnsureWorkflowSupportsStartRequest(Type workflowType, Type startRequestType)
{
var resolvedStartRequestType = ResolveStartRequestType(workflowType);
if (resolvedStartRequestType is null || resolvedStartRequestType != startRequestType)
{
throw new InvalidOperationException(
$"Workflow '{workflowType.FullName}' does not implement {typeof(ISerdicaWorkflow<>).Name}<{startRequestType.Name}>.");
}
return resolvedStartRequestType;
}
private static object BindStartRequest(IDictionary<string, object?> payload, Type? startRequestType)
{
if (startRequestType is null)
{
return new Dictionary<string, object?>(payload, StringComparer.OrdinalIgnoreCase);
}
var json = JsonSerializer.Serialize(payload, SerializerOptions);
return JsonSerializer.Deserialize(json, startRequestType, SerializerOptions)
?? throw new InvalidOperationException(
$"Unable to bind workflow payload to '{startRequestType.FullName}'.");
}
private static PropertyInfo? ResolveBusinessReferenceKeyProperty(Type startRequestType)
{
var markedProperties = startRequestType
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(x => x.GetCustomAttribute<WorkflowBusinessIdAttribute>() is not null)
.ToArray();
return markedProperties.Length switch
{
1 => markedProperties[0],
0 => null,
_ => throw new InvalidOperationException(
$"Workflow start request '{startRequestType.FullName}' declares more than one property marked with {nameof(WorkflowBusinessIdAttribute)}."),
};
}
private static IReadOnlyCollection<WorkflowBusinessReferencePartProperty> ResolveBusinessReferencePartProperties(
Type startRequestType)
{
var partProperties = startRequestType
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Select(x => new
{
Property = x,
Attribute = x.GetCustomAttribute<WorkflowBusinessReferencePartAttribute>(),
})
.Where(x => x.Attribute is not null)
.Select(x => new WorkflowBusinessReferencePartProperty(
x.Property,
string.IsNullOrWhiteSpace(x.Attribute!.PartName) ? x.Property.Name : x.Attribute.PartName!))
.ToArray();
var duplicatePartNames = partProperties
.GroupBy(x => x.PartName, StringComparer.OrdinalIgnoreCase)
.Where(x => x.Count() > 1)
.Select(x => x.Key)
.ToArray();
if (duplicatePartNames.Length > 0)
{
throw new InvalidOperationException(
$"Workflow start request '{startRequestType.FullName}' declares duplicate business reference part names: {string.Join(", ", duplicatePartNames)}.");
}
return partProperties;
}
private static WorkflowBusinessReference? ExtractBusinessReference(
object request,
PropertyInfo? businessReferenceKeyProperty,
IReadOnlyCollection<WorkflowBusinessReferencePartProperty> businessReferencePartProperties)
{
var parts = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var partProperty in businessReferencePartProperties)
{
parts[partProperty.PartName] = partProperty.Property.GetValue(request);
}
var keyPropertyAlreadyRepresented = businessReferenceKeyProperty is not null
&& businessReferencePartProperties.Any(x => x.Property == businessReferenceKeyProperty);
if (businessReferenceKeyProperty is not null
&& !keyPropertyAlreadyRepresented
&& !parts.ContainsKey(businessReferenceKeyProperty.Name))
{
parts[businessReferenceKeyProperty.Name] = businessReferenceKeyProperty.GetValue(request);
}
var key = businessReferenceKeyProperty is null
? WorkflowBusinessReferenceExtensions.BuildCanonicalBusinessReferenceKey(parts)
: ConvertValueToString(businessReferenceKeyProperty.GetValue(request));
return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference
{
Key = key,
Parts = parts,
});
}
private static string? ConvertValueToString(object? value)
{
return value switch
{
null => null,
string text => text,
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => jsonElement.GetString(),
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number => jsonElement.ToString(),
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.True => bool.TrueString,
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.False => bool.FalseString,
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString(),
};
}
private sealed record WorkflowBusinessReferencePartProperty(PropertyInfo Property, string PartName);
}

View File

@@ -0,0 +1,167 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowRenderLayoutProviderNames
{
public const string ElkSharp = "ElkSharp";
public const string ElkJs = "ElkJs";
public const string Msagl = "Msagl";
}
public sealed class WorkflowRenderingOptions
{
public const string SectionName = "WorkflowRendering";
public string? LayoutProvider { get; set; }
}
public enum WorkflowRenderLayoutDirection
{
TopToBottom = 0,
LeftToRight = 1,
}
public enum WorkflowRenderLayoutEffort
{
Draft = 0,
Balanced = 1,
Best = 2,
}
public sealed record WorkflowRenderPort
{
public required string Id { get; init; }
public string? Side { get; init; }
public double Width { get; init; } = 8;
public double Height { get; init; } = 8;
}
public sealed record WorkflowRenderNode
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Kind { get; init; }
public string? IconKey { get; init; }
public string? SemanticType { get; init; }
public string? SemanticKey { get; init; }
public string? Route { get; init; }
public string? TaskType { get; init; }
public string? ParentNodeId { get; init; }
public double Width { get; init; } = 160;
public double Height { get; init; } = 72;
public IReadOnlyCollection<WorkflowRenderPort> Ports { get; init; } = [];
}
public sealed record WorkflowRenderEdge
{
public required string Id { get; init; }
public required string SourceNodeId { get; init; }
public required string TargetNodeId { get; init; }
public string? SourcePortId { get; init; }
public string? TargetPortId { get; init; }
public string? Kind { get; init; }
public string? Label { get; init; }
}
public sealed record WorkflowRenderGraph
{
public required string Id { get; init; }
public required IReadOnlyCollection<WorkflowRenderNode> Nodes { get; init; }
public required IReadOnlyCollection<WorkflowRenderEdge> Edges { get; init; }
}
public sealed record WorkflowRenderLayoutRequest
{
public WorkflowRenderLayoutDirection Direction { get; init; } = WorkflowRenderLayoutDirection.LeftToRight;
public double NodeSpacing { get; init; } = 40;
public double LayerSpacing { get; init; } = 60;
public WorkflowRenderLayoutEffort Effort { get; init; } = WorkflowRenderLayoutEffort.Best;
public int? OrderingIterations { get; init; }
public int? PlacementIterations { get; init; }
}
public sealed record WorkflowRenderPoint
{
public required double X { get; init; }
public required double Y { get; init; }
}
public sealed record WorkflowRenderPositionedPort
{
public required string Id { get; init; }
public string? Side { get; init; }
public double X { get; init; }
public double Y { get; init; }
public double Width { get; init; }
public double Height { get; init; }
}
public sealed record WorkflowRenderPositionedNode
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Kind { get; init; }
public string? IconKey { get; init; }
public string? SemanticType { get; init; }
public string? SemanticKey { get; init; }
public string? Route { get; init; }
public string? TaskType { get; init; }
public string? ParentNodeId { get; init; }
public double X { get; init; }
public double Y { get; init; }
public double Width { get; init; }
public double Height { get; init; }
public IReadOnlyCollection<WorkflowRenderPositionedPort> Ports { get; init; } = [];
}
public sealed record WorkflowRenderEdgeSection
{
public required WorkflowRenderPoint StartPoint { get; init; }
public required WorkflowRenderPoint EndPoint { get; init; }
public IReadOnlyCollection<WorkflowRenderPoint> BendPoints { get; init; } = [];
}
public sealed record WorkflowRenderRoutedEdge
{
public required string Id { get; init; }
public required string SourceNodeId { get; init; }
public required string TargetNodeId { get; init; }
public string? SourcePortId { get; init; }
public string? TargetPortId { get; init; }
public string? Kind { get; init; }
public string? Label { get; init; }
public IReadOnlyCollection<WorkflowRenderEdgeSection> Sections { get; init; } = [];
}
public sealed record WorkflowRenderLayoutResult
{
public required string GraphId { get; init; }
public required IReadOnlyCollection<WorkflowRenderPositionedNode> Nodes { get; init; }
public required IReadOnlyCollection<WorkflowRenderRoutedEdge> Edges { get; init; }
}
public interface IWorkflowRenderGraphLayoutEngine
{
Task<WorkflowRenderLayoutResult> LayoutAsync(
WorkflowRenderGraph graph,
WorkflowRenderLayoutRequest? request = null,
CancellationToken cancellationToken = default);
}
public interface INamedWorkflowRenderGraphLayoutEngine : IWorkflowRenderGraphLayoutEngine
{
string ProviderName { get; }
}
public interface IWorkflowRenderLayoutEngineResolver
{
INamedWorkflowRenderGraphLayoutEngine Resolve(string? providerName = null);
}
public interface IWorkflowRenderGraphCompiler
{
WorkflowRenderGraph Compile(WorkflowRuntimeDefinition definition);
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
public sealed record WorkflowProjectionRetentionBatch
{
public IReadOnlyCollection<string> StaleWorkflowInstanceIds { get; init; } = [];
public int StaleInstancesMarked { get; init; }
public int StaleTasksMarked { get; init; }
public IReadOnlyCollection<string> PurgedWorkflowInstanceIds { get; init; } = [];
public int PurgedInstances { get; init; }
public int PurgedTasks { get; init; }
public int PurgedTaskEvents { get; init; }
}
public interface IWorkflowProjectionRetentionStore
{
Task<WorkflowProjectionRetentionBatch> RunAsync(
DateTime nowUtc,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,37 @@
using System.Collections.Generic;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public enum WorkflowRuntimeExecutionKind
{
DefinitionOnly = 0,
CustomHandler = 1,
Declarative = 2,
}
public sealed record WorkflowRuntimeDefinition
{
public required WorkflowRegistration Registration { get; init; }
public required WorkflowDefinitionDescriptor Descriptor { get; init; }
public required WorkflowRuntimeExecutionKind ExecutionKind { get; init; }
public WorkflowCanonicalDefinition? CanonicalDefinition { get; init; }
}
public interface IWorkflowRuntimeDefinitionStore
{
IReadOnlyCollection<WorkflowRuntimeDefinition> GetDefinitions();
WorkflowRuntimeDefinition? GetDefinition(string workflowName, string? workflowVersion = null);
WorkflowRuntimeDefinition GetRequiredDefinition(string workflowName, string? workflowVersion = null);
}
public interface IWorkflowRuntimeExecutionHandlerFactory
{
IWorkflowExecutionHandler? TryCreateHandler(WorkflowRuntimeDefinition definition);
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowSignalTypes
{
public const string TaskCompleted = "TaskCompleted";
public const string TimerDue = "TimerDue";
public const string RetryDue = "RetryDue";
public const string ExternalSignal = "ExternalSignal";
public const string SubWorkflowCompleted = "SubWorkflowCompleted";
public const string InternalContinue = "InternalContinue";
}
public sealed record WorkflowSignalEnvelope
{
public required string SignalId { get; init; }
public required string WorkflowInstanceId { get; init; }
public required string RuntimeProvider { get; init; }
public required string SignalType { get; init; }
public required long ExpectedVersion { get; init; }
public string? WaitingToken { get; init; }
public DateTime OccurredAtUtc { get; init; } = DateTime.UtcNow;
public DateTime? DueAtUtc { get; init; }
public IReadOnlyDictionary<string, JsonElement> Payload { get; init; } = new Dictionary<string, JsonElement>();
}
public interface IWorkflowRuntimeProvider : IWorkflowRuntimeOrchestrator
{
string ProviderName { get; }
}
public interface IWorkflowSignalLease : IAsyncDisposable
{
WorkflowSignalEnvelope Envelope { get; }
int DeliveryCount { get; }
Task CompleteAsync(CancellationToken cancellationToken = default);
Task AbandonAsync(CancellationToken cancellationToken = default);
Task DeadLetterAsync(CancellationToken cancellationToken = default);
}
public interface IWorkflowSignalBus
{
Task PublishAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default);
Task PublishDeadLetterAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default);
Task<IWorkflowSignalLease?> ReceiveAsync(
string consumerName,
CancellationToken cancellationToken = default);
}
public interface IWorkflowScheduleBus
{
Task ScheduleAsync(
WorkflowSignalEnvelope envelope,
DateTime dueAtUtc,
CancellationToken cancellationToken = default);
}
public interface IWorkflowSignalProcessor
{
Task ProcessAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowRuntimePayloadKeys
{
public const string RuntimeTaskTokenPayloadKey = "runtimeTaskToken";
public const string RuntimeProviderPayloadKey = "runtimeProvider";
public const string ProjectionWorkflowInstanceIdPayloadKey = "__serdica.projectionWorkflowInstanceId";
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowRuntimeProviderNames
{
public const string InProcess = "Stella.InProcess";
public const string Engine = "Stella.Engine";
}

View File

@@ -0,0 +1,21 @@
using System;
namespace StellaOps.Workflow.Abstractions;
public sealed class WorkflowRuntimeStateConcurrencyException : Exception
{
public WorkflowRuntimeStateConcurrencyException(string workflowInstanceId, long expectedVersion, long actualVersion)
: base(
$"Workflow runtime state '{workflowInstanceId}' version conflict. Expected next version '{expectedVersion}' but found '{actualVersion}'.")
{
WorkflowInstanceId = workflowInstanceId;
ExpectedVersion = expectedVersion;
ActualVersion = actualVersion;
}
public string WorkflowInstanceId { get; }
public long ExpectedVersion { get; }
public long ActualVersion { get; }
}

View File

@@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowSignalDeadLetterStore
{
Task<WorkflowSignalDeadLettersGetResponse> GetMessagesAsync(
WorkflowSignalDeadLettersGetRequest request,
CancellationToken cancellationToken = default);
Task<WorkflowSignalDeadLetterReplayResponse> ReplayAsync(
WorkflowSignalDeadLetterReplayRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,92 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Workflow.Abstractions;
public enum WorkflowSignalDriverDispatchMode
{
NativeTransactional = 0,
PostCommitNotification = 1,
WakeOutbox = 2,
}
public interface IWorkflowSignalDriverRegistrationMarker
{
string DriverName { get; }
}
public sealed record WorkflowSignalDriverRegistrationMarker(string DriverName) : IWorkflowSignalDriverRegistrationMarker;
public sealed record WorkflowSignalWakeNotification
{
public required string SignalId { get; init; }
public required string WorkflowInstanceId { get; init; }
public required string RuntimeProvider { get; init; }
public required string SignalType { get; init; }
public DateTime? DueAtUtc { get; init; }
}
public interface IWorkflowSignalClaimStore
{
Task<IWorkflowSignalLease?> TryClaimAsync(
string consumerName,
CancellationToken cancellationToken = default);
}
public interface IWorkflowSignalStore
{
Task PublishAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default);
Task PublishDeadLetterAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default);
}
public interface IWorkflowSignalDriver
{
string DriverName { get; }
WorkflowSignalDriverDispatchMode DispatchMode { get; }
Task NotifySignalAvailableAsync(
WorkflowSignalWakeNotification notification,
CancellationToken cancellationToken = default);
Task<IWorkflowSignalLease?> ReceiveAsync(
string consumerName,
CancellationToken cancellationToken = default);
}
public interface IWorkflowSignalScheduler
{
Task ScheduleAsync(
WorkflowSignalEnvelope envelope,
DateTime dueAtUtc,
CancellationToken cancellationToken = default);
}
public interface IWorkflowWakeOutbox
{
Task EnqueueAsync(
WorkflowSignalWakeNotification notification,
CancellationToken cancellationToken = default);
}
public interface IWorkflowWakeOutboxLease : IAsyncDisposable
{
WorkflowSignalWakeNotification Notification { get; }
string ConsumerName { get; }
Task CompleteAsync(CancellationToken cancellationToken = default);
Task AbandonAsync(CancellationToken cancellationToken = default);
}
public interface IWorkflowWakeOutboxReceiver
{
Task<IWorkflowWakeOutboxLease?> ReceiveAsync(
string consumerName,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,18 @@
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowSignalDriverConfigurationExtensions
{
public static string GetWorkflowSignalDriverProvider(this IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var providerName = configuration.GetSection(WorkflowSignalDriverOptions.SectionName)[nameof(WorkflowSignalDriverOptions.Provider)];
return string.IsNullOrWhiteSpace(providerName)
? WorkflowSignalDriverNames.Native
: providerName;
}
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowSignalDriverNames
{
public const string Native = "Native";
public const string Redis = "Redis";
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Workflow.Abstractions;
public sealed class WorkflowSignalDriverOptions
{
public const string SectionName = "WorkflowSignalDriver";
public string Provider { get; set; } = WorkflowSignalDriverNames.Native;
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowSignalPayloadKeys
{
public const string StartWorkflowRequestPayloadKey = "startWorkflowRequest";
public const string ExternalSignalNamePayloadKey = "signalName";
}

View File

@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowSpecExecutionContext
{
string WorkflowName { get; }
Dictionary<string, JsonElement> WorkflowState { get; }
IReadOnlyDictionary<string, JsonElement> Payload { get; }
IReadOnlyDictionary<string, JsonElement> ResultValues { get; }
WorkflowValueDictionary StateValues { get; }
WorkflowValueDictionary PayloadValues { get; }
TResponse GetRequiredResult<TResponse>(string resultKey);
TResponse? GetOptionalResult<TResponse>(string resultKey);
bool CompareValue(WorkflowValueSource source, string key, object? expectedValue);
}
public sealed class Address
{
public Address(
string microserviceName,
string command)
{
MicroserviceName = string.IsNullOrWhiteSpace(microserviceName)
? throw new ArgumentException("Microservice name is required.", nameof(microserviceName))
: microserviceName;
Command = string.IsNullOrWhiteSpace(command)
? throw new ArgumentException("Command is required.", nameof(command))
: command;
}
public string MicroserviceName { get; }
public string Command { get; }
}
public sealed class LegacyRabbitAddress
{
public LegacyRabbitAddress(
string command,
WorkflowLegacyRabbitMode mode = WorkflowLegacyRabbitMode.Envelope)
{
Command = string.IsNullOrWhiteSpace(command)
? throw new ArgumentException("Command is required.", nameof(command))
: command;
Mode = mode;
}
public string Command { get; }
public WorkflowLegacyRabbitMode Mode { get; }
}
public sealed class GraphqlAddress
{
public GraphqlAddress(
string target,
string query,
string? operationName = null)
{
Target = string.IsNullOrWhiteSpace(target)
? throw new ArgumentException("GraphQL target is required.", nameof(target))
: target;
Query = string.IsNullOrWhiteSpace(query)
? throw new ArgumentException("GraphQL query is required.", nameof(query))
: query;
OperationName = operationName;
}
public string Target { get; }
public string Query { get; }
public string? OperationName { get; }
}
public sealed class HttpAddress
{
public HttpAddress(
string target,
string path,
string method = "POST")
{
Target = string.IsNullOrWhiteSpace(target)
? throw new ArgumentException("HTTP target is required.", nameof(target))
: target;
Path = string.IsNullOrWhiteSpace(path)
? throw new ArgumentException("HTTP path is required.", nameof(path))
: path;
Method = string.IsNullOrWhiteSpace(method)
? throw new ArgumentException("HTTP method is required.", nameof(method))
: method.Trim().ToUpperInvariant();
}
public string Target { get; }
public string Method { get; }
public string Path { get; }
}
public sealed class WorkflowReference
{
private readonly string? staticWorkflowName;
private readonly string? staticWorkflowVersion;
public WorkflowReference(
string workflowName,
string? workflowVersion = null)
: this(
_ => workflowName,
_ => workflowVersion)
{
staticWorkflowName = workflowName;
staticWorkflowVersion = workflowVersion;
}
public WorkflowReference(
Func<IWorkflowSpecExecutionContext, string> workflowNameFactory,
Func<IWorkflowSpecExecutionContext, string?>? workflowVersionFactory = null)
{
WorkflowNameFactory = workflowNameFactory ?? throw new ArgumentNullException(nameof(workflowNameFactory));
WorkflowVersionFactory = workflowVersionFactory;
}
public Func<IWorkflowSpecExecutionContext, string> WorkflowNameFactory { get; }
public Func<IWorkflowSpecExecutionContext, string?>? WorkflowVersionFactory { get; }
public string? StaticWorkflowName => staticWorkflowName;
public string? StaticWorkflowVersion => staticWorkflowVersion;
public StartWorkflowRequest BuildStartWorkflowRequest(
IWorkflowSpecExecutionContext context,
object? payload,
WorkflowBusinessReference? businessReference = null)
{
return new StartWorkflowRequest
{
WorkflowName = WorkflowNameFactory(context),
WorkflowVersion = WorkflowVersionFactory?.Invoke(context),
BusinessReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference),
Payload = payload.AsWorkflowObjectDictionary(),
};
}
public WorkflowWorkflowInvocationDeclaration? TryBuildCanonicalInvocationDeclaration(
WorkflowExpressionDefinition? payloadExpression = null,
WorkflowBusinessReferenceDeclaration? businessReference = null)
{
if (string.IsNullOrWhiteSpace(staticWorkflowName))
{
return null;
}
return new WorkflowWorkflowInvocationDeclaration
{
WorkflowNameExpression = WorkflowExpr.String(staticWorkflowName),
WorkflowVersionExpression = string.IsNullOrWhiteSpace(staticWorkflowVersion)
? null
: WorkflowExpr.String(staticWorkflowVersion),
PayloadExpression = payloadExpression,
BusinessReference = businessReference,
};
}
}

View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Workflow.Abstractions;
internal static class WorkflowStepIdentityAssigner
{
public static void Assign<TStartRequest>(WorkflowSpec<TStartRequest> spec)
where TStartRequest : class
{
ArgumentNullException.ThrowIfNull(spec);
AssignSequence(spec.InitialSequence, "start");
foreach (var task in spec.TasksByName.Values)
{
AssignSequence(task.OnComplete, $"task:{task.TaskName}");
}
}
private static void AssignSequence<TStartRequest>(
WorkflowStepSequence<TStartRequest> sequence,
string prefix)
where TStartRequest : class
{
var steps = sequence.Steps;
var index = 0;
foreach (var step in steps)
{
step.StepId = $"{prefix}/{index}";
switch (step)
{
case WorkflowDecisionStepDefinition<TStartRequest> decision:
AssignSequence(decision.WhenTrue, $"{step.StepId}/true");
AssignSequence(decision.WhenFalse, $"{step.StepId}/false");
break;
case WorkflowConditionalStepDefinition<TStartRequest> conditional:
AssignSequence(conditional.WhenTrue, $"{step.StepId}/true");
AssignSequence(conditional.WhenElse, $"{step.StepId}/else");
break;
case WorkflowMicroserviceCallStepDefinition<TStartRequest> microserviceCall:
AssignFailureHandlers(microserviceCall.FailureHandlers, step.StepId);
break;
case WorkflowLegacyRabbitCallStepDefinition<TStartRequest> legacyRabbitCall:
AssignFailureHandlers(legacyRabbitCall.FailureHandlers, step.StepId);
break;
case WorkflowGraphqlCallStepDefinition<TStartRequest> graphqlCall:
AssignFailureHandlers(graphqlCall.FailureHandlers, step.StepId);
break;
case WorkflowHttpCallStepDefinition<TStartRequest> httpCall:
AssignFailureHandlers(httpCall.FailureHandlers, step.StepId);
break;
case WorkflowInlineStepDefinition<TStartRequest> inlineStep:
AssignFailureHandlers(inlineStep.FailureHandlers, step.StepId);
break;
case WorkflowRepeatStepDefinition<TStartRequest> repeat:
AssignSequence(repeat.Body, $"{step.StepId}/repeat");
break;
case WorkflowForkStepDefinition<TStartRequest> fork:
AssignForkBranches(fork.Branches, step.StepId);
break;
}
index++;
}
}
private static void AssignForkBranches<TStartRequest>(
IReadOnlyCollection<WorkflowStepSequence<TStartRequest>> branches,
string stepId)
where TStartRequest : class
{
var index = 0;
foreach (var branch in branches)
{
AssignSequence(branch, $"{stepId}/branch:{index}");
index++;
}
}
private static void AssignFailureHandlers<TStartRequest>(
WorkflowFailureHandlers<TStartRequest>? failureHandlers,
string stepId)
where TStartRequest : class
{
if (failureHandlers is null)
{
return;
}
AssignSequence(failureHandlers.WhenFailure, $"{stepId}/failure");
AssignSequence(failureHandlers.WhenTimeout, $"{stepId}/timeout");
}
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Default timeout values used across the workflow engine when no explicit timeout is specified.
/// </summary>
public static class WorkflowTimeoutDefaults
{
/// <summary>
/// Default timeout for service task (transport call) steps in seconds.
/// Applied when neither the step declaration nor the transport configuration specifies a timeout.
/// Value: 3600 seconds (1 hour).
/// </summary>
public const int DefaultTimeoutForServiceTaskCallsSeconds = 3600;
/// <summary>
/// Default transport-level timeout in seconds, used as fallback when transport options
/// do not specify a timeout. Value: 30 seconds.
/// </summary>
public const int DefaultTransportTimeoutSeconds = 30;
}

View File

@@ -0,0 +1,41 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.Abstractions;
public interface IWorkflowMicroserviceTransport
{
Task<WorkflowMicroserviceResponse> ExecuteAsync(
WorkflowMicroserviceRequest request,
CancellationToken cancellationToken = default);
}
public interface IWorkflowRabbitTransport
{
Task<WorkflowRabbitResponse> ExecuteAsync(
WorkflowRabbitRequest request,
CancellationToken cancellationToken = default);
}
public interface IWorkflowLegacyRabbitTransport
{
Task<WorkflowMicroserviceResponse> ExecuteAsync(
WorkflowLegacyRabbitRequest request,
CancellationToken cancellationToken = default);
}
public interface IWorkflowGraphqlTransport
{
Task<WorkflowGraphqlResponse> ExecuteAsync(
WorkflowGraphqlRequest request,
CancellationToken cancellationToken = default);
}
public interface IWorkflowHttpTransport
{
Task<WorkflowHttpResponse> ExecuteAsync(
WorkflowHttpRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.Workflow.Abstractions;
public class WorkflowValueNotFoundException : InvalidOperationException
{
public string MessageKey { get; }
public WorkflowValueNotFoundException(string messageKey, params object[] args)
: base($"Workflow value not found: {messageKey} [{string.Join(", ", args)}]")
{ MessageKey = messageKey; }
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace StellaOps.Workflow.Abstractions;
public static class WorkflowVersioning
{
private static readonly Regex SemanticVersionRegex = new(
@"^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static IComparer<string> SemanticComparer { get; } = new SemanticWorkflowVersionComparer();
public static bool TryParseSemanticVersion(string? value, out Version version)
{
var match = value is null ? null : SemanticVersionRegex.Match(value);
if (match is { Success: true }
&& int.TryParse(match.Groups["major"].Value, out var major)
&& int.TryParse(match.Groups["minor"].Value, out var minor)
&& int.TryParse(match.Groups["patch"].Value, out var patch))
{
version = new Version(major, minor, patch);
return true;
}
version = new Version(0, 0);
return false;
}
/// <summary>
/// Extracts the base version (without build metadata) from a version string.
/// "1.0.0+2" returns "1.0.0". "1.0.0" returns "1.0.0".
/// </summary>
public static string GetBaseVersion(string version)
{
var plusIndex = version.IndexOf('+');
return plusIndex >= 0 ? version[..plusIndex] : version;
}
/// <summary>
/// Extracts the build iteration from a version string.
/// "1.0.0+2" returns 2. "1.0.0" returns 0.
/// </summary>
public static int GetBuildIteration(string version)
{
var plusIndex = version.IndexOf('+');
if (plusIndex >= 0 && int.TryParse(version[(plusIndex + 1)..], out var iteration))
{
return iteration;
}
return 0;
}
/// <summary>
/// Formats a version with build metadata suffix.
/// FormatVersion("1.0.0", 0) returns "1.0.0".
/// FormatVersion("1.0.0", 2) returns "1.0.0+2".
/// </summary>
public static string FormatVersion(string baseVersion, int buildIteration)
{
return buildIteration > 0 ? $"{baseVersion}+{buildIteration}" : baseVersion;
}
private sealed class SemanticWorkflowVersionComparer : IComparer<string>
{
public int Compare(string? x, string? y)
{
var xParsed = TryParseSemanticVersion(x, out var xVersion);
var yParsed = TryParseSemanticVersion(y, out var yVersion);
if (xParsed && yParsed)
{
return xVersion.CompareTo(yVersion);
}
if (xParsed)
{
return 1;
}
if (yParsed)
{
return -1;
}
return string.Compare(x, y, StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,132 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Workflow.Abstractions;
/// <summary>
/// Extension methods for registering workflow webservice API implementations in DI.
/// </summary>
public static class WorkflowWebserviceServiceCollectionExtensions
{
/// <summary>
/// Registers all workflow webservice API interfaces using the provided implementation types.
/// Only interfaces whose implementations are supplied will be registered.
/// </summary>
public static IServiceCollection AddWorkflowWebserviceApis<TRuntime>(
this IServiceCollection services)
where TRuntime : class, IWorkflowRuntimeApi
{
services.TryAddScoped<IWorkflowRuntimeApi, TRuntime>();
return services;
}
/// <summary>
/// Registers a specific workflow webservice API interface.
/// </summary>
public static IServiceCollection AddWorkflowRuntimeApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowRuntimeApi
{
services.TryAddScoped<IWorkflowRuntimeApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow definition deployment API.
/// </summary>
public static IServiceCollection AddWorkflowDefinitionDeploymentApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowDefinitionDeploymentApi
{
services.TryAddScoped<IWorkflowDefinitionDeploymentApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow diagram API.
/// </summary>
public static IServiceCollection AddWorkflowDiagramApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowDiagramApi
{
services.TryAddScoped<IWorkflowDiagramApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow definition query API.
/// </summary>
public static IServiceCollection AddWorkflowDefinitionQueryApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowDefinitionQueryApi
{
services.TryAddScoped<IWorkflowDefinitionQueryApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow canonical definition API.
/// </summary>
public static IServiceCollection AddWorkflowCanonicalDefinitionApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowCanonicalDefinitionApi
{
services.TryAddScoped<IWorkflowCanonicalDefinitionApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow retention API.
/// </summary>
public static IServiceCollection AddWorkflowRetentionApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowRetentionApi
{
services.TryAddScoped<IWorkflowRetentionApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow signal dead-letter API.
/// </summary>
public static IServiceCollection AddWorkflowSignalDeadLetterApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowSignalDeadLetterApi
{
services.TryAddScoped<IWorkflowSignalDeadLetterApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow service metadata API.
/// </summary>
public static IServiceCollection AddWorkflowServiceMetadataApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowServiceMetadataApi
{
services.TryAddScoped<IWorkflowServiceMetadataApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow function catalog API.
/// </summary>
public static IServiceCollection AddWorkflowFunctionCatalogApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowFunctionCatalogApi
{
services.TryAddScoped<IWorkflowFunctionCatalogApi, TImplementation>();
return services;
}
/// <summary>
/// Registers the workflow signal pump telemetry API.
/// </summary>
public static IServiceCollection AddWorkflowSignalPumpTelemetryApi<TImplementation>(
this IServiceCollection services)
where TImplementation : class, IWorkflowSignalPumpTelemetryApi
{
services.TryAddScoped<IWorkflowSignalPumpTelemetryApi, TImplementation>();
return services;
}
}

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowBusinessReference
{
public string? Key { get; init; }
public IDictionary<string, object?> Parts { get; init; } = new Dictionary<string, object?>();
}

View File

@@ -0,0 +1,359 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Workflow.Contracts;
public static class WorkflowCanonicalDefinitionSchema
{
public const string Version1 = "stellaops.workflow.definition/v1";
/// <summary>
/// Obsolete alias retained for backward compatibility with definitions
/// serialized under the original Serdica schema version.
/// </summary>
[Obsolete("Use Version1 (\"stellaops.workflow.definition/v1\") instead.")]
public const string Version1Serdica = "serdica.workflow.definition/v1";
}
public sealed record WorkflowCanonicalDefinition
{
public string SchemaVersion { get; init; } = WorkflowCanonicalDefinitionSchema.Version1;
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public required string DisplayName { get; init; }
public WorkflowRequestContractDeclaration? StartRequest { get; init; }
public IReadOnlyCollection<string> WorkflowRoles { get; init; } = [];
public WorkflowBusinessReferenceDeclaration? BusinessReference { get; init; }
public required WorkflowStartDeclaration Start { get; init; }
public IReadOnlyCollection<WorkflowTaskDeclaration> Tasks { get; init; } = [];
public IReadOnlyCollection<WorkflowRequiredModuleDeclaration> RequiredModules { get; init; } = [];
public IReadOnlyCollection<string> RequiredCapabilities { get; init; } = [];
}
public sealed record WorkflowRequiredModuleDeclaration
{
public required string ModuleName { get; init; }
public string VersionExpression { get; init; } = ">=1.0.0";
public bool Optional { get; init; }
}
public sealed record WorkflowRequestContractDeclaration
{
/// <summary>
/// CLR type name of the start request. Retained for backward compatibility but
/// not used for canonical portability — use <see cref="Schema"/> instead.
/// </summary>
public string? ContractName { get; init; }
/// <summary>
/// JSON Schema describing the start request shape. This is the portable,
/// CLR-independent contract definition for the workflow's input.
/// </summary>
public IDictionary<string, object?>? Schema { get; init; }
public string? SchemaReference { get; init; }
public bool AllowAdditionalProperties { get; init; } = true;
}
public sealed record WorkflowStartDeclaration
{
public required WorkflowExpressionDefinition InitializeStateExpression { get; init; }
public string? InitialTaskName { get; init; }
public WorkflowStepSequenceDeclaration InitialSequence { get; init; } = new();
}
public sealed record WorkflowTaskDeclaration
{
public required string TaskName { get; init; }
public required string TaskType { get; init; }
public required WorkflowExpressionDefinition RouteExpression { get; init; }
public required WorkflowExpressionDefinition PayloadExpression { get; init; }
public IReadOnlyCollection<string> TaskRoles { get; init; } = [];
public WorkflowStepSequenceDeclaration OnComplete { get; init; } = new();
}
public sealed record WorkflowBusinessReferenceDeclaration
{
public WorkflowExpressionDefinition? KeyExpression { get; init; }
public IReadOnlyCollection<WorkflowNamedExpressionDefinition> Parts { get; init; } = [];
}
public sealed record WorkflowNamedExpressionDefinition
{
public required string Name { get; init; }
public required WorkflowExpressionDefinition Expression { get; init; }
}
public sealed record WorkflowStepSequenceDeclaration
{
public IReadOnlyCollection<WorkflowStepDeclaration> Steps { get; init; } = [];
}
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(WorkflowSetStateStepDeclaration), "set-state")]
[JsonDerivedType(typeof(WorkflowAssignBusinessReferenceStepDeclaration), "assign-business-reference")]
[JsonDerivedType(typeof(WorkflowTransportCallStepDeclaration), "call-transport")]
[JsonDerivedType(typeof(WorkflowDecisionStepDeclaration), "decision")]
[JsonDerivedType(typeof(WorkflowActivateTaskStepDeclaration), "activate-task")]
[JsonDerivedType(typeof(WorkflowContinueWithWorkflowStepDeclaration), "continue-with-workflow")]
[JsonDerivedType(typeof(WorkflowSubWorkflowStepDeclaration), "sub-workflow")]
[JsonDerivedType(typeof(WorkflowRepeatStepDeclaration), "repeat")]
[JsonDerivedType(typeof(WorkflowTimerStepDeclaration), "timer")]
[JsonDerivedType(typeof(WorkflowExternalSignalStepDeclaration), "external-signal")]
[JsonDerivedType(typeof(WorkflowForkStepDeclaration), "fork")]
[JsonDerivedType(typeof(WorkflowCompleteStepDeclaration), "complete")]
public abstract record WorkflowStepDeclaration;
public sealed record WorkflowSetStateStepDeclaration : WorkflowStepDeclaration
{
public required string StateKey { get; init; }
public required WorkflowExpressionDefinition ValueExpression { get; init; }
public bool OnlyIfPresent { get; init; }
}
public sealed record WorkflowAssignBusinessReferenceStepDeclaration : WorkflowStepDeclaration
{
public required WorkflowBusinessReferenceDeclaration BusinessReference { get; init; }
}
public sealed record WorkflowTransportCallStepDeclaration : WorkflowStepDeclaration
{
public required string StepName { get; init; }
public required WorkflowTransportInvocationDeclaration Invocation { get; init; }
public string? ResultKey { get; init; }
/// <summary>
/// Optional per-step timeout in seconds. Overrides the transport-level default.
/// When null, the default timeout (1 hour) is used.
/// </summary>
public int? TimeoutSeconds { get; init; }
public WorkflowStepSequenceDeclaration? WhenFailure { get; init; }
public WorkflowStepSequenceDeclaration? WhenTimeout { get; init; }
}
public sealed record WorkflowDecisionStepDeclaration : WorkflowStepDeclaration
{
public required string DecisionName { get; init; }
public required WorkflowExpressionDefinition ConditionExpression { get; init; }
public WorkflowStepSequenceDeclaration WhenTrue { get; init; } = new();
public WorkflowStepSequenceDeclaration WhenElse { get; init; } = new();
}
public sealed record WorkflowActivateTaskStepDeclaration : WorkflowStepDeclaration
{
public required string TaskName { get; init; }
public WorkflowExpressionDefinition? RuntimeRolesExpression { get; init; }
/// <summary>
/// Optional timeout for the human task in seconds.
/// When null, no deadline is set — the task runs indefinitely until completed or purged by retention.
/// When set, the task's <c>DeadlineUtc</c> is computed as <c>CreatedOnUtc + TimeoutSeconds</c>.
/// </summary>
public int? TimeoutSeconds { get; init; }
}
public sealed record WorkflowContinueWithWorkflowStepDeclaration : WorkflowStepDeclaration
{
public required string StepName { get; init; }
public required WorkflowWorkflowInvocationDeclaration Invocation { get; init; }
}
public sealed record WorkflowSubWorkflowStepDeclaration : WorkflowStepDeclaration
{
public required string StepName { get; init; }
public required WorkflowWorkflowInvocationDeclaration Invocation { get; init; }
public string? ResultKey { get; init; }
}
public sealed record WorkflowRepeatStepDeclaration : WorkflowStepDeclaration
{
public required string StepName { get; init; }
public required WorkflowExpressionDefinition MaxIterationsExpression { get; init; }
public string? IterationStateKey { get; init; }
public WorkflowExpressionDefinition? ContinueWhileExpression { get; init; }
public WorkflowStepSequenceDeclaration Body { get; init; } = new();
}
public sealed record WorkflowTimerStepDeclaration : WorkflowStepDeclaration
{
public required string StepName { get; init; }
public required WorkflowExpressionDefinition DelayExpression { get; init; }
}
public sealed record WorkflowExternalSignalStepDeclaration : WorkflowStepDeclaration
{
public required string StepName { get; init; }
public required WorkflowExpressionDefinition SignalNameExpression { get; init; }
public string? ResultKey { get; init; }
}
public sealed record WorkflowForkStepDeclaration : WorkflowStepDeclaration
{
public required string StepName { get; init; }
public IReadOnlyCollection<WorkflowStepSequenceDeclaration> Branches { get; init; } = [];
}
public sealed record WorkflowCompleteStepDeclaration : WorkflowStepDeclaration;
public sealed record WorkflowTransportInvocationDeclaration
{
public required WorkflowTransportAddressDeclaration Address { get; init; }
public WorkflowExpressionDefinition? PayloadExpression { get; init; }
}
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(WorkflowMicroserviceAddressDeclaration), "microservice")]
[JsonDerivedType(typeof(WorkflowRabbitAddressDeclaration), "rabbit")]
[JsonDerivedType(typeof(WorkflowLegacyRabbitAddressDeclaration), "legacy-rabbit")]
[JsonDerivedType(typeof(WorkflowGraphqlAddressDeclaration), "graphql")]
[JsonDerivedType(typeof(WorkflowHttpAddressDeclaration), "http")]
public abstract record WorkflowTransportAddressDeclaration
{
public string? Alias { get; init; }
}
public sealed record WorkflowMicroserviceAddressDeclaration : WorkflowTransportAddressDeclaration
{
public required string MicroserviceName { get; init; }
public required string Command { get; init; }
}
public sealed record WorkflowRabbitAddressDeclaration : WorkflowTransportAddressDeclaration
{
public required string Exchange { get; init; }
public required string RoutingKey { get; init; }
}
public sealed record WorkflowLegacyRabbitAddressDeclaration : WorkflowTransportAddressDeclaration
{
public required string Command { get; init; }
public WorkflowLegacyRabbitMode Mode { get; init; } = WorkflowLegacyRabbitMode.Envelope;
}
public sealed record WorkflowGraphqlAddressDeclaration : WorkflowTransportAddressDeclaration
{
public required string Target { get; init; }
public required string Query { get; init; }
public string? OperationName { get; init; }
}
public sealed record WorkflowHttpAddressDeclaration : WorkflowTransportAddressDeclaration
{
public required string Target { get; init; }
public required string Path { get; init; }
public string Method { get; init; } = "POST";
}
public sealed record WorkflowWorkflowInvocationDeclaration
{
public required WorkflowExpressionDefinition WorkflowNameExpression { get; init; }
public WorkflowExpressionDefinition? WorkflowVersionExpression { get; init; }
public WorkflowExpressionDefinition? PayloadExpression { get; init; }
public WorkflowBusinessReferenceDeclaration? BusinessReference { get; init; }
}
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(WorkflowNullExpressionDefinition), "null")]
[JsonDerivedType(typeof(WorkflowStringExpressionDefinition), "string")]
[JsonDerivedType(typeof(WorkflowNumberExpressionDefinition), "number")]
[JsonDerivedType(typeof(WorkflowBooleanExpressionDefinition), "boolean")]
[JsonDerivedType(typeof(WorkflowPathExpressionDefinition), "path")]
[JsonDerivedType(typeof(WorkflowObjectExpressionDefinition), "object")]
[JsonDerivedType(typeof(WorkflowArrayExpressionDefinition), "array")]
[JsonDerivedType(typeof(WorkflowFunctionExpressionDefinition), "function")]
[JsonDerivedType(typeof(WorkflowGroupExpressionDefinition), "group")]
[JsonDerivedType(typeof(WorkflowUnaryExpressionDefinition), "unary")]
[JsonDerivedType(typeof(WorkflowBinaryExpressionDefinition), "binary")]
public abstract record WorkflowExpressionDefinition;
public sealed record WorkflowNullExpressionDefinition : WorkflowExpressionDefinition;
public sealed record WorkflowStringExpressionDefinition : WorkflowExpressionDefinition
{
public required string Value { get; init; }
}
public sealed record WorkflowNumberExpressionDefinition : WorkflowExpressionDefinition
{
public required string Value { get; init; }
}
public sealed record WorkflowBooleanExpressionDefinition : WorkflowExpressionDefinition
{
public required bool Value { get; init; }
}
public sealed record WorkflowPathExpressionDefinition : WorkflowExpressionDefinition
{
public required string Path { get; init; }
}
public sealed record WorkflowObjectExpressionDefinition : WorkflowExpressionDefinition
{
public IReadOnlyCollection<WorkflowNamedExpressionDefinition> Properties { get; init; } = [];
}
public sealed record WorkflowArrayExpressionDefinition : WorkflowExpressionDefinition
{
public IReadOnlyCollection<WorkflowExpressionDefinition> Items { get; init; } = [];
}
public sealed record WorkflowFunctionExpressionDefinition : WorkflowExpressionDefinition
{
public required string FunctionName { get; init; }
public IReadOnlyCollection<WorkflowExpressionDefinition> Arguments { get; init; } = [];
}
public sealed record WorkflowGroupExpressionDefinition : WorkflowExpressionDefinition
{
public required WorkflowExpressionDefinition Expression { get; init; }
}
public sealed record WorkflowUnaryExpressionDefinition : WorkflowExpressionDefinition
{
public required string Operator { get; init; }
public required WorkflowExpressionDefinition Operand { get; init; }
}
public sealed record WorkflowBinaryExpressionDefinition : WorkflowExpressionDefinition
{
public required string Operator { get; init; }
public required WorkflowExpressionDefinition Left { get; init; }
public required WorkflowExpressionDefinition Right { get; init; }
}
public static class WorkflowCanonicalJsonSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
public static JsonSerializerOptions Options => SerializerOptions;
public static string Serialize(WorkflowCanonicalDefinition definition)
{
return JsonSerializer.Serialize(definition, SerializerOptions);
}
public static string SerializeFragment<TValue>(TValue value)
{
return JsonSerializer.Serialize(value, SerializerOptions);
}
public static WorkflowCanonicalDefinition Deserialize(string json)
{
return JsonSerializer.Deserialize<WorkflowCanonicalDefinition>(json, SerializerOptions)
?? throw new JsonException("Unable to deserialize canonical workflow definition.");
}
public static TValue DeserializeFragment<TValue>(string json)
{
return JsonSerializer.Deserialize<TValue>(json, SerializerOptions)
?? throw new JsonException($"Unable to deserialize canonical workflow fragment '{typeof(TValue).FullName}'.");
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
// ═══════════════════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════════════════
public sealed record WorkflowDefinitionExportRequest
{
/// <summary>Workflow name to export.</summary>
public required string WorkflowName { get; init; }
/// <summary>Specific version to export. Null = export the active version.</summary>
public string? Version { get; init; }
/// <summary>Include rendering assets (SVG, JSON, PNG) in the response.</summary>
public bool IncludeRendering { get; init; }
}
public sealed record WorkflowDefinitionExportResponse
{
public required string WorkflowName { get; init; }
public required string Version { get; init; }
public required string ContentHash { get; init; }
public bool IsActive { get; init; }
public required string CanonicalDefinitionJson { get; init; }
public string? RenderingSvgBase64 { get; init; }
public string? RenderingJsonBase64 { get; init; }
public string? RenderingPngBase64 { get; init; }
}
// ═══════════════════════════════════════════════════════════
// IMPORT
// ═══════════════════════════════════════════════════════════
public sealed record WorkflowDefinitionImportRequest
{
/// <summary>Canonical definition JSON to import.</summary>
public required string CanonicalDefinitionJson { get; init; }
/// <summary>Optional SVG diagram (base64-encoded).</summary>
public string? RenderingSvgBase64 { get; init; }
/// <summary>Optional render graph JSON (base64-encoded).</summary>
public string? RenderingJsonBase64 { get; init; }
/// <summary>Optional PNG screenshot (base64-encoded).</summary>
public string? RenderingPngBase64 { get; init; }
/// <summary>User or system identifier performing the import.</summary>
public string? ImportedBy { get; init; }
}
public sealed record WorkflowDefinitionImportResponse
{
public required string WorkflowName { get; init; }
public required string Version { get; init; }
public required string ContentHash { get; init; }
/// <summary>False if the content hash matched an existing version (no-op import).</summary>
public bool WasImported { get; init; }
/// <summary>True if this was the first import for this base version.</summary>
public bool WasNewVersion { get; init; }
/// <summary>Whether this version is now the active version.</summary>
public bool IsActive { get; init; }
/// <summary>Validation issues found during import (empty if successful).</summary>
public IReadOnlyCollection<WorkflowCanonicalValidationIssue> ValidationIssues { get; init; } = [];
}
// ═══════════════════════════════════════════════════════════
// VERSIONS
// ═══════════════════════════════════════════════════════════
public sealed record WorkflowDefinitionVersionsGetRequest
{
public required string WorkflowName { get; init; }
}
public sealed record WorkflowDefinitionVersionsGetResponse
{
public IReadOnlyCollection<WorkflowDefinitionVersionSummary> Versions { get; init; } = [];
}
public sealed record WorkflowDefinitionVersionSummary
{
public required string WorkflowName { get; init; }
public required string Version { get; init; }
public required string BaseVersion { get; init; }
public int BuildIteration { get; init; }
public required string ContentHash { get; init; }
public bool IsActive { get; init; }
public string? DisplayName { get; init; }
public DateTime CreatedOnUtc { get; init; }
public DateTime? ActivatedOnUtc { get; init; }
public string? ImportedBy { get; init; }
}
// ═══════════════════════════════════════════════════════════
// ACTIVATE
// ═══════════════════════════════════════════════════════════
public sealed record WorkflowDefinitionActivateRequest
{
public required string WorkflowName { get; init; }
public required string Version { get; init; }
}
public sealed record WorkflowDefinitionActivateResponse
{
public required string WorkflowName { get; init; }
public required string Version { get; init; }
public bool Activated { get; init; }
}
// ═══════════════════════════════════════════════════════════
// BY-ID (single definition lookup)
// ═══════════════════════════════════════════════════════════
public sealed record WorkflowDefinitionByIdRequest
{
/// <summary>Workflow name to retrieve.</summary>
public required string WorkflowName { get; init; }
/// <summary>Specific version. Null = active version.</summary>
public string? Version { get; init; }
/// <summary>Include rendering assets (SVG, JSON, PNG) in the response.</summary>
public bool IncludeRendering { get; init; }
}
public sealed record WorkflowDefinitionByIdResponse
{
public required string WorkflowName { get; init; }
public required string Version { get; init; }
public string? DisplayName { get; init; }
public required string CanonicalDefinitionJson { get; init; }
public string? ContentHash { get; init; }
public bool IsActive { get; init; }
public string? RenderingSvgBase64 { get; init; }
public string? RenderingJsonBase64 { get; init; }
public string? RenderingPngBase64 { get; init; }
}
// ═══════════════════════════════════════════════════════════
// RENDERING (format-specific output)
// ═══════════════════════════════════════════════════════════
public sealed record WorkflowRenderingRequest
{
/// <summary>Workflow name to render.</summary>
public required string WorkflowName { get; init; }
/// <summary>Specific version. Null = active version.</summary>
public string? Version { get; init; }
/// <summary>Output format: "svg", "png", or "json" (render graph).</summary>
public string Format { get; init; } = "svg";
}
public sealed record WorkflowRenderingResponse
{
public required string WorkflowName { get; init; }
public required string Version { get; init; }
public required string Format { get; init; }
/// <summary>Rendered content as base64.</summary>
public string? ContentBase64 { get; init; }
/// <summary>MIME type: image/svg+xml, image/png, or application/json.</summary>
public string? ContentType { get; init; }
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowTaskDescriptor
{
public required string TaskName { get; init; }
public required string TaskType { get; init; }
public required string Route { get; init; }
public IReadOnlyCollection<string> TaskRoles { get; init; } = [];
}
public sealed record WorkflowDefinitionDescriptor
{
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public required string DisplayName { get; init; }
public IReadOnlyCollection<string> WorkflowRoles { get; init; } = [];
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks { get; init; } = [];
}
public sealed record WorkflowDefinitionGetRequest
{
public string? WorkflowName { get; init; }
public string? WorkflowVersion { get; init; }
/// <summary>Filter by multiple workflow names.</summary>
public IReadOnlyCollection<string> WorkflowNames { get; init; } = [];
}
public sealed record WorkflowDefinitionGetResponse
{
public IReadOnlyCollection<WorkflowDefinitionDescriptor> Definitions { get; init; } = [];
}

View File

@@ -0,0 +1,75 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowDiagramNode
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string NodeType { get; init; }
public string? IconKey { get; init; }
public string? SemanticType { get; init; }
public string? SemanticKey { get; init; }
public string? Route { get; init; }
public string? TaskType { get; init; }
public double X { get; init; }
public double Y { get; init; }
public double Width { get; init; }
public double Height { get; init; }
public IReadOnlyCollection<string> WorkflowRoles { get; init; } = [];
public IReadOnlyCollection<string> TaskRoles { get; init; } = [];
public string? NodeStatus { get; init; }
public string? WorkflowTaskId { get; init; }
public string? Assignee { get; init; }
public IReadOnlyCollection<string> EffectiveRoles { get; init; } = [];
public int VisitCount { get; init; }
}
public sealed record WorkflowDiagramPoint
{
public required double X { get; init; }
public required double Y { get; init; }
}
public sealed record WorkflowDiagramEdgeSection
{
public required WorkflowDiagramPoint StartPoint { get; init; }
public required WorkflowDiagramPoint EndPoint { get; init; }
public IReadOnlyCollection<WorkflowDiagramPoint> BendPoints { get; init; } = [];
}
public sealed record WorkflowDiagramEdge
{
public required string SourceNodeId { get; init; }
public required string TargetNodeId { get; init; }
public string? Label { get; init; }
public IReadOnlyCollection<WorkflowDiagramEdgeSection> Sections { get; init; } = [];
}
public sealed record WorkflowDiagramGetRequest
{
public required string WorkflowName { get; init; }
public string? WorkflowVersion { get; init; }
public string? WorkflowInstanceId { get; init; }
public string? LayoutProvider { get; init; }
public string? LayoutEffort { get; init; }
public int? LayoutOrderingIterations { get; init; }
public int? LayoutPlacementIterations { get; init; }
}
public sealed record WorkflowDiagramGetResponse
{
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public required string DisplayName { get; init; }
public string? LayoutProvider { get; init; }
public string? LayoutEffort { get; init; }
public int? LayoutOrderingIterations { get; init; }
public int? LayoutPlacementIterations { get; init; }
public string? WorkflowInstanceId { get; init; }
public string? InstanceStatus { get; init; }
public string? RuntimeProvider { get; init; }
public string? RuntimeStatus { get; init; }
public IReadOnlyCollection<WorkflowDiagramNode> Nodes { get; init; } = [];
public IReadOnlyCollection<WorkflowDiagramEdge> Edges { get; init; } = [];
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowFunctionArgumentDescriptor
{
public required string Name { get; init; }
public required string Type { get; init; }
public required bool Required { get; init; }
public bool Variadic { get; init; }
public required string Description { get; init; }
}
public sealed record WorkflowFunctionReturnDescriptor
{
public required string Type { get; init; }
public required string Description { get; init; }
}
public sealed record WorkflowFunctionExample
{
public required string Expression { get; init; }
public string? Description { get; init; }
}
public sealed record WorkflowFunctionDescriptor
{
public required string FunctionName { get; init; }
public required string ModuleName { get; init; }
public required string ModuleVersion { get; init; }
public required string Summary { get; init; }
public bool Deterministic { get; init; } = true;
public IReadOnlyCollection<string> Aliases { get; init; } = [];
public IReadOnlyCollection<WorkflowFunctionArgumentDescriptor> Arguments { get; init; } = [];
public WorkflowFunctionReturnDescriptor? Return { get; init; }
public IReadOnlyCollection<WorkflowFunctionExample> Examples { get; init; } = [];
public string IntroducedInSchemaVersion { get; init; } = WorkflowCanonicalDefinitionSchema.Version1;
}
public sealed record WorkflowFunctionCatalogGetResponse
{
public IReadOnlyCollection<WorkflowFunctionDescriptor> Functions { get; init; } = [];
public IReadOnlyCollection<WorkflowModuleInfo> InstalledModules { get; init; } = [];
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowInstanceSummary
{
public required string WorkflowInstanceId { get; init; }
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public string Status { get; init; } = "Pending";
public string? RuntimeProvider { get; init; }
public string? RuntimeInstanceId { get; init; }
public string? RuntimeStatus { get; init; }
public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow;
public DateTime? CompletedOnUtc { get; init; }
/// <summary>The first open (non-completed) task for this instance, if any.</summary>
public WorkflowTaskSummary? ActiveTask { get; init; }
/// <summary>Current workflow state variables. Empty when not requested or unavailable.</summary>
public IDictionary<string, object?> WorkflowState { get; init; } = new Dictionary<string, object?>();
}
public sealed record WorkflowInstancesGetRequest
{
public string? WorkflowName { get; init; }
public string? WorkflowVersion { get; init; }
/// <summary>Filter by a single workflow instance ID.</summary>
public string? WorkflowInstanceId { get; init; }
/// <summary>Filter by multiple workflow instance IDs.</summary>
public IReadOnlyCollection<string> WorkflowInstanceIds { get; init; } = [];
public string? BusinessReferenceKey { get; init; }
public IDictionary<string, object?> BusinessReferenceParts { get; init; } = new Dictionary<string, object?>();
public string? Status { get; init; }
/// <summary>When true, populate ActiveTask and WorkflowState on each instance summary.</summary>
public bool IncludeDetails { get; init; }
}
public sealed record WorkflowInstancesGetResponse
{
public IReadOnlyCollection<WorkflowInstanceSummary> Instances { get; init; } = [];
}
public sealed record WorkflowInstanceGetRequest
{
public required string WorkflowInstanceId { get; init; }
public string? ActorId { get; init; }
public IReadOnlyCollection<string> ActorRoles { get; init; } = [];
}
public sealed record WorkflowInstanceGetResponse
{
public required WorkflowInstanceSummary Instance { get; init; }
public IDictionary<string, object?> WorkflowState { get; init; } = new Dictionary<string, object?>();
public IReadOnlyCollection<WorkflowTaskSummary> Tasks { get; init; } = [];
public IReadOnlyCollection<WorkflowTaskEventSummary> TaskEvents { get; init; } = [];
public WorkflowRuntimeStateSummary? RuntimeState { get; init; }
}
public sealed record WorkflowRuntimeStateSummary
{
public WorkflowBusinessReference? BusinessReference { get; init; }
public required string RuntimeProvider { get; init; }
public required string RuntimeInstanceId { get; init; }
public required string RuntimeStatus { get; init; }
public IDictionary<string, object?> State { get; init; } = new Dictionary<string, object?>();
public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow;
public DateTime? CompletedOnUtc { get; init; }
public DateTime? StaleAfterUtc { get; init; }
public DateTime? PurgeAfterUtc { get; init; }
public DateTime LastUpdatedOnUtc { get; init; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowServiceMetadata
{
public required string ServiceName { get; init; }
public required string DiagramProvider { get; init; }
public required bool SupportsDefinitionInspection { get; init; }
public required bool SupportsInstanceInspection { get; init; }
public bool SupportsCanonicalSchemaInspection { get; init; }
public bool SupportsCanonicalImportValidation { get; init; }
}
public sealed record WorkflowServiceMetadataGetResponse
{
public required WorkflowServiceMetadata Metadata { get; init; }
}
public sealed record WorkflowRetentionRunRequest
{
public DateTime? ReferenceUtc { get; init; }
}
public sealed record WorkflowRetentionRunResponse
{
public required DateTime ExecutedOnUtc { get; init; }
public required int StaleInstancesMarked { get; init; }
public required int StaleTasksMarked { get; init; }
public required int PurgedInstances { get; init; }
public required int PurgedTasks { get; init; }
public required int PurgedTaskEvents { get; init; }
public required int PurgedRuntimeStates { get; init; }
}
public sealed record WorkflowModuleInfo
{
public required string ModuleName { get; init; }
public required string Version { get; init; }
}
public sealed record WorkflowCanonicalValidationIssue
{
public required string Code { get; init; }
public required string Path { get; init; }
public required string Message { get; init; }
}
public sealed record WorkflowCanonicalSchemaGetResponse
{
public required string SchemaVersion { get; init; }
public required string SchemaJson { get; init; }
}
public sealed record WorkflowCanonicalValidateRequest
{
public required string CanonicalDefinitionJson { get; init; }
}
public sealed record WorkflowCanonicalValidateResponse
{
public required bool IsValid { get; init; }
public string? WorkflowName { get; init; }
public string? WorkflowVersion { get; init; }
public IReadOnlyCollection<WorkflowCanonicalValidationIssue> SchemaIssues { get; init; } = [];
public IReadOnlyCollection<WorkflowCanonicalValidationIssue> SemanticIssues { get; init; } = [];
public IReadOnlyCollection<WorkflowCanonicalValidationIssue> ModuleIssues { get; init; } = [];
public IReadOnlyCollection<WorkflowModuleInfo> InstalledModules { get; init; } = [];
}
public sealed record WorkflowSignalDeadLettersGetRequest
{
public string? SignalId { get; init; }
public string? WorkflowInstanceId { get; init; }
public string? SignalType { get; init; }
public int MaxMessages { get; init; } = 50;
public bool IncludeRawPayload { get; init; }
}
public sealed record WorkflowSignalDeadLetterMessage
{
public string? SignalId { get; init; }
public string? Correlation { get; init; }
public string? WorkflowInstanceId { get; init; }
public string? RuntimeProvider { get; init; }
public string? SignalType { get; init; }
public long? ExpectedVersion { get; init; }
public string? WaitingToken { get; init; }
public DateTime? OccurredAtUtc { get; init; }
public DateTime? DueAtUtc { get; init; }
public DateTime? EnqueuedOnUtc { get; init; }
public int DeliveryCount { get; init; }
public bool IsEnvelopeReadable { get; init; }
public string? ReadError { get; init; }
public IReadOnlyDictionary<string, JsonElement> Payload { get; init; } = new Dictionary<string, JsonElement>();
public string? RawPayloadBase64 { get; init; }
}
public sealed record WorkflowSignalDeadLettersGetResponse
{
public IReadOnlyCollection<WorkflowSignalDeadLetterMessage> Messages { get; init; } = [];
}
public sealed record WorkflowSignalDeadLetterReplayRequest
{
public required string SignalId { get; init; }
}
public sealed record WorkflowSignalDeadLetterReplayResponse
{
public required string SignalId { get; init; }
public bool Replayed { get; init; }
public string? WorkflowInstanceId { get; init; }
public string? SignalType { get; init; }
public bool WasEnvelopeReadable { get; init; }
}
public sealed record WorkflowSignalPumpStatsSignalType
{
public required string SignalType { get; init; }
public required long ProcessedCount { get; init; }
public required long FailureCount { get; init; }
public required long DeadLetterCount { get; init; }
public long ConcurrencySkipCount { get; init; }
}
public sealed record WorkflowSignalPumpLastEvent
{
public required string ConsumerName { get; init; }
public string? SignalId { get; init; }
public string? WorkflowInstanceId { get; init; }
public string? SignalType { get; init; }
public int DeliveryCount { get; init; }
public DateTime OccurredOnUtc { get; init; }
public long DurationMs { get; init; }
public string? Message { get; init; }
}
public sealed record WorkflowSignalPumpStats
{
public required DateTime StartedOnUtc { get; init; }
public DateTime? LastActivityOnUtc { get; init; }
public DateTime? LastSuccessOnUtc { get; init; }
public DateTime? LastFailureOnUtc { get; init; }
public DateTime? LastDeadLetterOnUtc { get; init; }
public required long EmptyPollCount { get; init; }
public required long ProcessedCount { get; init; }
public required long FailureCount { get; init; }
public required long DeadLetterCount { get; init; }
public long ConcurrencySkipCount { get; init; }
public IReadOnlyCollection<WorkflowSignalPumpStatsSignalType> SignalsByType { get; init; } = [];
public WorkflowSignalPumpLastEvent? LastSuccess { get; init; }
public WorkflowSignalPumpLastEvent? LastFailure { get; init; }
public WorkflowSignalPumpLastEvent? LastDeadLetter { get; init; }
}
public sealed record WorkflowSignalPumpStatsGetResponse
{
public required WorkflowSignalPumpStats Stats { get; init; }
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowExecutionSnapshot
{
public required WorkflowTaskSummary Task { get; init; }
public IReadOnlyDictionary<string, JsonElement> WorkflowState { get; init; } = new Dictionary<string, JsonElement>();
}
public sealed record WorkflowInstanceProjectionDetails
{
public required WorkflowInstanceSummary Instance { get; init; }
public IDictionary<string, object?> WorkflowState { get; init; } = new Dictionary<string, object?>();
public IReadOnlyCollection<WorkflowTaskSummary> Tasks { get; init; } = [];
public IReadOnlyCollection<WorkflowTaskEventSummary> TaskEvents { get; init; } = [];
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowSignalRaiseRequest
{
public required string WorkflowInstanceId { get; init; }
public string? WaitingToken { get; init; }
public string? SignalName { get; init; }
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
}
public sealed record WorkflowSignalRaiseResponse
{
public required string WorkflowInstanceId { get; init; }
public required string SignalId { get; init; }
public required bool Queued { get; init; }
}

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record StartWorkflowRequest
{
public required string WorkflowName { get; init; }
public string? WorkflowVersion { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
}
public sealed record StartWorkflowResponse
{
public required string WorkflowInstanceId { get; init; }
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowTaskSummary
{
public required string WorkflowTaskId { get; init; }
public required string WorkflowInstanceId { get; init; }
public required string WorkflowName { get; init; }
public required string WorkflowVersion { get; init; }
public required string TaskName { get; init; }
public required string TaskType { get; init; }
public required string Route { get; init; }
public WorkflowBusinessReference? BusinessReference { get; init; }
public string? Assignee { get; init; }
public string Status { get; init; } = "Pending";
public IReadOnlyCollection<string> WorkflowRoles { get; init; } = [];
public IReadOnlyCollection<string> TaskRoles { get; init; } = [];
public IReadOnlyCollection<string> RuntimeRoles { get; init; } = [];
public IReadOnlyCollection<string> EffectiveRoles { get; init; } = [];
public IReadOnlyCollection<string> AllowedActions { get; init; } = [];
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow;
public DateTime? CompletedOnUtc { get; init; }
public DateTime? StaleAfterUtc { get; init; }
public DateTime? PurgeAfterUtc { get; init; }
/// <summary>
/// The deadline by which the task should be completed.
/// Computed as <c>CreatedOnUtc + TimeoutSeconds</c> from the task declaration.
/// Null means no deadline — the task runs indefinitely until completed or purged by retention.
/// </summary>
public DateTime? DeadlineUtc { get; init; }
}
public sealed record WorkflowTaskEventSummary
{
public required string WorkflowTaskId { get; init; }
public string? TaskName { get; init; }
public required string EventType { get; init; }
public string? ActorId { get; init; }
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
public DateTime CreatedOnUtc { get; init; } = DateTime.UtcNow;
}
public sealed record WorkflowTasksGetRequest
{
public string? WorkflowName { get; init; }
public string? WorkflowVersion { get; init; }
public string? WorkflowInstanceId { get; init; }
public string? BusinessReferenceKey { get; init; }
public IDictionary<string, object?> BusinessReferenceParts { get; init; } = new Dictionary<string, object?>();
public string? Assignee { get; init; }
public string? Status { get; init; }
public string? ActorId { get; init; }
public IReadOnlyCollection<string> ActorRoles { get; init; } = [];
public IReadOnlyCollection<string> CandidateRoles { get; init; } = [];
}
public sealed record WorkflowTasksGetResponse
{
public IReadOnlyCollection<WorkflowTaskSummary> Tasks { get; init; } = [];
}
public sealed record WorkflowTaskGetRequest
{
public required string WorkflowTaskId { get; init; }
public string? ActorId { get; init; }
public IReadOnlyCollection<string> ActorRoles { get; init; } = [];
}
public sealed record WorkflowTaskGetResponse
{
public required WorkflowTaskSummary Task { get; init; }
}
public sealed record WorkflowTaskCompleteRequest
{
public required string WorkflowTaskId { get; init; }
public required string ActorId { get; init; }
public IReadOnlyCollection<string> ActorRoles { get; init; } = [];
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
}
public sealed record WorkflowTaskCompleteResponse
{
public required string WorkflowTaskId { get; init; }
public required bool Completed { get; init; }
}
public sealed record WorkflowTaskAssignRequest
{
public required string WorkflowTaskId { get; init; }
public required string ActorId { get; init; }
public string? TargetUserId { get; init; }
public IReadOnlyCollection<string> TargetRoles { get; init; } = [];
public IReadOnlyCollection<string> ActorRoles { get; init; } = [];
}
public sealed record WorkflowTaskAssignResponse
{
public required string WorkflowTaskId { get; init; }
public required string? Assignee { get; init; }
public required string Status { get; init; }
public IReadOnlyCollection<string> RuntimeRoles { get; init; } = [];
public IReadOnlyCollection<string> EffectiveRoles { get; init; } = [];
}
public sealed record WorkflowTaskReleaseRequest
{
public required string WorkflowTaskId { get; init; }
public required string ActorId { get; init; }
public IReadOnlyCollection<string> ActorRoles { get; init; } = [];
}
public sealed record WorkflowTaskReleaseResponse
{
public required string WorkflowTaskId { get; init; }
public required bool Released { get; init; }
}

View File

@@ -0,0 +1,75 @@
using System.Collections.Generic;
namespace StellaOps.Workflow.Contracts;
public sealed record WorkflowMicroserviceRequest
{
public required string MicroserviceName { get; init; }
public required string Command { get; init; }
public object? Payload { get; init; }
}
public sealed record WorkflowMicroserviceResponse
{
public required bool Succeeded { get; init; }
public object? Payload { get; init; }
public string? Error { get; init; }
}
public sealed record WorkflowRabbitRequest
{
public required string Exchange { get; init; }
public required string RoutingKey { get; init; }
public object? Payload { get; init; }
}
public sealed record WorkflowRabbitResponse
{
public required bool Succeeded { get; init; }
public object? Payload { get; init; }
public string? Error { get; init; }
}
public enum WorkflowLegacyRabbitMode
{
Envelope = 1,
MicroserviceConsumer = 2,
}
public sealed record WorkflowLegacyRabbitRequest
{
public required string Command { get; init; }
public required WorkflowLegacyRabbitMode Mode { get; init; }
public object? Payload { get; init; }
}
public sealed record WorkflowGraphqlRequest
{
public required string Target { get; init; }
public required string Query { get; init; }
public string? OperationName { get; init; }
public IDictionary<string, object?> Variables { get; init; } = new Dictionary<string, object?>();
}
public sealed record WorkflowGraphqlResponse
{
public required bool Succeeded { get; init; }
public string? JsonPayload { get; init; }
public string? Error { get; init; }
}
public sealed record WorkflowHttpRequest
{
public required string Target { get; init; }
public string Method { get; init; } = "POST";
public required string Path { get; init; }
public object? Payload { get; init; }
}
public sealed record WorkflowHttpResponse
{
public required bool Succeeded { get; init; }
public int? StatusCode { get; init; }
public string? JsonPayload { get; init; }
public string? Error { get; init; }
}

View File

@@ -0,0 +1,69 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using MongoDB.Driver;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.DataStore.MongoDB;
public static class MongoWorkflowDataStoreExtensions
{
public static IServiceCollection AddWorkflowMongoDataStore(
this IServiceCollection services, IConfiguration configuration)
{
services.AddWorkflowModule("workflow-store.mongo", "1.0.0");
services.AddSingleton<IWorkflowBackendRegistrationMarker>(
new WorkflowBackendRegistrationMarker(WorkflowBackendNames.Mongo));
if (!string.Equals(configuration.GetWorkflowBackendProvider(), WorkflowBackendNames.Mongo, StringComparison.OrdinalIgnoreCase))
{
return services;
}
var useNativeSignalDriver = string.Equals(
configuration.GetWorkflowSignalDriverProvider(),
WorkflowSignalDriverNames.Native,
StringComparison.OrdinalIgnoreCase);
services.Configure<WorkflowStoreMongoOptions>(configuration.GetSection(WorkflowStoreMongoOptions.SectionName));
services.AddSingleton<IMongoClient>(_ =>
{
var connectionString = configuration.GetConnectionString(
configuration[$"{WorkflowStoreMongoOptions.SectionName}:ConnectionStringName"]
?? "WorkflowMongo")
?? throw new InvalidOperationException(
"MongoDB workflow backend requires a configured connection string.");
return new MongoClient(connectionString);
});
services.AddScoped<MongoWorkflowMutationSessionAccessor>();
services.AddScoped<MongoWorkflowDatabase>();
services.AddScoped<MongoWorkflowSignalStore>();
services.AddScoped<MongoWorkflowScheduleBus>();
if (useNativeSignalDriver)
{
services.AddScoped<MongoWorkflowSignalBus>();
}
services.Replace(ServiceDescriptor.Scoped<IWorkflowRuntimeStateStore, MongoWorkflowRuntimeStateStore>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowHostedJobLockService, MongoWorkflowHostedJobLockService>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowProjectionRetentionStore, MongoWorkflowProjectionRetentionStore>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowProjectionStore, MongoWorkflowProjectionStore>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowMutationCoordinator, MongoWorkflowMutationCoordinator>());
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalStore>(sp => sp.GetRequiredService<MongoWorkflowSignalStore>()));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalClaimStore>(sp => sp.GetRequiredService<MongoWorkflowSignalStore>()));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalScheduler>(sp => sp.GetRequiredService<MongoWorkflowScheduleBus>()));
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalDeadLetterStore, MongoWorkflowSignalDeadLetterStore>());
if (useNativeSignalDriver)
{
services.Replace(ServiceDescriptor.Scoped<IWorkflowSignalDriver>(sp => sp.GetRequiredService<MongoWorkflowSignalBus>()));
}
services.AddSingleton<IWorkflowSignalDriverRegistrationMarker>(
new WorkflowSignalDriverRegistrationMarker(WorkflowSignalDriverNames.Native));
return services;
}
}

View File

@@ -0,0 +1,280 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowDatabase(
IMongoClient mongoClient,
IConfiguration configuration,
IOptions<WorkflowStoreMongoOptions> options,
MongoWorkflowMutationSessionAccessor sessionAccessor,
IWorkflowMutationScopeAccessor scopeAccessor)
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> InitializationLocks = new(StringComparer.Ordinal);
private readonly WorkflowStoreMongoOptions mongo = options.Value;
internal WorkflowStoreMongoOptions Options => mongo;
internal IMongoDatabase Database => mongoClient.GetDatabase(mongo.DatabaseName);
internal IMongoClient Client => mongoClient;
internal IConfiguration Configuration => configuration;
internal IClientSessionHandle? CurrentSession => sessionAccessor.Current;
internal IMongoCollection<TDocument> GetCollection<TDocument>(string collectionName)
{
return Database.GetCollection<TDocument>(collectionName);
}
internal async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
{
if (await CollectionExistsAsync(mongo.InstancesCollectionName, cancellationToken))
{
return;
}
var lockKey = $"{mongo.DatabaseName}:{mongo.ConnectionStringName}";
var gate = InitializationLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
if (await CollectionExistsAsync(mongo.InstancesCollectionName, cancellationToken))
{
return;
}
foreach (var collectionName in GetRequiredCollectionNames())
{
if (await CollectionExistsAsync(collectionName, cancellationToken))
{
continue;
}
try
{
await Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken);
}
catch (MongoCommandException exception)
when (exception.Message.Contains("NamespaceExists", StringComparison.OrdinalIgnoreCase)
|| exception.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
}
}
}
finally
{
gate.Release();
}
}
internal async Task<IWorkflowMutationScope> BeginMutationAsync(CancellationToken cancellationToken = default)
{
await EnsureInitializedAsync(cancellationToken);
if (scopeAccessor.Current is not null)
{
return new DelegatingMutationScope(scopeAccessor.Current);
}
if (sessionAccessor.Current is not null)
{
var currentScope = new MongoWorkflowMutationScope(sessionAccessor, scopeAccessor, session: null, ownsSession: true);
scopeAccessor.Current = currentScope;
return currentScope;
}
var session = await mongoClient.StartSessionAsync(cancellationToken: cancellationToken);
session.StartTransaction();
sessionAccessor.Current = session;
var scope = new MongoWorkflowMutationScope(sessionAccessor, scopeAccessor, session, ownsSession: true);
scopeAccessor.Current = scope;
return scope;
}
internal async Task<MongoWorkflowOwnedSession> OpenOwnedSessionAsync(
bool startTransaction,
CancellationToken cancellationToken = default)
{
await EnsureInitializedAsync(cancellationToken);
if (sessionAccessor.Current is not null)
{
return new MongoWorkflowOwnedSession(sessionAccessor.Current, ownsSession: false, startedTransaction: false);
}
var session = await mongoClient.StartSessionAsync(cancellationToken: cancellationToken);
if (startTransaction)
{
session.StartTransaction();
}
return new MongoWorkflowOwnedSession(session, ownsSession: true, startedTransaction: startTransaction);
}
private IEnumerable<string> GetRequiredCollectionNames()
{
return
[
mongo.RuntimeStatesCollectionName,
mongo.HostedJobLocksCollectionName,
mongo.InstancesCollectionName,
mongo.TasksCollectionName,
mongo.TaskEventsCollectionName,
mongo.SignalQueueCollectionName,
mongo.DeadLetterCollectionName,
mongo.WakeOutboxCollectionName,
];
}
private async Task<bool> CollectionExistsAsync(string collectionName, CancellationToken cancellationToken)
{
using var cursor = await Database.ListCollectionNamesAsync(
new ListCollectionNamesOptions
{
Filter = new BsonDocument("name", collectionName),
},
cancellationToken);
return await cursor.AnyAsync(cancellationToken);
}
}
public sealed class MongoWorkflowMutationSessionAccessor
{
internal IClientSessionHandle? Current { get; set; }
}
internal sealed class MongoWorkflowOwnedSession(
IClientSessionHandle session,
bool ownsSession,
bool startedTransaction) : IAsyncDisposable
{
private bool isCommitted;
public IClientSessionHandle Session => session;
public async Task CommitAsync(CancellationToken cancellationToken = default)
{
if (!ownsSession || !startedTransaction || isCommitted)
{
return;
}
await session.CommitTransactionAsync(cancellationToken);
isCommitted = true;
}
public async ValueTask DisposeAsync()
{
if (ownsSession && startedTransaction && !isCommitted)
{
try
{
await session.AbortTransactionAsync(CancellationToken.None);
}
catch
{
}
}
if (ownsSession)
{
session.Dispose();
}
}
}
internal sealed class MongoWorkflowMutationScope(
MongoWorkflowMutationSessionAccessor sessionAccessor,
IWorkflowMutationScopeAccessor scopeAccessor,
IClientSessionHandle? session,
bool ownsSession) : IWorkflowMutationScope
{
private readonly List<Func<CancellationToken, Task>> postCommitActions = [];
private bool isCommitted;
public void RegisterPostCommitAction(Func<CancellationToken, Task> action)
{
ArgumentNullException.ThrowIfNull(action);
postCommitActions.Add(action);
}
public async Task CommitAsync(CancellationToken cancellationToken = default)
{
if (!ownsSession || isCommitted)
{
return;
}
if (session is not null)
{
await session.CommitTransactionAsync(cancellationToken);
}
foreach (var action in postCommitActions)
{
await action(cancellationToken);
}
isCommitted = true;
}
public async ValueTask DisposeAsync()
{
try
{
if (ownsSession && session is not null && !isCommitted)
{
try
{
await session.AbortTransactionAsync(CancellationToken.None);
}
catch
{
}
}
}
finally
{
if (ownsSession)
{
sessionAccessor.Current = null;
if (scopeAccessor.Current == this)
{
scopeAccessor.Current = null;
}
session?.Dispose();
}
}
}
}
internal sealed class DelegatingMutationScope(IWorkflowMutationScope inner) : IWorkflowMutationScope
{
public void RegisterPostCommitAction(Func<CancellationToken, Task> action)
{
inner.RegisterPostCommitAction(action);
}
public Task CommitAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowHostedJobLockService(
MongoWorkflowDatabase database) : IWorkflowHostedJobLockService
{
private readonly IMongoCollection<WorkflowHostedJobLockDocument> collection =
database.GetCollection<WorkflowHostedJobLockDocument>(database.Options.HostedJobLocksCollectionName);
public async Task<bool> TryAcquireAsync(
string lockName,
string lockOwner,
DateTime acquiredOnUtc,
TimeSpan lease,
CancellationToken cancellationToken = default)
{
var expiresOnUtc = acquiredOnUtc.Add(lease);
var filter = Builders<WorkflowHostedJobLockDocument>.Filter.And(
Builders<WorkflowHostedJobLockDocument>.Filter.Eq(x => x.LockName, lockName),
Builders<WorkflowHostedJobLockDocument>.Filter.Or(
Builders<WorkflowHostedJobLockDocument>.Filter.Lte(x => x.ExpiresOnUtc, acquiredOnUtc),
Builders<WorkflowHostedJobLockDocument>.Filter.Eq(x => x.LockOwner, lockOwner)));
var update = Builders<WorkflowHostedJobLockDocument>.Update
.Set(x => x.LockOwner, lockOwner)
.Set(x => x.AcquiredOnUtc, acquiredOnUtc)
.Set(x => x.ExpiresOnUtc, expiresOnUtc);
var session = database.CurrentSession;
UpdateResult updated;
if (session is null)
{
updated = await collection.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
}
else
{
updated = await collection.UpdateOneAsync(session, filter, update, cancellationToken: cancellationToken);
}
if (updated.ModifiedCount > 0)
{
return true;
}
try
{
var document = new WorkflowHostedJobLockDocument
{
LockName = lockName,
LockOwner = lockOwner,
AcquiredOnUtc = acquiredOnUtc,
ExpiresOnUtc = expiresOnUtc,
};
if (session is null)
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken);
}
else
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken);
}
return true;
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
}
public async Task ReleaseAsync(
string lockName,
string lockOwner,
CancellationToken cancellationToken = default)
{
var session = database.CurrentSession;
if (session is null)
{
await collection.DeleteOneAsync(
x => x.LockName == lockName && x.LockOwner == lockOwner,
cancellationToken: cancellationToken);
}
else
{
await collection.DeleteOneAsync(
session,
x => x.LockName == lockName && x.LockOwner == lockOwner,
cancellationToken: cancellationToken);
}
}
private sealed class WorkflowHostedJobLockDocument
{
[BsonId]
public string LockName { get; set; } = string.Empty;
public string LockOwner { get; set; } = string.Empty;
public DateTime AcquiredOnUtc { get; set; }
public DateTime ExpiresOnUtc { get; set; }
}
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Workflow.DataStore.MongoDB;
internal static class MongoWorkflowJson
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public static string Serialize(object value)
{
return JsonSerializer.Serialize(value, SerializerOptions);
}
public static string? SerializeBusinessReference(WorkflowBusinessReference? businessReference)
{
var normalizedReference = WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference);
return normalizedReference is null ? null : Serialize(normalizedReference);
}
public static WorkflowBusinessReference? DeserializeBusinessReference(string? key, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference
{
Key = key,
});
}
var businessReference = JsonSerializer.Deserialize<WorkflowBusinessReference>(value, SerializerOptions);
if (businessReference is null)
{
return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(new WorkflowBusinessReference
{
Key = key,
});
}
if (string.IsNullOrWhiteSpace(businessReference.Key) && !string.IsNullOrWhiteSpace(key))
{
businessReference = businessReference with { Key = key };
}
return WorkflowBusinessReferenceExtensions.NormalizeBusinessReference(businessReference);
}
public static IReadOnlyDictionary<string, JsonElement> DeserializeJsonDictionary(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
}
return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(value, SerializerOptions)
?? new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
}
public static IDictionary<string, object?> DeserializeObjectDictionary(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
return JsonSerializer.Deserialize<Dictionary<string, object?>>(value, SerializerOptions)
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
public static IDictionary<string, object?> DeserializePublicTaskPayload(string value)
{
var payload = DeserializeObjectDictionary(value);
payload.Remove(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey);
return payload;
}
public static string? TryReadProjectionWorkflowInstanceId(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var payload = DeserializeJsonDictionary(value);
if (!payload.TryGetValue(WorkflowRuntimePayloadKeys.ProjectionWorkflowInstanceIdPayloadKey, out var element)
|| element.ValueKind != JsonValueKind.String)
{
return null;
}
return element.GetString();
}
}
internal static class MongoWorkflowRoleResolver
{
public static IReadOnlyCollection<string> NormalizeRoles(IReadOnlyCollection<string> roles)
{
return roles
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public static IReadOnlyCollection<string> ResolveEffectiveRoles(
IReadOnlyCollection<string> workflowRoles,
IReadOnlyCollection<string> taskRoles,
IReadOnlyCollection<string>? runtimeRoles = null)
{
if (runtimeRoles is { Count: > 0 })
{
return NormalizeRoles(runtimeRoles);
}
if (taskRoles.Count > 0)
{
return NormalizeRoles(taskRoles);
}
return NormalizeRoles(workflowRoles);
}
}
internal sealed class MongoWorkflowRetentionSettings
{
public int OpenStaleAfterDays { get; init; } = 30;
public int CompletedPurgeAfterDays { get; init; } = 180;
public static MongoWorkflowRetentionSettings FromConfiguration(IConfiguration configuration)
{
var section = configuration.GetSection("WorkflowRetention");
return new MongoWorkflowRetentionSettings
{
OpenStaleAfterDays = ReadInt(section["OpenStaleAfterDays"], 30),
CompletedPurgeAfterDays = ReadInt(section["CompletedPurgeAfterDays"], 180),
};
}
private static int ReadInt(string? value, int fallback)
{
return int.TryParse(value, out var parsed) ? parsed : fallback;
}
}

View File

@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowMutationCoordinator(
MongoWorkflowDatabase database) : IWorkflowMutationCoordinator
{
public Task<IWorkflowMutationScope> BeginAsync(CancellationToken cancellationToken = default)
{
return database.BeginMutationAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowProjectionRetentionStore(
MongoWorkflowDatabase database) : IWorkflowProjectionRetentionStore
{
private const string OpenInstanceStatus = "Open";
private const string StaleInstanceStatus = "Stale";
private const string OpenTaskStatus = "Open";
private const string AssignedTaskStatus = "Assigned";
private const string StaleTaskStatus = "Stale";
private readonly IMongoCollection<WorkflowInstanceRetentionDocument> instances =
database.GetCollection<WorkflowInstanceRetentionDocument>(database.Options.InstancesCollectionName);
private readonly IMongoCollection<WorkflowTaskRetentionDocument> tasks =
database.GetCollection<WorkflowTaskRetentionDocument>(database.Options.TasksCollectionName);
private readonly IMongoCollection<WorkflowTaskEventRetentionDocument> taskEvents =
database.GetCollection<WorkflowTaskEventRetentionDocument>(database.Options.TaskEventsCollectionName);
public async Task<WorkflowProjectionRetentionBatch> RunAsync(
DateTime nowUtc,
CancellationToken cancellationToken = default)
{
await using var ownedSession = await database.OpenOwnedSessionAsync(
startTransaction: database.CurrentSession is null,
cancellationToken);
var session = ownedSession.Session;
var staleInstanceIds = await GetStaleInstanceIdsAsync(session, nowUtc, cancellationToken);
var staleInstancesMarked = await MarkStaleInstancesAsync(session, staleInstanceIds, cancellationToken);
var staleTaskIds = await GetStaleTaskIdsAsync(session, nowUtc, cancellationToken);
var staleTasksMarked = await MarkStaleTasksAsync(session, staleTaskIds, cancellationToken);
var expiredInstanceIds = await GetExpiredInstanceIdsAsync(session, nowUtc, cancellationToken);
var taskIdsToPurge = await GetTaskIdsToPurgeAsync(session, expiredInstanceIds, nowUtc, cancellationToken);
var purgedTaskEvents = await PurgeTaskEventsAsync(session, taskIdsToPurge, cancellationToken);
var purgedTasks = await PurgeTasksAsync(session, taskIdsToPurge, cancellationToken);
var purgedInstances = await PurgeInstancesAsync(session, expiredInstanceIds, cancellationToken);
await ownedSession.CommitAsync(cancellationToken);
return new WorkflowProjectionRetentionBatch
{
StaleWorkflowInstanceIds = staleInstanceIds,
StaleInstancesMarked = staleInstancesMarked,
StaleTasksMarked = staleTasksMarked,
PurgedWorkflowInstanceIds = expiredInstanceIds,
PurgedInstances = purgedInstances,
PurgedTasks = purgedTasks,
PurgedTaskEvents = purgedTaskEvents,
};
}
private async Task<IReadOnlyCollection<string>> GetStaleInstanceIdsAsync(
IClientSessionHandle session,
DateTime nowUtc,
CancellationToken cancellationToken)
{
return await instances.Find(
session,
x => x.Status == OpenInstanceStatus && x.StaleAfterUtc != null && x.StaleAfterUtc <= nowUtc)
.Project(x => x.WorkflowInstanceId)
.ToListAsync(cancellationToken);
}
private async Task<int> MarkStaleInstancesAsync(
IClientSessionHandle session,
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken)
{
if (workflowInstanceIds.Count == 0)
{
return 0;
}
var result = await instances.UpdateManyAsync(
session,
Builders<WorkflowInstanceRetentionDocument>.Filter.In(x => x.WorkflowInstanceId, workflowInstanceIds),
Builders<WorkflowInstanceRetentionDocument>.Update.Set(x => x.Status, StaleInstanceStatus),
cancellationToken: cancellationToken);
return (int)result.ModifiedCount;
}
private async Task<IReadOnlyCollection<string>> GetStaleTaskIdsAsync(
IClientSessionHandle session,
DateTime nowUtc,
CancellationToken cancellationToken)
{
return await tasks.Find(
session,
x => (x.Status == OpenTaskStatus || x.Status == AssignedTaskStatus)
&& x.StaleAfterUtc != null
&& x.StaleAfterUtc <= nowUtc)
.Project(x => x.WorkflowTaskId)
.ToListAsync(cancellationToken);
}
private async Task<int> MarkStaleTasksAsync(
IClientSessionHandle session,
IReadOnlyCollection<string> taskIds,
CancellationToken cancellationToken)
{
if (taskIds.Count == 0)
{
return 0;
}
var result = await tasks.UpdateManyAsync(
session,
Builders<WorkflowTaskRetentionDocument>.Filter.In(x => x.WorkflowTaskId, taskIds),
Builders<WorkflowTaskRetentionDocument>.Update.Set(x => x.Status, StaleTaskStatus),
cancellationToken: cancellationToken);
return (int)result.ModifiedCount;
}
private async Task<IReadOnlyCollection<string>> GetExpiredInstanceIdsAsync(
IClientSessionHandle session,
DateTime nowUtc,
CancellationToken cancellationToken)
{
return await instances.Find(
session,
x => x.PurgeAfterUtc != null && x.PurgeAfterUtc <= nowUtc)
.Project(x => x.WorkflowInstanceId)
.ToListAsync(cancellationToken);
}
private async Task<IReadOnlyCollection<string>> GetTaskIdsToPurgeAsync(
IClientSessionHandle session,
IReadOnlyCollection<string> expiredInstanceIds,
DateTime nowUtc,
CancellationToken cancellationToken)
{
var filter = Builders<WorkflowTaskRetentionDocument>.Filter.Or(
Builders<WorkflowTaskRetentionDocument>.Filter.And(
Builders<WorkflowTaskRetentionDocument>.Filter.In(x => x.WorkflowInstanceId, expiredInstanceIds),
Builders<WorkflowTaskRetentionDocument>.Filter.Empty),
Builders<WorkflowTaskRetentionDocument>.Filter.And(
Builders<WorkflowTaskRetentionDocument>.Filter.Ne(x => x.PurgeAfterUtc, null),
Builders<WorkflowTaskRetentionDocument>.Filter.Lte(x => x.PurgeAfterUtc, nowUtc)));
return await tasks.Find(session, filter)
.Project(x => x.WorkflowTaskId)
.ToListAsync(cancellationToken);
}
private async Task<int> PurgeTaskEventsAsync(
IClientSessionHandle session,
IReadOnlyCollection<string> taskIds,
CancellationToken cancellationToken)
{
if (taskIds.Count == 0)
{
return 0;
}
var result = await taskEvents.DeleteManyAsync(
session,
Builders<WorkflowTaskEventRetentionDocument>.Filter.In(x => x.WorkflowTaskId, taskIds),
options: null,
cancellationToken: cancellationToken);
return (int)result.DeletedCount;
}
private async Task<int> PurgeTasksAsync(
IClientSessionHandle session,
IReadOnlyCollection<string> taskIds,
CancellationToken cancellationToken)
{
if (taskIds.Count == 0)
{
return 0;
}
var result = await tasks.DeleteManyAsync(
session,
Builders<WorkflowTaskRetentionDocument>.Filter.In(x => x.WorkflowTaskId, taskIds),
options: null,
cancellationToken: cancellationToken);
return (int)result.DeletedCount;
}
private async Task<int> PurgeInstancesAsync(
IClientSessionHandle session,
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken)
{
if (workflowInstanceIds.Count == 0)
{
return 0;
}
var result = await instances.DeleteManyAsync(
session,
Builders<WorkflowInstanceRetentionDocument>.Filter.In(x => x.WorkflowInstanceId, workflowInstanceIds),
options: null,
cancellationToken: cancellationToken);
return (int)result.DeletedCount;
}
private sealed class WorkflowInstanceRetentionDocument
{
[BsonId]
public string WorkflowInstanceId { get; set; } = string.Empty;
public string Status { get; set; } = OpenInstanceStatus;
public DateTime? StaleAfterUtc { get; set; }
public DateTime? PurgeAfterUtc { get; set; }
}
private sealed class WorkflowTaskRetentionDocument
{
[BsonId]
public string WorkflowTaskId { get; set; } = string.Empty;
public string WorkflowInstanceId { get; set; } = string.Empty;
public string Status { get; set; } = OpenTaskStatus;
public DateTime? StaleAfterUtc { get; set; }
public DateTime? PurgeAfterUtc { get; set; }
}
private sealed class WorkflowTaskEventRetentionDocument
{
[BsonId]
public string WorkflowTaskEventId { get; set; } = string.Empty;
public string WorkflowTaskId { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowRuntimeStateStore(
MongoWorkflowDatabase database) : IWorkflowRuntimeStateStore
{
private const string StaleRuntimeStatus = "Stale";
private readonly IMongoCollection<WorkflowRuntimeStateDocument> collection =
database.GetCollection<WorkflowRuntimeStateDocument>(database.Options.RuntimeStatesCollectionName);
public async Task UpsertAsync(
WorkflowRuntimeStateRecord state,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(state);
var document = Map(state);
var session = database.CurrentSession;
if (state.Version <= 1)
{
try
{
if (session is null)
{
await collection.InsertOneAsync(document, cancellationToken: cancellationToken);
}
else
{
await collection.InsertOneAsync(session, document, cancellationToken: cancellationToken);
}
return;
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var actual = await GetRequiredCurrentAsync(state.WorkflowInstanceId, cancellationToken);
throw new WorkflowRuntimeStateConcurrencyException(state.WorkflowInstanceId, state.Version, actual.Version);
}
}
var previousVersion = state.Version - 1;
ReplaceOneResult result;
if (session is null)
{
result = await collection.ReplaceOneAsync(
x => x.WorkflowInstanceId == state.WorkflowInstanceId && x.Version == previousVersion,
document,
cancellationToken: cancellationToken);
}
else
{
result = await collection.ReplaceOneAsync(
session,
x => x.WorkflowInstanceId == state.WorkflowInstanceId && x.Version == previousVersion,
document,
cancellationToken: cancellationToken);
}
if (result.ModifiedCount > 0)
{
return;
}
var actualRecord = await GetRequiredCurrentAsync(state.WorkflowInstanceId, cancellationToken);
throw new WorkflowRuntimeStateConcurrencyException(state.WorkflowInstanceId, state.Version, actualRecord.Version);
}
public async Task<WorkflowRuntimeStateRecord?> GetAsync(
string workflowInstanceId,
CancellationToken cancellationToken = default)
{
var session = database.CurrentSession;
WorkflowRuntimeStateDocument? document;
if (session is null)
{
document = await collection
.Find(x => x.WorkflowInstanceId == workflowInstanceId)
.FirstOrDefaultAsync(cancellationToken);
}
else
{
document = await collection
.Find(session, x => x.WorkflowInstanceId == workflowInstanceId)
.FirstOrDefaultAsync(cancellationToken);
}
return document is null ? null : Map(document);
}
public async Task<IReadOnlyCollection<WorkflowRuntimeStateRecord>> GetManyAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default)
{
if (workflowInstanceIds.Count == 0)
{
return [];
}
var ids = workflowInstanceIds.ToArray();
var session = database.CurrentSession;
List<WorkflowRuntimeStateDocument> documents;
if (session is null)
{
documents = await collection
.Find(x => ids.Contains(x.WorkflowInstanceId))
.ToListAsync(cancellationToken);
}
else
{
documents = await collection
.Find(session, x => ids.Contains(x.WorkflowInstanceId))
.ToListAsync(cancellationToken);
}
return documents.Select(Map).ToArray();
}
public async Task<int> MarkStaleAsync(
IReadOnlyCollection<string> workflowInstanceIds,
DateTime updatedOnUtc,
CancellationToken cancellationToken = default)
{
if (workflowInstanceIds.Count == 0)
{
return 0;
}
var ids = workflowInstanceIds.ToArray();
var update = Builders<WorkflowRuntimeStateDocument>.Update
.Set(x => x.RuntimeStatus, StaleRuntimeStatus)
.Set(x => x.StaleAfterUtc, null)
.Set(x => x.LastUpdatedOnUtc, updatedOnUtc);
var session = database.CurrentSession;
UpdateResult result;
if (session is null)
{
result = await collection.UpdateManyAsync(
x => ids.Contains(x.WorkflowInstanceId),
update,
cancellationToken: cancellationToken);
}
else
{
result = await collection.UpdateManyAsync(
session,
x => ids.Contains(x.WorkflowInstanceId),
update,
cancellationToken: cancellationToken);
}
return (int)result.ModifiedCount;
}
public async Task<int> DeleteAsync(
IReadOnlyCollection<string> workflowInstanceIds,
CancellationToken cancellationToken = default)
{
if (workflowInstanceIds.Count == 0)
{
return 0;
}
var ids = workflowInstanceIds.ToArray();
var session = database.CurrentSession;
DeleteResult result;
if (session is null)
{
result = await collection.DeleteManyAsync(x => ids.Contains(x.WorkflowInstanceId), cancellationToken: cancellationToken);
}
else
{
result = await collection.DeleteManyAsync(session, x => ids.Contains(x.WorkflowInstanceId), cancellationToken: cancellationToken);
}
return (int)result.DeletedCount;
}
private async Task<WorkflowRuntimeStateRecord> GetRequiredCurrentAsync(
string workflowInstanceId,
CancellationToken cancellationToken)
{
return await GetAsync(workflowInstanceId, cancellationToken)
?? throw new WorkflowRuntimeStateConcurrencyException(workflowInstanceId, 0, 0);
}
private static WorkflowRuntimeStateDocument Map(WorkflowRuntimeStateRecord state)
{
return new WorkflowRuntimeStateDocument
{
WorkflowInstanceId = state.WorkflowInstanceId,
WorkflowName = state.WorkflowName,
WorkflowVersion = state.WorkflowVersion,
Version = state.Version,
BusinessReferenceKey = state.BusinessReference?.Key,
BusinessReferenceJson = MongoWorkflowJson.SerializeBusinessReference(state.BusinessReference),
RuntimeProvider = state.RuntimeProvider,
RuntimeInstanceId = state.RuntimeInstanceId,
RuntimeStatus = state.RuntimeStatus,
StateJson = state.StateJson,
CreatedOnUtc = state.CreatedOnUtc,
CompletedOnUtc = state.CompletedOnUtc,
StaleAfterUtc = state.StaleAfterUtc,
PurgeAfterUtc = state.PurgeAfterUtc,
LastUpdatedOnUtc = state.LastUpdatedOnUtc,
};
}
private static WorkflowRuntimeStateRecord Map(WorkflowRuntimeStateDocument document)
{
return new WorkflowRuntimeStateRecord
{
WorkflowInstanceId = document.WorkflowInstanceId,
WorkflowName = document.WorkflowName,
WorkflowVersion = document.WorkflowVersion,
Version = document.Version,
BusinessReference = MongoWorkflowJson.DeserializeBusinessReference(document.BusinessReferenceKey, document.BusinessReferenceJson),
RuntimeProvider = document.RuntimeProvider,
RuntimeInstanceId = document.RuntimeInstanceId,
RuntimeStatus = document.RuntimeStatus,
StateJson = document.StateJson,
CreatedOnUtc = document.CreatedOnUtc,
CompletedOnUtc = document.CompletedOnUtc,
StaleAfterUtc = document.StaleAfterUtc,
PurgeAfterUtc = document.PurgeAfterUtc,
LastUpdatedOnUtc = document.LastUpdatedOnUtc,
};
}
private sealed class WorkflowRuntimeStateDocument
{
[BsonId]
public string WorkflowInstanceId { get; set; } = string.Empty;
public string WorkflowName { get; set; } = string.Empty;
public string WorkflowVersion { get; set; } = "1.0.0";
public long Version { get; set; }
public string? BusinessReferenceKey { get; set; }
public string? BusinessReferenceJson { get; set; }
public string RuntimeProvider { get; set; } = WorkflowRuntimeProviderNames.Engine;
public string RuntimeInstanceId { get; set; } = string.Empty;
public string RuntimeStatus { get; set; } = "Open";
public string StateJson { get; set; } = "{}";
public DateTime CreatedOnUtc { get; set; } = DateTime.UtcNow;
public DateTime? CompletedOnUtc { get; set; }
public DateTime? StaleAfterUtc { get; set; }
public DateTime? PurgeAfterUtc { get; set; }
public DateTime LastUpdatedOnUtc { get; set; } = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowScheduleBus(
MongoWorkflowSignalStore signalStore) : IWorkflowSignalScheduler
{
public Task ScheduleAsync(
WorkflowSignalEnvelope envelope,
DateTime dueAtUtc,
CancellationToken cancellationToken = default)
{
return signalStore.EnqueueLiveAsync(envelope with { DueAtUtc = dueAtUtc }, dueAtUtc, cancellationToken);
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowSignalBus(
MongoWorkflowSignalStore signalStore) : IWorkflowSignalDriver
{
public string DriverName => "Mongo.ChangeStream";
public WorkflowSignalDriverDispatchMode DispatchMode => WorkflowSignalDriverDispatchMode.NativeTransactional;
public Task NotifySignalAvailableAsync(
WorkflowSignalWakeNotification notification,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public async Task<IWorkflowSignalLease?> ReceiveAsync(
string consumerName,
CancellationToken cancellationToken = default)
{
var lease = await signalStore.TryClaimAsync(consumerName, cancellationToken);
if (lease is not null)
{
return lease;
}
await signalStore.WaitForWakeAsync(null, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return null;
}
return await signalStore.TryClaimAsync(consumerName, cancellationToken);
}
}

View File

@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowSignalDeadLetterStore(
MongoWorkflowSignalStore signalStore) : IWorkflowSignalDeadLetterStore
{
public Task<WorkflowSignalDeadLettersGetResponse> GetMessagesAsync(
WorkflowSignalDeadLettersGetRequest request,
CancellationToken cancellationToken = default)
{
return signalStore.GetDeadLettersAsync(request, cancellationToken);
}
public Task<WorkflowSignalDeadLetterReplayResponse> ReplayAsync(
WorkflowSignalDeadLetterReplayRequest request,
CancellationToken cancellationToken = default)
{
return signalStore.ReplayAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,503 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Contracts;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowSignalStore(
MongoWorkflowDatabase database) : IWorkflowSignalStore, IWorkflowSignalClaimStore
{
private readonly IMongoCollection<WorkflowSignalDocument> liveSignals =
database.GetCollection<WorkflowSignalDocument>(database.Options.SignalQueueCollectionName);
private readonly IMongoCollection<WorkflowSignalDocument> deadLetters =
database.GetCollection<WorkflowSignalDocument>(database.Options.DeadLetterCollectionName);
internal WorkflowStoreMongoOptions Options => database.Options;
public Task PublishAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
return EnqueueLiveAsync(envelope, envelope.DueAtUtc, cancellationToken);
}
public Task PublishDeadLetterAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
return EnqueueDeadLetterAsync(envelope, cancellationToken);
}
public async Task<IWorkflowSignalLease?> TryClaimAsync(
string consumerName,
CancellationToken cancellationToken = default)
{
var claimed = await TryClaimInternalAsync(consumerName, cancellationToken);
return claimed is null
? null
: new MongoWorkflowSignalLease(this, claimed);
}
internal async Task EnqueueLiveAsync(
WorkflowSignalEnvelope envelope,
DateTime? dueAtUtc,
CancellationToken cancellationToken = default)
{
await database.EnsureInitializedAsync(cancellationToken);
var document = WorkflowSignalDocument.FromEnvelope(envelope, dueAtUtc, deliveryCount: 0, DateTime.UtcNow);
var session = database.CurrentSession;
if (session is null)
{
await liveSignals.InsertOneAsync(document, cancellationToken: cancellationToken);
}
else
{
await liveSignals.InsertOneAsync(session, document, cancellationToken: cancellationToken);
}
}
internal async Task EnqueueDeadLetterAsync(
WorkflowSignalEnvelope envelope,
CancellationToken cancellationToken = default)
{
await database.EnsureInitializedAsync(cancellationToken);
var document = WorkflowSignalDocument.FromEnvelope(envelope, envelope.DueAtUtc, deliveryCount: 0, DateTime.UtcNow);
document.DeadLetteredOnUtc = DateTime.UtcNow;
var session = database.CurrentSession;
if (session is null)
{
await deadLetters.InsertOneAsync(document, cancellationToken: cancellationToken);
}
else
{
await deadLetters.InsertOneAsync(session, document, cancellationToken: cancellationToken);
}
}
internal async Task<MongoClaimedSignal?> TryClaimInternalAsync(
string consumerName,
CancellationToken cancellationToken = default)
{
await database.EnsureInitializedAsync(cancellationToken);
var now = DateTime.UtcNow;
var claimedUntilUtc = now.AddSeconds(Math.Max(1, Options.ClaimTimeoutSeconds));
var filter = Builders<WorkflowSignalDocument>.Filter.And(
Builders<WorkflowSignalDocument>.Filter.Or(
Builders<WorkflowSignalDocument>.Filter.Eq(x => x.DueAtUtc, null),
Builders<WorkflowSignalDocument>.Filter.Lte(x => x.DueAtUtc, now)),
Builders<WorkflowSignalDocument>.Filter.Or(
Builders<WorkflowSignalDocument>.Filter.Eq(x => x.ClaimedBy, null),
Builders<WorkflowSignalDocument>.Filter.Lte(x => x.ClaimedUntilUtc, now)));
var update = Builders<WorkflowSignalDocument>.Update
.Set(x => x.ClaimedBy, consumerName)
.Set(x => x.ClaimedUntilUtc, claimedUntilUtc)
.Inc(x => x.DeliveryCount, 1);
var options = new FindOneAndUpdateOptions<WorkflowSignalDocument>
{
ReturnDocument = ReturnDocument.After,
Sort = Builders<WorkflowSignalDocument>.Sort
.Ascending(x => x.DueAtUtc)
.Ascending(x => x.EnqueuedOnUtc),
};
var session = database.CurrentSession;
WorkflowSignalDocument? document;
if (session is null)
{
document = await liveSignals.FindOneAndUpdateAsync(filter, update, options, cancellationToken);
}
else
{
document = await liveSignals.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken);
}
return document is null
? null
: new MongoClaimedSignal
{
SignalId = document.SignalId,
ClaimedBy = consumerName,
DeliveryCount = document.DeliveryCount,
Envelope = document.ToEnvelope(),
};
}
internal async Task WaitForWakeAsync(
TimeSpan? maxWait,
CancellationToken cancellationToken = default)
{
await database.EnsureInitializedAsync(cancellationToken);
var defaultWait = TimeSpan.FromSeconds(Math.Max(1, Options.BlockingWaitSeconds));
var nextDueAtUtc = await GetNextDueAtUtcAsync(cancellationToken);
TimeSpan? waitForDue = null;
if (nextDueAtUtc.HasValue)
{
var dueDelay = nextDueAtUtc.Value - DateTime.UtcNow;
if (dueDelay <= TimeSpan.Zero)
{
return;
}
waitForDue = dueDelay;
}
TimeSpan? effectiveWait = maxWait ?? defaultWait;
if (waitForDue.HasValue)
{
effectiveWait = effectiveWait.HasValue
? TimeSpan.FromMilliseconds(Math.Min(effectiveWait.Value.TotalMilliseconds, waitForDue.Value.TotalMilliseconds))
: waitForDue;
}
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (effectiveWait.HasValue)
{
linkedCts.CancelAfter(effectiveWait.Value);
}
var pipeline = new EmptyPipelineDefinition<ChangeStreamDocument<WorkflowSignalDocument>>()
.Match(x =>
x.OperationType == ChangeStreamOperationType.Insert
|| x.OperationType == ChangeStreamOperationType.Update
|| x.OperationType == ChangeStreamOperationType.Replace);
var changeStreamOptions = new ChangeStreamOptions
{
FullDocument = ChangeStreamFullDocumentOption.UpdateLookup,
MaxAwaitTime = effectiveWait,
};
try
{
using var cursor = await liveSignals.WatchAsync(
pipeline,
changeStreamOptions,
linkedCts.Token);
await cursor.MoveNextAsync(linkedCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
}
}
internal async Task CompleteAsync(
string signalId,
string claimedBy,
CancellationToken cancellationToken = default)
{
var session = database.CurrentSession;
if (session is null)
{
await liveSignals.DeleteOneAsync(
x => x.SignalId == signalId && x.ClaimedBy == claimedBy,
cancellationToken: cancellationToken);
}
else
{
await liveSignals.DeleteOneAsync(
session,
x => x.SignalId == signalId && x.ClaimedBy == claimedBy,
cancellationToken: cancellationToken);
}
}
internal async Task AbandonAsync(
string signalId,
string claimedBy,
CancellationToken cancellationToken = default)
{
var update = Builders<WorkflowSignalDocument>.Update
.Set(x => x.ClaimedBy, null)
.Set(x => x.ClaimedUntilUtc, null);
var session = database.CurrentSession;
if (session is null)
{
await liveSignals.UpdateOneAsync(
x => x.SignalId == signalId && x.ClaimedBy == claimedBy,
update,
cancellationToken: cancellationToken);
}
else
{
await liveSignals.UpdateOneAsync(
session,
x => x.SignalId == signalId && x.ClaimedBy == claimedBy,
update,
cancellationToken: cancellationToken);
}
}
internal async Task DeadLetterAsync(
string signalId,
string claimedBy,
CancellationToken cancellationToken = default)
{
await using var ownedSession = await database.OpenOwnedSessionAsync(startTransaction: true, cancellationToken);
var document = await liveSignals.Find(ownedSession.Session, x => x.SignalId == signalId && x.ClaimedBy == claimedBy)
.FirstOrDefaultAsync(cancellationToken);
if (document is null)
{
return;
}
document.DeadLetteredOnUtc = DateTime.UtcNow;
await deadLetters.ReplaceOneAsync(
ownedSession.Session,
x => x.SignalId == signalId,
document,
new ReplaceOptions { IsUpsert = true },
cancellationToken);
await liveSignals.DeleteOneAsync(
ownedSession.Session,
x => x.SignalId == signalId && x.ClaimedBy == claimedBy,
cancellationToken: cancellationToken);
await ownedSession.CommitAsync(cancellationToken);
}
internal async Task<WorkflowSignalDeadLettersGetResponse> GetDeadLettersAsync(
WorkflowSignalDeadLettersGetRequest request,
CancellationToken cancellationToken = default)
{
await database.EnsureInitializedAsync(cancellationToken);
var filter = Builders<WorkflowSignalDocument>.Filter.Empty;
if (!string.IsNullOrWhiteSpace(request.SignalId))
{
filter &= Builders<WorkflowSignalDocument>.Filter.Eq(x => x.SignalId, request.SignalId);
}
if (!string.IsNullOrWhiteSpace(request.WorkflowInstanceId))
{
filter &= Builders<WorkflowSignalDocument>.Filter.Eq(x => x.WorkflowInstanceId, request.WorkflowInstanceId);
}
if (!string.IsNullOrWhiteSpace(request.SignalType))
{
filter &= Builders<WorkflowSignalDocument>.Filter.Eq(x => x.SignalType, request.SignalType);
}
var documents = await deadLetters.Find(filter)
.SortByDescending(x => x.DeadLetteredOnUtc)
.ThenBy(x => x.SignalId)
.Limit(Math.Clamp(request.MaxMessages, 1, 200))
.ToListAsync(cancellationToken);
return new WorkflowSignalDeadLettersGetResponse
{
Messages = documents.Select(document =>
{
try
{
return new WorkflowSignalDeadLetterMessage
{
SignalId = document.SignalId,
Correlation = document.SignalId,
WorkflowInstanceId = document.WorkflowInstanceId,
RuntimeProvider = document.RuntimeProvider,
SignalType = document.SignalType,
ExpectedVersion = document.ExpectedVersion,
WaitingToken = document.WaitingToken,
OccurredAtUtc = document.OccurredAtUtc,
DueAtUtc = document.DueAtUtc,
Payload = MongoWorkflowJson.DeserializeJsonDictionary(document.PayloadJson),
DeliveryCount = document.DeliveryCount,
EnqueuedOnUtc = document.EnqueuedOnUtc,
IsEnvelopeReadable = true,
RawPayloadBase64 = request.IncludeRawPayload
? Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes(document.PayloadJson))
: null,
};
}
catch (Exception exception)
{
return new WorkflowSignalDeadLetterMessage
{
SignalId = document.SignalId,
Correlation = document.SignalId,
WorkflowInstanceId = document.WorkflowInstanceId,
RuntimeProvider = document.RuntimeProvider,
SignalType = document.SignalType,
ExpectedVersion = document.ExpectedVersion,
WaitingToken = document.WaitingToken,
OccurredAtUtc = document.OccurredAtUtc,
DueAtUtc = document.DueAtUtc,
DeliveryCount = document.DeliveryCount,
EnqueuedOnUtc = document.EnqueuedOnUtc,
IsEnvelopeReadable = false,
ReadError = exception.Message,
RawPayloadBase64 = request.IncludeRawPayload
? Convert.ToBase64String(global::System.Text.Encoding.UTF8.GetBytes(document.PayloadJson))
: null,
};
}
}).ToArray(),
};
}
internal async Task<WorkflowSignalDeadLetterReplayResponse> ReplayAsync(
WorkflowSignalDeadLetterReplayRequest request,
CancellationToken cancellationToken = default)
{
await database.EnsureInitializedAsync(cancellationToken);
await using var ownedSession = await database.OpenOwnedSessionAsync(startTransaction: true, cancellationToken);
var document = await deadLetters.Find(ownedSession.Session, x => x.SignalId == request.SignalId)
.FirstOrDefaultAsync(cancellationToken);
if (document is null)
{
return new WorkflowSignalDeadLetterReplayResponse
{
SignalId = request.SignalId,
Replayed = false,
};
}
document.ClaimedBy = null;
document.ClaimedUntilUtc = null;
document.DeadLetteredOnUtc = null;
document.DeliveryCount = 0;
document.EnqueuedOnUtc = DateTime.UtcNow;
await liveSignals.ReplaceOneAsync(
ownedSession.Session,
x => x.SignalId == request.SignalId,
document,
new ReplaceOptions { IsUpsert = true },
cancellationToken);
await deadLetters.DeleteOneAsync(
ownedSession.Session,
x => x.SignalId == request.SignalId,
cancellationToken: cancellationToken);
await ownedSession.CommitAsync(cancellationToken);
return new WorkflowSignalDeadLetterReplayResponse
{
SignalId = request.SignalId,
Replayed = true,
WorkflowInstanceId = document.WorkflowInstanceId,
SignalType = document.SignalType,
WasEnvelopeReadable = true,
};
}
private async Task<DateTime?> GetNextDueAtUtcAsync(CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var filter = Builders<WorkflowSignalDocument>.Filter.And(
Builders<WorkflowSignalDocument>.Filter.Ne(x => x.DueAtUtc, null),
Builders<WorkflowSignalDocument>.Filter.Or(
Builders<WorkflowSignalDocument>.Filter.Eq(x => x.ClaimedBy, null),
Builders<WorkflowSignalDocument>.Filter.Lte(x => x.ClaimedUntilUtc, now)));
var document = await liveSignals.Find(filter)
.SortBy(x => x.DueAtUtc)
.Project(x => new WorkflowSignalDueProjection { DueAtUtc = x.DueAtUtc })
.FirstOrDefaultAsync(cancellationToken);
return document?.DueAtUtc;
}
internal sealed class WorkflowSignalDocument
{
[BsonId]
public string SignalId { get; set; } = string.Empty;
public string WorkflowInstanceId { get; set; } = string.Empty;
public string RuntimeProvider { get; set; } = WorkflowRuntimeProviderNames.Engine;
public string SignalType { get; set; } = WorkflowSignalTypes.ExternalSignal;
public long ExpectedVersion { get; set; }
public string? WaitingToken { get; set; }
public DateTime OccurredAtUtc { get; set; }
public DateTime? DueAtUtc { get; set; }
public string PayloadJson { get; set; } = "{}";
public int DeliveryCount { get; set; }
public DateTime EnqueuedOnUtc { get; set; }
public string? ClaimedBy { get; set; }
public DateTime? ClaimedUntilUtc { get; set; }
public string? LastError { get; set; }
public DateTime? DeadLetteredOnUtc { get; set; }
public static WorkflowSignalDocument FromEnvelope(
WorkflowSignalEnvelope envelope,
DateTime? dueAtUtc,
int deliveryCount,
DateTime enqueuedOnUtc)
{
return new WorkflowSignalDocument
{
SignalId = envelope.SignalId,
WorkflowInstanceId = envelope.WorkflowInstanceId,
RuntimeProvider = envelope.RuntimeProvider,
SignalType = envelope.SignalType,
ExpectedVersion = envelope.ExpectedVersion,
WaitingToken = envelope.WaitingToken,
OccurredAtUtc = envelope.OccurredAtUtc,
DueAtUtc = dueAtUtc,
PayloadJson = MongoWorkflowJson.Serialize(envelope.Payload),
DeliveryCount = deliveryCount,
EnqueuedOnUtc = enqueuedOnUtc,
};
}
public WorkflowSignalEnvelope ToEnvelope()
{
return new WorkflowSignalEnvelope
{
SignalId = SignalId,
WorkflowInstanceId = WorkflowInstanceId,
RuntimeProvider = RuntimeProvider,
SignalType = SignalType,
ExpectedVersion = ExpectedVersion,
WaitingToken = WaitingToken,
OccurredAtUtc = OccurredAtUtc,
DueAtUtc = DueAtUtc,
Payload = MongoWorkflowJson.DeserializeJsonDictionary(PayloadJson),
};
}
}
internal sealed class MongoClaimedSignal
{
public required string SignalId { get; init; }
public required string ClaimedBy { get; init; }
public required int DeliveryCount { get; init; }
public required WorkflowSignalEnvelope Envelope { get; init; }
}
private sealed class MongoWorkflowSignalLease(
MongoWorkflowSignalStore signalStore,
MongoClaimedSignal claimedSignal) : IWorkflowSignalLease
{
public WorkflowSignalEnvelope Envelope { get; } = claimedSignal.Envelope;
public int DeliveryCount => claimedSignal.DeliveryCount;
public Task CompleteAsync(CancellationToken cancellationToken = default)
{
return signalStore.CompleteAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken);
}
public Task AbandonAsync(CancellationToken cancellationToken = default)
{
return signalStore.AbandonAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken);
}
public Task DeadLetterAsync(CancellationToken cancellationToken = default)
{
return signalStore.DeadLetterAsync(claimedSignal.SignalId, claimedSignal.ClaimedBy, cancellationToken);
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
private sealed class WorkflowSignalDueProjection
{
public DateTime? DueAtUtc { get; set; }
}
}

View File

@@ -0,0 +1,216 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Workflow.Abstractions;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class MongoWorkflowWakeOutbox(
MongoWorkflowDatabase database) : IWorkflowWakeOutbox, IWorkflowWakeOutboxReceiver
{
private readonly IMongoCollection<WakeOutboxDocument> outbox =
database.GetCollection<WakeOutboxDocument>(database.Options.WakeOutboxCollectionName);
private WorkflowStoreMongoOptions Options => database.Options;
public async Task EnqueueAsync(
WorkflowSignalWakeNotification notification,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
await database.EnsureInitializedAsync(cancellationToken);
var document = new WakeOutboxDocument
{
OutboxId = Guid.NewGuid().ToString("N"),
SignalId = notification.SignalId,
WorkflowInstanceId = notification.WorkflowInstanceId,
RuntimeProvider = notification.RuntimeProvider,
SignalType = notification.SignalType,
DueAtUtc = notification.DueAtUtc,
CreatedOnUtc = DateTime.UtcNow,
};
var session = database.CurrentSession;
if (session is null)
{
await outbox.InsertOneAsync(document, cancellationToken: cancellationToken);
}
else
{
await outbox.InsertOneAsync(session, document, cancellationToken: cancellationToken);
}
}
public async Task<IWorkflowWakeOutboxLease?> ReceiveAsync(
string consumerName,
CancellationToken cancellationToken = default)
{
var lease = await TryClaimInternalAsync(consumerName, cancellationToken);
if (lease is not null)
{
return lease;
}
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, Options.BlockingWaitSeconds)));
var pipeline = new EmptyPipelineDefinition<ChangeStreamDocument<WakeOutboxDocument>>()
.Match(x =>
x.OperationType == ChangeStreamOperationType.Insert
|| x.OperationType == ChangeStreamOperationType.Update
|| x.OperationType == ChangeStreamOperationType.Replace);
var options = new ChangeStreamOptions
{
FullDocument = ChangeStreamFullDocumentOption.UpdateLookup,
MaxAwaitTime = TimeSpan.FromSeconds(Math.Max(1, Options.BlockingWaitSeconds)),
};
try
{
using var cursor = await outbox.WatchAsync(
pipeline,
options,
linkedCts.Token);
await cursor.MoveNextAsync(linkedCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
}
return await TryClaimInternalAsync(consumerName, cancellationToken);
}
internal async Task CompleteAsync(
string outboxId,
string consumerName,
CancellationToken cancellationToken = default)
{
var session = database.CurrentSession;
if (session is null)
{
await outbox.DeleteOneAsync(
x => x.OutboxId == outboxId && x.ClaimedBy == consumerName,
cancellationToken: cancellationToken);
}
else
{
await outbox.DeleteOneAsync(
session,
x => x.OutboxId == outboxId && x.ClaimedBy == consumerName,
cancellationToken: cancellationToken);
}
}
internal async Task AbandonAsync(
string outboxId,
string consumerName,
CancellationToken cancellationToken = default)
{
var update = Builders<WakeOutboxDocument>.Update
.Set(x => x.ClaimedBy, null)
.Set(x => x.ClaimedUntilUtc, null);
var session = database.CurrentSession;
if (session is null)
{
await outbox.UpdateOneAsync(
x => x.OutboxId == outboxId && x.ClaimedBy == consumerName,
update,
cancellationToken: cancellationToken);
}
else
{
await outbox.UpdateOneAsync(
session,
x => x.OutboxId == outboxId && x.ClaimedBy == consumerName,
update,
cancellationToken: cancellationToken);
}
}
private async Task<IWorkflowWakeOutboxLease?> TryClaimInternalAsync(
string consumerName,
CancellationToken cancellationToken)
{
await database.EnsureInitializedAsync(cancellationToken);
var now = DateTime.UtcNow;
var claimedUntilUtc = now.AddSeconds(Math.Max(1, Options.ClaimTimeoutSeconds));
var filter = Builders<WakeOutboxDocument>.Filter.Or(
Builders<WakeOutboxDocument>.Filter.Eq(x => x.ClaimedBy, null),
Builders<WakeOutboxDocument>.Filter.Lte(x => x.ClaimedUntilUtc, now));
var update = Builders<WakeOutboxDocument>.Update
.Set(x => x.ClaimedBy, consumerName)
.Set(x => x.ClaimedUntilUtc, claimedUntilUtc);
var options = new FindOneAndUpdateOptions<WakeOutboxDocument>
{
ReturnDocument = ReturnDocument.After,
Sort = Builders<WakeOutboxDocument>.Sort
.Ascending(x => x.CreatedOnUtc)
.Ascending(x => x.OutboxId),
};
var session = database.CurrentSession;
WakeOutboxDocument? document;
if (session is null)
{
document = await outbox.FindOneAndUpdateAsync(filter, update, options, cancellationToken);
}
else
{
document = await outbox.FindOneAndUpdateAsync(session, filter, update, options, cancellationToken);
}
return document is null
? null
: new Lease(this, document);
}
private sealed class Lease(
MongoWorkflowWakeOutbox outbox,
WakeOutboxDocument document) : IWorkflowWakeOutboxLease
{
public WorkflowSignalWakeNotification Notification { get; } = new()
{
SignalId = document.SignalId,
WorkflowInstanceId = document.WorkflowInstanceId,
RuntimeProvider = document.RuntimeProvider,
SignalType = document.SignalType,
DueAtUtc = document.DueAtUtc,
};
public string ConsumerName => document.ClaimedBy ?? string.Empty;
public Task CompleteAsync(CancellationToken cancellationToken = default)
{
return outbox.CompleteAsync(document.OutboxId, ConsumerName, cancellationToken);
}
public Task AbandonAsync(CancellationToken cancellationToken = default)
{
return outbox.AbandonAsync(document.OutboxId, ConsumerName, cancellationToken);
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
internal sealed class WakeOutboxDocument
{
[BsonId]
public string OutboxId { get; set; } = string.Empty;
public string SignalId { get; set; } = string.Empty;
public string WorkflowInstanceId { get; set; } = string.Empty;
public string RuntimeProvider { get; set; } = string.Empty;
public string SignalType { get; set; } = string.Empty;
public DateTime? DueAtUtc { get; set; }
public DateTime CreatedOnUtc { get; set; }
public string? ClaimedBy { get; set; }
public DateTime? ClaimedUntilUtc { get; set; }
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.1.25080.5" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-preview.1.25080.5" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Workflow.Contracts\StellaOps.Workflow.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.DataStore.MongoDB;
public sealed class WorkflowStoreMongoOptions
{
public const string SectionName = $"{WorkflowBackendOptions.SectionName}:Mongo";
public string ConnectionStringName { get; set; } = "WorkflowMongo";
public string DatabaseName { get; set; } = "serdica_workflow_store";
public string RuntimeStatesCollectionName { get; set; } = "workflow_runtime_states";
public string HostedJobLocksCollectionName { get; set; } = "workflow_host_locks";
public string InstancesCollectionName { get; set; } = "workflow_instances";
public string TasksCollectionName { get; set; } = "workflow_tasks";
public string TaskEventsCollectionName { get; set; } = "workflow_task_events";
public string SignalQueueCollectionName { get; set; } = "workflow_signals";
public string DeadLetterCollectionName { get; set; } = "workflow_signal_dead_letters";
public string WakeOutboxCollectionName { get; set; } = "workflow_signal_wake_outbox";
public int ClaimTimeoutSeconds { get; set; } = 60;
public int BlockingWaitSeconds { get; set; } = 30;
}

View File

@@ -0,0 +1,11 @@
using System;
namespace StellaOps.Workflow.DataStore.Oracle.Entities;
public class WorkflowHostedJobLockEntity
{
public string LockName { get; set; } = string.Empty;
public string LockOwner { get; set; } = string.Empty;
public DateTime AcquiredOnUtc { get; set; } = DateTime.UtcNow;
public DateTime ExpiresOnUtc { get; set; } = DateTime.UtcNow;
}

Some files were not shown because too many files have changed in this diff Show More