89 lines
3.7 KiB
C#
89 lines
3.7 KiB
C#
|
|
using w4c_workflows.Models;
|
||
|
|
|
||
|
|
namespace w4c_workflows.Services;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Precomputes the execution graph from a parsed YAML definition so validation
|
||
|
|
/// and compilation share one deterministic interpretation:
|
||
|
|
/// - <see cref="HeadId"/> is the entry of the success chain (the top-level
|
||
|
|
/// task with no incoming `next` that is not purely a compensation target).
|
||
|
|
/// - <see cref="Chain"/> is the linear happy path root → head → … → terminal.
|
||
|
|
/// - <see cref="CompensationOnly"/> marks tasks that are only ever reached as
|
||
|
|
/// an `onError` target (they have no `next` of their own).
|
||
|
|
/// </summary>
|
||
|
|
internal sealed class WorkflowGraph
|
||
|
|
{
|
||
|
|
public required IReadOnlyList<TaskDefinition> Tasks { get; init; }
|
||
|
|
public required IReadOnlyDictionary<string, TaskDefinition> ById { get; init; }
|
||
|
|
public required IReadOnlyDictionary<string, int> IncomingNext { get; init; }
|
||
|
|
public required IReadOnlySet<string> OnErrorTargets { get; init; }
|
||
|
|
public required IReadOnlySet<string> CompensationOnly { get; init; }
|
||
|
|
public string? HeadId { get; init; }
|
||
|
|
public required IReadOnlyList<string> Chain { get; init; }
|
||
|
|
|
||
|
|
public static WorkflowGraph Compute(WorkflowDefinition def)
|
||
|
|
{
|
||
|
|
var tasks = def.Tasks ?? new List<TaskDefinition>();
|
||
|
|
var byId = new Dictionary<string, TaskDefinition>(StringComparer.Ordinal);
|
||
|
|
|
||
|
|
foreach (var task in tasks)
|
||
|
|
if (!string.IsNullOrEmpty(task.Id))
|
||
|
|
byId[task.Id] = task;
|
||
|
|
|
||
|
|
var incomingNext = new Dictionary<string, int>(StringComparer.Ordinal);
|
||
|
|
var onErrorTargets = new HashSet<string>(StringComparer.Ordinal);
|
||
|
|
|
||
|
|
foreach (var task in tasks)
|
||
|
|
{
|
||
|
|
if (!string.IsNullOrEmpty(task.Next) && byId.ContainsKey(task.Next))
|
||
|
|
incomingNext[task.Next] = incomingNext.GetValueOrDefault(task.Next) + 1;
|
||
|
|
if (!string.IsNullOrEmpty(task.OnError) && byId.ContainsKey(task.OnError))
|
||
|
|
onErrorTargets.Add(task.OnError);
|
||
|
|
}
|
||
|
|
|
||
|
|
// A compensation-only task is an onError target that does not continue
|
||
|
|
// the success chain (no `next`). It lives outside the happy path.
|
||
|
|
var compensationOnly = new HashSet<string>(StringComparer.Ordinal);
|
||
|
|
foreach (var task in tasks)
|
||
|
|
{
|
||
|
|
if (task.Id == null) continue;
|
||
|
|
if (onErrorTargets.Contains(task.Id) && string.IsNullOrEmpty(task.Next))
|
||
|
|
compensationOnly.Add(task.Id);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Head: top-level (parent == root), no incoming `next`, not compensation-only.
|
||
|
|
string? headId = null;
|
||
|
|
foreach (var task in tasks)
|
||
|
|
{
|
||
|
|
if (task.Id == null) continue;
|
||
|
|
var parent = string.IsNullOrEmpty(task.Parent) ? "root" : task.Parent;
|
||
|
|
if (!string.Equals(parent, "root", StringComparison.Ordinal)) continue;
|
||
|
|
if (incomingNext.GetValueOrDefault(task.Id) > 0) continue;
|
||
|
|
if (compensationOnly.Contains(task.Id)) continue;
|
||
|
|
headId = task.Id;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Walk the success chain (each node has at most one `next` after validation).
|
||
|
|
var chain = new List<string>();
|
||
|
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||
|
|
var cursor = headId;
|
||
|
|
while (cursor != null && visited.Add(cursor) && byId.TryGetValue(cursor, out var task))
|
||
|
|
{
|
||
|
|
chain.Add(cursor);
|
||
|
|
cursor = string.IsNullOrEmpty(task.Next) ? null : task.Next;
|
||
|
|
}
|
||
|
|
|
||
|
|
return new WorkflowGraph
|
||
|
|
{
|
||
|
|
Tasks = tasks,
|
||
|
|
ById = byId,
|
||
|
|
IncomingNext = incomingNext,
|
||
|
|
OnErrorTargets = onErrorTargets,
|
||
|
|
CompensationOnly = compensationOnly,
|
||
|
|
HeadId = headId,
|
||
|
|
Chain = chain,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|