# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Action signature validator for the Typed Action Architecture (Phase 2). Verifies that every WorkflowActionDefinition exposed by a Method: 1. Declares a parameter `type` that is either a primitive or a known PORT_TYPE_CATALOG schema name. 2. Declares an `outputType` that exists in PORT_TYPE_CATALOG. 3. Declares container types (`List[X]`, `Dict[K,V]`) whose element types are also primitives or catalog schemas. Used at startup (and in CI tests) to prevent silent drift between backend method signatures and the type catalog. Plan: wiki/c-work/1-plan/2026-04-typed-action-architecture.md """ from __future__ import annotations import logging from typing import Dict, Iterable, List, Optional from modules.datamodels.datamodelWorkflowActions import ( WorkflowActionDefinition, WorkflowActionParameter, ) from modules.features.graphicalEditor.portTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, _stripContainer, ) logger = logging.getLogger(__name__) # Catalog types that are explicitly allowed as fire-and-forget outputs # (no typed payload expected by downstream nodes). _ALLOWED_GENERIC_OUTPUTS = frozenset({"ActionResult", "Transit"}) def _isKnownType(typeName: str) -> bool: """Primitive or catalog-resolvable type name.""" return typeName in PRIMITIVE_TYPES or typeName in PORT_TYPE_CATALOG def _validateTypeRef(typeStr: str) -> List[str]: """ Validate a single type reference string (the value of `type` on a WorkflowActionParameter or `outputType` on a WorkflowActionDefinition). Returns a list of human-readable error fragments (empty if OK). """ if not typeStr or not isinstance(typeStr, str): return ["empty/non-string type"] # Backwards-compatible aliases (lowercase Python builtins) if typeStr in {"list", "dict"}: return [ f"'{typeStr}' is too generic — use 'List[X]' / 'Dict[K,V]' or a " f"catalog schema name" ] parts = _stripContainer(typeStr) if not parts: return [f"could not parse type '{typeStr}'"] errors: List[str] = [] for part in parts: if not _isKnownType(part): errors.append( f"unknown type '{part}' (not a primitive and not in catalog)" ) return errors def _validateActionParameter( actionId: str, paramName: str, param: WorkflowActionParameter, ) -> List[str]: """Validate a single parameter; returns prefixed error messages.""" out: List[str] = [] for err in _validateTypeRef(param.type): out.append(f"{actionId}.{paramName}: {err}") return out def _validateActionDefinition( actionDef: WorkflowActionDefinition, ) -> List[str]: """Validate parameters + outputType of one action.""" errors: List[str] = [] actionId = actionDef.actionId or "" for paramName, param in (actionDef.parameters or {}).items(): errors.extend(_validateActionParameter(actionId, paramName, param)) outputType = actionDef.outputType if outputType not in _ALLOWED_GENERIC_OUTPUTS: for err in _validateTypeRef(outputType): errors.append(f"{actionId}.: {err}") return errors def _validateActionsDict( methodName: str, actions: Dict[str, WorkflowActionDefinition], ) -> List[str]: """Validate every action in a Method's _actions dict.""" errors: List[str] = [] if not actions: return errors for localName, actionDef in actions.items(): if not isinstance(actionDef, WorkflowActionDefinition): errors.append( f"{methodName}.{localName}: not a WorkflowActionDefinition instance" ) continue errors.extend(_validateActionDefinition(actionDef)) return errors # --------------------------------------------------------------------------- # Public entry points # --------------------------------------------------------------------------- def _validateMethods(methodInstances: Iterable) -> List[str]: """ Validate a sequence of Method instances. Each instance is expected to expose `_actions` (Dict[str, WorkflowActionDefinition]). """ errors: List[str] = [] for method in methodInstances: methodName = getattr(method, "name", method.__class__.__name__) actions = getattr(method, "_actions", None) or {} errors.extend(_validateActionsDict(methodName, actions)) return errors def _formatValidationReport(errors: List[str]) -> str: """Build a multi-line human-readable error report.""" if not errors: return "Action signatures are healthy." lines = [f"Found {len(errors)} action-signature drift(s):"] lines.extend(f" - {e}" for e in errors) return "\n".join(lines) def _logValidationReport(errors: List[str], strict: bool = False) -> None: """ Log validation results. If `strict=True`, raises RuntimeError on any error (use in tests / CI). Otherwise emits warnings (use at startup so the app keeps running but operators see the drift in the log). """ report = _formatValidationReport(errors) if errors: if strict: raise RuntimeError(report) logger.warning(report) else: logger.info(report) __all__ = [ "_validateMethods", "_validateActionsDict", "_validateActionDefinition", "_validateActionParameter", "_validateTypeRef", "_formatValidationReport", "_logValidationReport", ]