88 lines
3.3 KiB
Python
88 lines
3.3 KiB
Python
"""Unified candidate session lifecycle contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from datetime import datetime
|
|
|
|
from .errors import StateConflict
|
|
|
|
|
|
class SessionState(str, Enum):
|
|
CREATED = "CREATED"
|
|
PROVISIONED = "PROVISIONED"
|
|
ACTIVE = "ACTIVE"
|
|
INTERRUPTED = "INTERRUPTED"
|
|
CLOSED = "CLOSED"
|
|
REVOKED = "REVOKED"
|
|
|
|
|
|
_ALLOWED_TRANSITIONS: dict[SessionState, frozenset[SessionState]] = {
|
|
SessionState.CREATED: frozenset({SessionState.PROVISIONED, SessionState.CLOSED, SessionState.REVOKED}),
|
|
SessionState.PROVISIONED: frozenset({SessionState.ACTIVE, SessionState.CLOSED, SessionState.REVOKED}),
|
|
SessionState.ACTIVE: frozenset({SessionState.INTERRUPTED, SessionState.CLOSED, SessionState.REVOKED}),
|
|
SessionState.INTERRUPTED: frozenset({SessionState.ACTIVE, SessionState.CLOSED, SessionState.REVOKED}),
|
|
SessionState.CLOSED: frozenset({SessionState.REVOKED}),
|
|
SessionState.REVOKED: frozenset(),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SessionIdentity:
|
|
session_id: str
|
|
provider: str
|
|
provider_session_id: str
|
|
worker_id: str
|
|
task_id: str
|
|
created_at: datetime
|
|
|
|
def __post_init__(self) -> None:
|
|
if not all((self.session_id, self.provider, self.provider_session_id, self.worker_id, self.task_id)):
|
|
raise ValueError("all session identity fields are required")
|
|
if self.session_id == self.provider_session_id:
|
|
raise ValueError("broker and provider session IDs must be distinct")
|
|
|
|
|
|
class SessionOrchestrator:
|
|
"""Provider-neutral session lifecycle guard with explicit state transitions."""
|
|
|
|
def __init__(self) -> None:
|
|
self._sessions: dict[str, tuple[SessionIdentity, SessionState]] = {}
|
|
|
|
def create(self, identity: SessionIdentity) -> None:
|
|
if identity.session_id in self._sessions:
|
|
raise StateConflict("SESSION_EXISTS", "session already exists")
|
|
self._sessions[identity.session_id] = (identity, SessionState.CREATED)
|
|
|
|
def transition(self, session_id: str, state: SessionState) -> None:
|
|
current = self._sessions.get(session_id)
|
|
if current is None:
|
|
raise StateConflict("SESSION_NOT_FOUND", "session does not exist")
|
|
current_state = current[1]
|
|
if state not in _ALLOWED_TRANSITIONS[current_state]:
|
|
raise StateConflict(
|
|
"SESSION_TRANSITION_INVALID",
|
|
"session state transition is not allowed",
|
|
{"session_id": session_id, "from": current_state.value, "to": state.value},
|
|
)
|
|
self._sessions[session_id] = (current[0], state)
|
|
|
|
def identity(self, session_id: str) -> SessionIdentity:
|
|
current = self._sessions.get(session_id)
|
|
if current is None:
|
|
raise StateConflict("SESSION_NOT_FOUND", "session does not exist")
|
|
return current[0]
|
|
|
|
def status(self, session_id: str) -> SessionState:
|
|
current = self._sessions.get(session_id)
|
|
if current is None:
|
|
raise StateConflict("SESSION_NOT_FOUND", "session does not exist")
|
|
return current[1]
|
|
|
|
def close(self, session_id: str) -> None:
|
|
self.transition(session_id, SessionState.CLOSED)
|
|
|
|
def revoke(self, session_id: str) -> None:
|
|
self.transition(session_id, SessionState.REVOKED)
|