2026-09-11 22:02:46 +00:00
|
|
|
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;
|
2026-09-13 16:28:47 +00:00
|
|
|
private Dictionary<string, NodeGraphNode>? _byDisplayName;
|
|
|
|
|
private Dictionary<(string NodeId, int Output), List<NodeGraphEdge>>? _from;
|
|
|
|
|
private Dictionary<string, List<NodeGraphEdge>>? _to;
|
2026-09-11 22:02:46 +00:00
|
|
|
|
|
|
|
|
public NodeGraphNode? Find(string nodeId)
|
|
|
|
|
{
|
|
|
|
|
_byId ??= Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
|
|
|
|
return _byId.TryGetValue(nodeId, out var node) ? node : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 16:28:47 +00:00
|
|
|
/// <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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 22:02:46 +00:00
|
|
|
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)
|
2026-09-13 16:28:47 +00:00
|
|
|
{
|
|
|
|
|
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>();
|
|
|
|
|
}
|
2026-09-11 22:02:46 +00:00
|
|
|
|
|
|
|
|
/// <summary>Edges arriving at a node, across all inputs.</summary>
|
|
|
|
|
public IEnumerable<NodeGraphEdge> EdgesTo(string nodeId)
|
2026-09-13 16:28:47 +00:00
|
|
|
{
|
|
|
|
|
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>();
|
|
|
|
|
}
|
2026-09-11 22:02:46 +00:00
|
|
|
}
|