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:
34
docs/workflow/tutorials/04-human-tasks/README.md
Normal file
34
docs/workflow/tutorials/04-human-tasks/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Tutorial 4: Human Tasks with OnComplete Flows
|
||||
|
||||
The approve/reject pattern — the most common human task flow in insurance workflows.
|
||||
|
||||
## Concepts Introduced
|
||||
|
||||
- `WorkflowHumanTask.For<T>()` — define a task with name, type, route, and roles
|
||||
- `.WithPayload()` — data sent to the UI when the task is displayed
|
||||
- `.WithTimeout(seconds)` — optional deadline for the task
|
||||
- `.WithRoles()` — restrict which roles can interact with this task
|
||||
- `.OnComplete(flow => ...)` — sequence executed after user completes the task
|
||||
- `.ActivateTask()` — pause workflow and wait for user action
|
||||
- `.AddTask()` — register a task in the workflow spec (separate from activation)
|
||||
- Re-activation — send the user back to the same task on validation failure
|
||||
|
||||
## Approve/Reject Pattern
|
||||
|
||||
1. Workflow starts, runs some service tasks
|
||||
2. `.ActivateTask("Approve")` — workflow pauses
|
||||
3. User sees the task in their inbox, assigns it, submits an answer
|
||||
4. `.OnComplete` checks `payload.answer`:
|
||||
- `"approve"` — run confirmation operations, convert to policy
|
||||
- `"reject"` — cancel the application
|
||||
5. If operations fail, re-activate the same task for correction
|
||||
|
||||
## Variants
|
||||
|
||||
- [C# Fluent DSL](csharp/)
|
||||
- [Canonical JSON](json/)
|
||||
|
||||
## Next
|
||||
|
||||
[Tutorial 5: Sub-Workflows](../05-sub-workflows/) — inline vs fire-and-forget child workflows.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
using WorkflowEngine.Abstractions;
|
||||
using WorkflowEngine.Contracts;
|
||||
|
||||
namespace WorkflowEngine.Tutorials;
|
||||
|
||||
public sealed class ApprovalRequest
|
||||
{
|
||||
public long PolicyId { get; set; }
|
||||
public long AnnexId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalWorkflow : IDeclarativeWorkflow<ApprovalRequest>
|
||||
{
|
||||
public string WorkflowName => "ApprovalExample";
|
||||
public string WorkflowVersion => "1.0.0";
|
||||
public string DisplayName => "Approval Example";
|
||||
public IReadOnlyCollection<string> WorkflowRoles => ["DBA", "UR_UNDERWRITER"];
|
||||
|
||||
public WorkflowSpec<ApprovalRequest> Spec { get; } = WorkflowSpec
|
||||
.For<ApprovalRequest>()
|
||||
.InitializeState(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("start.policyId")),
|
||||
WorkflowExpr.Prop("annexId", WorkflowExpr.Path("start.annexId"))))
|
||||
|
||||
// Register the task definition (separate from activation).
|
||||
.AddTask(approveTask)
|
||||
|
||||
// Start flow: validate, then activate the approval task.
|
||||
.StartWith(flow => flow
|
||||
.Call("Validate",
|
||||
Address.LegacyRabbit("pas_policy_validate"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete)
|
||||
.ActivateTask("Approve Policy")) // pauses here
|
||||
.Build();
|
||||
|
||||
public IReadOnlyCollection<WorkflowTaskDescriptor> Tasks => Spec.TaskDescriptors;
|
||||
|
||||
// Define the human task with roles, payload, optional deadline, and OnComplete flow.
|
||||
private static readonly WorkflowHumanTaskDefinition<ApprovalRequest> approveTask =
|
||||
WorkflowHumanTask.For<ApprovalRequest>(
|
||||
"Approve Policy", // task name
|
||||
"PolicyApproval", // task type (UI component)
|
||||
"business/policies", // route
|
||||
taskRoles: ["UR_UNDERWRITER"]) // only underwriters
|
||||
.WithPayload(WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
|
||||
WorkflowExpr.Prop("annexId", WorkflowExpr.Path("state.annexId"))))
|
||||
.WithTimeout(86400) // 24-hour deadline (optional)
|
||||
.OnComplete(BuildApprovalFlow);
|
||||
|
||||
private static void BuildApprovalFlow(WorkflowFlowBuilder<ApprovalRequest> flow)
|
||||
{
|
||||
flow
|
||||
// Store the user's answer in state for auditability.
|
||||
.Set("answer", WorkflowExpr.Path("payload.answer"))
|
||||
|
||||
// Branch on the answer.
|
||||
.WhenPayloadEquals("answer", "reject", "Rejected?",
|
||||
rejected => rejected
|
||||
.Call("Cancel Application",
|
||||
Address.LegacyRabbit("pas_annexprocessing_cancelaplorqt"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete)
|
||||
.Complete(),
|
||||
|
||||
approved => approved
|
||||
.Call<object>("Perform Operations",
|
||||
Address.LegacyRabbit("pas_operations_perform",
|
||||
SerdicaLegacyRabbitMode.MicroserviceConsumer),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId")),
|
||||
WorkflowExpr.Prop("stages", WorkflowExpr.Array(
|
||||
WorkflowExpr.String("UNDERWRITING"),
|
||||
WorkflowExpr.String("CONFIRMATION")))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
resultKey: "operations")
|
||||
.Set("passed", WorkflowExpr.Path("result.operations.passed"))
|
||||
|
||||
.WhenStateFlag("passed", true, "Operations Passed?",
|
||||
passed => passed
|
||||
.Call("Convert To Policy",
|
||||
Address.LegacyRabbit("pas_polreg_convertapltopol"),
|
||||
WorkflowExpr.Object(
|
||||
WorkflowExpr.Prop("policyId", WorkflowExpr.Path("state.policyId"))),
|
||||
WorkflowHandledBranchAction.Complete,
|
||||
WorkflowHandledBranchAction.Complete)
|
||||
.Complete(),
|
||||
|
||||
// Operations failed: re-open the same task for the user to fix and retry.
|
||||
failed => failed.ActivateTask("Approve Policy")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"schemaVersion": "serdica.workflow.definition/v1",
|
||||
"workflowName": "ApprovalExample",
|
||||
"workflowVersion": "1.0.0",
|
||||
"displayName": "Approval Example",
|
||||
"workflowRoles": ["DBA", "UR_UNDERWRITER"],
|
||||
"start": {
|
||||
"initializeStateExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "start.policyId" } },
|
||||
{ "name": "annexId", "expression": { "$type": "path", "path": "start.annexId" } }
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Validate",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_policy_validate", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{
|
||||
"$type": "activate-task",
|
||||
"taskName": "Approve Policy",
|
||||
"timeoutSeconds": 86400
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"taskName": "Approve Policy",
|
||||
"taskType": "PolicyApproval",
|
||||
"routeExpression": { "$type": "string", "value": "business/policies" },
|
||||
"taskRoles": ["UR_UNDERWRITER"],
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
|
||||
{ "name": "annexId", "expression": { "$type": "path", "path": "state.annexId" } }
|
||||
]
|
||||
},
|
||||
"onCompleteSequence": {
|
||||
"steps": [
|
||||
{ "$type": "set-state", "stateKey": "answer", "valueExpression": { "$type": "path", "path": "payload.answer" } },
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "Rejected?",
|
||||
"conditionExpression": {
|
||||
"$type": "binary", "operator": "eq",
|
||||
"left": { "$type": "path", "path": "payload.answer" },
|
||||
"right": { "$type": "string", "value": "reject" }
|
||||
},
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Cancel Application",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_annexprocessing_cancelaplorqt", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Perform Operations",
|
||||
"resultKey": "operations",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_operations_perform", "mode": "MicroserviceConsumer" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } },
|
||||
{ "name": "stages", "expression": { "$type": "array", "items": [
|
||||
{ "$type": "string", "value": "UNDERWRITING" },
|
||||
{ "$type": "string", "value": "CONFIRMATION" }
|
||||
]}}
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{ "$type": "set-state", "stateKey": "passed", "valueExpression": { "$type": "path", "path": "result.operations.passed" } },
|
||||
{
|
||||
"$type": "decision",
|
||||
"decisionName": "Operations Passed?",
|
||||
"conditionExpression": { "$type": "path", "path": "state.passed" },
|
||||
"whenTrue": {
|
||||
"steps": [
|
||||
{
|
||||
"$type": "call-transport",
|
||||
"stepName": "Convert To Policy",
|
||||
"invocation": {
|
||||
"address": { "$type": "legacy-rabbit", "command": "pas_polreg_convertapltopol", "mode": "Envelope" },
|
||||
"payloadExpression": {
|
||||
"$type": "object",
|
||||
"properties": [
|
||||
{ "name": "policyId", "expression": { "$type": "path", "path": "state.policyId" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
"whenFailure": { "steps": [{ "$type": "complete" }] },
|
||||
"whenTimeout": { "steps": [{ "$type": "complete" }] }
|
||||
},
|
||||
{ "$type": "complete" }
|
||||
]
|
||||
},
|
||||
"whenElse": {
|
||||
"steps": [
|
||||
{ "$type": "activate-task", "taskName": "Approve Policy" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user