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

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

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

WebService: ASP.NET Core Minimal API with 22 endpoints

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

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

View File

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

View File

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

View File

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

View File

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

View File

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