212 lines
6.2 KiB
Python
212 lines
6.2 KiB
Python
"""State vocabularies and allowed transitions for recoverable workflows."""
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
|
|
|
|
class CaptureState(StrEnum):
|
|
RECEIVED = "received"
|
|
SPOOLING = "spooling"
|
|
STORED = "stored"
|
|
DEDUPLICATED = "deduplicated"
|
|
QUEUED = "queued"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class JobState(StrEnum):
|
|
QUEUED = "queued"
|
|
RUNNING = "running"
|
|
RETRY_WAIT = "retry_wait"
|
|
SUCCEEDED = "succeeded"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class ExtractionState(StrEnum):
|
|
PENDING = "pending"
|
|
LOCAL_PROCESSING = "local_processing"
|
|
FALLBACK_PENDING = "fallback_pending"
|
|
FALLBACK_PROCESSING = "fallback_processing"
|
|
CANDIDATE_READY = "candidate_ready"
|
|
ABSTAINED = "abstained"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class GroupingState(StrEnum):
|
|
UNGROUPED = "ungrouped"
|
|
PROPOSED = "proposed"
|
|
GROUPED = "grouped"
|
|
SPLIT = "split"
|
|
SUPERSEDED = "superseded"
|
|
REJECTED = "rejected"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class ReviewState(StrEnum):
|
|
PENDING = "pending"
|
|
IN_REVIEW = "in_review"
|
|
CHANGES_REQUESTED = "changes_requested"
|
|
APPROVED = "approved"
|
|
REJECTED = "rejected"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class ExportState(StrEnum):
|
|
NOT_REQUESTED = "not_requested"
|
|
QUEUED = "queued"
|
|
BUILDING = "building"
|
|
READY = "ready"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
EXPIRED = "expired"
|
|
|
|
|
|
class ErrorState(StrEnum):
|
|
OPEN = "open"
|
|
RETRYABLE = "retryable"
|
|
RETRYING = "retrying"
|
|
RECOVERED = "recovered"
|
|
TERMINAL = "terminal"
|
|
DISMISSED = "dismissed"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class StateMachine:
|
|
name: str
|
|
transitions: Mapping[str, frozenset[str]]
|
|
|
|
def allows(self, current: str, target: str) -> bool:
|
|
return target in self.transitions.get(current, frozenset())
|
|
|
|
|
|
def _machine(name: str, transitions: dict[str, set[str]]) -> StateMachine:
|
|
return StateMachine(
|
|
name=name,
|
|
transitions=MappingProxyType(
|
|
{state: frozenset(targets) for state, targets in transitions.items()}
|
|
),
|
|
)
|
|
|
|
|
|
STATE_MACHINES: Final[Mapping[str, StateMachine]] = MappingProxyType(
|
|
{
|
|
"capture": _machine(
|
|
"capture",
|
|
{
|
|
"received": {"spooling", "deduplicated", "failed", "cancelled"},
|
|
"spooling": {"stored", "failed", "cancelled"},
|
|
"stored": {"queued", "failed"},
|
|
"deduplicated": set(),
|
|
"queued": set(),
|
|
"failed": {"spooling", "cancelled"},
|
|
"cancelled": set(),
|
|
},
|
|
),
|
|
"job": _machine(
|
|
"job",
|
|
{
|
|
"queued": {"running", "cancelled"},
|
|
"running": {"succeeded", "retry_wait", "failed", "cancelled"},
|
|
"retry_wait": {"queued", "failed", "cancelled"},
|
|
"succeeded": set(),
|
|
"failed": {"queued"},
|
|
"cancelled": set(),
|
|
},
|
|
),
|
|
"extraction": _machine(
|
|
"extraction",
|
|
{
|
|
"pending": {"local_processing", "cancelled"},
|
|
"local_processing": {
|
|
"candidate_ready",
|
|
"fallback_pending",
|
|
"abstained",
|
|
"failed",
|
|
"cancelled",
|
|
},
|
|
"fallback_pending": {"fallback_processing", "cancelled"},
|
|
"fallback_processing": {
|
|
"candidate_ready",
|
|
"abstained",
|
|
"failed",
|
|
"cancelled",
|
|
},
|
|
"candidate_ready": set(),
|
|
"abstained": set(),
|
|
"failed": {"pending"},
|
|
"cancelled": set(),
|
|
},
|
|
),
|
|
"grouping": _machine(
|
|
"grouping",
|
|
{
|
|
"ungrouped": {"proposed", "cancelled"},
|
|
"proposed": {"grouped", "rejected", "cancelled"},
|
|
"grouped": {"split", "superseded"},
|
|
"split": {"superseded"},
|
|
"superseded": set(),
|
|
"rejected": {"ungrouped"},
|
|
"cancelled": set(),
|
|
},
|
|
),
|
|
"review": _machine(
|
|
"review",
|
|
{
|
|
"pending": {"in_review", "cancelled"},
|
|
"in_review": {
|
|
"changes_requested",
|
|
"approved",
|
|
"rejected",
|
|
"cancelled",
|
|
},
|
|
"changes_requested": {"in_review", "rejected", "cancelled"},
|
|
"approved": {"in_review"},
|
|
"rejected": {"in_review"},
|
|
"cancelled": set(),
|
|
},
|
|
),
|
|
"export": _machine(
|
|
"export",
|
|
{
|
|
"not_requested": {"queued"},
|
|
"queued": {"building", "cancelled"},
|
|
"building": {"ready", "failed", "cancelled"},
|
|
"ready": {"expired"},
|
|
"failed": {"queued", "cancelled"},
|
|
"cancelled": set(),
|
|
"expired": {"queued"},
|
|
},
|
|
),
|
|
"error": _machine(
|
|
"error",
|
|
{
|
|
"open": {"retryable", "terminal", "dismissed"},
|
|
"retryable": {"retrying", "terminal", "dismissed"},
|
|
"retrying": {"recovered", "retryable", "terminal"},
|
|
"recovered": set(),
|
|
"terminal": {"dismissed"},
|
|
"dismissed": set(),
|
|
},
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
class InvalidStateTransition(ValueError):
|
|
"""Raised when a caller attempts a transition absent from the contract."""
|
|
|
|
|
|
def require_transition(machine_name: str, current: str, target: str) -> None:
|
|
machine = STATE_MACHINES.get(machine_name)
|
|
if machine is None:
|
|
raise InvalidStateTransition(f"Unknown state machine: {machine_name}")
|
|
if current not in machine.transitions:
|
|
raise InvalidStateTransition(f"Unknown {machine_name} state: {current}")
|
|
if not machine.allows(current, target):
|
|
raise InvalidStateTransition(f"Invalid {machine_name} transition: {current} -> {target}")
|