chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:51 +08:00
commit d0e4308def
614 changed files with 74458 additions and 0 deletions
View File
Executable
+121
View File
@@ -0,0 +1,121 @@
"""Utilities for loading, validating design_0.4.0 workflows."""
from pathlib import Path
from typing import Any, Dict, Optional
from runtime.bootstrap.schema import ensure_schema_registry_populated
from check.check_yaml import validate_design
from check.check_workflow import check_workflow_structure
from entity.config_loader import prepare_design_mapping
from entity.configs import DesignConfig, ConfigError
from schema_registry import iter_node_schemas
from utils.io_utils import read_yaml
ensure_schema_registry_populated()
class DesignError(RuntimeError):
"""Raised when a workflow design cannot be loaded or validated."""
def _allowed_node_types() -> set[str]:
names = set(iter_node_schemas().keys())
if not names:
raise DesignError("No node types registered; cannot validate workflow")
return names
def _ensure_supported(graph: Dict[str, Any]) -> None:
"""Ensure the MVP constraints are satisfied for the provided graph."""
for node in graph.get("nodes", []) or []:
nid = node.get("id")
ntype = node.get("type")
allowed = _allowed_node_types()
if ntype not in allowed:
raise DesignError(
f"Unsupported node type '{ntype}' for node '{nid}'. Only {allowed} nodes are supported."
)
if ntype == "agent":
agent_cfg = node.get("config") or {}
if not isinstance(agent_cfg, dict):
raise DesignError(f"Agent node '{nid}' config must be an object")
for legacy_key in ["memory"]:
if legacy_key in agent_cfg:
raise DesignError(
f"'{legacy_key}' is deprecated. Use the new graph-level memory stores for node '{nid}'."
)
def load_config(
config_path: Path,
*,
fn_module: Optional[str] = None,
set_defaults: bool = True,
vars_override: Optional[Dict[str, Any]] = None,
) -> DesignConfig:
"""Load, validate, and sanity-check a workflow file."""
try:
raw_data = read_yaml(config_path)
except FileNotFoundError as exc:
raise DesignError(f"Design file not found: {config_path}") from exc
if not isinstance(raw_data, dict):
raise DesignError("YAML root must be a mapping")
if vars_override:
merged_vars = dict(raw_data.get("vars") or {})
merged_vars.update(vars_override)
raw_data = dict(raw_data)
raw_data["vars"] = merged_vars
data = prepare_design_mapping(raw_data, source=str(config_path))
schema_errors = validate_design(data, set_defaults=set_defaults, fn_module_ref=fn_module)
if schema_errors:
formatted = "\n".join(f"- {err}" for err in schema_errors)
raise DesignError(f"Design validation failed for '{config_path}':\n{formatted}")
try:
design = DesignConfig.from_dict(data, path="root")
except ConfigError as exc:
raise DesignError(f"Design parsing failed for '{config_path}': {exc}") from exc
logic_errors = check_workflow_structure(data)
if logic_errors:
formatted = "\n".join(f"- {err}" for err in logic_errors)
raise DesignError(f"Workflow logical issues detected for '{config_path}':\n{formatted}")
else:
print("Workflow OK.")
graph = data.get("graph") or {}
_ensure_supported(graph)
return design
def check_config(yaml_content: Any) -> str:
if not isinstance(yaml_content, dict):
return "YAML root must be a mapping"
# Skip placeholder resolution during save - users may configure env vars at runtime
# Use yaml_content directly instead of prepare_design_mapping()
schema_errors = validate_design(yaml_content)
if schema_errors:
formatted = "\n".join(f"- {err}" for err in schema_errors)
return formatted
logic_errors = check_workflow_structure(yaml_content)
if logic_errors:
formatted = "\n".join(f"- {err}" for err in logic_errors)
return formatted
graph = yaml_content.get("graph") or {}
try:
_ensure_supported(graph)
except Exception as e:
return str(e)
return ""
+161
View File
@@ -0,0 +1,161 @@
import argparse
from typing import Any, Dict, List, Optional, Tuple
import yaml
from check import check_yaml
from utils.io_utils import read_yaml
def _node_ids(graph: Dict[str, Any]) -> List[str]:
nodes = graph.get("nodes", []) or []
ids: List[str] = []
for n in nodes:
nid = n.get("id")
if isinstance(nid, str):
ids.append(nid)
return ids
def _edge_list(graph: Dict[str, Any]) -> List[Dict[str, Any]]:
edges = graph.get("edges", []) or []
return [e for e in edges if isinstance(e, dict) and "from" in e and "to" in e]
def _analyze_graph(graph: Dict[str, Any], base_path: str, errors: List[str]) -> None:
# Majority voting graphs are skipped for start/end structure checks
is_mv = graph.get("is_majority_voting", False)
if is_mv:
return
nodes = _node_ids(graph)
node_set = set(nodes)
# Validate provided start/end (if any) reference existing nodes
# start = graph.get("start")
end = graph.get("end")
# if start is not None and start not in node_set:
# errors.append(f"{base_path}.start references unknown node id '{start}'")
# Normalize to list
if end is not None:
if isinstance(end, str):
end_list = [end]
elif isinstance(end, list):
end_list = end
else:
errors.append(f"{base_path}.end must be a string or list of strings")
return
# Check each node ID in the end list
for end_node_id in end_list:
if not isinstance(end_node_id, str):
errors.append(
f"{base_path}.end contains non-string element: {end_node_id}"
)
elif end_node_id not in node_set:
errors.append(
f"{base_path}.end references unknown node id '{end_node_id}'"
)
# Compute in/out degrees within this graph scope
indeg = {nid: 0 for nid in nodes}
outdeg = {nid: 0 for nid in nodes}
for e in _edge_list(graph):
frm = e.get("from")
to = e.get("to")
if frm in outdeg:
outdeg[frm] += 1
if to in indeg:
indeg[to] += 1
# sources = [nid for nid in nodes if indeg.get(nid, 0) == 0]
sinks = [nid for nid in nodes if outdeg.get(nid, 0) == 0]
# # Rule:
# # - A non-cyclic (sub)graph should have exactly one natural source AND exactly one natural sink.
# # - Otherwise (e.g., multiple sources/sinks or cycles -> none), require explicit start or end.
# has_unique_source = len(sources) == 1
# has_unique_sink = len(sinks) == 1
# if not (has_unique_source and has_unique_sink):
# if start is None and end is None:
# errors.append(
# f"{base_path}: graph lacks a unique natural start and end; specify 'start' or 'end' explicitly"
# )
if not (len(sinks) == 1):
if end is None:
errors.append(
f"{base_path}: graph lacks a unique natural end; specify 'end' explicitly"
)
# Recurse into subgraphs
for i, n in enumerate(graph.get("nodes", []) or []):
if isinstance(n, dict) and n.get("type") == "subgraph":
sub = n.get("config") or {}
if not isinstance(sub, dict):
errors.append(f"{base_path}.nodes[{i}].config must be object for subgraph nodes")
continue
sg_type = sub.get("type")
if sg_type == "config":
config_block = sub.get("config")
if not isinstance(config_block, dict):
errors.append(
f"{base_path}.nodes[{i}].config.config must be object when type=config"
)
continue
_analyze_graph(config_block, f"{base_path}.nodes[{i}].config.config", errors)
elif sg_type == "file":
file_block = sub.get("config")
if not (isinstance(file_block, dict) and isinstance(file_block.get("path"), str)):
errors.append(
f"{base_path}.nodes[{i}].config.config.path must be string when type=file"
)
else:
errors.append(
f"{base_path}.nodes[{i}].config.type must be 'config' or 'file'"
)
def check_workflow_structure(data: Any) -> List[str]:
errors: List[str] = []
if not isinstance(data, dict) or "graph" not in data:
return ["<root>.graph is required"]
graph = data["graph"]
if not isinstance(graph, dict):
return ["<root>.graph must be object"]
_analyze_graph(graph, "graph", errors)
return errors
def main():
parser = argparse.ArgumentParser(
description="Check workflow structure: unique natural start/end or explicit start/end per (sub)graph")
parser.add_argument("path", nargs="?", default="design_0.4.0.yaml", help="Path to YAML file")
parser.add_argument("--no-schema", action="store_true", help="Skip schema validation (0.4.0)")
parser.add_argument("--fn-module", dest="fn_module", default=None,
help="Module name or .py path where edge functions are defined (for schema validation)")
args = parser.parse_args()
data = read_yaml(args.path)
if not args.no_schema:
schema_errors = check_yaml.validate_design(data, set_defaults=True, fn_module_ref=args.fn_module)
if schema_errors:
print("Invalid schema:")
for e in schema_errors:
print(f"- {e}")
raise SystemExit(1)
logic_errors = check_workflow_structure(data)
if logic_errors:
print("Workflow issues:")
for e in logic_errors:
print(f"- {e}")
raise SystemExit(2)
else:
print("Workflow OK.")
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
"""Lightweight schema validation leveraging typed config loaders."""
import argparse
from pathlib import Path
from typing import Any, List, Optional
from entity.configs import ConfigError, DesignConfig
from utils.io_utils import read_yaml
def validate_design(data: Any, set_defaults: bool = True, fn_module_ref: Optional[str] = None) -> List[str]:
"""Validate raw YAML data using the typed config loader.
Note: This function validates schema structure only, without resolving
environment variable placeholders like ${VAR}. This allows workflows to
be saved even when environment variables are not yet configured - they
will be resolved at runtime.
"""
try:
if not isinstance(data, dict):
raise ConfigError("YAML root must be a mapping", path="root")
# Use DesignConfig.from_dict directly to skip placeholder resolution
# Users may configure environment variables at runtime
DesignConfig.from_dict(data)
return []
except ConfigError as exc:
return [str(exc)]
def main() -> None:
parser = argparse.ArgumentParser(description="Validate workflow YAML structure against the typed config loader")
parser.add_argument("path", help="Path to the workflow YAML file")
args = parser.parse_args()
data = read_yaml(args.path)
errors = validate_design(data)
if errors:
print("Design validation failed:")
for err in errors:
print(f"- {err}")
raise SystemExit(1)
print("Design validation successful.")
if __name__ == "__main__":
main()