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.
///
/// The class is split across partial files by responsibility:
/// NodeGraphRunner.Invocation.cs (parameter/context resolution + executor
/// invocation), NodeGraphRunner.Routing.cs (edge propagation, listeners,
/// error ports) and NodeGraphRunner.Shaping.cs (result shaping).
///
public sealed partial 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);
var queued = new HashSet(StringComparer.Ordinal);
// Per-node invocation count: 0 on the first pass, incremented on every
// re-trigger (a loop-back edge), so loop bodies see a real iteration
// index through $runIndex / NodeExecutionContext.RunIndex.
var invocations = graph.Nodes.ToDictionary(n => n.Id, _ => 0, StringComparer.Ordinal);
inputs[graph.EntryNodeId][0].AddRange(seed);
var ready = new Queue();
ready.Enqueue(graph.EntryNodeId);
queued.Add(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();
queued.Remove(nodeId);
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 runIndex = invocations[nodeId]++;
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, runIndex, ct);
var nodeFailure = outcome.Failure;
IReadOnlyList>? errorPorts = null;
if (!outcome.Succeeded)
{
var errorPort = node.ErrorOutputIndex;
// A failure is only "handled" when an error branch is actually
// wired (an edge leaves the error port) or the node opted into
// continueOnFail. A blueprint that merely declares an error port
// (e.g. core.httpRequest) must not silently swallow the failure:
// with no branch the run fails, so the message reaches
// WorkflowRun.Error instead of only the per-node TaskRun row.
var errorHandled = node.ContinueOnFail
|| (errorPort >= 0 && graph.EdgesFrom(node.Id, errorPort).Any());
if (!errorHandled)
{
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, runIndex);
outputs[nodeId] = ports;
await NotifyFinished(listener, node, ports, nodeFailure, ct);
if (outcome.LoopComplete)
exhausted.Add(nodeId);
Propagate(graph, nodeId, ports, inputs, arrived, incoming, ready, queued, 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 };
}
}