using System.Text.RegularExpressions; using w4c_workflows.Models; namespace w4c_workflows.Services; /// /// Validates a parsed . Pure and side-effect /// free: it returns a list of human-readable errors (empty = valid). The /// compiler calls this before building the EF entities. /// public partial class WorkflowValidator { private static readonly string[] Modes = { WorkflowMode.Function, WorkflowMode.Durable, WorkflowMode.Handler }; private static readonly string[] TriggerTypes = { TriggerType.Cron, TriggerType.Interval, TriggerType.Webhook, TriggerType.Event, TriggerType.Queue }; [GeneratedRegex(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled)] private static partial Regex IdentifierRegex(); private readonly LanguageRegistry _languages; public WorkflowValidator(LanguageRegistry languages) { _languages = languages; } public IReadOnlyList Validate(WorkflowDefinition def) { var errors = new List(); ValidateHeader(def, errors); ValidateTrigger(def, errors); var tasks = def.Tasks ?? new List(); ValidateTasks(def, tasks, errors); ValidateFlow(tasks, errors); return errors; } /// /// Validates the header + trigger of a node-mode workflow. Script-specific /// requirements (header language/entry, per-task entry) /// do not apply here: the node graph compiler validates the steps, their /// parameters and the graph shape instead. mode is optional and /// defaults to function. /// public IReadOnlyList ValidateNodeWorkflow(WorkflowDefinition def) { var errors = new List(); ValidateName(def, errors); if (!string.IsNullOrWhiteSpace(def.Mode) && !Modes.Contains(def.Mode)) errors.Add($"mode '{def.Mode}' is invalid: expected one of {string.Join(", ", Modes)}"); ValidateTrigger(def, errors); return errors; } private void ValidateHeader(WorkflowDefinition def, List errors) { ValidateName(def, errors); if (string.IsNullOrWhiteSpace(def.Mode)) errors.Add("mode is required (function | durable | handler)"); else if (!Modes.Contains(def.Mode)) errors.Add($"mode '{def.Mode}' is invalid: expected one of {string.Join(", ", Modes)}"); if (string.IsNullOrWhiteSpace(def.Language)) errors.Add("language is required (shell | javascript | typescript | csharp | python)"); else if (!_languages.IsKnown(def.Language)) errors.Add($"language '{def.Language}' is not supported"); if (def.Entry == null || string.IsNullOrWhiteSpace(def.Entry.File)) errors.Add("entry.file is required"); } private static void ValidateName(WorkflowDefinition def, List errors) { if (string.IsNullOrWhiteSpace(def.Name)) { errors.Add("name is required"); } else if (def.Name.Length > 200 || !IdentifierRegex().IsMatch(def.Name)) { errors.Add($"name '{def.Name}' is invalid: use 1-200 chars of letters, digits, '.', '_', '-'"); } } private void ValidateTrigger(WorkflowDefinition def, List errors) { var trigger = def.Trigger; if (trigger == null) return; // no auto-trigger → manual (event) only; valid if (string.IsNullOrWhiteSpace(trigger.Type) || !TriggerTypes.Contains(trigger.Type)) { errors.Add($"trigger.type is invalid: expected one of {string.Join(", ", TriggerTypes)}"); return; } if (def.Mode == WorkflowMode.Handler) { // Handler mode is a stream subscription — its trigger must be a queue. if (trigger.Type != TriggerType.Queue) errors.Add("handler mode requires trigger.type = 'queue'"); return; } switch (trigger.Type) { case TriggerType.Cron: if (string.IsNullOrWhiteSpace(trigger.Cron) || trigger.Cron.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length != 5) errors.Add("trigger.cron is required and must be a 5-field cron expression"); break; case TriggerType.Interval: if (!DurationParser.TryParse(trigger.Interval, out _)) errors.Add("trigger.interval is required and must be a duration like '30m' or '2h'"); break; case TriggerType.Webhook: if (string.IsNullOrWhiteSpace(trigger.WebhookPath) || !trigger.WebhookPath.StartsWith('/')) errors.Add("trigger.webhookPath is required and must start with '/'"); break; case TriggerType.Queue: errors.Add("queue trigger is only valid for handler mode"); break; } } private void ValidateTasks(WorkflowDefinition def, IReadOnlyList tasks, List errors) { var ids = new HashSet(StringComparer.Ordinal); for (var i = 0; i < tasks.Count; i++) { var task = tasks[i]; var label = $"tasks[{i}]"; if (string.IsNullOrWhiteSpace(task.Id)) { errors.Add($"{label}.id is required"); continue; } if (string.Equals(task.Id, "root", StringComparison.OrdinalIgnoreCase)) { errors.Add($"{label}.id 'root' is reserved"); continue; } if (!IdentifierRegex().IsMatch(task.Id) || task.Id.Length > 100) { errors.Add($"{label}.id '{task.Id}' is invalid"); continue; } if (!ids.Add(task.Id)) { errors.Add($"{label}.id '{task.Id}' is duplicated"); continue; } if (task.Language != null && !_languages.IsKnown(task.Language)) errors.Add($"{label}.language '{task.Language}' is not supported"); if (task.Mode != null && !Modes.Contains(task.Mode)) errors.Add($"{label}.mode '{task.Mode}' is invalid: expected one of {string.Join(", ", Modes)}"); if (task.Entry == null || string.IsNullOrWhiteSpace(task.Entry.File)) errors.Add($"{label}.entry.file is required"); } // Reference resolution: parent/next/onError must point at an existing // task (or "root" for parent). var knownIds = ids; foreach (var task in tasks) { if (task.Id == null) continue; var label = $"task '{task.Id}'"; var parent = string.IsNullOrEmpty(task.Parent) ? "root" : task.Parent; if (!string.Equals(parent, "root", StringComparison.Ordinal) && !knownIds.Contains(parent)) errors.Add($"{label}.parent '{parent}' does not reference a known task or 'root'"); if (!string.IsNullOrEmpty(task.Next)) { if (!knownIds.Contains(task.Next)) errors.Add($"{label}.next '{task.Next}' does not reference a known task"); else if (string.Equals(task.Next, task.Id, StringComparison.Ordinal)) errors.Add($"{label}.next must not reference itself"); } if (!string.IsNullOrEmpty(task.OnError)) { if (!knownIds.Contains(task.OnError)) errors.Add($"{label}.onError '{task.OnError}' does not reference a known task"); else if (string.Equals(task.OnError, task.Id, StringComparison.Ordinal)) errors.Add($"{label}.onError must not reference itself"); } } } private void ValidateFlow(IReadOnlyList tasks, List errors) { if (tasks.Count == 0) return; // root-only workflow // Skip flow validation if the reference/structural checks already failed — // the graph would be incomplete and produce misleading errors. if (errors.Count > 0) return; var graph = WorkflowGraph.Compute(new WorkflowDefinition { Tasks = tasks.ToList() }); // Linear chain: each task is the `next` target of at most one other task. foreach (var (taskId, incoming) in graph.IncomingNext) { if (incoming > 1) errors.Add($"task '{taskId}' has {incoming} incoming 'next' edges; the success chain must be linear"); } if (graph.HeadId == null) { errors.Add("no chain head: expected a top-level task (parent: root) that starts the success chain"); return; } // Cycle detection on the success chain. var seen = new HashSet(StringComparer.Ordinal); var cursor = graph.HeadId; while (cursor != null) { if (!seen.Add(cursor)) { errors.Add($"cycle detected in the success chain at task '{cursor}'"); return; } graph.ById.TryGetValue(cursor, out var task); cursor = string.IsNullOrEmpty(task?.Next) ? null : task.Next; } // Every task must be either on the success chain or compensation-only. var chained = new HashSet(graph.Chain, StringComparer.Ordinal); foreach (var task in tasks) { if (task.Id == null) continue; if (chained.Contains(task.Id) || graph.CompensationOnly.Contains(task.Id)) continue; errors.Add($"task '{task.Id}' is unreachable: it is not on the success chain and not an onError target"); } } }