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; /// Outcome of compiling a node workflow: the graph, or the reasons it is invalid. public sealed class NodeGraphCompileResult { public List Errors { get; } = new(); public bool Success => Errors.Count == 0; public NodeGraph? Graph { get; set; } } /// /// Turns the node-mode half of a into a /// validated : /// - resolves each step's blueprint (and pinned version) from the catalog; /// - checks parameters against the blueprint's schema; /// - lowers next/onError into port edges and merges explicit edges; /// - finds the single entry node and rejects cycles, except loops that close /// through a loop-capable node (). /// /// Script (legacy) tasks are handled by and are /// out of scope here: a definition is either node-mode or script-mode, not both. /// public sealed partial class NodeGraphCompiler { private static readonly string[] RunModes = { NodeRunMode.EachItem, NodeRunMode.AllItems }; [GeneratedRegex(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled)] private static partial Regex StepIdRegex(); private readonly NodeBlueprintCatalog _catalog; private readonly NodePermissionPolicy? _permissions; public NodeGraphCompiler(NodeBlueprintCatalog catalog, NodePermissionPolicy? permissions = null) { _catalog = catalog; _permissions = permissions; } /// True when at least one step declares a node type. public static bool IsNodeWorkflow(WorkflowDefinition def) => def.Tasks?.Any(t => t.Node != null) == true; public NodeGraphCompileResult Compile(WorkflowDefinition def, string? tenantId = null) { var result = new NodeGraphCompileResult(); var tasks = def.Tasks ?? new List(); if (tasks.Count == 0) { result.Errors.Add("workflow has no tasks"); return result; } var nodes = new List(); var byId = new Dictionary(StringComparer.Ordinal); var blueprints = new Dictionary(StringComparer.Ordinal); foreach (var (task, index) in tasks.Select((t, i) => (t, i))) { var label = $"tasks[{index}]"; if (!ValidateStep(task, label, result.Errors)) continue; var blueprint = ResolveBlueprint(task, result.Errors); if (blueprint == null) continue; // Reject a barred node at save time so the author learns about the // policy immediately instead of at run time. The run kernel checks // again, because a policy can change after a workflow is stored. var permission = _permissions?.Evaluate(blueprint, tenantId); if (permission is { Allowed: false }) { result.Errors.Add( $"task '{task.Id}': {permission.Reason ?? $"node type '{blueprint.Type}' is not permitted"}"); continue; } byId[task.Id!] = task; blueprints[task.Id!] = blueprint; nodes.Add(BuildNode(task, blueprint, result.Errors)); } if (result.Errors.Count > 0) return result; ValidateCredentialAliases(nodes, blueprints, result.Errors); if (result.Errors.Count > 0) return result; var edges = BuildEdges(def, byId, blueprints, result.Errors); if (result.Errors.Count > 0) return result; var (markedEdges, loopError) = NodeGraphLinks.MarkLoopBackEdges(nodes, edges); if (loopError != null) { result.Errors.Add(loopError); return result; } edges = markedEdges; var entry = FindEntry(nodes, edges, result.Errors); if (result.Errors.Count > 0) return result; result.Graph = new NodeGraph { Nodes = nodes, Edges = edges, EntryNodeId = entry!, }; return result; } // ------------------------------------------------------------------ steps /// /// Validates each step's credential aliases: they must be declared by the /// blueprint, required links must be present, and an alias must not be bound /// to two different credentials within one workflow (which is what lets the /// run resolve credentials once into a single alias → data map). /// private static void ValidateCredentialAliases( IReadOnlyList nodes, IReadOnlyDictionary blueprints, List errors) { var seen = new Dictionary(StringComparer.Ordinal); foreach (var node in nodes) { var blueprint = blueprints[node.Id]; var declared = blueprint.Credentials.Select(c => c.Alias).ToHashSet(StringComparer.Ordinal); foreach (var (alias, reference) in node.CredentialRefs) { if (!declared.Contains(alias)) errors.Add($"task '{node.Id}': node '{blueprint.Type}' has no credential alias '{alias}'"); if (seen.TryGetValue(alias, out var existing) && !string.Equals(existing, reference, StringComparison.Ordinal)) errors.Add($"credential alias '{alias}' is bound to two different credentials ('{existing}' and '{reference}')"); else seen[alias] = reference; } foreach (var link in blueprint.Credentials.Where(c => c.Required)) { if (!node.CredentialRefs.ContainsKey(link.Alias)) errors.Add($"task '{node.Id}': node '{blueprint.Type}' requires credential '{link.Alias}'"); } } } private bool ValidateStep(TaskDefinition task, string label, List errors) { if (string.IsNullOrWhiteSpace(task.Id)) { errors.Add($"{label}.id is required"); return false; } if (string.Equals(task.Id, "root", StringComparison.OrdinalIgnoreCase)) { errors.Add($"{label}.id 'root' is reserved"); return false; } if (!StepIdRegex().IsMatch(task.Id) || task.Id.Length > 100) { errors.Add($"{label}.id '{task.Id}' is invalid"); return false; } if (task.Node == null) { errors.Add($"task '{task.Id}': a node workflow step must declare 'node'"); return false; } if (string.IsNullOrWhiteSpace(task.Node.Type)) { errors.Add($"task '{task.Id}'.node.type is required"); return false; } if (task.Entry != null || !string.IsNullOrWhiteSpace(task.Language)) { errors.Add($"task '{task.Id}': 'node' and 'entry'/'language' are mutually exclusive"); return false; } return true; } private NodeBlueprint? ResolveBlueprint(TaskDefinition task, List errors) { var version = task.Node!.Version; var blueprint = _catalog.Resolve(task.Node.Type!, version); if (blueprint != null) return blueprint; errors.Add(version is null or 0 ? $"task '{task.Id}': unknown node type '{task.Node.Type}'" : $"task '{task.Id}': node type '{task.Node.Type}' has no version {version}"); return null; } private static NodeGraphNode BuildNode(TaskDefinition task, NodeBlueprint blueprint, List errors) { var parameters = NodeParameterReader.ToJsonObject(task.Parameters); ValidateParameters(task.Id!, blueprint, parameters, errors); if (!string.IsNullOrWhiteSpace(task.RunMode) && !RunModes.Contains(task.RunMode)) { errors.Add( $"task '{task.Id}'.runMode '{task.RunMode}' is invalid: expected one of {string.Join(", ", RunModes)}"); } return new NodeGraphNode { Id = task.Id!, Blueprint = blueprint, Parameters = parameters, CredentialRefs = task.Credentials != null ? new Dictionary(task.Credentials, StringComparer.Ordinal) : new Dictionary(), RunMode = task.RunMode, ContinueOnFail = task.ContinueOnFail ?? false, Retry = task.Retry, }; } private static void ValidateParameters( string taskId, NodeBlueprint blueprint, JsonObject parameters, List errors) { foreach (var (name, value) in parameters) { if (!blueprint.HasParameter(name)) { errors.Add($"task '{taskId}': node '{blueprint.Type}' has no parameter '{name}'"); continue; } var descriptor = FindParameter(blueprint, name); if (descriptor != null) ValidateParameterValue(taskId, descriptor, value, errors); } foreach (var descriptor in blueprint.Parameters.Where(p => p.Required)) { if (!parameters.ContainsKey(descriptor.Name)) errors.Add($"task '{taskId}': node '{blueprint.Type}' requires parameter '{descriptor.Name}'"); } } private static NodeParameter? FindParameter(NodeBlueprint blueprint, string name) { foreach (var parameter in blueprint.Parameters) { if (string.Equals(parameter.Name, name, StringComparison.Ordinal)) return parameter; } return null; } private static void ValidateParameterValue( string taskId, NodeParameter descriptor, JsonNode? value, List errors) { // Expressions are resolved at run time, so their static type is unknown. if (IsExpression(value)) return; switch (descriptor.Type) { case NodeParameterType.Options: case NodeParameterType.MultiOptions: ValidateOptionValue(taskId, descriptor, value, errors); break; case NodeParameterType.Boolean when value is not null && !IsBoolean(value): errors.Add($"task '{taskId}': parameter '{descriptor.Name}' must be a boolean"); break; case NodeParameterType.Number when value is not null && !IsNumber(value): errors.Add($"task '{taskId}': parameter '{descriptor.Name}' must be a number"); break; } } private static bool IsExpression(JsonNode? value) => value is JsonValue jsonValue && jsonValue.TryGetValue(out var text) && (text.StartsWith('=') || text.Contains("{{", StringComparison.Ordinal)); private static void ValidateOptionValue( string taskId, NodeParameter descriptor, JsonNode? value, List errors) { if (value is null || descriptor.Options is not { Count: > 0 }) return; var allowed = descriptor.Options.Select(option => option.Value.GetRawText()).ToHashSet(StringComparer.Ordinal); if (descriptor.Type == NodeParameterType.MultiOptions && value is JsonArray array) { foreach (var element in array) { if (element is not null && !allowed.Contains(element.ToJsonString())) errors.Add($"task '{taskId}': parameter '{descriptor.Name}' has unsupported value {element.ToJsonString()}"); } return; } if (!allowed.Contains(value.ToJsonString())) errors.Add($"task '{taskId}': parameter '{descriptor.Name}' has unsupported value {value.ToJsonString()}"); } private static bool IsBoolean(JsonNode value) => value is JsonValue jsonValue && jsonValue.TryGetValue(out _); private static bool IsNumber(JsonNode value) => value is JsonValue jsonValue && (jsonValue.TryGetValue(out _) || jsonValue.TryGetValue(out _) || jsonValue.TryGetValue(out _)); // ------------------------------------------------------------------ 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]; } }