2026-04-14 10:11:56 -05:00
|
|
|
"""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.
|
2026-06-16 18:03:45 -05:00
|
|
|
- ``load_custom_steps`` — loads community-installed step types into STEP_REGISTRY.
|
2026-04-14 10:11:56 -05:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-16 18:03:45 -05:00
|
|
|
from pathlib import Path
|
2026-04-14 10:11:56 -05:00
|
|
|
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
|
2026-06-17 11:46:51 -05:00
|
|
|
from .steps.init import InitStep
|
2026-04-14 10:11:56 -05:00
|
|
|
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())
|
2026-06-17 11:46:51 -05:00
|
|
|
_register_step(InitStep())
|
2026-04-14 10:11:56 -05:00
|
|
|
_register_step(PromptStep())
|
|
|
|
|
_register_step(ShellStep())
|
|
|
|
|
_register_step(SwitchStep())
|
|
|
|
|
_register_step(WhileStep())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_register_builtin_steps()
|
2026-06-16 18:03:45 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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_<hash>").
|
|
|
|
|
# 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
|