diff --git a/scripts/sidecar_validator.py b/scripts/sidecar_validator.py new file mode 100755 index 00000000..d4da98d1 --- /dev/null +++ b/scripts/sidecar_validator.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +sidecar_validator.py - Pre-deploy validation for timmy-config sidecar configs. +Validates YAML/JSON configs against expected schemas before deploy. +Usage: + python3 scripts/sidecar_validator.py [config_path ...] + python3 scripts/sidecar_validator.py --all + python3 scripts/sidecar_validator.py --pre-deploy +""" +import json, os, sys +from pathlib import Path +try: + import yaml +except ImportError: + print("ERROR: PyYAML not installed.", file=sys.stderr); sys.exit(2) + +SCHEMAS = { + "wizard_config": { + "description": "Wizard agent configuration", + "required": {"model": dict}, + "optional": {"toolsets": list, "fallback_providers": list, "agent": dict, "providers": dict, "terminal": dict, "browser": dict, "compression": dict, "auxiliary": dict}, + "nested_required": {"model": {"default": str, "provider": str}}, + "nested_optional": {"model": {"fallback": str}, "agent": {"max_turns": (int, float), "reasoning_effort": str, "verbose": bool}}, + }, + "sidecar_config": { + "description": "Sidecar agent configuration", + "required": {"name": str, "role": str}, + "optional": {"capabilities": list, "instructions": str, "model": str, "provider": str, "toolsets": list}, + }, + "main_config": { + "description": "Main hermes configuration", + "required": {"model": dict}, + "optional": {"toolsets": list, "agent": dict, "terminal": dict, "browser": dict, "checkpoints": dict, "compression": dict, "auxiliary": dict, "fallback_providers": list, "providers": dict}, + "nested_required": {"model": {"default": str, "provider": str}}, + }, + "cron_pipeline": { + "description": "Cron/pipeline schedule", + "required": {"name": str}, + "optional": {"schedule": str, "cron": str, "tasks": list, "steps": list, "prompt": str, "model": dict}, + }, + "playbook": { + "description": "Agent playbook", + "required": {"name": str}, + "optional": {"description": str, "model": str, "steps": list, "prompt": str}, + }, +} + +def classify_config(filepath): + parts, name = filepath.parts, filepath.name + if "wizards" in parts and "-sidecar." in name: return "sidecar_config" + if "wizards" in parts and name in ("config.yaml", "config.yml"): return "wizard_config" + if name == "config.yaml" and len(parts) <= 2: return "main_config" + if "cron" in parts and name.endswith((".yml", ".yaml")): return "cron_pipeline" + if "playbooks" in parts and name.endswith((".yaml", ".yml")): return "playbook" + return None + +def type_name(t): + if isinstance(t, tuple): return " or ".join(tt.__name__ for tt in t) + return t.__name__ + +def validate_config(data, schema_name, schema): + errors = [] + for key, expected_type in schema["required"].items(): + if key not in data: + errors.append(f"missing required key: '{key}' (expected {type_name(expected_type)})") + elif not isinstance(data[key], expected_type): + errors.append(f"'{key}' wrong type: got {type(data[key]).__name__}, expected {type_name(expected_type)}") + for pk, cs in schema.get("nested_required", {}).items(): + if pk in data and isinstance(data[pk], dict): + for ck, et in cs.items(): + if ck not in data[pk]: + errors.append(f"'{pk}' missing key: '{ck}'") + elif not isinstance(data[pk][ck], et): + errors.append(f"'{pk}.{ck}' wrong type: {type(data[pk][ck]).__name__}") + for pk, cs in schema.get("nested_optional", {}).items(): + if pk in data and isinstance(data[pk], dict): + for ck, et in cs.items(): + if ck in data[pk] and not isinstance(data[pk][ck], et): + errors.append(f"'{pk}.{ck}' wrong type: {type(data[pk][ck]).__name__}") + if schema_name == "wizard_config" and "fallback_providers" in data and isinstance(data["fallback_providers"], list): + for i, fb in enumerate(data["fallback_providers"]): + if not isinstance(fb, dict): + errors.append(f"fallback_providers[{i}]: expected dict") + elif "provider" not in fb: errors.append(f"fallback_providers[{i}]: missing 'provider'") + elif "model" not in fb: errors.append(f"fallback_providers[{i}]: missing 'model'") + if schema_name == "sidecar_config" and "capabilities" in data: + if not isinstance(data["capabilities"], list): + errors.append(f"'capabilities' must be a list") + if schema_name == "cron_pipeline": + s = data.get("schedule") or data.get("cron", "") + if isinstance(s, str) and s.strip() and len(s.strip().split()) != 5: + errors.append(f"schedule has {len(s.strip().split())} fields, expected 5") + return errors + +def validate_file(filepath): + schema_name = classify_config(filepath) + if schema_name is None: return True, [] + schema = SCHEMAS[schema_name] + try: text = filepath.read_text(encoding="utf-8", errors="replace") + except Exception as e: return False, [f"cannot read: {e}"] + try: + data = json.loads(text) if filepath.suffix == ".json" else yaml.safe_load(text) + except Exception as e: return False, [f"parse error: {e}"] + if not isinstance(data, dict): return False, [f"expected mapping, got {type(data).__name__}"] + errors = validate_config(data, schema_name, schema) + return len(errors) == 0, errors + +def find_deploy_targets(root): + targets = [] + for p in ["config.yaml", "wizards/*/config.yaml", "wizards/*-sidecar.json"]: + targets.extend(root.glob(p)) + return sorted(targets) + +def find_all_configs(root): + skip = {".git", "node_modules", "venv", "__pycache__"} + results = [] + for dp, dns, fns in os.walk(root): + dns[:] = [d for d in dns if d not in skip] + for fn in fns: + if fn.endswith((".yaml", ".yml", ".json")): + fp = Path(dp) / fn + if classify_config(fp) is not None: results.append(fp) + return sorted(results) + +def main(): + root = Path(__file__).resolve().parent.parent + args = sys.argv[1:] + if not args or args == ["--help"]: print(__doc__); sys.exit(2) + if "--all" in args: files = find_all_configs(root); mode = "all configs" + elif "--pre-deploy" in args: files = find_deploy_targets(root); mode = "deploy targets" + else: files = [Path(a) for a in args if not a.startswith("-")]; mode = "specified files" + if not files: print(f"No files found: {mode}"); sys.exit(0) + print(f"Sidecar Validator - {mode}"); print("=" * 60) + total = 0; failed = [] + for fp in files: + rel = fp.relative_to(root) if fp.is_absolute() else fp + sn = classify_config(fp) or "unknown" + ok, errs = validate_file(fp) + if ok: print(f"PASS {rel} [{sn}]") + else: failed.append((rel, sn, errs)); total += len(errs); print(f"FAIL {rel} [{sn}]") + print(); print("=" * 60) + print(f"Results: {len(files)-len(failed)}/{len(files)} valid") + if failed: + print(f"\n{total} error(s) in {len(failed)} file(s):\n") + for rel, sn, errs in failed: + print(f" {rel} ({sn}):") + for e in errs: print(f" - {e}") + print(); sys.exit(1) + else: print("\nAll configs valid!"); sys.exit(0) +if __name__ == "__main__": main()