From 5586de0a72c8ba4e6da8919103fec9feedc3ab23 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 15 Apr 2026 00:55:30 +0300 Subject: [PATCH] chore(workflow): remove BPMN source-format converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BPMN is not a viable source format for Stella workflow definitions — the installed canonical JSONs are generated from IDeclarativeWorkflow 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) --- .../WorkflowSourceFormatAbstractions.cs | 2 +- .../Converters/BpmnCanonicalMapping.cs | 564 ------------------ .../Converters/BpmnXmlSourceConverter.cs | 186 ------ ...WorkflowCoreServiceCollectionExtensions.cs | 3 +- .../Converters/BpmnXmlSourceConverterTests.cs | 192 ------ .../WorkflowSourceFormatRegistryTests.cs | 5 +- 6 files changed, 4 insertions(+), 948 deletions(-) delete mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnCanonicalMapping.cs delete mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnXmlSourceConverter.cs delete mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/Services/Converters/BpmnXmlSourceConverterTests.cs diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSourceFormatAbstractions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSourceFormatAbstractions.cs index ac494582e..7e77c3587 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSourceFormatAbstractions.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Abstractions/WorkflowSourceFormatAbstractions.cs @@ -12,7 +12,7 @@ namespace StellaOps.Workflow.Abstractions; /// public interface IWorkflowSourceFormatConverter { - /// Stable, machine-readable identifier (e.g., "canonical-yaml", "canonical-bundle", "bpmn-xml"). + /// Stable, machine-readable identifier (e.g., "canonical-json", "canonical-yaml", "canonical-bundle"). string FormatId { get; } /// Human-friendly name shown in the UI format picker. diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnCanonicalMapping.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnCanonicalMapping.cs deleted file mode 100644 index db74a9c25..000000000 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnCanonicalMapping.cs +++ /dev/null @@ -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; - -/// -/// 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 schemaVersion = "serdica.workflow.definition/v1" shape. -/// -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 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 is required, found {startEvents.Length}.", - }); - return new { }; - } - - var startId = (string)startEvents[0].Attribute("id")!; - - // Collect userTask declarations separately (they become .tasks) — each userTask - // produces both a task declaration and an ActivateTask step placed in the initial sequence. - var tasksForCanonical = new List(); - 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 - { - ["taskName"] = taskName, - ["taskType"] = "HumanTask", - ["routeExpression"] = StringExpr(route), - ["payloadExpression"] = new Dictionary { ["$type"] = "object", ["properties"] = new object[0] }, - ["taskRoles"] = roles, - ["onComplete"] = new Dictionary { ["steps"] = new object[0] }, - }); - } - - // Build the initial sequence by walking from startEvent. - var initialSequence = BuildSequenceFromNode(startId, flowNodes, outgoingBySource, visited: new HashSet(), diagnostics); - - return new Dictionary - { - ["schemaVersion"] = "serdica.workflow.definition/v1", - ["workflowName"] = workflowName, - ["workflowVersion"] = "1.0.0", - ["displayName"] = displayName, - ["workflowRoles"] = Array.Empty(), - ["start"] = new Dictionary - { - ["initializeStateExpression"] = new Dictionary { ["$type"] = "object", ["properties"] = new object[0] }, - ["initialSequence"] = initialSequence, - }, - ["tasks"] = tasksForCanonical, - }; - } - - private static Dictionary BuildSequenceFromNode( - string nodeId, - Dictionary flowNodes, - Dictionary outgoingBySource, - HashSet visited, - List diagnostics) - { - var steps = new List(); - 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 { ["$type"] = "complete" }); - break; - } - - if (localName == "userTask") - { - steps.Add(new Dictionary - { - ["$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 - { - ["$type"] = "call-transport", - ["stepName"] = (string)node.Attribute("id")!, - ["invocation"] = new Dictionary - { - ["address"] = new Dictionary - { - ["$type"] = "legacy-rabbit", - ["command"] = topic, - }, - ["payloadExpression"] = new Dictionary { ["$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 - { - ["$type"] = "continue-with-workflow", - ["stepName"] = (string)node.Attribute("id")!, - ["invocation"] = new Dictionary - { - ["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 = $" 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 - { - ["$type"] = "decision", - ["decisionName"] = (string?)node.Attribute("name") ?? (string)node.Attribute("id")!, - ["conditionExpression"] = conditionExpr, - ["whenTrue"] = BuildSequenceFromNode(trueFlow.TargetRef, flowNodes, outgoingBySource, new HashSet(visited), diagnostics), - ["whenElse"] = BuildSequenceFromNode(elseFlow.TargetRef, flowNodes, outgoingBySource, new HashSet(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(visited), diagnostics)).ToArray(); - steps.Add(new Dictionary - { - ["$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 (not mapped to a canonical step).", - }); - if (!outgoingBySource.TryGetValue(current, out var skipOuts) || skipOuts.Length == 0) break; - current = skipOuts[0].TargetRef; - } - - return new Dictionary { ["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*(?[A-Za-z_][\w.\[\]]*)\s*==\s*(?:'(?[^']*)'|"(?[^"]*)"|(?-?\d+(?:\.\d+)?)|(?true|false))\s*\}""", - RegexOptions.Compiled); - - private static readonly Regex JuelPathPattern = new( - """^\#\{\s*(?[A-Za-z_][\w.\[\]]*)\s*\}$""", - RegexOptions.Compiled); - - internal static Dictionary? TranslateJuel(string? juel, List 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 { ["$type"] = "boolean", ["value"] = bool.Parse(eqMatch.Groups["bool"].Value) }; - - return new Dictionary - { - ["$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 StringExpr(string value) => new() - { - ["$type"] = "string", - ["value"] = value, - }; - - internal static Dictionary PathExpr(string path) => new() - { - ["$type"] = "path", - ["path"] = path, - }; - - internal static Dictionary 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 nodes = []; - private readonly List 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; - } -} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnXmlSourceConverter.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnXmlSourceConverter.cs deleted file mode 100644 index a1be2f3cc..000000000 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/Converters/BpmnXmlSourceConverter.cs +++ /dev/null @@ -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; - -/// -/// 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: -/// -/// startEvent → anchors the initial sequence. -/// endEvent → WorkflowCompleteStepDeclaration. -/// userTask (camunda:candidateGroups → taskRoles, id → taskName) → human task + WorkflowActivateTaskStepDeclaration. -/// serviceTask (camunda:topic → LegacyRabbitAddress.Command) → WorkflowTransportCallStepDeclaration. -/// callActivity (calledElement → workflow name) → WorkflowContinueWithWorkflowStepDeclaration. -/// exclusiveGateway with 2 outgoing flows and a single conditionExpression → WorkflowDecisionStepDeclaration. -/// parallelGateway with N outgoing flows → WorkflowForkStepDeclaration. -/// sequenceFlow → graph edges used for topological traversal. -/// -/// -/// Rejected (diagnostic + fail, no partial conversion): -/// boundary events, event sub-processes, compensation, multi-instance characteristics, complex -/// JUEL expressions beyond #{state.X == 'Y'} / #{payload.X == 'Y'} / #{X}, -/// signal-triggered start events, timer events inside gateways, embedded sub-processes. -/// -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 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 element found.")); - } - - var diagnostics = new List(); - - // 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 ExportAsync(WorkflowDefinitionRecord record, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(record); - - var canonical = JsonSerializer.Deserialize(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, - }, - ], - }; -} diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs index cdc74756c..6dc3e4dbc 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Engine/Services/WorkflowCoreServiceCollectionExtensions.cs @@ -96,11 +96,10 @@ public static class WorkflowCoreServiceCollectionExtensions services.TryAddScoped(); services.AddScoped(); - // 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()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddSingleton(); // Signal pump telemetry diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/Services/Converters/BpmnXmlSourceConverterTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/Services/Converters/BpmnXmlSourceConverterTests.cs deleted file mode 100644 index 34700a045..000000000 --- a/src/Workflow/__Tests/StellaOps.Workflow.Engine.Tests/Services/Converters/BpmnXmlSourceConverterTests.cs +++ /dev/null @@ -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 = """ - - - - - - - - - - - - -"""; - 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 = """ - - - - - - - - - - - -"""; - 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 = """ - - - - - - - - - - #{state.approved == 'yes'} - - - - -"""; - 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 = """ - - - - - - - - - - - - -"""; - 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 = """ - - - - - - - - - - -"""; - 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(" 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"); } }