Source code for exlab_wizard.controller.state_machine

"""Creation-session state machine. Backend Spec §4.7, §4.7.1.

Defines two enums and the mapping/transition tables that govern a
``CreationController`` session's lifecycle:

- :class:`SessionState` -- the *internal* state the controller drives
  (Backend Spec §4.7). Includes terminal-error states (``FAILED``,
  ``ABORTED``) and the holding state ``INPUT_REQUIRED`` that emit no
  ``phase`` WebSocket frame on entry.
- :class:`Phase` -- the *externally-emitted* ``phase`` enum
  (Backend Spec §4.6.2). Sent over the ``WS /api/v1/sessions/{id}/events``
  channel on every state transition that has a corresponding phase event.
- :func:`state_to_phase` -- the §4.7.1 mapping table verbatim. Returns
  ``None`` for transitional / terminal-error states; returns
  :data:`Phase.INPUT_REQUIRED` for ``INPUT_REQUIRED`` (the API surface
  encodes this as a ``kind: "input_required"`` envelope rather than a
  ``phase`` frame -- the helper still emits the phase value so a single
  switch on ``state_to_phase`` can drive both code paths).
- :data:`VALID_TRANSITIONS` -- the per-state outbound transition table
  per the §4.7 diagram. ``FAILED`` and ``ABORTED`` are reachable from any
  non-terminal state (cancel/fail can fire mid-pipeline); the table is
  hand-written rather than generated to keep the spec ↔ code mapping
  auditable.
- :func:`assert_transition` -- guard used by :class:`SessionStore` and
  :class:`CreationController` to reject invalid state transitions
  (caught by the type system at the API surface; the runtime raise is
  defensive against logic bugs in the controller's pipeline).
"""

from __future__ import annotations

from enum import StrEnum

from exlab_wizard.utils.state import assert_forward_transition

__all__ = [
    "VALID_TRANSITIONS",
    "Phase",
    "SessionState",
    "assert_transition",
    "state_to_phase",
]


[docs] class SessionState(StrEnum): """Internal creation-session state. Backend Spec §4.7. Values mirror the §4.7 state-machine diagram. Lower-case strings so JSON encoding is direct (``StrEnum`` makes ``SessionState.PENDING`` render as ``"pending"``). """ PENDING = "pending" VALIDATING = "validating" RENDERING = "rendering" PLUGIN_PASS = "plugin_pass" INPUT_REQUIRED = "input_required" CACHE_WRITE = "cache_write" POST_VALIDATE = "post_validate" SYNC_QUEUED = "sync_queued" DONE = "done" FAILED = "failed" ABORTED = "aborted"
[docs] class Phase(StrEnum): """Externally-emitted ``phase`` event. Backend Spec §4.6.2. Sent over the ``WS /api/v1/sessions/{id}/events`` channel on every state transition that has a corresponding phase event. The enum values match the spec's wire-format strings verbatim. """ VALIDATING_INPUTS = "validating_inputs" RENDERING_TEMPLATE = "rendering_template" RUNNING_PLUGINS = "running_plugins" INPUT_REQUIRED = "input_required" WRITING_CACHE = "writing_cache" VALIDATING_POST_CREATION = "validating_post_creation" QUEUEING_NAS_SYNC = "queueing_nas_sync" DONE = "done"
# Backend Spec §4.7.1 mapping table verbatim. Internal state names that # emit no phase frame are mapped to ``None`` so callers can ``if phase # is None: continue`` cleanly. _STATE_TO_PHASE: dict[SessionState, Phase | None] = { SessionState.PENDING: None, SessionState.VALIDATING: Phase.VALIDATING_INPUTS, SessionState.RENDERING: Phase.RENDERING_TEMPLATE, SessionState.PLUGIN_PASS: Phase.RUNNING_PLUGINS, SessionState.INPUT_REQUIRED: Phase.INPUT_REQUIRED, SessionState.CACHE_WRITE: Phase.WRITING_CACHE, SessionState.POST_VALIDATE: Phase.VALIDATING_POST_CREATION, SessionState.SYNC_QUEUED: Phase.QUEUEING_NAS_SYNC, SessionState.DONE: Phase.DONE, SessionState.FAILED: None, SessionState.ABORTED: None, }
[docs] def state_to_phase(state: SessionState) -> Phase | None: """Return the :class:`Phase` event corresponding to ``state``. Backend Spec §4.7.1 mapping table. ``PENDING``, ``FAILED``, and ``ABORTED`` return ``None`` (no phase event). ``INPUT_REQUIRED`` returns :data:`Phase.INPUT_REQUIRED`; the API surface encodes that as a ``kind: "input_required"`` envelope rather than a ``phase`` frame, but the mapping is preserved so a single dispatch knows the relationship. """ return _STATE_TO_PHASE[state]
# Terminal states from which no further transition is legal. _TERMINAL_STATES: frozenset[SessionState] = frozenset( {SessionState.DONE, SessionState.FAILED, SessionState.ABORTED} ) # Spec §4.7 transition diagram. ``FAILED`` and ``ABORTED`` are added # below so any non-terminal state can fail / cancel without a per-state # enumeration. ``DONE`` is reachable only from ``SYNC_QUEUED``. _FORWARD_TRANSITIONS: dict[SessionState, frozenset[SessionState]] = { SessionState.PENDING: frozenset({SessionState.VALIDATING}), SessionState.VALIDATING: frozenset({SessionState.RENDERING}), SessionState.RENDERING: frozenset({SessionState.PLUGIN_PASS}), SessionState.PLUGIN_PASS: frozenset({SessionState.INPUT_REQUIRED, SessionState.CACHE_WRITE}), SessionState.INPUT_REQUIRED: frozenset({SessionState.PLUGIN_PASS}), SessionState.CACHE_WRITE: frozenset({SessionState.POST_VALIDATE}), SessionState.POST_VALIDATE: frozenset({SessionState.SYNC_QUEUED}), SessionState.SYNC_QUEUED: frozenset({SessionState.DONE}), SessionState.DONE: frozenset(), SessionState.FAILED: frozenset(), SessionState.ABORTED: frozenset(), } def _build_valid_transitions() -> dict[SessionState, frozenset[SessionState]]: """Augment forward transitions with cancel/fail edges.""" result: dict[SessionState, frozenset[SessionState]] = {} cancel_targets = frozenset({SessionState.FAILED, SessionState.ABORTED}) for state, forward in _FORWARD_TRANSITIONS.items(): if state in _TERMINAL_STATES: result[state] = forward else: result[state] = forward | cancel_targets return result # Per-state outbound transition table. Per the spec: # - Forward edges follow the §4.7 diagram exactly. # - ``cancel`` from any non-terminal state -> ``ABORTED``. # - ``fail`` from any non-terminal state -> ``FAILED``. # Terminal states (``DONE`` / ``FAILED`` / ``ABORTED``) are sinks. VALID_TRANSITIONS: dict[SessionState, frozenset[SessionState]] = _build_valid_transitions()
[docs] def assert_transition(current: SessionState, new_state: SessionState) -> None: """Raise :class:`ValueError` if ``current -> new_state`` is illegal. Defensive guard used by :class:`SessionStore.transition` and the controller's pipeline. Backend Spec §4.7 / §4.7.1 are the source of truth for the legal edges; this function consults :data:`VALID_TRANSITIONS` via the shared :func:`exlab_wizard.utils.state.assert_forward_transition` helper. """ assert_forward_transition(current, new_state, VALID_TRANSITIONS)