BPMN 2.0 workflow engine standards — process modeling, gateway patterns (exclusive/parallel/inclusive), human tasks, timer events, error handling, compensation, state machines, and workflow persistence. Use this skill when implementing workflow engine features, designing process definitions, building gateway logic, implementing human task flows, or working with the workflow builder.
This skill defines patterns for workflow engine implementation following BPMN 2.0 standards.
| BPMN Element | Our Implementation | Shape |
|---|---|---|
| Start Event | Trigger node (Manual, Cron, Webhook) | Circle |
| End Event | End node | Circle (thick border) |
| Task | Action node (HTTP, DB, Email, etc.) | Rounded rectangle |
| User Task | Human Task node | Rounded rectangle with person icon |
| Exclusive Gateway | Decision node — ONE path taken | Diamond |
| Parallel Gateway | Split & Wait — ALL paths, no conditions | Diamond |
| Inclusive Gateway | Match node — MATCHING paths taken | Diamond |
| Timer Event | Delay node | Circle with clock |
| Sub-Process | For Each (loop) node | Rounded rectangle with scope zone |
Created → Running → [Suspended | WaitingForHuman | Completed | Failed]
↓ ↓
Running Running (on resume)
public enum WorkflowStatus
{
Pending, // Created but not started
Running, // Currently executing
Suspended, // Waiting on timer/retry
WaitingForHuman, // Human task pending
Completed, // Successfully finished
Failed // Unrecoverable error
}
Valid transitions:
Pending → Running (trigger fires)Running → Completed (reached end node)Running → Failed (unrecoverable error)Running → Suspended (delay, retry backoff)Running → WaitingForHuman (human task created)Suspended → Running (timer/retry fires)WaitingForHuman → Running (human approves/rejects)public async Task ExecuteInstanceAsync(WorkflowInstance instance, CancellationToken ct)
{
instance.Status = WorkflowStatus.Running;
instance.ErrorCode = null; // Clear stale errors from retry
instance.ErrorMessage = null;
while (instance.CurrentNodeId != null)
{
var node = graph.GetNode(instance.CurrentNodeId);
var executor = _registry.Resolve(node.Type);
var result = await executor.ExecuteAsync(context);
// Handle result: advance to next node, suspend, complete, or fail
}
}
Every node type has:
ExecuteAsync, produces output DataBag// Descriptor declares what the executor produces
public OutputFieldDef[] DefaultOutputs => new[]
{
new OutputFieldDef("order_id", "Order ID", OutputType.String),
new OutputFieldDef("total", "Total Amount", OutputType.Number),
};
// Executor MUST set exactly these keys
output.Set("order_id", orderId);
output.Set("total", totalAmount);
CRITICAL: DefaultOutputs keys must EXACTLY match executor DataBag keys AND frontend nodeDefinitions.ts keys. All three locations must stay in sync.
{{ nodes.node_1.field_name }}WorkflowVariablesJsonElement — evaluator handles both CLR types and JsonElementpublic async Task ExecuteAsync(NodeContext ctx)
{
if (ctx.IsTest)
{
ctx.Output.Set("result", "[TEST] Simulated output");
return;
}
// Real execution...
}
Side-effecting executors MUST check ctx.IsTest. Read-only executors don't need guards.
WaitingForHumanPOST /api/workflows/{id}/resume with decision payloadvars["__resume"] (namespaced){{ __resume.decision }}CRITICAL: Never merge resume payloads into top-level workflow variables. Namespace under __resume to prevent variable collision with downstream node configs.
// Suspend instance with ResumeAfter timestamp
instance.Status = WorkflowStatus.Suspended;
instance.ResumeAfter = DateTimeOffset.UtcNow.Add(delay);
_lastFired per definition to prevent double-firinginstance.Status = WorkflowStatus.Suspended;
instance.ErrorCode = "TRANSIENT_FAILURE"; // MUST set for scheduler to distinguish retry from delay
instance.ResumeAfter = DateTimeOffset.UtcNow.AddSeconds(Math.Pow(2, instance.RetryCount));
| Type | Example | Action |
|---|---|---|
| Transient | HTTP 503, timeout, connection refused | Retry with backoff (max 3 attempts) |
| Permanent | 404, validation failure, business rule | Fail immediately with clear error message |
| Compensation | Partial completion of multi-step | Run compensation handlers |
try
{
await executor.ExecuteAsync(context);
}
catch (TransientException ex)
{
instance.RetryCount++;
if (instance.RetryCount >= MaxRetries)
{
instance.Status = WorkflowStatus.Failed;
instance.ErrorMessage = $"Max retries exceeded: {ex.Message}";
}
else
{
instance.Status = WorkflowStatus.Suspended;
instance.ErrorCode = "TRANSIENT_FAILURE";
instance.ResumeAfter = CalculateBackoff(instance.RetryCount);
}
}
catch (Exception ex)
{
instance.Status = WorkflowStatus.Failed;
instance.ErrorMessage = ex.Message;
}
JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) to match global confignextNodeId past all existing IDs to prevent collisions"Can a non-technical user who uses basic office tools daily but has never written a formula, build this workflow in under 5 minutes without help?"