w4c-workflows-api/Models/Nodes/NodeGraph.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

151 lines
6.1 KiB
C#

using System.Text.Json.Nodes;
namespace w4c_workflows.Models.Nodes;
// ---------------------------------------------------------------------------
// A compiled node graph: the executable form of the node-mode half of a
// workflow definition. It is deliberately free of EF/DB types so it can be
// built, validated and run in isolation (unit tests, previews, dry runs).
//
// `next`/`onError` from the YAML are lowered to edges here, so the run kernel
// only ever follows edges — never a single "next" pointer.
// ---------------------------------------------------------------------------
/// <summary>One step in a node graph: a resolved blueprint plus its parameters.</summary>
public sealed record NodeGraphNode
{
/// <summary>The YAML task id; stable and used in expressions such as <c>$("stepId")</c>.</summary>
public required string Id { get; init; }
/// <summary>Blueprint resolved from the catalog (validated at compile time).</summary>
public required NodeBlueprint Blueprint { get; init; }
/// <summary>Raw (unresolved) parameters; interpolation happens per item at run time.</summary>
public required JsonObject Parameters { get; init; }
/// <summary>
/// Credentials this step needs, keyed by the blueprint credential alias;
/// the value is a vault credential id or unique name, resolved at run time.
/// </summary>
public IReadOnlyDictionary<string, string> CredentialRefs { get; init; } =
new Dictionary<string, string>();
/// <summary>Run-mode override; null means "use the blueprint default".</summary>
public string? RunMode { get; init; }
/// <summary>Effective run mode (override, else blueprint).</summary>
public string EffectiveRunMode =>
string.IsNullOrWhiteSpace(RunMode) ? Blueprint.RunMode : RunMode!;
public bool ContinueOnFail { get; init; }
public TaskRetryDefinition? Retry { get; init; }
public NodePort Output(int index)
=> index >= 0 && index < Blueprint.Outputs.Count ? Blueprint.Outputs[index] : NodePort.Main;
public NodePort Input(int index)
=> index >= 0 && index < Blueprint.Inputs.Count ? Blueprint.Inputs[index] : NodePort.Main;
/// <summary>Index of the blueprint's error output, or -1 when it has none.</summary>
public int ErrorOutputIndex
=> Blueprint.Outputs.FindIndex(p => string.Equals(p.Kind, PortKind.Error, StringComparison.Ordinal));
}
/// <summary>A directed connection between two steps on specific ports.</summary>
public sealed record NodeGraphEdge
{
public required string FromNodeId { get; init; }
public int FromOutput { get; init; }
public required string ToNodeId { get; init; }
public int ToInput { get; init; }
/// <summary>
/// True when this edge closes a loop (a back edge into a loop node). It does
/// not count toward the target's readiness; instead it re-triggers the loop
/// node for the next iteration.
/// </summary>
public bool IsLoopBack { get; init; }
}
/// <summary>
/// A validated, runnable node graph. <see cref="EntryNodeId"/> is the single
/// node with no incoming non-loop edge. Cycles are rejected at compile time
/// unless they close through a loop-capable node (a loop-back edge).
/// </summary>
public sealed record NodeGraph
{
public required IReadOnlyList<NodeGraphNode> Nodes { get; init; }
public required IReadOnlyList<NodeGraphEdge> Edges { get; init; }
public required string EntryNodeId { get; init; }
private Dictionary<string, NodeGraphNode>? _byId;
private Dictionary<string, NodeGraphNode>? _byDisplayName;
private Dictionary<(string NodeId, int Output), List<NodeGraphEdge>>? _from;
private Dictionary<string, List<NodeGraphEdge>>? _to;
public NodeGraphNode? Find(string nodeId)
{
_byId ??= Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
return _byId.TryGetValue(nodeId, out var node) ? node : null;
}
/// <summary>
/// First node whose blueprint display name matches, or null. Lets
/// <c>$("Send message")</c> resolve without scanning <see cref="Nodes"/> per
/// item; built once and reused.
/// </summary>
public NodeGraphNode? FindByDisplayName(string displayName)
{
if (_byDisplayName == null)
{
// First-wins: duplicate display names are ambiguous but legal.
var index = new Dictionary<string, NodeGraphNode>(StringComparer.Ordinal);
foreach (var node in Nodes)
index.TryAdd(node.Blueprint.DisplayName, node);
_byDisplayName = index;
}
return _byDisplayName.TryGetValue(displayName, out var match) ? match : null;
}
public NodeGraphNode Require(string nodeId)
=> Find(nodeId) ?? throw new InvalidOperationException($"node '{nodeId}' is not in the graph");
/// <summary>Edges leaving a specific output port.</summary>
public IEnumerable<NodeGraphEdge> EdgesFrom(string nodeId, int output)
{
if (_from == null)
{
var index = new Dictionary<(string, int), List<NodeGraphEdge>>();
foreach (var edge in Edges)
{
if (!index.TryGetValue((edge.FromNodeId, edge.FromOutput), out var outgoing))
index[(edge.FromNodeId, edge.FromOutput)] = outgoing = new List<NodeGraphEdge>();
outgoing.Add(edge);
}
_from = index;
}
return _from.TryGetValue((nodeId, output), out var edges) ? edges : Array.Empty<NodeGraphEdge>();
}
/// <summary>Edges arriving at a node, across all inputs.</summary>
public IEnumerable<NodeGraphEdge> EdgesTo(string nodeId)
{
if (_to == null)
{
var index = new Dictionary<string, List<NodeGraphEdge>>(StringComparer.Ordinal);
foreach (var edge in Edges)
{
if (!index.TryGetValue(edge.ToNodeId, out var incoming))
index[edge.ToNodeId] = incoming = new List<NodeGraphEdge>();
incoming.Add(edge);
}
_to = index;
}
return _to.TryGetValue(nodeId, out var edges) ? edges : Array.Empty<NodeGraphEdge>();
}
}