from __future__ import annotations import json import os import subprocess import sys import time from collections import deque from datetime import datetime, timezone from pathlib import Path from typing import Any import psutil import pytest import gyxx_flow.workflow.steps as steps_module 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"), ({"timeout_seconds": True}, "timeout_seconds"), ({"timeout_seconds": "60"}, "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] = {} class FakeProcess: pid = 424242 def wait(self, timeout: float | None = None) -> int: captured["timeout"] = timeout return 0 def fake_start(argv: list[str], **kwargs: object) -> tuple[FakeProcess, None]: captured["argv"] = argv captured.update(kwargs) kwargs["stdout"].write("ok") # type: ignore[union-attr] return FakeProcess(), None monkeypatch.setattr(steps_module, "_start_process", fake_start) 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["stdout"].closed is True # type: ignore[union-attr] assert captured["stderr"].closed is True # type: ignore[union-attr] 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(steps_module, "_start_process", 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_uses_stdout_as_failure_detail_when_stderr_is_empty( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: class FakeProcess: pid = 424243 def wait(self, timeout: float | None = None) -> int: del timeout return 1 def fake_start(argv: list[str], **kwargs: object) -> tuple[FakeProcess, None]: del argv kwargs["stdout"].write("business workflow failed") # type: ignore[union-attr] return FakeProcess(), None monkeypatch.setattr(steps_module, "_start_process", fake_start) step = CommandStep(argv=("worker.exe",), cwd=tmp_path, env={}) outcome = step.execute(context=_context(), timeout_seconds=None, dry_run=False) assert outcome.exit_code == 1 assert outcome.error == "business workflow failed" def test_command_step_failure_detail_preserves_log_head_and_root_cause_tail( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: class FakeProcess: pid = 424244 def wait(self, timeout: float | None = None) -> int: del timeout return 1 def fake_start(argv: list[str], **kwargs: object) -> tuple[FakeProcess, None]: del argv kwargs["stdout"].write( # type: ignore[union-attr] "collector startup context\n" + "progress line\n" * 2_000 ) kwargs["stderr"].write( # type: ignore[union-attr] "Traceback: ROOT CAUSE AT LOG TAIL" ) return FakeProcess(), None monkeypatch.setattr(steps_module, "_start_process", fake_start) step = CommandStep(argv=("worker.exe",), cwd=tmp_path, env={}) outcome = step.execute(context=_context(), timeout_seconds=None, dry_run=False) assert outcome.exit_code == 1 assert outcome.error is not None assert outcome.error.startswith("collector startup context") assert "[middle output omitted]" in outcome.error assert outcome.error.endswith("Traceback: ROOT CAUSE AT LOG TAIL") assert len(outcome.error) <= 16_000 def test_command_step_reports_timeout_as_a_failed_exit( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: terminated: list[object] = [] class TimeoutProcess: pid = 424242 def wait(self, timeout: float | None = None) -> int: raise subprocess.TimeoutExpired(cmd=["tool.exe"], timeout=timeout) def fake_start(*_args: object, **_kwargs: object) -> tuple[TimeoutProcess, None]: return TimeoutProcess(), None monkeypatch.setattr(steps_module, "_start_process", fake_start) monkeypatch.setattr( steps_module, "_terminate_process_tree", lambda process, scope: terminated.append((process, scope)), ) 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 "") assert len(terminated) == 1 def test_command_step_timeout_terminates_grandchild_before_return( tmp_path: Path, ) -> None: identity_file = tmp_path / "grandchild.json" marker_file = tmp_path / "grandchild-finished.txt" grandchild_code = ( "import json,os,pathlib,psutil,time; " f"pathlib.Path({str(identity_file)!r}).write_text(" "json.dumps({'pid': os.getpid(), " "'created': psutil.Process().create_time()}), encoding='utf-8'); " "time.sleep(1.2); " f"pathlib.Path({str(marker_file)!r}).write_text('alive', encoding='utf-8')" ) parent_code = ( "import pathlib,subprocess,sys,time; " f"subprocess.Popen([sys.executable, '-c', {grandchild_code!r}], " "stdout=sys.stdout, stderr=sys.stderr, close_fds=False); " f"identity = pathlib.Path({str(identity_file)!r}); " "deadline = time.monotonic() + 2; " "\nwhile not identity.exists() and time.monotonic() < deadline: " "time.sleep(0.01)" "\ntime.sleep(10)" ) step = CommandStep( argv=(sys.executable, "-c", parent_code), cwd=tmp_path, env=dict(os.environ), ) outcome = step.execute(context=_context(), timeout_seconds=0.8, dry_run=False) assert outcome.exit_code == 124 assert identity_file.is_file() identity = json.loads(identity_file.read_text(encoding="utf-8")) try: descendant = psutil.Process(identity["pid"]) same_process_is_live = ( descendant.create_time() == pytest.approx(identity["created"], abs=0.01) and descendant.is_running() and descendant.status() != psutil.STATUS_ZOMBIE ) except psutil.Error: same_process_is_live = False assert same_process_is_live is False time.sleep(1.3) assert marker_file.exists() is False def test_command_step_does_not_wait_for_grandchild_output_handle( tmp_path: Path, ) -> None: child_code = ( "import subprocess,sys; " "subprocess.Popen([sys.executable, '-c', " "'import time; time.sleep(3)'], stdout=sys.stdout, stderr=sys.stderr, " "close_fds=False); " "print('parent done')" ) step = CommandStep( argv=(sys.executable, "-c", child_code), cwd=tmp_path, env=dict(os.environ), ) outcome = step.execute(context=_context(), timeout_seconds=1, dry_run=False) assert outcome.exit_code == 0 assert "parent done" in outcome.stdout def test_command_step_maps_reserved_cookie_exit_to_a_skip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: class SkippedProcess: pid = 424242 def wait(self, timeout: float | None = None) -> int: del timeout return 75 def skipped(*_args: object, **kwargs: object) -> tuple[SkippedProcess, None]: kwargs["stdout"].write( # type: ignore[union-attr] "[SKIPPED_COOKIE] state missing" ) return SkippedProcess(), None monkeypatch.setattr(steps_module, "_start_process", skipped) step = CommandStep(argv=("collector.exe",), cwd=tmp_path, env={}) outcome = step.execute(context=_context(), timeout_seconds=2, dry_run=False) assert outcome.exit_code == 75 assert outcome.skipped is True assert outcome.reason == "cookie-preflight" 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_idempotent_sink_runs_again_for_same_business_date(tmp_path: Path) -> None: action = FakeStep(StepExecution(exit_code=0), StepExecution(exit_code=0)) workflow = WorkflowDefinition( "shop.weekly", ( StepDefinition( "publish", action, production_sink=True, replay_policy="idempotent", ), ), ) ledger = EffectLedger(tmp_path) first_context = _context() second_context = RunContext.create( "shop.weekly", "2026-07-27", random_suffix="replay3" ) engine = WorkflowEngine( LockManager(tmp_path / "locks"), effect_ledger=ledger ) first = engine.execute( workflow, context=first_context, journal=RunJournal.create(DataLayout(tmp_path), first_context), ) 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 is None assert len(action.calls) == 2 assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == [] def test_skipped_production_sink_cancels_effect_claim_for_future_runs( tmp_path: Path, ) -> None: action = FakeStep(StepExecution(exit_code=75, skipped=True, reason="cookie-preflight")) workflow = WorkflowDefinition( "shop.weekly", (StepDefinition("collect", action, production_sink=True),), ) ledger = EffectLedger(tmp_path) context = _context() result = WorkflowEngine( LockManager(tmp_path / "locks"), effect_ledger=ledger, ).execute( workflow, context=context, journal=RunJournal.create(DataLayout(tmp_path), context), ) assert result.status == "success" assert result.steps["collect"].status == "skipped" assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == [] 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 == [] def test_run_after_failure_executes_downstream_but_preserves_workflow_failure( tmp_path: Path, ) -> None: product = FakeStep() workflow = WorkflowDefinition( "shop.weekly", ( StepDefinition( "brand", FakeStep(StepExecution(exit_code=3, error="brand failed")), ), StepDefinition( "product", product, depends_on=("brand",), run_after_failure=True, ), ), ) context = _context() result = WorkflowEngine(LockManager(tmp_path / "locks")).execute( workflow, context=context, journal=RunJournal.create(DataLayout(tmp_path), context), ) assert result.status == "failed" assert result.exit_code == 1 assert result.steps["brand"].status == "failed" assert result.steps["product"].status == "success" assert len(product.calls) == 1