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 BuildEdges( WorkflowDefinition def, IReadOnlyDictionary byId, IReadOnlyDictionary blueprints, List errors) { var edges = new List(); var seen = new HashSet(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()) Add(edge, task.Id); } foreach (var edge in def.Edges ?? new List()) 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 nodes, IReadOnlyList edges, List 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(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]; } }