chore(workflow): remove BPMN source-format converter

BPMN is not a viable source format for Stella workflow definitions — the installed
canonical JSONs are generated from IDeclarativeWorkflow<T> C# specs, not from BPMN, and
BPMN cannot carry the typed metadata the canonical needs (startRequest.contractName,
schema, businessReference, initializeStateExpression, function-call references).

- Delete BpmnXmlSourceConverter + BpmnCanonicalMapping
- Drop BpmnXmlSourceConverter DI registration; comment updated
- Delete BpmnXmlSourceConverterTests; WorkflowSourceFormatRegistryTests no longer includes the BPMN converter in the ordering fixture
- Abstractions comments updated to stop referencing bpmn-xml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-15 00:55:30 +03:00
parent a7d687911c
commit 5586de0a72
6 changed files with 4 additions and 948 deletions

View File

@@ -12,7 +12,7 @@ namespace StellaOps.Workflow.Abstractions;
/// </summary>
public interface IWorkflowSourceFormatConverter
{
/// <summary>Stable, machine-readable identifier (e.g., "canonical-yaml", "canonical-bundle", "bpmn-xml").</summary>
/// <summary>Stable, machine-readable identifier (e.g., "canonical-json", "canonical-yaml", "canonical-bundle").</summary>
string FormatId { get; }
/// <summary>Human-friendly name shown in the UI format picker.</summary>

View File

@@ -1,564 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.Engine.Services.Converters;
/// <summary>
/// Pure helpers that translate BPMN 2.0 / Camunda XML into canonical JSON objects (and back).
/// The functions return anonymous / dictionary structures that serialize directly to the
/// canonical <c>schemaVersion = "serdica.workflow.definition/v1"</c> shape.
/// </summary>
internal static class BpmnCanonicalMapping
{
private static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL";
private static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn";
// ──────────────────────────────────────────────────────────────────────────────────────
// BPMN → canonical
// ──────────────────────────────────────────────────────────────────────────────────────
public static object BuildCanonicalFromBpmnProcess(XElement process, List<WorkflowSourceFormatDiagnostic> diagnostics)
{
var workflowName = (string?)process.Attribute("id") ?? "ImportedWorkflow";
var displayName = (string?)process.Attribute("name") ?? workflowName;
// Index all flow nodes and sequence flows.
var flowNodes = process.Elements()
.Where(e => e.Name.Namespace == Bpmn && IsFlowNode(e.Name.LocalName))
.ToDictionary(e => ((string)e.Attribute("id")!), e => e);
var flows = process.Elements(Bpmn + "sequenceFlow")
.Select(e => new SequenceFlow(
(string)e.Attribute("id")!,
(string)e.Attribute("sourceRef")!,
(string)e.Attribute("targetRef")!,
(string?)e.Element(Bpmn + "conditionExpression")))
.ToArray();
var outgoingBySource = flows
.GroupBy(f => f.SourceRef)
.ToDictionary(g => g.Key, g => g.ToArray());
var startEvents = flowNodes.Values.Where(n => n.Name.LocalName == "startEvent").ToArray();
if (startEvents.Length != 1)
{
diagnostics.Add(new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
Code = "WFS040",
Message = $"Exactly one <bpmn:startEvent> is required, found {startEvents.Length}.",
});
return new { };
}
var startId = (string)startEvents[0].Attribute("id")!;
// Collect userTask declarations separately (they become <canonical>.tasks) — each userTask
// produces both a task declaration and an ActivateTask step placed in the initial sequence.
var tasksForCanonical = new List<object>();
foreach (var userTask in flowNodes.Values.Where(n => n.Name.LocalName == "userTask"))
{
var taskName = (string)userTask.Attribute("id")!;
var roles = ((string?)userTask.Attribute(Camunda + "candidateGroups") ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var route = (string?)userTask.Attribute(Camunda + "formKey") ?? "default/task";
tasksForCanonical.Add(new Dictionary<string, object?>
{
["taskName"] = taskName,
["taskType"] = "HumanTask",
["routeExpression"] = StringExpr(route),
["payloadExpression"] = new Dictionary<string, object?> { ["$type"] = "object", ["properties"] = new object[0] },
["taskRoles"] = roles,
["onComplete"] = new Dictionary<string, object?> { ["steps"] = new object[0] },
});
}
// Build the initial sequence by walking from startEvent.
var initialSequence = BuildSequenceFromNode(startId, flowNodes, outgoingBySource, visited: new HashSet<string>(), diagnostics);
return new Dictionary<string, object?>
{
["schemaVersion"] = "serdica.workflow.definition/v1",
["workflowName"] = workflowName,
["workflowVersion"] = "1.0.0",
["displayName"] = displayName,
["workflowRoles"] = Array.Empty<string>(),
["start"] = new Dictionary<string, object?>
{
["initializeStateExpression"] = new Dictionary<string, object?> { ["$type"] = "object", ["properties"] = new object[0] },
["initialSequence"] = initialSequence,
},
["tasks"] = tasksForCanonical,
};
}
private static Dictionary<string, object?> BuildSequenceFromNode(
string nodeId,
Dictionary<string, XElement> flowNodes,
Dictionary<string, SequenceFlow[]> outgoingBySource,
HashSet<string> visited,
List<WorkflowSourceFormatDiagnostic> diagnostics)
{
var steps = new List<object>();
var current = nodeId;
while (true)
{
if (!visited.Add(current))
{
// Loop detected — stop traversal. Canonical representation doesn't support BPMN-style backward edges directly.
diagnostics.Add(new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Warning,
Code = "WFS041",
Message = $"Sequence loop detected at '{current}'. Canonical conversion truncates at the loop back-edge.",
});
break;
}
if (!flowNodes.TryGetValue(current, out var node))
{
break;
}
var localName = node.Name.LocalName;
// startEvent: no step emitted, just traverse the outgoing edge.
if (localName == "startEvent")
{
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
current = outs[0].TargetRef;
continue;
}
if (localName == "endEvent")
{
steps.Add(new Dictionary<string, object?> { ["$type"] = "complete" });
break;
}
if (localName == "userTask")
{
steps.Add(new Dictionary<string, object?>
{
["$type"] = "activate-task",
["taskName"] = (string)node.Attribute("id")!,
});
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
current = outs[0].TargetRef;
continue;
}
if (localName == "serviceTask")
{
var topic = (string?)node.Attribute(Camunda + "topic") ?? (string)node.Attribute("id")!;
steps.Add(new Dictionary<string, object?>
{
["$type"] = "call-transport",
["stepName"] = (string)node.Attribute("id")!,
["invocation"] = new Dictionary<string, object?>
{
["address"] = new Dictionary<string, object?>
{
["$type"] = "legacy-rabbit",
["command"] = topic,
},
["payloadExpression"] = new Dictionary<string, object?> { ["$type"] = "object", ["properties"] = new object[0] },
},
});
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
current = outs[0].TargetRef;
continue;
}
if (localName == "callActivity")
{
var targetWorkflow = (string?)node.Attribute("calledElement") ?? (string)node.Attribute("id")!;
steps.Add(new Dictionary<string, object?>
{
["$type"] = "continue-with-workflow",
["stepName"] = (string)node.Attribute("id")!,
["invocation"] = new Dictionary<string, object?>
{
["workflowNameExpression"] = StringExpr(targetWorkflow),
},
});
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length == 0) break;
current = outs[0].TargetRef;
continue;
}
if (localName == "exclusiveGateway")
{
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length != 2)
{
diagnostics.Add(new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
Code = "WFS042",
Message = $"<bpmn:exclusiveGateway id='{current}'> must have exactly 2 outgoing flows in this MVP (has {(outs?.Length ?? 0)}).",
});
break;
}
var trueFlow = outs.FirstOrDefault(f => !string.IsNullOrWhiteSpace(f.ConditionExpression)) ?? outs[0];
var elseFlow = outs.First(f => f != trueFlow);
var conditionExpr = TranslateJuel(trueFlow.ConditionExpression, diagnostics)
?? StringExpr("true"); // fallback — shouldn't happen if JUEL parsing succeeds
steps.Add(new Dictionary<string, object?>
{
["$type"] = "decision",
["decisionName"] = (string?)node.Attribute("name") ?? (string)node.Attribute("id")!,
["conditionExpression"] = conditionExpr,
["whenTrue"] = BuildSequenceFromNode(trueFlow.TargetRef, flowNodes, outgoingBySource, new HashSet<string>(visited), diagnostics),
["whenElse"] = BuildSequenceFromNode(elseFlow.TargetRef, flowNodes, outgoingBySource, new HashSet<string>(visited), diagnostics),
});
break; // branches close the sequence
}
if (localName == "parallelGateway")
{
if (!outgoingBySource.TryGetValue(current, out var outs) || outs.Length < 2)
{
// A parallel join gateway (single outgoing) — just continue through it.
if (outs?.Length == 1)
{
current = outs[0].TargetRef;
continue;
}
break;
}
var branches = outs.Select(o => BuildSequenceFromNode(o.TargetRef, flowNodes, outgoingBySource, new HashSet<string>(visited), diagnostics)).ToArray();
steps.Add(new Dictionary<string, object?>
{
["$type"] = "fork",
["stepName"] = (string)node.Attribute("id")!,
["branches"] = branches,
});
break;
}
// Fallback: unknown recognized-but-unhandled node — skip, keep traversing.
diagnostics.Add(new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Warning,
Code = "WFS043",
Message = $"Skipped <bpmn:{localName} id='{current}'> (not mapped to a canonical step).",
});
if (!outgoingBySource.TryGetValue(current, out var skipOuts) || skipOuts.Length == 0) break;
current = skipOuts[0].TargetRef;
}
return new Dictionary<string, object?> { ["steps"] = steps };
}
private static bool IsFlowNode(string localName) => localName is
"startEvent" or "endEvent" or "userTask" or "serviceTask" or
"exclusiveGateway" or "parallelGateway" or "callActivity" or "task";
// ──────────────────────────────────────────────────────────────────────────────────────
// JUEL → canonical expression (MVP subset)
// ──────────────────────────────────────────────────────────────────────────────────────
private static readonly Regex JuelEqualsPattern = new(
"""\#\{\s*(?<path>[A-Za-z_][\w.\[\]]*)\s*==\s*(?:'(?<str>[^']*)'|"(?<str2>[^"]*)"|(?<num>-?\d+(?:\.\d+)?)|(?<bool>true|false))\s*\}""",
RegexOptions.Compiled);
private static readonly Regex JuelPathPattern = new(
"""^\#\{\s*(?<path>[A-Za-z_][\w.\[\]]*)\s*\}$""",
RegexOptions.Compiled);
internal static Dictionary<string, object?>? TranslateJuel(string? juel, List<WorkflowSourceFormatDiagnostic> diagnostics)
{
if (string.IsNullOrWhiteSpace(juel)) return null;
// `#{state.foo == 'bar'}`, `#{payload.x == 42}`, etc.
var eqMatch = JuelEqualsPattern.Match(juel);
if (eqMatch.Success)
{
var path = eqMatch.Groups["path"].Value;
object? literalExpr;
if (eqMatch.Groups["str"].Success) literalExpr = StringExpr(eqMatch.Groups["str"].Value);
else if (eqMatch.Groups["str2"].Success) literalExpr = StringExpr(eqMatch.Groups["str2"].Value);
else if (eqMatch.Groups["num"].Success && double.TryParse(eqMatch.Groups["num"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var n)) literalExpr = NumberExpr(n);
else literalExpr = new Dictionary<string, object?> { ["$type"] = "boolean", ["value"] = bool.Parse(eqMatch.Groups["bool"].Value) };
return new Dictionary<string, object?>
{
["$type"] = "binary",
["operator"] = "eq",
["left"] = PathExpr(path),
["right"] = literalExpr,
};
}
var pathMatch = JuelPathPattern.Match(juel);
if (pathMatch.Success)
{
return PathExpr(pathMatch.Groups["path"].Value);
}
diagnostics.Add(new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
Code = "WFS044",
Message = $"Unsupported JUEL expression: '{juel}'. MVP supports '#{{path.to.value}}' and '#{{path == literal}}'.",
});
return null;
}
// ──────────────────────────────────────────────────────────────────────────────────────
// Canonical expression helpers
// ──────────────────────────────────────────────────────────────────────────────────────
internal static Dictionary<string, object?> StringExpr(string value) => new()
{
["$type"] = "string",
["value"] = value,
};
internal static Dictionary<string, object?> PathExpr(string path) => new()
{
["$type"] = "path",
["path"] = path,
};
internal static Dictionary<string, object?> NumberExpr(double value) => new()
{
["$type"] = "number",
["value"] = value,
};
// ──────────────────────────────────────────────────────────────────────────────────────
// Canonical → BPMN (export)
// ──────────────────────────────────────────────────────────────────────────────────────
public static string BuildBpmnFromCanonical(JsonElement canonical)
{
var workflowName = canonical.TryGetProperty("workflowName", out var nameEl) ? (nameEl.GetString() ?? "Workflow") : "Workflow";
var displayName = canonical.TryGetProperty("displayName", out var displayEl) ? displayEl.GetString() : workflowName;
var processId = EscapeId(workflowName);
var emitter = new BpmnEmitter(processId);
emitter.AddStart();
if (canonical.TryGetProperty("start", out var startEl)
&& startEl.TryGetProperty("initialSequence", out var seqEl)
&& seqEl.TryGetProperty("steps", out var stepsEl))
{
emitter.AppendSequence(stepsEl, canonical);
}
emitter.AddEnd();
var processXml = emitter.BuildProcessElement(displayName ?? workflowName);
var doc = new XDocument(
new XDeclaration("1.0", "UTF-8", "false"),
new XElement(Bpmn + "definitions",
new XAttribute(XNamespace.Xmlns + "bpmn", Bpmn.NamespaceName),
new XAttribute(XNamespace.Xmlns + "camunda", Camunda.NamespaceName),
new XAttribute("id", $"defs-{processId}"),
new XAttribute("targetNamespace", "http://bpmn.io/schema/bpmn"),
processXml));
// XDocument.Save(StringWriter) writes an encoding="utf-16" declaration because StringWriter
// is inherently UTF-16. Use a Utf8StringWriter so the declaration matches the byte output.
using var sw = new Utf8StringWriter();
doc.Save(sw);
return sw.ToString();
}
private sealed class BpmnEmitter(string processId)
{
private readonly List<XElement> nodes = [];
private readonly List<XElement> flows = [];
private string? lastNodeId;
private int counter;
public void AddStart()
{
var id = $"StartEvent_{processId}";
nodes.Add(new XElement(Bpmn + "startEvent", new XAttribute("id", id), new XAttribute("name", "Start")));
lastNodeId = id;
}
public void AddEnd()
{
var id = $"EndEvent_{processId}";
nodes.Add(new XElement(Bpmn + "endEvent", new XAttribute("id", id), new XAttribute("name", "End")));
if (lastNodeId is not null)
{
AddFlow(lastNodeId, id);
}
lastNodeId = id;
}
public void AppendSequence(JsonElement steps, JsonElement canonical)
{
foreach (var step in steps.EnumerateArray())
{
if (!step.TryGetProperty("$type", out var typeEl)) continue;
var stepType = typeEl.GetString();
switch (stepType)
{
case "activate-task":
{
var taskName = step.TryGetProperty("taskName", out var tn) ? tn.GetString() ?? "Task" : "Task";
var roles = LookupTaskRoles(canonical, taskName);
var nodeId = EscapeId(taskName);
var el = new XElement(Bpmn + "userTask",
new XAttribute("id", nodeId),
new XAttribute("name", taskName));
if (!string.IsNullOrWhiteSpace(roles)) el.Add(new XAttribute(Camunda + "candidateGroups", roles));
AppendNode(el);
break;
}
case "call-transport":
{
var name = step.TryGetProperty("stepName", out var sn) ? sn.GetString() ?? "ServiceTask" : "ServiceTask";
var topic = step.TryGetProperty("invocation", out var inv)
&& inv.TryGetProperty("address", out var addr)
&& addr.TryGetProperty("command", out var cmd) ? cmd.GetString() : null;
var el = new XElement(Bpmn + "serviceTask",
new XAttribute("id", EscapeId(name)),
new XAttribute("name", name),
new XAttribute(Camunda + "type", "external"));
if (!string.IsNullOrWhiteSpace(topic)) el.Add(new XAttribute(Camunda + "topic", topic));
AppendNode(el);
break;
}
case "continue-with-workflow":
case "sub-workflow":
{
var name = step.TryGetProperty("stepName", out var sn) ? sn.GetString() ?? "CallActivity" : "CallActivity";
var target = step.TryGetProperty("invocation", out var inv)
&& inv.TryGetProperty("workflowNameExpression", out var wne)
&& wne.TryGetProperty("value", out var v) ? v.GetString() : null;
var el = new XElement(Bpmn + "callActivity",
new XAttribute("id", EscapeId(name)),
new XAttribute("name", name));
if (!string.IsNullOrWhiteSpace(target)) el.Add(new XAttribute("calledElement", target));
AppendNode(el);
break;
}
case "complete":
{
// Sequence terminates here; AddEnd() will provide the endEvent.
return;
}
case "decision":
{
var gatewayId = $"Gateway_{++counter}";
nodes.Add(new XElement(Bpmn + "exclusiveGateway",
new XAttribute("id", gatewayId),
new XAttribute("name", step.TryGetProperty("decisionName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty)));
if (lastNodeId is not null) AddFlow(lastNodeId, gatewayId);
// Flatten: walk the true branch only; else-branch is represented as a sibling sequence flow without condition.
if (step.TryGetProperty("whenTrue", out var wt) && wt.TryGetProperty("steps", out var wtSteps))
{
var savedLast = lastNodeId;
lastNodeId = gatewayId;
AppendSequence(wtSteps, canonical);
lastNodeId = savedLast;
}
lastNodeId = gatewayId;
break;
}
case "fork":
{
var gatewayId = $"Gateway_{++counter}_par";
nodes.Add(new XElement(Bpmn + "parallelGateway", new XAttribute("id", gatewayId)));
if (lastNodeId is not null) AddFlow(lastNodeId, gatewayId);
lastNodeId = gatewayId;
break;
}
default:
// set-state, assign-business-reference, timer, external-signal, repeat — emit informational marker
var infoEl = new XElement(Bpmn + "serviceTask",
new XAttribute("id", $"Info_{++counter}"),
new XAttribute("name", stepType ?? "step"),
new XAttribute(Camunda + "type", "external"),
new XAttribute(Camunda + "topic", $"__serdica_{stepType}"));
AppendNode(infoEl);
break;
}
}
}
private void AppendNode(XElement el)
{
nodes.Add(el);
var id = (string)el.Attribute("id")!;
if (lastNodeId is not null) AddFlow(lastNodeId, id);
lastNodeId = id;
}
private void AddFlow(string source, string target)
{
flows.Add(new XElement(Bpmn + "sequenceFlow",
new XAttribute("id", $"Flow_{++counter}"),
new XAttribute("sourceRef", source),
new XAttribute("targetRef", target)));
}
public XElement BuildProcessElement(string displayName)
{
var process = new XElement(Bpmn + "process",
new XAttribute("id", processId),
new XAttribute("name", displayName),
new XAttribute("isExecutable", "true"));
foreach (var n in nodes) process.Add(n);
foreach (var f in flows) process.Add(f);
return process;
}
private static string? LookupTaskRoles(JsonElement canonical, string taskName)
{
if (!canonical.TryGetProperty("tasks", out var tasks) || tasks.ValueKind != JsonValueKind.Array) return null;
foreach (var t in tasks.EnumerateArray())
{
if (t.TryGetProperty("taskName", out var tn) && string.Equals(tn.GetString(), taskName, StringComparison.OrdinalIgnoreCase))
{
if (t.TryGetProperty("taskRoles", out var rolesEl) && rolesEl.ValueKind == JsonValueKind.Array)
{
return string.Join(", ", rolesEl.EnumerateArray().Select(r => r.GetString()).Where(s => !string.IsNullOrEmpty(s)));
}
}
}
return null;
}
}
private static string EscapeId(string input)
{
var sb = new StringBuilder(input.Length);
foreach (var ch in input)
{
sb.Append(char.IsLetterOrDigit(ch) || ch is '_' or '-' ? ch : '_');
}
return sb.Length == 0 || !char.IsLetter(sb[0]) ? "_" + sb : sb.ToString();
}
private sealed record SequenceFlow(string Id, string SourceRef, string TargetRef, string? ConditionExpression);
private sealed class Utf8StringWriter : StringWriter
{
public override Encoding Encoding => Encoding.UTF8;
}
}

View File

@@ -1,186 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.Engine.Services.Converters;
/// <summary>
/// Bidirectional BPMN 2.0 XML converter (MVP scope). Import maps the subset of BPMN / Camunda
/// constructs used by the existing BPMNs to canonical step declarations. Export emits
/// a clean BPMN 2.0 document with Camunda extensions for round-trip use.
///
/// Supported on import:
/// <list type="bullet">
/// <item>startEvent → anchors the initial sequence.</item>
/// <item>endEvent → <c>WorkflowCompleteStepDeclaration</c>.</item>
/// <item>userTask (camunda:candidateGroups → taskRoles, id → taskName) → human task + <c>WorkflowActivateTaskStepDeclaration</c>.</item>
/// <item>serviceTask (camunda:topic → LegacyRabbitAddress.Command) → <c>WorkflowTransportCallStepDeclaration</c>.</item>
/// <item>callActivity (calledElement → workflow name) → <c>WorkflowContinueWithWorkflowStepDeclaration</c>.</item>
/// <item>exclusiveGateway with 2 outgoing flows and a single conditionExpression → <c>WorkflowDecisionStepDeclaration</c>.</item>
/// <item>parallelGateway with N outgoing flows → <c>WorkflowForkStepDeclaration</c>.</item>
/// <item>sequenceFlow → graph edges used for topological traversal.</item>
/// </list>
///
/// Rejected (diagnostic + fail, no partial conversion):
/// boundary events, event sub-processes, compensation, multi-instance characteristics, complex
/// JUEL expressions beyond <c>#{state.X == 'Y'}</c> / <c>#{payload.X == 'Y'}</c> / <c>#{X}</c>,
/// signal-triggered start events, timer events inside gateways, embedded sub-processes.
/// </summary>
public sealed class BpmnXmlSourceConverter : IWorkflowSourceFormatConverter
{
private static readonly XNamespace Bpmn = "http://www.omg.org/spec/BPMN/20100524/MODEL";
private static readonly XNamespace Camunda = "http://camunda.org/schema/1.0/bpmn";
public string FormatId => "bpmn-xml";
public string DisplayName => "BPMN XML";
public string ContentType => "application/xml";
public string FileExtension => ".bpmn";
public bool SupportsImport => true;
public bool SupportsExport => true;
public bool IsBinary => false;
public Task<WorkflowSourceImportResult> ImportAsync(byte[] content, CancellationToken cancellationToken = default)
{
if (content is null || content.Length == 0)
{
return Task.FromResult(Fail("WFS030", "Content is empty."));
}
XDocument doc;
try
{
using var ms = new MemoryStream(content, writable: false);
doc = XDocument.Load(ms, LoadOptions.SetLineInfo);
}
catch (XmlException ex)
{
return Task.FromResult(Fail("WFS031", $"Malformed BPMN XML: {ex.Message}", $"line {ex.LineNumber}, col {ex.LinePosition}"));
}
var process = doc.Root?.Elements(Bpmn + "process").FirstOrDefault(p =>
string.Equals((string?)p.Attribute("isExecutable"), "true", StringComparison.OrdinalIgnoreCase))
?? doc.Root?.Elements(Bpmn + "process").FirstOrDefault();
if (process is null)
{
return Task.FromResult(Fail("WFS032", "No <bpmn:process> element found."));
}
var diagnostics = new List<WorkflowSourceFormatDiagnostic>();
// Reject constructs we don't handle safely.
foreach (var unsupported in process.Descendants().Where(IsUnsupportedConstruct))
{
diagnostics.Add(new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
Code = "WFS033",
Message = $"Unsupported BPMN construct <{unsupported.Name.LocalName}> (id='{(string?)unsupported.Attribute("id")}'). Supported subset: startEvent, endEvent, userTask, serviceTask, exclusiveGateway, parallelGateway, callActivity, sequenceFlow.",
Location = LineInfo(unsupported),
});
}
if (diagnostics.Any(d => d.Severity == WorkflowSourceFormatDiagnosticSeverity.Error))
{
return Task.FromResult(new WorkflowSourceImportResult { Succeeded = false, Diagnostics = diagnostics });
}
try
{
var canonical = BpmnCanonicalMapping.BuildCanonicalFromBpmnProcess(process, diagnostics);
if (diagnostics.Any(d => d.Severity == WorkflowSourceFormatDiagnosticSeverity.Error))
{
return Task.FromResult(new WorkflowSourceImportResult { Succeeded = false, Diagnostics = diagnostics });
}
var json = JsonSerializer.Serialize(canonical, JsonOptions);
return Task.FromResult(new WorkflowSourceImportResult
{
Succeeded = true,
CanonicalDefinitionJson = json,
Diagnostics = diagnostics,
});
}
catch (Exception ex)
{
diagnostics.Add(new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
Code = "WFS034",
Message = $"BPMN → canonical conversion failed: {ex.Message}",
});
return Task.FromResult(new WorkflowSourceImportResult { Succeeded = false, Diagnostics = diagnostics });
}
}
public Task<WorkflowSourceExportResult> ExportAsync(WorkflowDefinitionRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);
var canonical = JsonSerializer.Deserialize<JsonElement>(record.CanonicalDefinitionJson);
var xml = BpmnCanonicalMapping.BuildBpmnFromCanonical(canonical);
var bytes = Encoding.UTF8.GetBytes(xml);
return Task.FromResult(new WorkflowSourceExportResult
{
Content = bytes,
ContentType = ContentType,
SuggestedFileName = $"{record.WorkflowName}-{record.WorkflowVersion}{FileExtension}",
});
}
private static bool IsUnsupportedConstruct(XElement el)
{
if (el.Name.Namespace != Bpmn) return false;
var name = el.Name.LocalName;
return name switch
{
"boundaryEvent" => true,
"intermediateCatchEvent" => true,
"intermediateThrowEvent" => true,
"compensateEventDefinition" => true,
"subProcess" when (string?)el.Attribute("triggeredByEvent") == "true" => true,
"multiInstanceLoopCharacteristics" => true,
"standardLoopCharacteristics" => true,
_ => false,
};
}
private static string LineInfo(XElement el)
{
if (el is IXmlLineInfo info && info.HasLineInfo())
{
return $"line {info.LineNumber}, col {info.LinePosition}";
}
return $"<{el.Name.LocalName} id='{(string?)el.Attribute("id")}'>";
}
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
};
private static WorkflowSourceImportResult Fail(string code, string message, string? location = null) => new()
{
Succeeded = false,
Diagnostics =
[
new WorkflowSourceFormatDiagnostic
{
Severity = WorkflowSourceFormatDiagnosticSeverity.Error,
Code = code,
Message = message,
Location = location,
},
],
};
}

