474 lines
18 KiB
C#
474 lines
18 KiB
C#
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
using w4c_workflows.Models;
|
|
using w4c_workflows.Models.Nodes;
|
|
using w4c_workflows.Services.Security;
|
|
|
|
namespace w4c_workflows.Services.Nodes;
|
|
|
|
/// <summary>Outcome of compiling a node workflow: the graph, or the reasons it is invalid.</summary>
|
|
public sealed class NodeGraphCompileResult
|
|
{
|
|
public List<string> Errors { get; } = new();
|
|
public bool Success => Errors.Count == 0;
|
|
public NodeGraph? Graph { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns the node-mode half of a <see cref="WorkflowDefinition"/> into a
|
|
/// validated <see cref="NodeGraph"/>:
|
|
/// - resolves each step's blueprint (and pinned version) from the catalog;
|
|
/// - checks parameters against the blueprint's schema;
|
|
/// - lowers <c>next</c>/<c>onError</c> into port edges and merges explicit edges;
|
|
/// - finds the single entry node and rejects cycles, except loops that close
|
|
/// through a loop-capable node (<see cref="NodeGraphLinks"/>).
|
|
///
|
|
/// Script (legacy) tasks are handled by <see cref="WorkflowCompiler"/> and are
|
|
/// out of scope here: a definition is either node-mode or script-mode, not both.
|
|
/// </summary>
|
|
public sealed partial class NodeGraphCompiler
|
|
{
|
|
private static readonly string[] RunModes = { NodeRunMode.EachItem, NodeRunMode.AllItems };
|
|
|
|
[GeneratedRegex(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled)]
|
|
private static partial Regex StepIdRegex();
|
|
|
|
private readonly NodeBlueprintCatalog _catalog;
|
|
private readonly NodePermissionPolicy? _permissions;
|
|
|
|
public NodeGraphCompiler(NodeBlueprintCatalog catalog, NodePermissionPolicy? permissions = null)
|
|
{
|
|
_catalog = catalog;
|
|
_permissions = permissions;
|
|
}
|
|
|
|
/// <summary>True when at least one step declares a node type.</summary>
|
|
public static bool IsNodeWorkflow(WorkflowDefinition def)
|
|
=> def.Tasks?.Any(t => t.Node != null) == true;
|
|
|
|
public NodeGraphCompileResult Compile(WorkflowDefinition def, string? tenantId = null)
|
|
{
|
|
var result = new NodeGraphCompileResult();
|
|
var tasks = def.Tasks ?? new List<TaskDefinition>();
|
|
|
|
if (tasks.Count == 0)
|
|
{
|
|
result.Errors.Add("workflow has no tasks");
|
|
return result;
|
|
}
|
|
|
|
var nodes = new List<NodeGraphNode>();
|
|
var byId = new Dictionary<string, TaskDefinition>(StringComparer.Ordinal);
|
|
var blueprints = new Dictionary<string, NodeBlueprint>(StringComparer.Ordinal);
|
|
|
|
foreach (var (task, index) in tasks.Select((t, i) => (t, i)))
|
|
{
|
|
var label = $"tasks[{index}]";
|
|
if (!ValidateStep(task, label, result.Errors))
|
|
continue;
|
|
|
|
var blueprint = ResolveBlueprint(task, result.Errors);
|
|
if (blueprint == null)
|
|
continue;
|
|
|
|
// Reject a barred node at save time so the author learns about the
|
|
// policy immediately instead of at run time. The run kernel checks
|
|
// again, because a policy can change after a workflow is stored.
|
|
var permission = _permissions?.Evaluate(blueprint, tenantId);
|
|
if (permission is { Allowed: false })
|
|
{
|
|
result.Errors.Add(
|
|
$"task '{task.Id}': {permission.Reason ?? $"node type '{blueprint.Type}' is not permitted"}");
|
|
continue;
|
|
}
|
|
|
|
byId[task.Id!] = task;
|
|
blueprints[task.Id!] = blueprint;
|
|
nodes.Add(BuildNode(task, blueprint, result.Errors));
|
|
}
|
|
|
|
if (result.Errors.Count > 0)
|
|
return result;
|
|
|
|
ValidateCredentialAliases(nodes, blueprints, result.Errors);
|
|
if (result.Errors.Count > 0)
|
|
return result;
|
|
|
|
var edges = BuildEdges(def, byId, blueprints, result.Errors);
|
|
if (result.Errors.Count > 0)
|
|
return result;
|
|
|
|
var (markedEdges, loopError) = NodeGraphLinks.MarkLoopBackEdges(nodes, edges);
|
|
if (loopError != null)
|
|
{
|
|
result.Errors.Add(loopError);
|
|
return result;
|
|
}
|
|
edges = markedEdges;
|
|
|
|
var entry = FindEntry(nodes, edges, result.Errors);
|
|
if (result.Errors.Count > 0)
|
|
return result;
|
|
|
|
result.Graph = new NodeGraph
|
|
{
|
|
Nodes = nodes,
|
|
Edges = edges,
|
|
EntryNodeId = entry!,
|
|
};
|
|
return result;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ steps
|
|
|
|
/// <summary>
|
|
/// Validates each step's credential aliases: they must be declared by the
|
|
/// blueprint, required links must be present, and an alias must not be bound
|
|
/// to two different credentials within one workflow (which is what lets the
|
|
/// run resolve credentials once into a single alias → data map).
|
|
/// </summary>
|
|
private static void ValidateCredentialAliases(
|
|
IReadOnlyList<NodeGraphNode> nodes,
|
|
IReadOnlyDictionary<string, NodeBlueprint> blueprints,
|
|
List<string> errors)
|
|
{
|
|
var seen = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
var blueprint = blueprints[node.Id];
|
|
var declared = blueprint.Credentials.Select(c => c.Alias).ToHashSet(StringComparer.Ordinal);
|
|
|
|
foreach (var (alias, reference) in node.CredentialRefs)
|
|
{
|
|
if (!declared.Contains(alias))
|
|
errors.Add($"task '{node.Id}': node '{blueprint.Type}' has no credential alias '{alias}'");
|
|
|
|
if (seen.TryGetValue(alias, out var existing) && !string.Equals(existing, reference, StringComparison.Ordinal))
|
|
errors.Add($"credential alias '{alias}' is bound to two different credentials ('{existing}' and '{reference}')");
|
|
else
|
|
seen[alias] = reference;
|
|
}
|
|
|
|
foreach (var link in blueprint.Credentials.Where(c => c.Required))
|
|
{
|
|
if (!node.CredentialRefs.ContainsKey(link.Alias))
|
|
errors.Add($"task '{node.Id}': node '{blueprint.Type}' requires credential '{link.Alias}'");
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool ValidateStep(TaskDefinition task, string label, List<string> errors)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(task.Id))
|
|
{
|
|
errors.Add($"{label}.id is required");
|
|
return false;
|
|
}
|
|
if (string.Equals(task.Id, "root", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
errors.Add($"{label}.id 'root' is reserved");
|
|
return false;
|
|
}
|
|
if (!StepIdRegex().IsMatch(task.Id) || task.Id.Length > 100)
|
|
{
|
|
errors.Add($"{label}.id '{task.Id}' is invalid");
|
|
return false;
|
|
}
|
|
|
|
if (task.Node == null)
|
|
{
|
|
errors.Add($"task '{task.Id}': a node workflow step must declare 'node'");
|
|
return false;
|
|
}
|
|
if (string.IsNullOrWhiteSpace(task.Node.Type))
|
|
{
|
|
errors.Add($"task '{task.Id}'.node.type is required");
|
|
return false;
|
|
}
|
|
if (task.Entry != null || !string.IsNullOrWhiteSpace(task.Language))
|
|
{
|
|
errors.Add($"task '{task.Id}': 'node' and 'entry'/'language' are mutually exclusive");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private NodeBlueprint? ResolveBlueprint(TaskDefinition task, List<string> errors)
|
|
{
|
|
var version = task.Node!.Version;
|
|
var blueprint = _catalog.Resolve(task.Node.Type!, version);
|
|
if (blueprint != null)
|
|
return blueprint;
|
|
|
|
errors.Add(version is null or 0
|
|
? $"task '{task.Id}': unknown node type '{task.Node.Type}'"
|
|
: $"task '{task.Id}': node type '{task.Node.Type}' has no version {version}");
|
|
return null;
|
|
}
|
|
|
|
private static NodeGraphNode BuildNode(TaskDefinition task, NodeBlueprint blueprint, List<string> errors)
|
|
{
|
|
var parameters = NodeParameterReader.ToJsonObject(task.Parameters);
|
|
ValidateParameters(task.Id!, blueprint, parameters, errors);
|
|
|
|
if (!string.IsNullOrWhiteSpace(task.RunMode) && !RunModes.Contains(task.RunMode))
|
|
{
|
|
errors.Add(
|
|
$"task '{task.Id}'.runMode '{task.RunMode}' is invalid: expected one of {string.Join(", ", RunModes)}");
|
|
}
|
|
|
|
return new NodeGraphNode
|
|
{
|
|
Id = task.Id!,
|
|
Blueprint = blueprint,
|
|
Parameters = parameters,
|
|
CredentialRefs = task.Credentials != null
|
|
? new Dictionary<string, string>(task.Credentials, StringComparer.Ordinal)
|
|
: new Dictionary<string, string>(),
|
|
RunMode = task.RunMode,
|
|
ContinueOnFail = task.ContinueOnFail ?? false,
|
|
Retry = task.Retry,
|
|
};
|
|
}
|
|
|
|
private static void ValidateParameters(
|
|
string taskId, NodeBlueprint blueprint, JsonObject parameters, List<string> errors)
|
|
{
|
|
foreach (var (name, value) in parameters)
|
|
{
|
|
if (!blueprint.HasParameter(name))
|
|
{
|
|
errors.Add($"task '{taskId}': node '{blueprint.Type}' has no parameter '{name}'");
|
|
continue;
|
|
}
|
|
|
|
var descriptor = FindParameter(blueprint, name);
|
|
if (descriptor != null)
|
|
ValidateParameterValue(taskId, descriptor, value, errors);
|
|
}
|
|
|
|
foreach (var descriptor in blueprint.Parameters.Where(p => p.Required))
|
|
{
|
|
if (!parameters.ContainsKey(descriptor.Name))
|
|
errors.Add($"task '{taskId}': node '{blueprint.Type}' requires parameter '{descriptor.Name}'");
|
|
}
|
|
}
|
|
|
|
private static NodeParameter? FindParameter(NodeBlueprint blueprint, string name)
|
|
{
|
|
foreach (var parameter in blueprint.Parameters)
|
|
{
|
|
if (string.Equals(parameter.Name, name, StringComparison.Ordinal))
|
|
return parameter;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static void ValidateParameterValue(
|
|
string taskId, NodeParameter descriptor, JsonNode? value, List<string> errors)
|
|
{
|
|
// Expressions are resolved at run time, so their static type is unknown.
|
|
if (IsExpression(value))
|
|
return;
|
|
|
|
switch (descriptor.Type)
|
|
{
|
|
case NodeParameterType.Options:
|
|
case NodeParameterType.MultiOptions:
|
|
ValidateOptionValue(taskId, descriptor, value, errors);
|
|
break;
|
|
case NodeParameterType.Boolean when value is not null && !IsBoolean(value):
|
|
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' must be a boolean");
|
|
break;
|
|
case NodeParameterType.Number when value is not null && !IsNumber(value):
|
|
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' must be a number");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static bool IsExpression(JsonNode? value)
|
|
=> value is JsonValue jsonValue &&
|
|
jsonValue.TryGetValue<string>(out var text) &&
|
|
(text.StartsWith('=') || text.Contains("{{", StringComparison.Ordinal));
|
|
|
|
private static void ValidateOptionValue(
|
|
string taskId, NodeParameter descriptor, JsonNode? value, List<string> errors)
|
|
{
|
|
if (value is null || descriptor.Options is not { Count: > 0 })
|
|
return;
|
|
|
|
var allowed = descriptor.Options.Select(option => option.Value.GetRawText()).ToHashSet(StringComparer.Ordinal);
|
|
|
|
if (descriptor.Type == NodeParameterType.MultiOptions && value is JsonArray array)
|
|
{
|
|
foreach (var element in array)
|
|
{
|
|
if (element is not null && !allowed.Contains(element.ToJsonString()))
|
|
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' has unsupported value {element.ToJsonString()}");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!allowed.Contains(value.ToJsonString()))
|
|
errors.Add($"task '{taskId}': parameter '{descriptor.Name}' has unsupported value {value.ToJsonString()}");
|
|
}
|
|
|
|
private static bool IsBoolean(JsonNode value)
|
|
=> value is JsonValue jsonValue && jsonValue.TryGetValue<bool>(out _);
|
|
|
|
private static bool IsNumber(JsonNode value)
|
|
=> value is JsonValue jsonValue &&
|
|
(jsonValue.TryGetValue<decimal>(out _) || jsonValue.TryGetValue<double>(out _) || jsonValue.TryGetValue<long>(out _));
|
|
|
|
// ------------------------------------------------------------------ edges
|
|
|
|
private List<NodeGraphEdge> BuildEdges(
|
|
WorkflowDefinition def,
|
|
IReadOnlyDictionary<string, TaskDefinition> byId,
|
|
IReadOnlyDictionary<string, NodeBlueprint> blueprints,
|
|
List<string> errors)
|
|
{
|
|
var edges = new List<NodeGraphEdge>();
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
void Add(TaskEdgeDefinition raw, string? ownerId)
|
|
{
|
|
var from = string.IsNullOrWhiteSpace(raw.From) ? ownerId : raw.From;
|
|
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(raw.To))
|
|
{
|
|
errors.Add("an edge is missing 'from' or 'to'");
|
|
return;
|
|
}
|
|
if (!byId.ContainsKey(from!))
|
|
{
|
|
errors.Add($"edge references unknown source task '{from}'");
|
|
return;
|
|
}
|
|
if (!byId.ContainsKey(raw.To!))
|
|
{
|
|
errors.Add($"edge from '{from}' references unknown target task '{raw.To}'");
|
|
return;
|
|
}
|
|
|
|
var fromOutput = raw.Output ?? 0;
|
|
var toInput = raw.Input ?? 0;
|
|
var sourcePorts = blueprints[from!].Outputs.Count;
|
|
var targetPorts = blueprints[raw.To!].Inputs.Count;
|
|
|
|
if (fromOutput < 0 || fromOutput >= sourcePorts)
|
|
{
|
|
errors.Add($"edge from '{from}': output index {fromOutput} is out of range for '{blueprints[from!].Type}'");
|
|
return;
|
|
}
|
|
if (toInput < 0 || toInput >= targetPorts)
|
|
{
|
|
errors.Add($"edge to '{raw.To}': input index {toInput} is out of range for '{blueprints[raw.To!].Type}'");
|
|
return;
|
|
}
|
|
|
|
var key = $"{from}|{fromOutput}|{raw.To}|{toInput}";
|
|
if (seen.Add(key))
|
|
{
|
|
edges.Add(new NodeGraphEdge
|
|
{
|
|
FromNodeId = from!,
|
|
FromOutput = fromOutput,
|
|
ToNodeId = raw.To!,
|
|
ToInput = toInput,
|
|
});
|
|
}
|
|
}
|
|
|
|
foreach (var task in byId.Values)
|
|
{
|
|
if (task.Next != null)
|
|
Add(new TaskEdgeDefinition { To = task.Next }, task.Id);
|
|
|
|
if (task.OnError != null)
|
|
{
|
|
var errorPort = ErrorOutputIndex(blueprints[task.Id!]);
|
|
if (errorPort < 0)
|
|
{
|
|
errors.Add($"task '{task.Id}': 'onError' is set but node '{task.Node!.Type}' has no error output");
|
|
}
|
|
else
|
|
{
|
|
Add(new TaskEdgeDefinition { To = task.OnError, Output = errorPort }, task.Id);
|
|
}
|
|
}
|
|
|
|
foreach (var edge in task.Edges ?? new List<TaskEdgeDefinition>())
|
|
Add(edge, task.Id);
|
|
}
|
|
|
|
foreach (var edge in def.Edges ?? new List<TaskEdgeDefinition>())
|
|
Add(edge, null);
|
|
|
|
return edges;
|
|
}
|
|
|
|
private static int ErrorOutputIndex(NodeBlueprint blueprint)
|
|
=> blueprint.Outputs.FindIndex(p => string.Equals(p.Kind, PortKind.Error, StringComparison.Ordinal));
|
|
|
|
// ------------------------------------------------------------------ graph shape
|
|
|
|
private static string? FindEntry(
|
|
IReadOnlyList<NodeGraphNode> nodes, IReadOnlyList<NodeGraphEdge> edges, List<string> errors)
|
|
{
|
|
// Loop-back edges are deliberately ignored here: the forward graph (edges
|
|
// without the loop-backs) must be acyclic with a single entry.
|
|
var forward = edges.Where(e => !e.IsLoopBack).ToList();
|
|
|
|
var incoming = nodes.ToDictionary(n => n.Id, _ => 0, StringComparer.Ordinal);
|
|
foreach (var edge in forward)
|
|
{
|
|
if (incoming.ContainsKey(edge.ToNodeId))
|
|
incoming[edge.ToNodeId]++;
|
|
}
|
|
|
|
var roots = nodes.Where(n => incoming[n.Id] == 0).Select(n => n.Id).ToList();
|
|
if (roots.Count == 0)
|
|
{
|
|
errors.Add("no entry node: every step has an incoming edge (the graph is a cycle)");
|
|
return null;
|
|
}
|
|
if (roots.Count > 1)
|
|
{
|
|
errors.Add($"multiple entry nodes ({string.Join(", ", roots)}): a node workflow must have exactly one entry");
|
|
return null;
|
|
}
|
|
|
|
// Kahn topological check over the forward edges — rejects cycles that do
|
|
// not pass through a loop node before the runner ever sees them.
|
|
var indegree = nodes.ToDictionary(n => n.Id, n => incoming[n.Id], StringComparer.Ordinal);
|
|
var adjacency = forward.GroupBy(e => e.FromNodeId)
|
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ToNodeId).ToList(), StringComparer.Ordinal);
|
|
var queue = new Queue<string>(roots);
|
|
var visited = 0;
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var id = queue.Dequeue();
|
|
visited++;
|
|
if (!adjacency.TryGetValue(id, out var targets))
|
|
continue;
|
|
foreach (var target in targets)
|
|
{
|
|
if (--indegree[target] == 0)
|
|
queue.Enqueue(target);
|
|
}
|
|
}
|
|
|
|
if (visited != nodes.Count)
|
|
{
|
|
var cyclic = nodes.Where(n => indegree.GetValueOrDefault(n.Id) > 0).Select(n => n.Id);
|
|
errors.Add($"cycle detected in the node graph involving: {string.Join(", ", cyclic)}");
|
|
return null;
|
|
}
|
|
|
|
return roots[0];
|
|
}
|
|
}
|