"""Validated workflow and step definitions.""" from __future__ import annotations import heapq import math import re from dataclasses import dataclass from typing import TYPE_CHECKING, Literal, Protocol if TYPE_CHECKING: from gyxx_flow.core.context import RunContext from .steps import StepExecution _ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") _RESOURCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]*$") # ``repeatable`` is reserved for production actions that intentionally submit # on every invocation. Unlike ``guarded``, it does not use the business-date # keyed effect ledger; unlike ``idempotent``, it does not claim that the # external action itself is idempotent. ReplayPolicy = Literal["guarded", "idempotent", "repeatable"] class WorkflowValidationError(ValueError): """Raised when a workflow definition is unsafe or inconsistent.""" class ExecutableStep(Protocol): """Action contract consumed by the workflow engine.""" def execute( self, *, context: RunContext, timeout_seconds: float | None, dry_run: bool, ) -> StepExecution: ... @dataclass(frozen=True, slots=True) class StepDefinition: """Execution policy and dependency metadata for one workflow step.""" step_id: str action: ExecutableStep depends_on: tuple[str, ...] = () timeout_seconds: float | None = None max_attempts: int = 1 retry_delay_seconds: float = 0.0 critical: bool = True resources: tuple[str, ...] = () production_sink: bool = False official_notification: bool = False replay_policy: ReplayPolicy = "guarded" run_after_failure: bool = False resource_lock_timeout_seconds: float = 30.0 def __post_init__(self) -> None: object.__setattr__(self, "depends_on", tuple(self.depends_on)) object.__setattr__(self, "resources", tuple(self.resources)) if not isinstance(self.step_id, str) or not _ID.fullmatch(self.step_id): raise WorkflowValidationError(f"invalid step id: {self.step_id!r}") if not callable(getattr(self.action, "execute", None)): raise WorkflowValidationError(f"step {self.step_id!r} has no executable action") if len(set(self.depends_on)) != len(self.depends_on): raise WorkflowValidationError(f"duplicate dependency for step {self.step_id!r}") for dependency in self.depends_on: if not isinstance(dependency, str) or not _ID.fullmatch(dependency): raise WorkflowValidationError( f"invalid dependency for step {self.step_id!r}: {dependency!r}" ) if dependency == self.step_id: raise WorkflowValidationError(f"step {self.step_id!r} depends on itself") if self.timeout_seconds is not None and ( isinstance(self.timeout_seconds, bool) or not isinstance(self.timeout_seconds, (int, float)) or not math.isfinite(self.timeout_seconds) or self.timeout_seconds <= 0 ): raise WorkflowValidationError( f"timeout_seconds must be positive for step {self.step_id!r}" ) if ( isinstance(self.resource_lock_timeout_seconds, bool) or not isinstance(self.resource_lock_timeout_seconds, (int, float)) or not math.isfinite(self.resource_lock_timeout_seconds) or self.resource_lock_timeout_seconds <= 0 ): raise WorkflowValidationError( "resource_lock_timeout_seconds must be positive for step " f"{self.step_id!r}" ) if ( isinstance(self.max_attempts, bool) or not isinstance(self.max_attempts, int) or self.max_attempts < 1 ): raise WorkflowValidationError( f"max_attempts must be a positive integer for step {self.step_id!r}" ) if isinstance(self.retry_delay_seconds, bool) or self.retry_delay_seconds < 0: raise WorkflowValidationError( f"retry_delay_seconds must be non-negative for step {self.step_id!r}" ) if len(set(self.resources)) != len(self.resources): raise WorkflowValidationError(f"duplicate resource for step {self.step_id!r}") for resource in self.resources: if not isinstance(resource, str) or not _RESOURCE.fullmatch(resource): raise WorkflowValidationError( f"invalid resource for step {self.step_id!r}: {resource!r}" ) if not isinstance(self.run_after_failure, bool): raise WorkflowValidationError( f"run_after_failure must be boolean for step {self.step_id!r}" ) if self.replay_policy not in {"guarded", "idempotent", "repeatable"}: raise WorkflowValidationError( f"invalid replay_policy for step {self.step_id!r}: " f"{self.replay_policy!r}" ) if self.replay_policy == "idempotent" and not self.production_sink: raise WorkflowValidationError( f"idempotent replay_policy requires production_sink for step " f"{self.step_id!r}" ) @dataclass(frozen=True, slots=True) class WorkflowDefinition: """A validated DAG with deterministic execution ordering.""" workflow_id: str steps: tuple[StepDefinition, ...] def __post_init__(self) -> None: object.__setattr__(self, "steps", tuple(self.steps)) if not isinstance(self.workflow_id, str) or not _ID.fullmatch(self.workflow_id): raise WorkflowValidationError(f"invalid workflow id: {self.workflow_id!r}") ids = [step.step_id for step in self.steps] duplicate = _first_duplicate(ids) if duplicate is not None: raise WorkflowValidationError(f"duplicate step id: {duplicate}") known = set(ids) for step in self.steps: for dependency in step.depends_on: if dependency not in known: raise WorkflowValidationError( f"unknown dependency {dependency!r} for step {step.step_id!r}" ) self.ordered_steps() def ordered_steps(self) -> tuple[StepDefinition, ...]: """Return a stable topological order or reject dependency cycles.""" positions = {step.step_id: index for index, step in enumerate(self.steps)} indegrees = {step.step_id: len(step.depends_on) for step in self.steps} dependents: dict[str, list[str]] = {step.step_id: [] for step in self.steps} for step in self.steps: for dependency in step.depends_on: dependents[dependency].append(step.step_id) ready = [positions[step.step_id] for step in self.steps if not step.depends_on] heapq.heapify(ready) ordered: list[StepDefinition] = [] while ready: position = heapq.heappop(ready) step = self.steps[position] ordered.append(step) for dependent in dependents[step.step_id]: indegrees[dependent] -= 1 if indegrees[dependent] == 0: heapq.heappush(ready, positions[dependent]) if len(ordered) != len(self.steps): cyclic = sorted(step_id for step_id, degree in indegrees.items() if degree) raise WorkflowValidationError( f"dependency cycle detected: {', '.join(cyclic)}" ) return tuple(ordered) def _first_duplicate(values: list[str]) -> str | None: seen: set[str] = set() for value in values: if value in seen: return value seen.add(value) return None