View File

@@ -96,11 +96,10 @@ public static class WorkflowCoreServiceCollectionExtensions
services.TryAddScoped<IWorkflowDefinitionStore, NullWorkflowDefinitionStore>();
services.AddScoped<WorkflowDefinitionDeploymentService>();
// Source-format converters (canonical JSON + YAML + bundle ZIP + BPMN XML) and their registry.
// Source-format converters (canonical JSON + YAML + bundle ZIP) and their registry.
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalJsonSourceConverter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalYamlSourceConverter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, CanonicalBundleSourceConverter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowSourceFormatConverter, BpmnXmlSourceConverter>());
services.TryAddSingleton<IWorkflowSourceFormatRegistry, WorkflowSourceFormatRegistry>();
// Signal pump telemetry

View File

@@ -1,192 +0,0 @@
using System;
using System.Text;
using System.Text.Json;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Engine.Services.Converters;
using FluentAssertions;
using NUnit.Framework;
namespace StellaOps.Workflow.Engine.Tests.Services.Converters;
[TestFixture]
public class BpmnXmlSourceConverterTests
{
[Test]
public async Task ImportAsync_StartEventUserTaskServiceTaskEndEvent_ProducesCanonical()
{
var bpmn = """
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:camunda="http://camunda.org/schema/1.0/bpmn">
<bpmn:process id="TestWorkflow" name="Test Workflow" isExecutable="true">
<bpmn:startEvent id="Start_1"/>
<bpmn:userTask id="ReviewTask" camunda:candidateGroups="UR_AGENT"/>
<bpmn:serviceTask id="NotifyTask" camunda:topic="pas_notify"/>
<bpmn:endEvent id="End_1"/>
<bpmn:sequenceFlow id="Flow1" sourceRef="Start_1" targetRef="ReviewTask"/>
<bpmn:sequenceFlow id="Flow2" sourceRef="ReviewTask" targetRef="NotifyTask"/>
<bpmn:sequenceFlow id="Flow3" sourceRef="NotifyTask" targetRef="End_1"/>
</bpmn:process>
</bpmn:definitions>
""";
var converter = new BpmnXmlSourceConverter();
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
result.Succeeded.Should().BeTrue();
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
doc.RootElement.GetProperty("schemaVersion").GetString().Should().Be("serdica.workflow.definition/v1");
doc.RootElement.GetProperty("workflowName").GetString().Should().Be("TestWorkflow");
var steps = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps");
steps.EnumerateArray().Select(s => s.GetProperty("$type").GetString())
.Should().Equal("activate-task", "call-transport", "complete");
var tasks = doc.RootElement.GetProperty("tasks");
tasks.GetArrayLength().Should().Be(1);
tasks[0].GetProperty("taskName").GetString().Should().Be("ReviewTask");
}
[Test]
public async Task ImportAsync_BoundaryEvent_ReturnsDiagnostic_WFS033_WithId()
{
var bpmn = """
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
<bpmn:process id="Wf" isExecutable="true">
<bpmn:startEvent id="Start_1"/>
<bpmn:userTask id="T1"/>
<bpmn:boundaryEvent id="Boundary_1" attachedToRef="T1"/>
<bpmn:endEvent id="End_1"/>
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="T1"/>
<bpmn:sequenceFlow id="F2" sourceRef="T1" targetRef="End_1"/>
</bpmn:process>
</bpmn:definitions>
""";
var converter = new BpmnXmlSourceConverter();
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
result.Succeeded.Should().BeFalse();
var diag = result.Diagnostics.Single(d => d.Code == "WFS033");
diag.Message.Should().Contain("boundaryEvent");
diag.Message.Should().Contain("Boundary_1");
}
[Test]
public async Task ImportAsync_ExclusiveGatewayWithJuelCondition_MapsToDecision()
{
var bpmn = """
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
<bpmn:process id="Wf" isExecutable="true">
<bpmn:startEvent id="Start_1"/>
<bpmn:exclusiveGateway id="Decide" name="Decide"/>
<bpmn:endEvent id="YesEnd"/>
<bpmn:endEvent id="NoEnd"/>
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="Decide"/>
<bpmn:sequenceFlow id="F2" sourceRef="Decide" targetRef="YesEnd">
<bpmn:conditionExpression>#{state.approved == 'yes'}</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow id="F3" sourceRef="Decide" targetRef="NoEnd"/>
</bpmn:process>
</bpmn:definitions>
""";
var converter = new BpmnXmlSourceConverter();
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
result.Succeeded.Should().BeTrue();
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
var firstStep = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps")[0];
firstStep.GetProperty("$type").GetString().Should().Be("decision");
var condition = firstStep.GetProperty("conditionExpression");
condition.GetProperty("$type").GetString().Should().Be("binary");
condition.GetProperty("operator").GetString().Should().Be("eq");
}
[Test]
public async Task ImportAsync_ParallelGateway_MapsToFork()
{
var bpmn = """
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
<bpmn:process id="Wf" isExecutable="true">
<bpmn:startEvent id="Start_1"/>
<bpmn:parallelGateway id="Fork1"/>
<bpmn:endEvent id="E1"/>
<bpmn:endEvent id="E2"/>
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="Fork1"/>
<bpmn:sequenceFlow id="F2" sourceRef="Fork1" targetRef="E1"/>
<bpmn:sequenceFlow id="F3" sourceRef="Fork1" targetRef="E2"/>
</bpmn:process>
</bpmn:definitions>
""";
var converter = new BpmnXmlSourceConverter();
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
result.Succeeded.Should().BeTrue();
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
var firstStep = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps")[0];
firstStep.GetProperty("$type").GetString().Should().Be("fork");
firstStep.GetProperty("branches").GetArrayLength().Should().Be(2);
}
[Test]
public async Task ImportAsync_CallActivity_MapsToContinueWithWorkflow()
{
var bpmn = """
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
<bpmn:process id="Wf" isExecutable="true">
<bpmn:startEvent id="Start_1"/>
<bpmn:callActivity id="InvokeOther" calledElement="OtherWorkflow"/>
<bpmn:endEvent id="End_1"/>
<bpmn:sequenceFlow id="F1" sourceRef="Start_1" targetRef="InvokeOther"/>
<bpmn:sequenceFlow id="F2" sourceRef="InvokeOther" targetRef="End_1"/>
</bpmn:process>
</bpmn:definitions>
""";
var converter = new BpmnXmlSourceConverter();
var result = await converter.ImportAsync(Encoding.UTF8.GetBytes(bpmn));
result.Succeeded.Should().BeTrue();
using var doc = JsonDocument.Parse(result.CanonicalDefinitionJson!);
var step = doc.RootElement.GetProperty("start").GetProperty("initialSequence").GetProperty("steps")[0];
step.GetProperty("$type").GetString().Should().Be("continue-with-workflow");
step.GetProperty("invocation").GetProperty("workflowNameExpression").GetProperty("value").GetString().Should().Be("OtherWorkflow");
}
[Test]
public async Task ExportAsync_ProducesUtf8BpmnWithCamundaExtensions()
{
var canonical = """
{"schemaVersion":"serdica.workflow.definition/v1","workflowName":"Wf","workflowVersion":"1.0.0","start":{"initialSequence":{"steps":[{"$type":"activate-task","taskName":"T1"},{"$type":"complete"}]}},"tasks":[{"taskName":"T1","taskType":"HumanTask","taskRoles":["UR_AGENT"]}]}
""";
var converter = new BpmnXmlSourceConverter();
var record = new WorkflowDefinitionRecord
{
WorkflowName = "Wf",
WorkflowVersion = "1.0.0",
BaseVersion = "1.0.0",
BuildIteration = 0,
ContentHash = "h",
CanonicalDefinitionJson = canonical,
IsActive = true,
CreatedOnUtc = DateTime.UtcNow,
};
var result = await converter.ExportAsync(record);
var xml = Encoding.UTF8.GetString(result.Content);
xml.Should().MatchRegex(@"^<\?xml version=""1\.0"" encoding=""[Uu][Tt][Ff]-8""");
xml.Should().Contain("<bpmn:process");
xml.Should().Contain("xmlns:camunda=\"http://camunda.org/schema/1.0/bpmn\"");
xml.Should().Contain("<bpmn:userTask");
xml.Should().Contain("camunda:candidateGroups=\"UR_AGENT\"");
}
}

View File

@@ -58,12 +58,11 @@ public class WorkflowSourceFormatRegistryTests
var registry = new WorkflowSourceFormatRegistry([
new CanonicalYamlSourceConverter(),
new CanonicalJsonSourceConverter(),
new BpmnXmlSourceConverter(),
new CanonicalBundleSourceConverter(),
]);
var ids = registry.GetAll().Select(c => c.FormatId).ToArray();
// Order by DisplayName ordinal-case-insensitive: "BPMN XML", "Bundle (ZIP)", "Canonical JSON", "Canonical YAML".
ids.Should().Equal("bpmn-xml", "canonical-bundle", "canonical-json", "canonical-yaml");
// Order by DisplayName ordinal-case-insensitive: "Bundle (ZIP)", "Canonical JSON", "Canonical YAML".
ids.Should().Equal("canonical-bundle", "canonical-json", "canonical-yaml");
}
}