459 lines
18 KiB
C#
459 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>Ambient data a node run needs beyond the graph itself.</summary>
|
|
public sealed record NodeRunEnvironment
|
|
{
|
|
public string TenantId { get; init; } = string.Empty;
|
|
public string RunId { get; init; } = string.Empty;
|
|
|
|
/// <summary>Workflow variables exposed to expressions as <c>$env</c>.</summary>
|
|
public JsonObject Variables { get; init; } = new();
|
|
|
|
/// <summary>Decrypted credentials keyed by the blueprint alias the step uses.</summary>
|
|
public IReadOnlyDictionary<string, CredentialData> Credentials { get; init; } =
|
|
new Dictionary<string, CredentialData>();
|
|
|
|
/// <summary>Directory code-executing nodes (<c>core.code</c>) resolve their entry file from.</summary>
|
|
public string? WorkingDirectory { get; init; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public IDictionary<string, object?> State { get; init; } = new Dictionary<string, object?>();
|
|
|
|
/// <summary>
|
|
/// Invokes another workflow in-process from a <c>core.executeWorkflow</c>
|
|
/// node. Null when the host does not support sub-workflows (e.g. a bare
|
|
/// <see cref="NodeGraphRunner"/> in a unit test); the node then fails cleanly.
|
|
/// </summary>
|
|
public ISubWorkflowInvoker? SubWorkflows { get; init; }
|
|
|
|
/// <summary>Nesting depth of this run: 0 top-level, +1 per sub-workflow call.</summary>
|
|
public int Depth { get; init; }
|
|
|
|
/// <summary>
|
|
/// Workflow ids from the root run down to (and including) this run, used to
|
|
/// reject recursive sub-workflow cycles before they can loop forever.
|
|
/// </summary>
|
|
public IReadOnlyList<Guid> Ancestry { get; init; } = Array.Empty<Guid>();
|
|
|
|
public IServiceProvider? Services { get; init; }
|
|
}
|
|
|
|
/// <summary>Result of running a node graph, with per-node outputs and a run-level failure.</summary>
|
|
public sealed record NodeGraphRunResult
|
|
{
|
|
public required IReadOnlyDictionary<string, IReadOnlyList<IReadOnlyList<FlowItem>>> OutputsByNode { get; init; }
|
|
|
|
/// <summary>Node ids in the order they were executed.</summary>
|
|
public required IReadOnlyList<string> ExecutionOrder { get; init; }
|
|
|
|
public NodeFailure? Failure { get; init; }
|
|
|
|
public bool Succeeded => Failure == null;
|
|
|
|
/// <summary>Items emitted by a node on a given output port (empty when absent).</summary>
|
|
public IReadOnlyList<FlowItem> OutputOf(string nodeId, int port = 0)
|
|
=> OutputsByNode.TryGetValue(nodeId, out var ports) && port >= 0 && port < ports.Count
|
|
? ports[port]
|
|
: Array.Empty<FlowItem>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drives a <see cref="NodeGraph"/> 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 <see cref="NodeGraphCompiler"/>: exactly one entry
|
|
/// node and no cycles, so a node fires exactly once.
|
|
/// </summary>
|
|
public sealed class NodeGraphRunner
|
|
{
|
|
private readonly NodeExecutorRegistry _executors;
|
|
private readonly NodeParameterInterpolator _interpolator;
|
|
private readonly NodePermissionPolicy? _permissions;
|
|
private readonly ILogger<NodeGraphRunner>? _logger;
|
|
|
|
public NodeGraphRunner(
|
|
NodeExecutorRegistry executors,
|
|
NodeParameterInterpolator interpolator,
|
|
ILogger<NodeGraphRunner>? logger = null,
|
|
NodePermissionPolicy? permissions = null)
|
|
{
|
|
_executors = executors;
|
|
_interpolator = interpolator;
|
|
_logger = logger;
|
|
_permissions = permissions;
|
|
}
|
|
|
|
public async Task<NodeGraphRunResult> RunAsync(
|
|
NodeGraph graph,
|
|
IReadOnlyList<FlowItem> seed,
|
|
NodeRunEnvironment? environment = null,
|
|
INodeRunListener? listener = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
environment ??= new NodeRunEnvironment();
|
|
|
|
var outputs = new Dictionary<string, List<IReadOnlyList<FlowItem>>>(StringComparer.Ordinal);
|
|
var order = new List<string>();
|
|
// 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<string>(StringComparer.Ordinal);
|
|
|
|
inputs[graph.EntryNodeId][0].AddRange(seed);
|
|
var ready = new Queue<string>();
|
|
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<FlowItem>)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<IReadOnlyList<FlowItem>>? errorPorts = null;
|
|
if (!outcome.Succeeded)
|
|
{
|
|
var errorPort = node.ErrorOutputIndex;
|
|
if (errorPort < 0 && !node.ContinueOnFail)
|
|
{
|
|
await NotifyFinished(listener, node, Array.Empty<IReadOnlyList<FlowItem>>(), 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<NodeExecutionOutcome> InvokeAsync(
|
|
NodeGraphNode node,
|
|
IReadOnlyList<IReadOnlyList<FlowItem>> inputs,
|
|
NodeRunEnvironment environment,
|
|
IReadOnlyDictionary<string, List<IReadOnlyList<FlowItem>>> 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<FlowItem>();
|
|
|
|
// 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<List<FlowItem>>();
|
|
|
|
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<FlowItem>)new[] { mainItems[index] } }, environment, index, loopBackInput),
|
|
ct);
|
|
|
|
if (!outcome.Succeeded)
|
|
return outcome;
|
|
|
|
while (accumulated.Count < outcome.Outputs.Count)
|
|
accumulated.Add(new List<FlowItem>());
|
|
|
|
for (var port = 0; port < outcome.Outputs.Count; port++)
|
|
accumulated[port].AddRange(outcome.Outputs[port]);
|
|
}
|
|
|
|
return new NodeExecutionOutcome
|
|
{
|
|
Outputs = accumulated.Select(list => (IReadOnlyList<FlowItem>)list).ToList(),
|
|
};
|
|
}
|
|
|
|
private NodeExecutionContext BuildContext(
|
|
NodeGraphNode node,
|
|
JsonObject parameters,
|
|
IReadOnlyList<IReadOnlyList<FlowItem>> 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<string, List<IReadOnlyList<FlowItem>>> 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<FlowItem> ResolveNodeItems(
|
|
string name,
|
|
IReadOnlyDictionary<string, List<IReadOnlyList<FlowItem>>> 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<FlowItem>();
|
|
}
|
|
|
|
// ------------------------------------------------------------------ routing
|
|
|
|
private static void Propagate(
|
|
NodeGraph graph,
|
|
string nodeId,
|
|
IReadOnlyList<IReadOnlyList<FlowItem>> ports,
|
|
IReadOnlyDictionary<string, List<FlowItem>[]> inputs,
|
|
IDictionary<string, int> arrived,
|
|
IReadOnlyDictionary<string, int> incoming,
|
|
Queue<string> ready,
|
|
ISet<string> 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<IReadOnlyList<FlowItem>> inputs,
|
|
CancellationToken ct)
|
|
=> listener == null ? Task.CompletedTask : listener.NodeStartedAsync(node, inputs, ct);
|
|
|
|
private static Task NotifyFinished(
|
|
INodeRunListener? listener,
|
|
NodeGraphNode node,
|
|
IReadOnlyList<IReadOnlyList<FlowItem>> outputs,
|
|
NodeFailure? failure,
|
|
CancellationToken ct)
|
|
=> listener == null ? Task.CompletedTask : listener.NodeFinishedAsync(node, outputs, failure, ct);
|
|
|
|
private List<IReadOnlyList<FlowItem>> BuildErrorOutputs(
|
|
NodeGraphNode node,
|
|
IReadOnlyList<IReadOnlyList<FlowItem>> inputs,
|
|
NodeFailure failure,
|
|
int errorPort)
|
|
{
|
|
var portCount = Math.Max(1, node.Blueprint.Outputs.Count);
|
|
var ports = Enumerable.Range(0, portCount).Select(_ => (IReadOnlyList<FlowItem>)new List<FlowItem>()).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<FlowItem>();
|
|
|
|
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<IReadOnlyList<FlowItem>> StampAndNormalize(
|
|
IReadOnlyList<IReadOnlyList<FlowItem>> produced,
|
|
NodeGraphNode node)
|
|
{
|
|
var portCount = Math.Max(1, node.Blueprint.Outputs.Count);
|
|
var ports = new List<IReadOnlyList<FlowItem>>(portCount);
|
|
|
|
for (var port = 0; port < portCount; port++)
|
|
{
|
|
var items = port < produced.Count ? produced[port] : Array.Empty<FlowItem>();
|
|
var stamped = new List<FlowItem>(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<FlowItem>[] NewInputPorts(NodeGraphNode node)
|
|
{
|
|
var ports = new List<FlowItem>[Math.Max(1, node.Blueprint.Inputs.Count)];
|
|
for (var i = 0; i < ports.Length; i++)
|
|
ports[i] = new List<FlowItem>();
|
|
return ports;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, IReadOnlyList<IReadOnlyList<FlowItem>>> Freeze(
|
|
IReadOnlyDictionary<string, List<IReadOnlyList<FlowItem>>> outputs)
|
|
=> outputs.ToDictionary(
|
|
pair => pair.Key,
|
|
pair => (IReadOnlyList<IReadOnlyList<FlowItem>>)pair.Value,
|
|
StringComparer.Ordinal);
|
|
|
|
private static NodeGraphRunResult Failure(
|
|
IReadOnlyDictionary<string, List<IReadOnlyList<FlowItem>>> outputs,
|
|
IReadOnlyList<string> order,
|
|
NodeFailure failure)
|
|
=> new()
|
|
{
|
|
OutputsByNode = Freeze(outputs),
|
|
ExecutionOrder = order.ToList(),
|
|
Failure = failure,
|
|
};
|
|
}
|