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; /// /// A successfully compiled workflow + its task tree (EF entities, detached). /// Node-mode workflows additionally carry their port edges; script workflows /// leave empty and keep using the NextId chain. /// public sealed record CompiledWorkflow( Workflow Workflow, IReadOnlyList Tasks, IReadOnlyList Edges); public sealed class CompileResult { public List Errors { get; } = new(); public bool Success => Errors.Count == 0; public CompiledWorkflow? Workflow { get; set; } } /// /// 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: /// - node mode: every step declares a catalog node; steps are /// validated by and compiled with their port /// edges. /// - script mode (legacy): language + entry tasks driven /// by the linear next/onError chain. /// The two never mix within one definition. /// public class WorkflowCompiler { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; /// Language marker stored on node-mode tasks (they are not scripts). 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(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(); // 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()); } // ------------------------------------------------------------------ node mode /// /// 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. /// 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(); 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(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? env) => env == null || env.Count == 0 ? null : JsonSerializer.Serialize(env, Json); }