w4c-workflows-api/Services/WorkflowCompiler.cs
2026-09-12 01:02:46 +03:00

277 lines
11 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Nodes;
using w4c_workflows.Models;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Nodes;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace w4c_workflows.Services;
/// <summary>
/// A successfully compiled workflow + its task tree (EF entities, detached).
/// Node-mode workflows additionally carry their port edges; script workflows
/// leave <see cref="Edges"/> empty and keep using the <c>NextId</c> chain.
/// </summary>
public sealed record CompiledWorkflow(
Workflow Workflow,
IReadOnlyList<WorkflowTask> Tasks,
IReadOnlyList<WorkflowTaskEdge> Edges);
public sealed class CompileResult
{
public List<string> Errors { get; } = new();
public bool Success => Errors.Count == 0;
public CompiledWorkflow? Workflow { get; set; }
}
/// <summary>
/// Parses and compiles a workflow YAML definition into the EF entity model.
/// Ids are deterministic (tenant + path + task id) so re-syncing the same file
/// upserts instead of duplicating. The compiler performs no DB writes — the
/// sync service persists the result.
///
/// Two execution modes are supported:
/// - <b>node mode</b>: every step declares a catalog <c>node</c>; steps are
/// validated by <see cref="NodeGraphCompiler"/> and compiled with their port
/// edges.
/// - <b>script mode</b> (legacy): <c>language</c> + <c>entry</c> tasks driven
/// by the linear <c>next</c>/<c>onError</c> chain.
/// The two never mix within one definition.
/// </summary>
public class WorkflowCompiler
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Language marker stored on node-mode tasks (they are not scripts).</summary>
private const string NodeLanguage = "node";
private readonly WorkflowValidator _validator;
private readonly NodeGraphCompiler _nodeCompiler;
private readonly IDeserializer _yaml;
public WorkflowCompiler(WorkflowValidator validator, NodeGraphCompiler? nodeCompiler = null)
{
_validator = validator;
_nodeCompiler = nodeCompiler
?? new NodeGraphCompiler(new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded()));
_yaml = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithAttemptingUnquotedStringTypeDeserialization()
.IgnoreUnmatchedProperties()
.Build();
}
public CompileResult Compile(string yaml, string path, string tenantId, string repoName = "workflows")
{
var result = new CompileResult();
WorkflowDefinition def;
try
{
def = _yaml.Deserialize<WorkflowDefinition>(yaml) ?? new WorkflowDefinition();
}
catch (Exception ex)
{
result.Errors.Add($"YAML parse error: {ex.Message}");
return result;
}
if (NodeGraphCompiler.IsNodeWorkflow(def))
{
result.Errors.AddRange(_validator.ValidateNodeWorkflow(def));
var graph = _nodeCompiler.Compile(def, tenantId);
result.Errors.AddRange(graph.Errors);
if (!result.Success)
return result;
result.Workflow = BuildNodeWorkflow(def, graph.Graph!, path, tenantId, repoName);
return result;
}
result.Errors.AddRange(_validator.Validate(def));
if (!result.Success)
return result;
result.Workflow = Build(def, path, tenantId, repoName);
return result;
}
private CompiledWorkflow Build(WorkflowDefinition def, string path, string tenantId, string repoName)
{
var graph = WorkflowGraph.Compute(def);
var now = DateTime.UtcNow;
var workflowId = DeterministicGuid.For($"wf::{tenantId}::{repoName}::{path}");
var workflow = new Workflow
{
Id = workflowId,
TenantId = tenantId,
Name = def.Name!,
Path = path,
Repo = repoName,
Status = WorkflowStatus.Compiled,
Mode = def.Mode!,
Version = string.IsNullOrWhiteSpace(def.Version) ? "1.0.0" : def.Version!,
Target = string.IsNullOrWhiteSpace(def.Target) ? "default" : def.Target,
TriggerJson = def.Trigger == null ? null : JsonSerializer.Serialize(def.Trigger, Json),
CompiledAt = now,
CreatedAt = now,
UpdatedAt = now,
};
var tasks = new List<WorkflowTask>();
// Root task = the workflow entry (language/mode/entry/env from the header).
var rootId = DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::root");
var headId = graph.HeadId == null
? (Guid?)null
: DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{graph.HeadId}");
tasks.Add(new WorkflowTask
{
Id = rootId,
WorkflowId = workflowId,
Key = "root",
ParentId = null,
NextId = headId,
OnErrorId = null,
Language = def.Language!,
Mode = def.Mode!,
EntryJson = JsonSerializer.Serialize(new EntryDefinition
{
File = def.Entry!.File,
Function = def.Entry.Function,
}, Json),
EnvJson = SerializeEnv(def.Env),
Server = NormalizeServer(def.Server),
Order = 0,
});
for (var i = 0; i < graph.Tasks.Count; i++)
{
var task = graph.Tasks[i];
var taskId = DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.Id}");
tasks.Add(new WorkflowTask
{
Id = taskId,
WorkflowId = workflowId,
Key = task.Id!,
ParentId = string.IsNullOrEmpty(task.Parent) || task.Parent == "root"
? rootId
: DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.Parent}"),
NextId = string.IsNullOrEmpty(task.Next)
? null
: DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.Next}"),
OnErrorId = string.IsNullOrEmpty(task.OnError)
? null
: DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.OnError}"),
Language = task.Language ?? def.Language!,
Mode = task.Mode ?? def.Mode!,
EntryJson = JsonSerializer.Serialize(new EntryDefinition
{
File = task.Entry!.File,
Function = task.Entry.Function,
}, Json),
EnvJson = SerializeEnv(task.Env),
Server = NormalizeServer(task.Server ?? def.Server),
Order = i + 1,
});
}
return new CompiledWorkflow(workflow, tasks, Array.Empty<WorkflowTaskEdge>());
}
// ------------------------------------------------------------------ node mode
/// <summary>
/// Builds the EF entity set for a node-mode definition. Step ids and edge
/// ids are deterministic so re-syncing upserts. Parameters are stored as
/// JSON so the run kernel can interpolate them per item.
/// </summary>
private static CompiledWorkflow BuildNodeWorkflow(
WorkflowDefinition def, NodeGraph graph, string path, string tenantId, string repoName)
{
var now = DateTime.UtcNow;
var workflowId = DeterministicGuid.For($"wf::{tenantId}::{repoName}::{path}");
var mode = string.IsNullOrWhiteSpace(def.Mode) ? WorkflowMode.Function : def.Mode!;
var workflow = new Workflow
{
Id = workflowId,
TenantId = tenantId,
Name = def.Name!,
Path = path,
Repo = repoName,
Status = WorkflowStatus.Compiled,
Mode = mode,
Version = string.IsNullOrWhiteSpace(def.Version) ? "1.0.0" : def.Version!,
Target = string.IsNullOrWhiteSpace(def.Target) ? "default" : def.Target,
TriggerJson = def.Trigger == null ? null : JsonSerializer.Serialize(def.Trigger, Json),
CompiledAt = now,
CreatedAt = now,
UpdatedAt = now,
};
var tasks = new List<WorkflowTask>();
for (var i = 0; i < graph.Nodes.Count; i++)
{
var node = graph.Nodes[i];
tasks.Add(new WorkflowTask
{
Id = TaskId(tenantId, repoName, path, node.Id),
WorkflowId = workflowId,
Key = node.Id,
ParentId = null,
NextId = null,
OnErrorId = null,
Language = NodeLanguage,
Mode = mode,
NodeType = node.Blueprint.Type,
NodeVersion = node.Blueprint.Version,
ParametersJson = node.Parameters.ToJsonString(),
CredentialsJson = node.CredentialRefs.Count > 0
? JsonSerializer.Serialize(node.CredentialRefs, Json)
: null,
RetryJson = node.Retry == null ? null : JsonSerializer.Serialize(node.Retry, Json),
ContinueOnFail = node.ContinueOnFail,
RunMode = node.RunMode,
Server = null,
Order = i,
});
}
var edges = new List<WorkflowTaskEdge>(graph.Edges.Count);
foreach (var edge in graph.Edges)
{
edges.Add(new WorkflowTaskEdge
{
Id = DeterministicGuid.For(
$"edge::{tenantId}::{repoName}::{path}::{edge.FromNodeId}::{edge.FromOutput}::{edge.ToNodeId}::{edge.ToInput}"),
WorkflowId = workflowId,
FromTaskId = TaskId(tenantId, repoName, path, edge.FromNodeId),
FromOutput = edge.FromOutput,
ToTaskId = TaskId(tenantId, repoName, path, edge.ToNodeId),
ToInput = edge.ToInput,
IsLoopBack = edge.IsLoopBack,
});
}
return new CompiledWorkflow(workflow, tasks, edges);
}
private static Guid TaskId(string tenantId, string repoName, string path, string taskId)
=> DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{taskId}");
private static string? NormalizeServer(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? SerializeEnv(Dictionary<string, string>? env)
=> env == null || env.Count == 0 ? null : JsonSerializer.Serialize(env, Json);
}