233 lines
6.8 KiB
Python
233 lines
6.8 KiB
Python
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
|