feat: consolidate legacy workflows into gyxx-flow
This commit is contained in:
commit
c23b62a8c8
374 files changed
+132990
No files matched your search
@@ -0,0 +1,153 @@
|
||||
"""Validated workflow and step definitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, 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:._-]*$")
|
||||
|
||||
|
||||
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
|
||||
|
||||
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 self.timeout_seconds <= 0
|
||||
):
|
||||
raise WorkflowValidationError(
|
||||
f"timeout_seconds must be positive for step {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}"
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
Reference in new issue
Block a user