366 lines
12 KiB
Python
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 == []
|