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;
/// Outcome of compiling a node workflow: the graph, or the reasons it is invalid.
public sealed class NodeGraphCompileResult
{
public List Errors { get; } = new();
public bool Success => Errors.Count == 0;
public NodeGraph? Graph { get; set; }
}
///
/// Turns the node-mode half of a into a
/// validated :
/// - resolves each step's blueprint (and pinned version) from the catalog;
/// - checks parameters against the blueprint's schema;
/// - lowers next/onError into port edges and merges explicit edges;
/// - finds the single entry node and rejects cycles, except loops that close
/// through a loop-capable node ().
///
/// Script (legacy) tasks are handled by and are
/// out of scope here: a definition is either node-mode or script-mode, not both.
///
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;
}
/// True when at least one step declares a node type.
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();
if (tasks.Count == 0)
{
result.Errors.Add("workflow has no tasks");
return result;
}
var nodes = new List();
var byId = new Dictionary(StringComparer.Ordinal);
var blueprints = new Dictionary(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
///
/// 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).
///
private static void ValidateCredentialAliases(
IReadOnlyList nodes,
IReadOnlyDictionary blueprints,
List errors)
{
var seen = new Dictionary(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 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 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 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(task.Credentials, StringComparer.Ordinal)
: new Dictionary(),
RunMode = task.RunMode,
ContinueOnFail = task.ContinueOnFail ?? false,
Retry = task.Retry,
};
}
private static void ValidateParameters(
string taskId, NodeBlueprint blueprint, JsonObject parameters, List 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 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(out var text) &&
(text.StartsWith('=') || text.Contains("{{", StringComparison.Ordinal));
private static void ValidateOptionValue(
string taskId, NodeParameter descriptor, JsonNode? value, List 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(out _);
private static bool IsNumber(JsonNode value)
=> value is JsonValue jsonValue &&
(jsonValue.TryGetValue(out _) || jsonValue.TryGetValue(out _) || jsonValue.TryGetValue(out _));
// ------------------------------------------------------------------ edges
private List BuildEdges(
WorkflowDefinition def,
IReadOnlyDictionary byId,
IReadOnlyDictionary blueprints,
List errors)
{
var edges = new List();
var seen = new HashSet(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())
Add(edge, task.Id);
}
foreach (var edge in def.Edges ?? new List())
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 nodes, IReadOnlyList edges, List 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(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];
}
}