orchestrata_ultimate.gateway.handlers.session

Reference › orchestrata_ultimate › gateway › handlers › session

SessionHandler serves the session.* RPCs: session.active, session.list, session.get, session.open, session.close, session.events and session.facts.subscribe. These are the work sessions tracked by the SessionController in the session package, not the WebSocket connections held by the connection manager. rpc creates it. See gateway.

The handler is a thin coordinator over two helpers. _session_kg owns every graph read and write, and _session_events owns the session.events broadcasts. _resolve_session_root takes the root from the constructor argument or the SESSION_ROOT setting and raises ValueError if neither is present; it never guesses from the working directory.

At a glance

Source src/orchestrata_ultimate/gateway/handlers/session.py
Lines of code 445
Classes 1
Functions 1
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.gateway.handlers._session_events, orchestrata_ultimate.gateway.handlers._session_kg, orchestrata_ultimate.gateway.pubsub, orchestrata_ultimate.session.session
Imported by orchestrata_ultimate.gateway.handlers, orchestrata_ultimate.gateway.rpc
Tests that import it tests/gateway/handlers/test_session.py, tests/gateway/test_handlers_behavioral.py, tests/gateway/test_memory_engine_wiring.py, tests/gateway/test_publisher_wiring.py, tests/gateway/test_rpc_behavioral.py, tests/generated_gateway/test_handlers_session_generated.py

Class diagram

Class diagram orchestrata_ultimate.gateway.handlers.session

Dependency diagram

Dependency diagram orchestrata_ultimate.gateway.handlers.session

API

Classes

SessionHandler

