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 == []