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

125 lines
4.5 KiB
C#

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.
///
/// Per-step/parameter validation lives in <c>NodeGraphCompiler.Validation.cs</c>;
/// edge lowering and forward-graph shape analysis in
/// <c>NodeGraphCompiler.Edges.cs</c>.
/// </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;
}
}