164 lines
5.9 KiB
C#
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];
|
|
}
|
|
}
|