277 lines
11 KiB
C#
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);
|
|
}
|