import re from collections import defaultdict from typing import Protocol from app.models.workflow import ValidationIssue, ValidationResult, WorkflowDocument from app.services.catalog import SUPPORTED_NODE_TYPES EXPRESSION_RE = re.compile(r"^=.*\{\{.+\}\}.*$", re.DOTALL) class ValidationRule(Protocol): code: str def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: ... class TriggerRule: code = "missing_trigger" def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: if any(node.data.category == "trigger" for node in workflow.nodes): return [] return [ ValidationIssue( code=self.code, severity="error", message="Workflow does not have a trigger node.", suggestion="Add a Webhook, Schedule Trigger, or service trigger.", ) ] class CredentialRule: code = "missing_credentials" def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: issues = [] for node in workflow.nodes: for credential in (node.data.credentials or {}).values(): if not credential.id: issues.append( ValidationIssue( code=self.code, severity="error", nodeId=node.id, message=f"{node.data.label} has an unconfigured credential.", suggestion=f"Select a {credential.type} credential before activation.", ) ) return issues class ConnectionRule: code = "broken_connection" def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: ids = {node.id for node in workflow.nodes} issues = [] for edge in workflow.edges: if edge.source not in ids or edge.target not in ids: issues.append( ValidationIssue( code=self.code, severity="error", message=f"Connection {edge.id} references a missing node.", suggestion="Delete the connection or reconnect it to an existing node.", ) ) connected = {edge.source for edge in workflow.edges} | { edge.target for edge in workflow.edges } for node in workflow.nodes: if len(workflow.nodes) > 1 and node.id not in connected: issues.append( ValidationIssue( code="disconnected_node", severity="warning", nodeId=node.id, message=f"{node.data.label} is not connected.", suggestion="Connect or remove this node.", ) ) return issues class LoopRule: code = "infinite_loop" def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: graph: dict[str, list[str]] = defaultdict(list) for edge in workflow.edges: graph[edge.source].append(edge.target) visiting: set[str] = set() visited: set[str] = set() def has_cycle(node_id: str) -> bool: if node_id in visiting: return True if node_id in visited: return False visiting.add(node_id) if any(has_cycle(target) for target in graph[node_id]): return True visiting.remove(node_id) visited.add(node_id) return False if any(has_cycle(node.id) for node in workflow.nodes if node.id not in visited): return [ ValidationIssue( code=self.code, severity="error", message="Workflow contains a cycle that may execute indefinitely.", suggestion="Break the cycle or add an explicit loop termination condition.", ) ] return [] class ParameterRule: code = "empty_parameters" def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: issues = [] for node in workflow.nodes: if node.data.category != "trigger" and not node.data.parameters: issues.append( ValidationIssue( code=self.code, severity="warning", nodeId=node.id, message=f"{node.data.label} has no parameters.", suggestion="Configure the required operation and resource fields.", ) ) return issues class ExpressionRule: code = "invalid_expression" def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: issues = [] def inspect(value, node_id: str, label: str) -> None: if isinstance(value, dict): for nested in value.values(): inspect(nested, node_id, label) elif isinstance(value, list): for nested in value: inspect(nested, node_id, label) elif isinstance(value, str) and ("{{" in value or "}}" in value): if value.count("{{") != value.count("}}") or not EXPRESSION_RE.match(value): issues.append( ValidationIssue( code=self.code, severity="error", nodeId=node_id, message=f"{label} contains an invalid n8n expression.", suggestion="Expressions should start with = and use balanced {{ }} braces.", ) ) for node in workflow.nodes: inspect(node.data.parameters, node.id, node.data.label) return issues class SupportedNodeRule: code = "unsupported_node" def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: return [ ValidationIssue( code=self.code, severity="warning", nodeId=node.id, message=f"{node.data.label} is not in the bundled node catalog.", suggestion="Confirm that the node package is installed on the target n8n instance.", ) for node in workflow.nodes if node.data.type not in SUPPORTED_NODE_TYPES and not node.data.type.startswith("@n8n/n8n-nodes-langchain.") ] class Validator: def __init__(self) -> None: self.rules: list[ValidationRule] = [] def register(self, rule: ValidationRule) -> None: self.rules.append(rule) def validate(self, workflow: WorkflowDocument) -> ValidationResult: issues = [issue for rule in self.rules for issue in rule.validate(workflow)] deductions = {"error": 18, "warning": 6, "info": 1} score = max(0, 100 - sum(deductions[issue.severity] for issue in issues)) return ValidationResult( valid=not any(issue.severity == "error" for issue in issues), score=score, issues=issues, ) validator = Validator() for validation_rule in ( TriggerRule(), CredentialRule(), ConnectionRule(), LoopRule(), ParameterRule(), ExpressionRule(), SupportedNodeRule(), ): validator.register(validation_rule)