w4c-workflows-api/Services/Nodes/NodeGraphCompiler.Edges.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

164 lines
5.9 KiB
C#

using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using w4c_workflows.Models;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Security;
namespace w4c_workflows.Services.Nodes;
// Edge-construction and forward-graph shape analysis of the node-graph compiler:
// lowers next/onError/explicit edges to port edges, then finds the single entry
// and rejects cycles that do not pass through a loop node.
public sealed partial class NodeGraphCompiler
{
// ------------------------------------------------------------------ edges
private List<NodeGraphEdge> BuildEdges(
WorkflowDefinition def,
IReadOnlyDictionary<string, TaskDefinition> byId,
IReadOnlyDictionary<string, NodeBlueprint> blueprints,
List<string> errors)
{
var edges = new List<NodeGraphEdge>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void Add(TaskEdgeDefinition raw, string? ownerId)
{
var from = string.IsNullOrWhiteSpace(raw.From) ? ownerId : raw.From;
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(raw.To))
{
errors.Add("an edge is missing 'from' or 'to'");
return;
}
if (!byId.ContainsKey(from!))
{
errors.Add($"edge references unknown source task '{from}'");
return;
}
if (!byId.ContainsKey(raw.To!))
{
errors.Add($"edge from '{from}' references unknown target task '{raw.To}'");
return;
}
var fromOutput = raw.Output ?? 0;
var toInput = raw.Input ?? 0;
var sourcePorts = blueprints[from!].Outputs.Count;
var targetPorts = blueprints[raw.To!].Inputs.Count;
if (fromOutput < 0 || fromOutput >= sourcePorts)
{
errors.Add($"edge from '{from}': output index {fromOutput} is out of range for '{blueprints[from!].Type}'");
return;
}
if (toInput < 0 || toInput >= targetPorts)
{
errors.Add($"edge to '{raw.To}': input index {toInput} is out of range for '{blueprints[raw.To!].Type}'");
return;
}
var key = $"{from}|{fromOutput}|{raw.To}|{toInput}";
if (seen.Add(key))
{
edges.Add(new NodeGraphEdge
{
FromNodeId = from!,
FromOutput = fromOutput,
ToNodeId = raw.To!,
ToInput = toInput,
});
}
}
foreach (var task in byId.Values)
{
if (task.Next != null)
Add(new TaskEdgeDefinition { To = task.Next }, task.Id);
if (task.OnError != null)
{
var errorPort = ErrorOutputIndex(blueprints[task.Id!]);
if (errorPort < 0)
{
errors.Add($"task '{task.Id}': 'onError' is set but node '{task.Node!.Type}' has no error output");
}
else
{
Add(new TaskEdgeDefinition { To = task.OnError, Output = errorPort }, task.Id);
}
}
foreach (var edge in task.Edges ?? new List<TaskEdgeDefinition>())
Add(edge, task.Id);
}
foreach (var edge in def.Edges ?? new List<TaskEdgeDefinition>())
Add(edge, null);
return edges;
}
private static int ErrorOutputIndex(NodeBlueprint blueprint)
=> blueprint.Outputs.FindIndex(p => string.Equals(p.Kind, PortKind.Error, StringComparison.Ordinal));
// ------------------------------------------------------------------ graph shape
private static string? FindEntry(
IReadOnlyList<NodeGraphNode> nodes, IReadOnlyList<NodeGraphEdge> edges, List<string> errors)
{
// Loop-back edges are deliberately ignored here: the forward graph (edges
// without the loop-backs) must be acyclic with a single entry.
var forward = edges.Where(e => !e.IsLoopBack).ToList();
var incoming = nodes.ToDictionary(n => n.Id, _ => 0, StringComparer.Ordinal);
foreach (var edge in forward)
{
if (incoming.ContainsKey(edge.ToNodeId))
incoming[edge.ToNodeId]++;
}
var roots = nodes.Where(n => incoming[n.Id] == 0).Select(n => n.Id).ToList();
if (roots.Count == 0)
{
errors.Add("no entry node: every step has an incoming edge (the graph is a cycle)");
return null;
}
if (roots.Count > 1)
{
errors.Add($"multiple entry nodes ({string.Join(", ", roots)}): a node workflow must have exactly one entry");
return null;
}
// Kahn topological check over the forward edges — rejects cycles that do
// not pass through a loop node before the runner ever sees them.
var indegree = nodes.ToDictionary(n => n.Id, n => incoming[n.Id], StringComparer.Ordinal);
var adjacency = forward.GroupBy(e => e.FromNodeId)
.ToDictionary(g => g.Key, g => g.Select(e => e.ToNodeId).ToList(), StringComparer.Ordinal);
var queue = new Queue<string>(roots);
var visited = 0;
while (queue.Count > 0)
{
var id = queue.Dequeue();
visited++;
if (!adjacency.TryGetValue(id, out var targets))
continue;
foreach (var target in targets)
{
if (--indegree[target] == 0)
queue.Enqueue(target);
}
}
if (visited != nodes.Count)
{
var cyclic = nodes.Where(n => indegree.GetValueOrDefault(n.Id) > 0).Select(n => n.Id);
errors.Add($"cycle detected in the node graph involving: {string.Join(", ", cyclic)}");
return null;
}
return roots[0];
}
}