using System.Text.Json.Nodes; using w4c_workflows.Models.Credentials; using w4c_workflows.Models.Nodes; using w4c_workflows.Services.Nodes.Interpolation; using w4c_workflows.Services.Security; namespace w4c_workflows.Services.Nodes; /// Ambient data a node run needs beyond the graph itself. public sealed record NodeRunEnvironment { public string TenantId { get; init; } = string.Empty; public string RunId { get; init; } = string.Empty; /// Workflow variables exposed to expressions as $env. public JsonObject Variables { get; init; } = new(); /// Decrypted credentials keyed by the blueprint alias the step uses. public IReadOnlyDictionary Credentials { get; init; } = new Dictionary(); /// Directory code-executing nodes (core.code) resolve their entry file from. public string? WorkingDirectory { get; init; } /// /// Run-scoped mutable state shared by all nodes of one run. Loop nodes use it /// to remember their iteration cursor; it is created fresh per run. /// public IDictionary State { get; init; } = new Dictionary(); /// /// Invokes another workflow in-process from a core.executeWorkflow /// node. Null when the host does not support sub-workflows (e.g. a bare /// in a unit test); the node then fails cleanly. /// public ISubWorkflowInvoker? SubWorkflows { get; init; } /// Nesting depth of this run: 0 top-level, +1 per sub-workflow call. public int Depth { get; init; } /// /// Workflow ids from the root run down to (and including) this run, used to /// reject recursive sub-workflow cycles before they can loop forever. /// public IReadOnlyList Ancestry { get; init; } = Array.Empty(); public IServiceProvider? Services { get; init; } } /// Result of running a node graph, with per-node outputs and a run-level failure. public sealed record NodeGraphRunResult { public required IReadOnlyDictionary>> OutputsByNode { get; init; } /// Node ids in the order they were executed. public required IReadOnlyList ExecutionOrder { get; init; } public NodeFailure? Failure { get; init; } public bool Succeeded => Failure == null; /// Items emitted by a node on a given output port (empty when absent). public IReadOnlyList OutputOf(string nodeId, int port = 0) => OutputsByNode.TryGetValue(nodeId, out var ports) && port >= 0 && port < ports.Count ? ports[port] : Array.Empty(); } /// /// Drives a to completion: accumulates items on each /// input port, fires a node once every incoming edge has delivered, resolves the /// node's parameters per item, runs the registered executor, stamps provenance /// on produced items and forwards them along the outgoing edges. /// /// Invariants guaranteed by : exactly one entry /// node and no cycles, so a node fires exactly once. /// public sealed class NodeGraphRunner { private readonly NodeExecutorRegistry _executors; private readonly NodeParameterInterpolator _interpolator; private readonly NodePermissionPolicy? _permissions; private readonly ILogger? _logger; public NodeGraphRunner( NodeExecutorRegistry executors, NodeParameterInterpolator interpolator, ILogger? logger = null, NodePermissionPolicy? permissions = null) { _executors = executors; _interpolator = interpolator; _logger = logger; _permissions = permissions; } public async Task RunAsync( NodeGraph graph, IReadOnlyList seed, NodeRunEnvironment? environment = null, INodeRunListener? listener = null, CancellationToken ct = default) { environment ??= new NodeRunEnvironment(); var outputs = new Dictionary>>(StringComparer.Ordinal); var order = new List(); // Loop-back edges do not count toward readiness: they re-trigger the loop // node after its body has run, rather than being a normal predecessor. var incoming = graph.Nodes.ToDictionary( n => n.Id, n => graph.EdgesTo(n.Id).Count(e => !e.IsLoopBack), StringComparer.Ordinal); var arrived = graph.Nodes.ToDictionary(n => n.Id, _ => 0, StringComparer.Ordinal); var inputs = graph.Nodes.ToDictionary(n => n.Id, NewInputPorts, StringComparer.Ordinal); var exhausted = new HashSet(StringComparer.Ordinal); inputs[graph.EntryNodeId][0].AddRange(seed); var ready = new Queue(); ready.Enqueue(graph.EntryNodeId); var hasLoops = graph.Edges.Any(e => e.IsLoopBack); var maxSteps = hasLoops ? Math.Max(1024, graph.Nodes.Count * 256) : Math.Max(64, graph.Nodes.Count * 64); var steps = 0; while (ready.Count > 0) { ct.ThrowIfCancellationRequested(); if (++steps > maxSteps) return Failure(outputs, order, new NodeFailure("node graph exceeded its execution budget", "budget")); var nodeId = ready.Dequeue(); var node = graph.Require(nodeId); if (!_executors.CanRun(node.Blueprint.Type)) return Failure(outputs, order, new NodeFailure($"no executor is installed for node type '{node.Blueprint.Type}'", "missing_executor")); order.Add(nodeId); var nodeInputs = inputs[nodeId].Select(port => (IReadOnlyList)port).ToList(); await NotifyStarted(listener, node, nodeInputs, ct); // Node permissions are enforced right before invocation, so a graph // persisted before a policy change still cannot run a barred node. // A denial is a normal node failure: it routes to the error output or // fails the run, exactly like an executor error. var permission = _permissions?.Evaluate(node.Blueprint, environment.TenantId); var outcome = permission is { Allowed: false } ? NodeExecutionOutcome.Failed( permission.Reason ?? $"node type '{node.Blueprint.Type}' is not permitted", permission.Code ?? "node_not_permitted") : await InvokeAsync(node, nodeInputs, environment, outputs, graph, ct); var nodeFailure = outcome.Failure; IReadOnlyList>? errorPorts = null; if (!outcome.Succeeded) { var errorPort = node.ErrorOutputIndex; if (errorPort < 0 && !node.ContinueOnFail) { await NotifyFinished(listener, node, Array.Empty>(), nodeFailure, ct); return Failure(outputs, order, outcome.Failure!); } errorPorts = BuildErrorOutputs(node, nodeInputs, outcome.Failure!, errorPort); } var ports = StampAndNormalize(errorPorts ?? outcome.Outputs, node); outputs[nodeId] = ports; await NotifyFinished(listener, node, ports, nodeFailure, ct); if (outcome.LoopComplete) exhausted.Add(nodeId); Propagate(graph, nodeId, ports, inputs, arrived, incoming, ready, exhausted, outcome.LoopComplete); // Consume this node's inputs so a loop can gather them again next // iteration; cleared after use, never before. arrived[nodeId] = 0; foreach (var port in inputs[nodeId]) port.Clear(); } _logger?.LogDebug("Node graph run finished: {Count} nodes executed", order.Count); return new NodeGraphRunResult { OutputsByNode = Freeze(outputs), ExecutionOrder = order }; } private async Task InvokeAsync( NodeGraphNode node, IReadOnlyList> inputs, NodeRunEnvironment environment, IReadOnlyDictionary>> outputs, NodeGraph graph, CancellationToken ct) { var executor = _executors.Resolve(node.Blueprint.Type) ?? throw new InvalidOperationException($"no executor for '{node.Blueprint.Type}'"); var loopBackInput = graph.EdgesTo(node.Id).Any(e => e.IsLoopBack); // Nodes with several inputs, or an explicit all-items run mode, receive the // whole array at once; the default is one invocation per input item. var allItems = node.EffectiveRunMode == NodeRunMode.AllItems || node.Blueprint.Inputs.Count > 1; if (allItems) { var parameters = ResolveParameters(node, null, 0, environment, outputs, graph); return await executor.RunAsync( BuildContext(node, parameters, inputs, environment, 0, loopBackInput), ct); } var mainItems = inputs.Count > 0 ? inputs[0] : Array.Empty(); // A node with no incoming items is skipped, except the entry node: like a // trigger/source it runs once with an empty item so it can generate data. var isEntry = string.Equals(node.Id, graph.EntryNodeId, StringComparison.Ordinal); if (mainItems.Count == 0 && !isEntry) return NodeExecutionOutcome.Empty; if (mainItems.Count == 0) mainItems = new[] { new FlowItem { Json = new JsonObject() } }; var accumulated = new List>(); for (var index = 0; index < mainItems.Count; index++) { var parameters = ResolveParameters(node, mainItems[index], index, environment, outputs, graph); var outcome = await executor.RunAsync( BuildContext(node, parameters, new[] { (IReadOnlyList)new[] { mainItems[index] } }, environment, index, loopBackInput), ct); if (!outcome.Succeeded) return outcome; while (accumulated.Count < outcome.Outputs.Count) accumulated.Add(new List()); for (var port = 0; port < outcome.Outputs.Count; port++) accumulated[port].AddRange(outcome.Outputs[port]); } return new NodeExecutionOutcome { Outputs = accumulated.Select(list => (IReadOnlyList)list).ToList(), }; } private NodeExecutionContext BuildContext( NodeGraphNode node, JsonObject parameters, IReadOnlyList> inputs, NodeRunEnvironment environment, int itemIndex, bool loopBackInput = false) => new() { Blueprint = node.Blueprint, Parameters = parameters, Inputs = inputs, Credentials = environment.Credentials, Environment = environment.Variables, TenantId = environment.TenantId, RunId = environment.RunId, TaskId = node.Id, NodeName = node.Id, ItemIndex = itemIndex, RunIndex = 0, WorkingDirectory = environment.WorkingDirectory, LoopBackInput = loopBackInput, State = environment.State, SubWorkflows = environment.SubWorkflows, Depth = environment.Depth, Services = environment.Services, }; private JsonObject ResolveParameters( NodeGraphNode node, FlowItem? item, int itemIndex, NodeRunEnvironment environment, IReadOnlyDictionary>> outputs, NodeGraph graph) { var scope = new InterpolationScope { Item = item?.Json, Parameters = node.Parameters, Environment = environment.Variables, ItemIndex = itemIndex, RunIndex = 0, NodeItems = name => ResolveNodeItems(name, outputs, graph), }; return _interpolator.ResolveObject(node.Parameters, scope); } private static IReadOnlyList ResolveNodeItems( string name, IReadOnlyDictionary>> outputs, NodeGraph graph) { if (outputs.TryGetValue(name, out var direct) && direct.Count > 0) return direct[0]; // Fall back to the blueprint display name, so $("Send message") also works. var byName = graph.Nodes.FirstOrDefault( n => string.Equals(n.Blueprint.DisplayName, name, StringComparison.Ordinal)); if (byName != null && outputs.TryGetValue(byName.Id, out var named) && named.Count > 0) return named[0]; return Array.Empty(); } // ------------------------------------------------------------------ routing private static void Propagate( NodeGraph graph, string nodeId, IReadOnlyList> ports, IReadOnlyDictionary[]> inputs, IDictionary arrived, IReadOnlyDictionary incoming, Queue ready, ISet exhausted, bool skipEmptyPorts) { for (var port = 0; port < ports.Count; port++) { var produced = ports[port]; // On a loop node's final ("done") invocation the loop port is empty: // skip it so the body is not re-triggered for a no-op iteration. if (skipEmptyPorts && produced.Count == 0) continue; foreach (var edge in graph.EdgesFrom(nodeId, port)) { // An exhausted loop node is not re-triggered by its loop-back edge. if (exhausted.Contains(edge.ToNodeId) && edge.IsLoopBack) continue; var targetInputs = inputs[edge.ToNodeId]; if (edge.ToInput >= 0 && edge.ToInput < targetInputs.Length) targetInputs[edge.ToInput].AddRange(produced); arrived[edge.ToNodeId]++; if (arrived[edge.ToNodeId] == incoming[edge.ToNodeId]) ready.Enqueue(edge.ToNodeId); } } } // ------------------------------------------------------------------ listeners private static Task NotifyStarted( INodeRunListener? listener, NodeGraphNode node, IReadOnlyList> inputs, CancellationToken ct) => listener == null ? Task.CompletedTask : listener.NodeStartedAsync(node, inputs, ct); private static Task NotifyFinished( INodeRunListener? listener, NodeGraphNode node, IReadOnlyList> outputs, NodeFailure? failure, CancellationToken ct) => listener == null ? Task.CompletedTask : listener.NodeFinishedAsync(node, outputs, failure, ct); private List> BuildErrorOutputs( NodeGraphNode node, IReadOnlyList> inputs, NodeFailure failure, int errorPort) { var portCount = Math.Max(1, node.Blueprint.Outputs.Count); var ports = Enumerable.Range(0, portCount).Select(_ => (IReadOnlyList)new List()).ToList(); if (errorPort < 0 || errorPort >= ports.Count) return ports; var errorJson = new JsonObject { ["message"] = failure.Message, ["code"] = failure.Code, ["description"] = failure.Description, ["httpStatus"] = failure.HttpStatus, }; var source = inputs.FirstOrDefault(list => list.Count > 0); var items = new List(); if (source == null || source.Count == 0) { items.Add(new FlowItem { Json = new JsonObject { ["error"] = errorJson } }); } else { foreach (var item in source) { var json = (JsonObject)item.Json.DeepClone(); json["error"] = errorJson.DeepClone(); items.Add(new FlowItem { Json = json, Binary = item.Binary, Origin = item.Origin }); } } ports[errorPort] = items; return ports; } // ------------------------------------------------------------------ shaping private static List> StampAndNormalize( IReadOnlyList> produced, NodeGraphNode node) { var portCount = Math.Max(1, node.Blueprint.Outputs.Count); var ports = new List>(portCount); for (var port = 0; port < portCount; port++) { var items = port < produced.Count ? produced[port] : Array.Empty(); var stamped = new List(items.Count); for (var index = 0; index < items.Count; index++) { var item = items[index]; stamped.Add(item.Origin == null ? item with { Origin = new FlowItemOrigin(node.Id, port, 0, index) } : item); } ports.Add(stamped); } return ports; } private static List[] NewInputPorts(NodeGraphNode node) { var ports = new List[Math.Max(1, node.Blueprint.Inputs.Count)]; for (var i = 0; i < ports.Length; i++) ports[i] = new List(); return ports; } private static IReadOnlyDictionary>> Freeze( IReadOnlyDictionary>> outputs) => outputs.ToDictionary( pair => pair.Key, pair => (IReadOnlyList>)pair.Value, StringComparer.Ordinal); private static NodeGraphRunResult Failure( IReadOnlyDictionary>> outputs, IReadOnlyList order, NodeFailure failure) => new() { OutputsByNode = Freeze(outputs), ExecutionOrder = order.ToList(), Failure = failure, }; }