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:
@@ -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);
|
||||
}
|
||||
}
|
||||
35
src/Workflow/StellaOps.Workflow.WebService/Program.cs
Normal file
35
src/Workflow/StellaOps.Workflow.WebService/Program.cs
Normal 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 { }
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Workflow.Host": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:49940;http://localhost:49941"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
33
src/Workflow/StellaOps.Workflow.WebService/appsettings.json
Normal file
33
src/Workflow/StellaOps.Workflow.WebService/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
28
src/Workflow/StellaOps.Workflow.slnx
Normal file
28
src/Workflow/StellaOps.Workflow.slnx
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Workflow.Abstractions;
|
||||
|
||||
public interface IWorkflowBackendRegistrationMarker
|
||||
{
|
||||
string BackendName { get; }
|
||||
}
|
||||
|
||||
public sealed record WorkflowBackendRegistrationMarker(string BackendName) : IWorkflowBackendRegistrationMarker;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Workflow.Abstractions;
|
||||
|
||||
public sealed class WorkflowBackendOptions
|
||||
{
|
||||
public const string SectionName = "WorkflowBackend";
|
||||
|
||||
public string Provider { get; set; } = WorkflowBackendNames.Oracle;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Workflow.Abstractions;
|
||||
|
||||
public static class WorkflowRuntimeProviderNames
|
||||
{
|
||||
public const string InProcess = "Stella.InProcess";
|
||||
public const string Engine = "Stella.Engine";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Workflow.Abstractions;
|
||||
|
||||
public static class WorkflowSignalDriverNames
|
||||
{
|
||||
public const string Native = "Native";
|
||||
public const string Redis = "Redis";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Workflow.Abstractions;
|
||||
|
||||
public sealed class WorkflowSignalDriverOptions
|
||||
{
|
||||
public const string SectionName = "WorkflowSignalDriver";
|
||||
|
||||
public string Provider { get; set; } = WorkflowSignalDriverNames.Native;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Workflow.Abstractions;
|
||||
|
||||
public static class WorkflowSignalPayloadKeys
|
||||
{
|
||||
public const string StartWorkflowRequestPayloadKey = "startWorkflowRequest";
|
||||
public const string ExternalSignalNamePayloadKey = "signalName";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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?>();
|
||||
}
|
||||
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user