from __future__ import annotations import json from io import StringIO from pathlib import Path import pytest from gyxx_flow.cli import EXIT_CONFIGURATION, EXIT_SUCCESS, main from gyxx_flow.core.config import Settings from gyxx_flow.ops.effects import ( EffectAlreadyApplied, EffectLedger, EffectStateAmbiguous, ) def test_effect_ledger_blocks_applied_and_ambiguous_replays(tmp_path: Path) -> None: ledger = EffectLedger(tmp_path) first = ledger.begin( workflow_id="product.daily", business_date="2026-07-27", step_id="legacy_execute", run_id="run-001", ) ledger.mark_applied(first) with pytest.raises(EffectAlreadyApplied): ledger.begin( workflow_id="product.daily", business_date="2026-07-27", step_id="legacy_execute", run_id="run-002", ) ambiguous = ledger.begin( workflow_id="product.daily", business_date="2026-07-28", step_id="legacy_execute", run_id="run-003", ) ledger.mark_ambiguous(ambiguous, reason="nonzero-exit") with pytest.raises(EffectStateAmbiguous): ledger.begin( workflow_id="product.daily", business_date="2026-07-28", step_id="legacy_execute", run_id="run-004", ) def test_effect_ledger_leaves_in_progress_claim_ambiguous_after_crash( tmp_path: Path, ) -> None: ledger = EffectLedger(tmp_path) ledger.begin( workflow_id="shop.metrics.weekly", business_date="2026-07-27", step_id="legacy_collect", run_id="run-001", ) with pytest.raises(EffectStateAmbiguous): ledger.begin( workflow_id="shop.metrics.weekly", business_date="2026-07-27", step_id="legacy_collect", run_id="run-002", ) def test_effect_ledger_reconcile_retry_preserves_audit_and_allows_one_new_claim( tmp_path: Path, ) -> None: ledger = EffectLedger(tmp_path) failed = ledger.begin( workflow_id="product.daily", business_date="2026-08-01", step_id="collect", run_id="run-original", ) ledger.mark_ambiguous(failed, reason="preflight-failed") result = ledger.reconcile( workflow_id="product.daily", business_date="2026-08-01", step_id="collect", expected_run_id="run-original", action="retry", operator="operator@example", reason="verified command did not reach the external sink", evidence="journal:run-original", ) retry = ledger.begin( workflow_id="product.daily", business_date="2026-08-01", step_id="collect", run_id="run-retry", ) assert result["status"] == "retry_allowed" assert retry.run_id == "run-retry" receipt_path = next((tmp_path / "state" / "ops" / "effects").glob("*.json")) receipt = json.loads(receipt_path.read_text(encoding="utf-8")) assert receipt["status"] == "in_progress" assert receipt["run_id"] == "run-retry" assert receipt["reconciliations"] == [ { "action": "retry", "operator": "operator@example", "reason": "verified command did not reach the external sink", "evidence": "journal:run-original", "expected_run_id": "run-original", "reconciled_at": result["reconciled_at"], } ] def test_effect_ledger_reconcile_can_mark_verified_effect_applied( tmp_path: Path, ) -> None: ledger = EffectLedger(tmp_path) claim = ledger.begin( workflow_id="product.alert.daily", business_date="2026-08-01", step_id="notify", run_id="run-original", ) ledger.mark_ambiguous(claim, reason="response-lost") result = ledger.reconcile( workflow_id="product.alert.daily", business_date="2026-08-01", step_id="notify", expected_run_id="run-original", action="applied", operator="operator@example", reason="verified the message receipt in Feishu", evidence="message:verified-receipt", ) assert result["status"] == "applied" with pytest.raises(EffectAlreadyApplied): ledger.begin( workflow_id="product.alert.daily", business_date="2026-08-01", step_id="notify", run_id="run-second", ) def test_effect_ledger_reconcile_rejects_active_or_changed_claims( tmp_path: Path, ) -> None: ledger = EffectLedger(tmp_path) claim = ledger.begin( workflow_id="product.daily", business_date="2026-08-02", step_id="collect", run_id="run-active", ) with pytest.raises(EffectStateAmbiguous, match="only ambiguous"): ledger.reconcile( workflow_id="product.daily", business_date="2026-08-02", step_id="collect", expected_run_id="run-active", action="retry", operator="operator@example", reason="should not unlock a live claim", evidence="journal:run-active", ) ledger.mark_ambiguous(claim, reason="failed") with pytest.raises(EffectStateAmbiguous, match="run_id changed"): ledger.reconcile( workflow_id="product.daily", business_date="2026-08-02", step_id="collect", expected_run_id="run-other", action="retry", operator="operator@example", reason="stale operator view", evidence="journal:run-other", ) def test_effect_reconcile_cli_requires_confirmation_and_returns_safe_projection( tmp_path: Path, ) -> None: data_root = tmp_path / "data" settings = Settings(project_root=tmp_path, data_root=data_root) ledger = EffectLedger(data_root) claim = ledger.begin( workflow_id="product.daily", business_date="2026-08-03", step_id="collect", run_id="run-original", ) ledger.mark_ambiguous(claim, reason="preflight-failed") base_args = [ "effects", "reconcile", "product.daily", "--date", "2026-08-03", "--step", "collect", "--expected-run-id", "run-original", "--action", "retry", "--operator", "operator@example", "--reason", "verified no external write", "--evidence", "journal:run-original", ] errors = StringIO() assert main(base_args, settings=settings, stderr=errors) == EXIT_CONFIGURATION assert "requires --execute" in errors.getvalue() output = StringIO() assert main([*base_args, "--execute"], settings=settings, stdout=output) == EXIT_SUCCESS result = json.loads(output.getvalue()) assert result["status"] == "retry_allowed" assert "operator" not in result assert "reason" not in result assert "evidence" not in result