From f3858ac43ae1cdec0d5436daf859d94bfcfb429e Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 21:43:54 +0200 Subject: [PATCH] feat(v2): add guarded execution state transitions --- backend/src/backup_tool/execution.py | 28 ++++++++++++++++++++++++++ tests/unit/test_execution_lifecycle.py | 23 +++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 backend/src/backup_tool/execution.py create mode 100644 tests/unit/test_execution_lifecycle.py diff --git a/backend/src/backup_tool/execution.py b/backend/src/backup_tool/execution.py new file mode 100644 index 0000000..a3479ac --- /dev/null +++ b/backend/src/backup_tool/execution.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from collections.abc import Mapping + +ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"}) +TERMINAL_STATES = frozenset({"committed", "cancelled", "failed"}) + +_ALLOWED: Mapping[str, frozenset[str]] = { + "queued": frozenset({"preparing", "cancelled", "failed"}), + "preparing": frozenset({"running", "cancelling", "failed"}), + "running": frozenset({"verifying", "cancelling", "failed"}), + "verifying": frozenset({"committed", "failed"}), + "cancelling": frozenset({"cancelled", "failed"}), + "committed": frozenset(), + "cancelled": frozenset(), + "failed": frozenset(), +} + + +class TransitionError(ValueError): + code = "invalid_transition" + + +def transition(current: str, target: str) -> str: + """Validate and return one durable execution-state transition.""" + if target not in _ALLOWED.get(current, frozenset()): + raise TransitionError(f"Cannot transition execution from {current!r} to {target!r}.") + return target diff --git a/tests/unit/test_execution_lifecycle.py b/tests/unit/test_execution_lifecycle.py new file mode 100644 index 0000000..ca85851 --- /dev/null +++ b/tests/unit/test_execution_lifecycle.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from backup_tool.execution import ACTIVE_STATES, TERMINAL_STATES, TransitionError, transition + + +def test_transitions_are_monotonic_and_terminal_states_cannot_change() -> None: + assert transition("queued", "preparing") == "preparing" + assert transition("preparing", "running") == "running" + assert transition("running", "verifying") == "verifying" + assert transition("verifying", "committed") == "committed" + assert "committed" in TERMINAL_STATES + assert "queued" in ACTIVE_STATES + try: + transition("committed", "cancelled") + except TransitionError as error: + assert error.code == "invalid_transition" + else: + raise AssertionError("terminal execution changed state") + + +def test_cancellation_is_cooperative() -> None: + assert transition("running", "cancelling") == "cancelling" + assert transition("cancelling", "cancelled") == "cancelled"