Files
gyxx-flow/tests/test_workflow_engine.py
T

366 lines
12 KiB
Python

from __future__ import annotations
import json
import subprocess
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from gyxx_flow.core.context import RunContext
from gyxx_flow.core.layout import DataLayout
from gyxx_flow.core.locks import LockManager
from gyxx_flow.core.records import RunJournal
from gyxx_flow.ops.effects import EffectLedger
from gyxx_flow.workflow.engine import WorkflowEngine
from gyxx_flow.workflow.model import (
StepDefinition,
WorkflowDefinition,
WorkflowValidationError,
)
from gyxx_flow.workflow.steps import CommandStep, StepExecution
def _context(*, shadow: bool = False) -> RunContext:
return RunContext.create(
"shop.weekly",
"2026-07-27",
shadow=shadow,
now=datetime(2026, 7, 27, 4, 0, tzinfo=timezone.utc),
random_suffix="abc123",
)
class FakeStep:
def __init__(self, *outcomes: StepExecution) -> None:
self.outcomes = deque(outcomes or (StepExecution(exit_code=0),))
self.calls: list[dict[str, Any]] = []
def execute(
self,
*,
context: RunContext,
timeout_seconds: float | None,
dry_run: bool,
) -> StepExecution:
self.calls.append(
{
"context": context,
"timeout_seconds": timeout_seconds,
"dry_run": dry_run,
}
)
return self.outcomes.popleft()
def test_workflow_validates_relations_and_uses_stable_topological_order() -> None:
action = FakeStep()
workflow = WorkflowDefinition(
workflow_id="shop.weekly",
steps=(
StepDefinition("publish", action, depends_on=("collect",)),
StepDefinition("audit", action),
StepDefinition("collect", action),
),
)
assert [step.step_id for step in workflow.ordered_steps()] == [
"audit",
"collect",
"publish",
]
with pytest.raises(WorkflowValidationError, match="duplicate step id"):
WorkflowDefinition(
"shop.weekly",
(StepDefinition("collect", action), StepDefinition("collect", action)),
)
with pytest.raises(WorkflowValidationError, match="unknown dependency"):
WorkflowDefinition(
"shop.weekly",
(StepDefinition("publish", action, depends_on=("missing",)),),
)
with pytest.raises(WorkflowValidationError, match="cycle"):
WorkflowDefinition(
"shop.weekly",
(
StepDefinition("first", action, depends_on=("second",)),
StepDefinition("second", action, depends_on=("first",)),
),
)
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"max_attempts": 0}, "max_attempts"),
({"timeout_seconds": 0}, "timeout_seconds"),
({"retry_delay_seconds": -1}, "retry_delay_seconds"),
({"depends_on": ("one", "one")}, "duplicate dependency"),
],
)
def test_step_definition_validates_retry_timeout_and_dependencies(
kwargs: dict[str, object], message: str
) -> None:
with pytest.raises(WorkflowValidationError, match=message):
StepDefinition("collect", FakeStep(), **kwargs)
def test_command_step_uses_argv_explicit_cwd_env_and_timeout(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
captured: dict[str, object] = {}
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
captured["argv"] = argv
captured.update(kwargs)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
step = CommandStep(
argv=("C:/Python312/python.exe", "worker.py", "--date", "2026-07-27"),
cwd=tmp_path,
env={"MODE": "test"},
)
outcome = step.execute(context=_context(), timeout_seconds=9.5, dry_run=False)
assert outcome.exit_code == 0
assert outcome.stdout == "ok"
assert captured["argv"] == [
"C:/Python312/python.exe",
"worker.py",
"--date",
"2026-07-27",
]
assert captured["cwd"] == tmp_path
assert captured["env"] == {"MODE": "test"}
assert captured["timeout"] == 9.5
assert captured["shell"] is False
def test_command_step_dry_run_never_starts_a_process(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
def fail_if_called(*args: object, **kwargs: object) -> None:
raise AssertionError("subprocess must not be started")
monkeypatch.setattr(subprocess, "run", fail_if_called)
step = CommandStep(argv=("tool.exe", "--write"), cwd=tmp_path, env={})
outcome = step.execute(context=_context(), timeout_seconds=None, dry_run=True)
assert outcome.skipped is True
assert outcome.reason == "dry-run"
assert outcome.exit_code == 0
def test_command_step_reports_timeout_as_a_failed_exit(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
def timeout(*args: object, **kwargs: object) -> None:
raise subprocess.TimeoutExpired(cmd=["tool.exe"], timeout=2)
monkeypatch.setattr(subprocess, "run", timeout)
step = CommandStep(argv=("tool.exe",), cwd=tmp_path, env={})
outcome = step.execute(context=_context(), timeout_seconds=2, dry_run=False)
assert outcome.exit_code == 124
assert "timed out" in (outcome.error or "")
def test_engine_retries_records_every_attempt_and_aggregates_failures(tmp_path: Path) -> None:
flaky = FakeStep(
StepExecution(exit_code=7, error="temporary"),
StepExecution(exit_code=0),
)
optional = FakeStep(StepExecution(exit_code=5, error="optional failed"))
required = FakeStep(StepExecution(exit_code=9, error="required failed"))
workflow = WorkflowDefinition(
"shop.weekly",
(
StepDefinition(
"flaky",
flaky,
timeout_seconds=4,
max_attempts=2,
retry_delay_seconds=0,
resources=("browser:shop",),
),
StepDefinition("optional", optional, critical=False),
StepDefinition("required", required),
),
)
context = _context()
layout = DataLayout(tmp_path)
journal = RunJournal.create(layout, context)
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
workflow,
context=context,
journal=journal,
)
assert result.status == "failed"
assert result.exit_code == 1
assert result.steps["flaky"].status == "success"
assert result.steps["flaky"].final_exit_code == 0
assert [attempt.exit_code for attempt in result.steps["flaky"].attempts] == [7, 0]
assert result.steps["optional"].status == "failed"
assert result.steps["required"].status == "failed"
assert len(flaky.calls) == 2
assert all(call["timeout_seconds"] == 4 for call in flaky.calls)
payload = json.loads(journal.path.read_text(encoding="utf-8"))
assert payload["status"] == "failed"
assert payload["steps"]["flaky.attempt-1"]["exit_code"] == 7
assert payload["steps"]["flaky.attempt-2"]["exit_code"] == 0
assert payload["steps"]["required.attempt-1"]["exit_code"] == 9
assert payload["trace"]["inputs"] == [
"step:flaky",
"step:optional",
"step:required",
]
assert "step:flaky:attempt:1:failed:exit:7" in payload["trace"]["outputs"]
assert "step:flaky:attempt:2:success:exit:0" in payload["trace"]["outputs"]
assert list((tmp_path / "locks").glob("*.lock")) == []
def test_noncritical_failure_does_not_fail_the_workflow(tmp_path: Path) -> None:
workflow = WorkflowDefinition(
"shop.weekly",
(
StepDefinition(
"optional",
FakeStep(StepExecution(exit_code=2, error="not available")),
critical=False,
),
StepDefinition("required", FakeStep()),
),
)
context = _context()
journal = RunJournal.create(DataLayout(tmp_path), context)
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
workflow, context=context, journal=journal
)
assert result.status == "success"
assert result.exit_code == 0
assert result.warnings == ("optional",)
def test_engine_shadow_mode_skips_production_sinks_and_official_notifications(
tmp_path: Path,
) -> None:
safe = FakeStep()
sink = FakeStep()
notify = FakeStep()
workflow = WorkflowDefinition(
"shop.weekly",
(
StepDefinition("safe", safe),
StepDefinition("publish", sink, production_sink=True, depends_on=("safe",)),
StepDefinition(
"notify",
notify,
official_notification=True,
depends_on=("publish",),
),
),
)
context = _context(shadow=True)
journal = RunJournal.create(DataLayout(tmp_path), context)
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
workflow, context=context, journal=journal
)
assert result.status == "success"
assert result.steps["safe"].status == "success"
assert result.steps["publish"].status == "skipped"
assert result.steps["publish"].reason == "shadow-policy"
assert result.steps["notify"].status == "skipped"
assert sink.calls == []
assert notify.calls == []
payload = json.loads(journal.path.read_text(encoding="utf-8"))
assert payload["steps"]["publish.attempt-1"]["status"] == "skipped"
assert payload["steps"]["notify.attempt-1"]["status"] == "skipped"
assert payload["trace"]["external_writes"] == []
def test_successful_sink_records_declared_external_write_in_trace(tmp_path: Path) -> None:
workflow = WorkflowDefinition(
"shop.weekly",
(StepDefinition("publish", FakeStep(), production_sink=True),),
)
context = _context()
journal = RunJournal.create(DataLayout(tmp_path), context)
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
workflow, context=context, journal=journal
)
assert result.status == "success"
payload = json.loads(journal.path.read_text(encoding="utf-8"))
assert payload["trace"]["external_writes"] == ["step:publish:production_sink"]
def test_effect_ledger_skips_a_successful_production_sink_replay(tmp_path: Path) -> None:
action = FakeStep()
workflow = WorkflowDefinition(
"shop.weekly",
(StepDefinition("publish", action, production_sink=True),),
)
ledger = EffectLedger(tmp_path)
engine = WorkflowEngine(
LockManager(tmp_path / "locks"), effect_ledger=ledger
)
first_context = _context()
first = engine.execute(
workflow,
context=first_context,
journal=RunJournal.create(DataLayout(tmp_path), first_context),
)
second_context = RunContext.create(
"shop.weekly", "2026-07-27", random_suffix="replay2"
)
second = engine.execute(
workflow,
context=second_context,
journal=RunJournal.create(DataLayout(tmp_path), second_context),
)
assert first.status == "success"
assert second.status == "success"
assert second.steps["publish"].reason == "idempotency-replay"
assert len(action.calls) == 1
def test_failed_dependency_skips_downstream_critical_step(tmp_path: Path) -> None:
downstream = FakeStep()
workflow = WorkflowDefinition(
"shop.weekly",
(
StepDefinition("collect", FakeStep(StepExecution(exit_code=3, error="bad input"))),
StepDefinition("publish", downstream, depends_on=("collect",)),
),
)
context = _context()
journal = RunJournal.create(DataLayout(tmp_path), context)
result = WorkflowEngine(LockManager(tmp_path / "locks")).execute(
workflow, context=context, journal=journal
)
assert result.status == "failed"
assert result.steps["publish"].status == "skipped"
assert result.steps["publish"].reason == "dependency-failed"
assert downstream.calls == []