exlab_wizard.controller.session_store#

In-memory session store + GC for creation sessions. Backend Spec §4.4.7.

The SessionStore is a dict[session_id, Session] keyed by UUID4. v1 is intentionally non-persistent: the store lives in the long-lived tray-server process, and a server crash forfeits all in-flight sessions (Backend Spec §4.8). Persistence may return in v2 when unattended workflows ship.

The GC pass closes any session in SessionState.INPUT_REQUIRED with no client heartbeat for >1 hour (SESSION_GC_AFTER_SECONDS); see Backend Spec §4.4.7.

The transition method is the single mutation surface for a session’s SessionState and current_phase – both fields are updated atomically (under no-lock by virtue of the asyncio single-threaded event loop) so the WebSocket subscriber sees a consistent view.

Classes

Session(session_id, kind, state, request, ...)

One creation session.

SessionStore()

In-memory session store.

class exlab_wizard.controller.session_store.Session(session_id, kind, state, request, created_at, last_heartbeat, current_phase=None, next_action=NextAction.NONE, event_queue=None, pending_input=None, error=None, result=None)[source]#

Bases: object

One creation session. Backend Spec §4.4.7.

session_id#

UUID4 string assigned by the store on open().

kind#

"project" or "run" – mirrors the controller’s create_* entry point.

state#

Current SessionState. Mutated only via SessionStore.transition().

request#

The original create request bundle (ProjectCreateRequest or RunCreateRequest).

created_at#

UTC timestamp at SessionStore.open().

last_heartbeat#

Most recent client-driven heartbeat. Refreshed by SessionStore.heartbeat(); consulted by the GC.

current_phase#

Mirrors state_to_phase() of state. Maintained by SessionStore.transition().

next_action#

"awaiting_input" while the session is in SessionState.INPUT_REQUIRED; "none" otherwise.

event_queue#

WebSocket fan-out queue. Set by SessionStore.attach_event_queue().

pending_input#

Latest InputRequiredPayload dict surfaced by the plugin host; cleared on resume.

error#

Structured error envelope ({code, message, ...}) on failure. None while the session is in flight.

result#

Structured done payload at session close. None while in flight or on failure.

Parameters:
created_at: datetime#
current_phase: Phase | None = None#
error: dict[str, Any] | None = None#
event_queue: Queue[dict[str, Any]] | None = None#
is_terminal()[source]#

Return True if the session reached a terminal state.

Return type:

bool

kind: SessionKind#
last_heartbeat: datetime#
next_action: NextAction = 'none'#
pending_input: dict[str, Any] | None = None#
request: Any#
result: dict[str, Any] | None = None#
session_id: str#
state: SessionState#
class exlab_wizard.controller.session_store.SessionStore[source]#

Bases: object

In-memory session store. Backend Spec §4.4.7.

Sessions are keyed by UUID4 string; the dict is in-memory for v1 (no persistence across server restarts – Backend Spec §4.8).

abandoned_older_than(age)[source]#

Return ids of SessionState.INPUT_REQUIRED sessions whose last_heartbeat is older than age.

Used by gc_loop() to identify sessions abandoned by their operator (no client heartbeat for the configured window). Only INPUT_REQUIRED sessions are eligible – transient states are owned by the controller and finish on their own.

Parameters:

age (timedelta)

Return type:

list[str]

attach_event_queue(session_id, queue)[source]#

Attach a WebSocket fan-out queue to the session.

The controller pushes WebSocket frames onto the queue; the WS /api/v1/sessions/{id}/events channel reads from it. One queue per session; re-attaching replaces the prior queue.

Parameters:
Return type:

None

close(session_id, outcome)[source]#

Stamp a terminal-state session with the outcome envelope.

outcome is the structured payload that the WebSocket done / failed frame carried. DONE outcomes go into result; FAILED outcomes go into error; ABORTED sessions store the outcome under result so the operator can recover the partial-creation summary if the cancel was a deliberate abort.

Parameters:
Return type:

None

async gc_loop(interval_seconds=300.0)[source]#

Run the abandoned-session GC forever. Backend Spec §4.4.7.

Sleeps interval_seconds between passes (default 5 min); on each wake closes every INPUT_REQUIRED session whose heartbeat is older than SESSION_GC_AFTER_SECONDS (default 1 hour). Cancellation is honored cleanly: the loop catches asyncio.CancelledError and re-raises so the caller’s cancellation propagates.

Parameters:

interval_seconds (float)

Return type:

None

get(session_id)[source]#

Return the session keyed by session_id, or None.

Parameters:

session_id (str)

Return type:

Session | None

heartbeat(session_id)[source]#

Refresh last_heartbeat so the GC will not close this session.

No-op when the session is unknown so a stale client does not crash the server.

Parameters:

session_id (str)

Return type:

None

open(kind, req)[source]#

Create a fresh session in SessionState.PENDING state.

Parameters:
  • kind (SessionKind)

  • req (Any)

Return type:

Session

transition(session_id, new_state)[source]#

Move session_id to new_state, updating current_phase.

Validates the transition against exlab_wizard.controller.state_machine.VALID_TRANSITIONS and raises ValueError on illegal edges. next_action is updated alongside state: INPUT_REQUIRED -> "awaiting_input", every other state -> "none".

Parameters:
Return type:

None