Decorators: traces(spec='014', story='US-004', reqs=['REQ-014-051', 'REQ-014-053', 'REQ-014-054', 'REQ-014-056', 'REQ-014-059', 'RE…, traces(spec='016', story='US-013', reqs=['REQ-016-245', 'REQ-016-246', 'REQ-016-247', 'REQ-016-248', 'REQ-016-249', 'RE… · Line 87

Session lifecycle and subscription methods.

Method Signature Summary
__init__ __init__(pubsub: PubSubManager | None, processor: Any = None, session_root: str | Path | None = None, env: Mapping[str, str] | None = None, kg_ops: SessionKgOps | None = None, event_ops: SessionEventOps | None = None) -> None Wire handler to session subsystems.
set_processor set_processor(processor: Any) -> None Inject the message processor after construction.
set_memory_engine set_memory_engine(engine: Any) -> None Inject the gateway's shared memory engine for cold migration.
set_graph_repository set_graph_repository(graph_repository: Any) -> None Inject the operational-graph repository.
handle_active async handle_active(connection_id: str, params: dict[str, Any]) -> dict[str, Any] Handle session.active -- get current active session info.
handle_list async handle_list(connection_id: str, params: dict[str, Any]) -> dict[str, Any] Handle session.list -- list sessions from the graph.
handle_get async handle_get(connection_id: str, params: dict[str, Any]) -> dict[str, Any] Handle session.get -- get session by ID from the graph.
handle_open async handle_open(connection_id: str, params: dict[str, Any]) -> dict[str, Any] Handle session.open -- open a new session via SessionController.
handle_close async handle_close(connection_id: str, params: dict[str, Any]) -> dict[str, Any] Handle session.close -- close an active session via SessionController.
_end_active_session _end_active_session(session_id: str, summary: Any, carryover: Any) -> None End the SessionController session, if it's the active one.
_migrate_close_memories async _migrate_close_memories(session_id: str) -> int Migrate this session's hot memories to cold storage (SPEC-010 FR-008).
handle_events async handle_events(connection_id: str, params: dict[str, Any]) -> dict[str, Any] Handle session.events -- subscribe to session events.
handle_facts_subscribe async handle_facts_subscribe(connection_id: str, params: dict[str, Any]) -> dict[str, Any] Handle session.facts.subscribe -- subscribe to session fact events.

Functions

Function Signature Summary
_resolve_session_root traces _resolve_session_root(session_root: str | Path | None, env: Mapping[str, str] | None) -> str | Path Resolve the explicit session root: session_root, else SESSION_ROOT.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/gateway/handlers/session.py

"""Session RPC handlers -- list, get, open, close, events, facts.

Implements SPEC-014 FR-017 session management methods.
"""

import asyncio
import logging
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any, cast

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.gateway.handlers._session_events import SessionEventOps
from orchestrata_ultimate.gateway.handlers._session_kg import (
    SessionKgOps,
    _deserialize_session,
    build_active_query,
    build_get_query,
    build_list_query,
    shape_get_result,
)
from orchestrata_ultimate.gateway.pubsub import PubSubManager
from orchestrata_ultimate.session.session import SessionController

logger = logging.getLogger(__name__)

_SESSION_ROOT_ERROR = (
    "SessionHandler requires a session-persistence root: pass "
    "session_root=<path> to the constructor, or set the SESSION_ROOT "
    "environment variable. Neither the process cwd nor this package's own "
    "location is used as a fallback -- an implicit root previously caused "
    "session lifecycle git operations (commit/push) to land on whatever "
    "repository happened to be checked out at the process's cwd "
    "(fix/session-root, RUL-011)."
)


@traces(spec="016", story="US-013", reqs=["REQ-016-245"])
def _resolve_session_root(
    session_root: str | Path | None, env: Mapping[str, str] | None
) -> str | Path:
    """Resolve the explicit session root: *session_root*, else ``SESSION_ROOT``.

    Never falls back to the process cwd or this package's own location.

    Raises:
        ValueError: neither source supplies a root.
    """
    if session_root is not None:
        return session_root
    resolved_env = os.environ if env is None else env
    env_root = resolved_env.get("SESSION_ROOT")
    if env_root:
        return env_root
    raise ValueError(_SESSION_ROOT_ERROR)


@traces(
    spec="014",
    story="US-004",
    reqs=[
        "REQ-014-051",
        "REQ-014-053",
        "REQ-014-054",
        "REQ-014-056",
        "REQ-014-059",
        "REQ-014-061",
        "REQ-014-063",
        "REQ-014-064",
    ],
)
@traces(
    spec="016",
    story="US-013",
    reqs=[
        "REQ-016-245",
        "REQ-016-246",
        "REQ-016-247",
        "REQ-016-248",
        "REQ-016-249",
        "REQ-016-250",
        "REQ-016-251",
        "REQ-016-252",
    ],
)
class SessionHandler:
    """Session lifecycle and subscription methods."""

    @traces(spec="016", story="US-013", reqs=["REQ-016-245"])
    def __init__(
        self,
        pubsub: PubSubManager | None,
        processor: Any = None,
        session_root: str | Path | None = None,
        env: Mapping[str, str] | None = None,
        kg_ops: SessionKgOps | None = None,
        event_ops: SessionEventOps | None = None,
    ) -> None:
        """Wire handler to session subsystems.

        Args:
            pubsub: Pub/sub subscription manager, or ``None`` when pub/sub is
                unavailable (``handle_events``/``handle_facts_subscribe`` then
                report "PubSub unavailable" instead of erroring).
            processor: Message processor for broadcast/push.
            session_root: Explicit session-persistence root. Falls back to
                the ``SESSION_ROOT`` variable in *env* (or ``os.environ``
                when *env* is not given) when omitted. Never inferred from
                the process cwd or this package's own location -- see
                ``_SESSION_ROOT_ERROR`` (fix/session-root, RUL-011).
            env: Optional environment mapping consulted for ``SESSION_ROOT``
                when *session_root* is not given. Defaults to ``os.environ``.
            kg_ops: Optional pre-built ``SessionKgOps`` (DI seam); defaults
                to a real instance bound to this handler's own
                ``SessionController``.
            event_ops: Optional pre-built ``SessionEventOps`` (DI seam);
                defaults to a real instance bound to *processor*.

        Raises:
            ValueError: neither *session_root* nor ``SESSION_ROOT`` supplies
                a root.
        """
        self._pubsub = pubsub
        self._memory_engine: Any = None
        self._session_controller = SessionController(
            _resolve_session_root(session_root, env),
            explicit_root=True,
        )
        self._kg = kg_ops or SessionKgOps(self._session_controller)
        self._events = event_ops or SessionEventOps(processor)

    @traces(spec="016", story="US-013", reqs=["REQ-016-246"])
    def set_processor(self, processor: Any) -> None:
        """Inject the message processor after construction.

        Args:
            processor: MessageProcessor instance.
        """
        self._events.set_processor(processor)

    @traces(spec="016", story="US-013", reqs=["REQ-016-246"])
    def set_memory_engine(self, engine: Any) -> None:
        """Inject the gateway's shared memory engine for cold migration.

        On session close the handler migrates that session's hot memories to
        cold storage (SPEC-010 FR-008). The engine is the same DI-registered
        singleton the memory RPC handler uses, so it sees exactly the entries
        stored during the session.

        Args:
            engine: The gateway's singleton ``MemoryEngine`` (or None to leave
                cold migration disabled).
        """
        self._memory_engine = engine

    @traces(spec="016", story="US-013", reqs=["REQ-016-246"])
    def set_graph_repository(self, graph_repository: Any) -> None:
        """Inject the operational-graph repository.

        Args:
            graph_repository: An AsyncGraphRepository instance.
        """
        self._kg.set_graph_repository(graph_repository)

    @traces(spec="016", story="US-013", reqs=["REQ-016-246"])
    async def handle_active(
        self,
        connection_id: str,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle session.active -- get current active session info.

        Queries the graph for the most recent active session.

        Returns:
            Active session or null.
        """
        if not self._kg.available:
            return {"session": None, "error": "Graph unavailable"}

        try:
            query, query_params = build_active_query()
            results = await asyncio.wait_for(
                self._kg.execute(query, query_params),
                timeout=10.0,
            )
            if results:
                session = _deserialize_session(results[0])
                return {"session": session}
            return {"session": None}
        except TimeoutError:
            return {"session": None, "error": "Query timeout"}
        except Exception as exc:
            return {"session": None, "error": str(exc)}

    @traces(spec="016", story="US-013", reqs=["REQ-016-247"])
    @traces(spec="014", story="US-004", reqs=["REQ-014-051"])
    async def handle_list(
        self,
        connection_id: str,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle session.list -- list sessions from the graph.

        Also serves SPEC-018 REQ-018-042 for Fireflies session linking.

        Args:
            connection_id: Caller connection ID.
            params: Optional status filter, limit, offset.

        Returns:
            Paginated list of sessions.
        """
        if not self._kg.available:
            return {"sessions": [], "error": "Graph unavailable"}

        try:
            query, query_params = build_list_query(params)
            results = await asyncio.wait_for(
                self._kg.execute(query, query_params),
                timeout=10.0,
            )
            sessions = [_deserialize_session(row) for row in results]
            return {"sessions": sessions}
        except TimeoutError:
            return {"sessions": [], "error": "Query timeout"}
        except Exception as exc:
            return {"sessions": [], "error": str(exc)}

    @traces(spec="016", story="US-013", reqs=["REQ-016-248"])
    @traces(
        spec="014",
        story="US-004",
        reqs=["REQ-014-053", "REQ-014-054"],
    )
    async def handle_get(
        self,
        connection_id: str,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle session.get -- get session by ID from the graph.

        Args:
            connection_id: Caller connection ID.
            params: session_id (camelCase sessionId accepted).

        Returns:
            Session state dict with context and facts.
        """
        session_id = params.get("session_id") or params.get("sessionId", "")
        if not session_id:
            return {"error": "Missing session_id"}

        if not self._kg.available:
            return {"error": "Graph unavailable"}

        try:
            query, query_params = build_get_query(
                self._kg.session_key(session_id), str(session_id)
            )
            results = await asyncio.wait_for(
                self._kg.execute(query, query_params),
                timeout=10.0,
            )
            return {"session": shape_get_result(results)}
        except TimeoutError:
            return {"error": "Query timeout"}
        except Exception as exc:
            return {"error": str(exc)}

    @traces(spec="016", story="US-013", reqs=["REQ-016-249"])
    @traces(
        spec="014",
        story="US-004",
        reqs=["REQ-014-061", "REQ-014-057", "REQ-014-058"],
    )
    async def handle_open(
        self,
        connection_id: str,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle session.open -- open a new session via SessionController.

        Creates a real session and persists it to the graph; see
        ``_session_kg.build_open_query`` for the session-254 root-connectivity
        ``HAS_SESSION`` MERGE this write performs.

        Args:
            connection_id: Caller connection ID.
            params: Session creation parameters (goals, portfolio, purpose).

        Returns:
            Created session info.
        """
        goals = params.get("goals", [])
        portfolio = params.get("portfolio", "")
        purpose = params.get("purpose", "")

        if not purpose:
            return {"error": "Missing purpose"}

        # Ensure context is initialized before beginning a session
        if not self._session_controller.ctx.file_exists():
            self._session_controller.ctx.initialize()

        # Create session via SessionController
        session = self._session_controller.begin(
            purpose=purpose,
            objectives=goals if isinstance(goals, list) else [goals],
        )

        await self._kg.persist_open(session, goals, portfolio, purpose)
        await self._events.broadcast_opened(session, purpose)

        return {"session_id": session.session_id, "status": "active"}

    @traces(spec="016", story="US-013", reqs=["REQ-016-250"])
    @traces(
        spec="014",
        story="US-004",
        reqs=["REQ-014-063", "REQ-014-064"],
    )
    async def handle_close(
        self,
        connection_id: str,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle session.close -- close an active session via SessionController.

        Ends the session and updates Neo4j.

        Args:
            connection_id: Caller connection ID.
            params: Session close parameters (session_id, summary, carryover_tasks).

        Returns:
            Closed session info.
        """
        session_id = params.get("session_id", "")
        summary = params.get("summary", [])
        carryover = params.get("carryover_tasks", [])

        if not session_id:
            return {"error": "Missing session_id"}

        self._end_active_session(session_id, summary, carryover)
        await self._kg.persist_close(session_id, summary, carryover)
        migrated = await self._migrate_close_memories(session_id)
        await self._events.broadcast_closed(session_id, migrated)

        return {"closed": True, "session_id": session_id, "memories_migrated": migrated}

    @traces(spec="016", story="US-013", reqs=["REQ-016-250"])
    def _end_active_session(
        self, session_id: str, summary: Any, carryover: Any
    ) -> None:
        """End the SessionController session, if it's the active one."""
        active = self._session_controller.active_session
        if not (active and active.session_id == session_id):
            return
        achievements = summary if isinstance(summary, list) else [summary]
        upcoming = carryover if isinstance(carryover, list) else [carryover]
        self._session_controller.end(
            achievements=achievements,
            upcoming=upcoming,
        )

    @traces(spec="016", story="US-013", reqs=["REQ-016-250"])
    async def _migrate_close_memories(self, session_id: str) -> int:
        """Migrate this session's hot memories to cold storage (SPEC-010 FR-008).

        Best-effort and only when the shared engine was actually used this
        session (running): an unused engine has nothing to migrate and a
        missing hot store must not fail the close.
        """
        engine = self._memory_engine
        if engine is None or not getattr(engine, "_running", False):
            return 0
        try:
            return cast(int, await engine.migrate_to_cold(session_id))
        except Exception:
            return 0  # cold migration is best-effort

    @traces(spec="016", story="US-013", reqs=["REQ-016-251"])
    @traces(
        spec="014",
        story="US-004",
        reqs=["REQ-014-056"],
    )
    async def handle_events(
        self,
        connection_id: str,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle session.events -- subscribe to session events.

        Args:
            connection_id: Caller connection ID.
            params: Subscription parameters (optional session_id filter).

        Returns:
            Subscription confirmation.
        """
        if not self._pubsub:
            return {"error": "PubSub unavailable"}

        sub_id = await self._pubsub.subscribe(connection_id, "session.events", params)
        return {
            "subscription_id": sub_id,
            "subscription": "session.events",
            "status": "subscribed",
            "filters": params,
        }

    @traces(spec="016", story="US-013", reqs=["REQ-016-252"])
    @traces(
        spec="014",
        story="US-004",
        reqs=["REQ-014-059"],
    )
    async def handle_facts_subscribe(
        self,
        connection_id: str,
        params: dict[str, Any],
    ) -> dict[str, Any]:
        """Handle session.facts.subscribe -- subscribe to session fact events.

        Args:
            connection_id: Caller connection ID.
            params: Fact subscription parameters (session_id, fact_types).

        Returns:
            Subscription confirmation.
        """
        if not self._pubsub:
            return {"error": "PubSub unavailable"}

        sub_id = await self._pubsub.subscribe(connection_id, "session.facts", params)
        return {
            "subscription_id": sub_id,
            "subscription": "session.facts",
            "status": "subscribed",
            "filters": params,
        }