"""Workflow engine for multi-step, resumable automation workflows. Provides: - ``StepBase`` — abstract base every step type must implement. - ``StepContext`` — execution context passed to each step. - ``StepResult`` — return value from step execution. - ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances. - ``WorkflowEngine`` — orchestrator that loads, validates, and executes workflow YAML definitions. - ``load_custom_steps`` — loads community-installed step types into STEP_REGISTRY. """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from .base import StepBase # Maps step type_key → StepBase instance. STEP_REGISTRY: dict[str, StepBase] = {} def _register_step(step: StepBase) -> None: """Register a step type instance in the global registry. Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. """ key = step.type_key if not key: raise ValueError("Cannot register step type with an empty type_key.") if key in STEP_REGISTRY: raise KeyError(f"Step type with key {key!r} is already registered.") STEP_REGISTRY[key] = step def get_step_type(type_key: str) -> StepBase | None: """Return the step type for *type_key*, or ``None`` if not registered.""" return STEP_REGISTRY.get(type_key) # -- Register built-in step types ---------------------------------------- def _register_builtin_steps() -> None: """Register all built-in step types.""" from .steps.command import CommandStep from .steps.do_while import DoWhileStep from .steps.fan_in import FanInStep from .steps.fan_out import FanOutStep from .steps.gate import GateStep from .steps.if_then import IfThenStep from .steps.init import InitStep from .steps.prompt import PromptStep from .steps.shell import ShellStep from .steps.switch import SwitchStep from .steps.while_loop import WhileStep _register_step(CommandStep()) _register_step(DoWhileStep()) _register_step(FanInStep()) _register_step(FanOutStep()) _register_step(GateStep()) _register_step(IfThenStep()) _register_step(InitStep()) _register_step(PromptStep()) _register_step(ShellStep()) _register_step(SwitchStep()) _register_step(WhileStep()) _register_builtin_steps() def load_custom_steps(project_root: Path) -> list[str]: """Load community-installed custom step types into STEP_REGISTRY. Scans ``.specify/workflows/steps/`` for installed step packages. Each valid package must contain ``step.yml`` (with a ``step.type_key`` field) and ``__init__.py`` (a ``StepBase`` subclass). Returns a list of type_keys that were successfully loaded. Silently skips packages that fail to import or validate. """ import hashlib as _hashlib import importlib.util as _importlib_util import re as _re import sys as _sys steps_dir = Path(project_root) / ".specify" / "workflows" / "steps" # Defense-in-depth: refuse to execute step code from a symlinked # parent directory under .specify/workflows/steps, which could redirect # the import outside the project root and bypass the install-time # symlink guard. Check symlinks *before* is_dir() since the latter # follows symlinks and would stat an external target. _current = Path(project_root) for _part in (".specify", "workflows", "steps"): _current = _current / _part if _current.is_symlink(): return [] if not steps_dir.is_dir(): return [] loaded: list[str] = [] for step_dir in steps_dir.iterdir(): # Check symlinks before is_dir() since the latter follows symlinks # and would stat an external target through a symlinked directory. if step_dir.is_symlink(): continue if not step_dir.is_dir(): continue step_yml = step_dir / "step.yml" init_py = step_dir / "__init__.py" if step_yml.is_symlink() or init_py.is_symlink(): continue if not step_yml.is_file() or not init_py.is_file(): continue try: import yaml as _yaml meta = _yaml.safe_load(step_yml.read_text(encoding="utf-8")) or {} step_meta = meta.get("step", {}) type_key = step_meta.get("type_key", "") if not type_key: continue # Skip if already registered (e.g. built-in or previously loaded) if type_key in STEP_REGISTRY: continue # Sanitize type_key so the synthetic module name is a valid identifier # (e.g. "test-custom" → "_speckit_custom_step_test_custom_"). # The 8-char SHA-256 hash of the original type_key makes the name # collision-resistant when different type_keys produce the same # sanitized form (e.g. "a-b" and "a_b" both sanitize to "a_b" but # have different hashes). safe_key = _re.sub(r"[^A-Za-z0-9_]", "_", type_key) key_hash = _hashlib.sha256(type_key.encode()).hexdigest()[:8] module_name = f"_speckit_custom_step_{safe_key}_{key_hash}" # Treat the step directory as a proper package so that relative # imports inside the step (e.g. ``from .helpers import …``) work. spec = _importlib_util.spec_from_file_location( module_name, init_py, submodule_search_locations=[str(step_dir)], ) if spec is None or spec.loader is None: continue module = _importlib_util.module_from_spec(spec) module.__package__ = module_name # Register before exec so relative imports resolve correctly. _sys.modules[module_name] = module registered = False try: spec.loader.exec_module(module) # type: ignore[union-attr] # Find the StepBase subclass in the module from .base import StepBase as _StepBase step_class = None for attr_name in dir(module): attr = getattr(module, attr_name) try: if ( isinstance(attr, type) and issubclass(attr, _StepBase) and attr is not _StepBase and getattr(attr, "type_key", "") == type_key ): step_class = attr break except TypeError: continue if step_class is None: continue _register_step(step_class()) loaded.append(type_key) registered = True finally: # If the step wasn't successfully registered (failed import, # no matching StepBase subclass, or registration error), remove # the synthetic module — and any submodules loaded via relative # imports (e.g. ``from .helpers import …``) — from sys.modules so # a broken/skipped step package leaves no lingering import state # behind. if not registered: _sys.modules.pop(module_name, None) submodule_prefix = module_name + "." for _mod_key in [ k for k in _sys.modules if k.startswith(submodule_prefix) ]: _sys.modules.pop(_mod_key, None) except Exception: # noqa: BLE001 # Silently skip broken step packages at load time continue return loaded