diff --git a/backend/app/services/openclaw/internal/session_keys.py b/backend/app/services/openclaw/internal/session_keys.py new file mode 100644 index 00000000..848d9085 --- /dev/null +++ b/backend/app/services/openclaw/internal/session_keys.py @@ -0,0 +1,40 @@ +"""Deterministic session-key helpers for OpenClaw agents. + +Session keys are part of Mission Control's contract with the OpenClaw gateway. +Centralize the string formats here to avoid drift across provisioning, DB workflows, +and API-facing services. +""" + +from __future__ import annotations + +from uuid import UUID + +from app.services.openclaw.constants import AGENT_SESSION_PREFIX +from app.services.openclaw.shared import GatewayAgentIdentity + + +def gateway_main_session_key(gateway_id: UUID) -> str: + """Return the deterministic session key for a gateway-main agent.""" + return GatewayAgentIdentity.session_key_for_id(gateway_id) + + +def board_lead_session_key(board_id: UUID) -> str: + """Return the deterministic session key for a board lead agent.""" + return f"{AGENT_SESSION_PREFIX}:lead-{board_id}:main" + + +def board_agent_session_key(agent_id: UUID) -> str: + """Return the deterministic session key for a non-lead, board-scoped agent.""" + return f"{AGENT_SESSION_PREFIX}:mc-{agent_id}:main" + + +def board_scoped_session_key( + *, + agent_id: UUID, + board_id: UUID, + is_board_lead: bool, +) -> str: + """Return the deterministic session key for a board-scoped agent.""" + if is_board_lead: + return board_lead_session_key(board_id) + return board_agent_session_key(agent_id) diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index 53389fa4..6c71ef38 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -40,6 +40,10 @@ from app.services.openclaw.gateway_rpc import ( ) from app.services.openclaw.internal.agent_key import agent_key as _agent_key from app.services.openclaw.internal.agent_key import slugify +from app.services.openclaw.internal.session_keys import ( + board_agent_session_key, + board_lead_session_key, +) from app.services.openclaw.shared import GatewayAgentIdentity if TYPE_CHECKING: @@ -251,8 +255,8 @@ def _session_key(agent: Agent) -> str: """ if agent.is_board_lead and agent.board_id is not None: - return f"agent:lead-{agent.board_id}:main" - return f"agent:mc-{agent.id}:main" + return board_lead_session_key(agent.board_id) + return board_agent_session_key(agent.id) def _render_agent_files( diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index 8d00a8e0..ae84f0cf 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -45,7 +45,6 @@ from app.schemas.gateways import GatewayTemplatesSyncError, GatewayTemplatesSync from app.services.activity_log import record_activity from app.services.openclaw.constants import ( _TOOLS_KV_RE, - AGENT_SESSION_PREFIX, DEFAULT_HEARTBEAT_CONFIG, OFFLINE_AFTER, ) @@ -68,6 +67,10 @@ from app.services.openclaw.gateway_rpc import ( ) from app.services.openclaw.internal.agent_key import agent_key as _agent_key from app.services.openclaw.internal.retry import GatewayBackoff +from app.services.openclaw.internal.session_keys import ( + board_agent_session_key, + board_lead_session_key, +) from app.services.openclaw.policies import OpenClawAuthorizationPolicy from app.services.openclaw.provisioning import ( OpenClawGatewayControlPlane, @@ -139,7 +142,7 @@ class OpenClawProvisioningService(OpenClawDBService): @staticmethod def lead_session_key(board: Board) -> str: - return f"agent:lead-{board.id}:main" + return board_lead_session_key(board.id) @staticmethod def lead_agent_name(_: Board) -> str: @@ -732,8 +735,8 @@ class AgentLifecycleService(OpenClawDBService): detail="Gateway main agent session key is required", ) if agent.is_board_lead: - return f"{AGENT_SESSION_PREFIX}:lead-{agent.board_id}:main" - return f"{AGENT_SESSION_PREFIX}:mc-{agent.id}:main" + return board_lead_session_key(agent.board_id) + return board_agent_session_key(agent.id) @classmethod def workspace_path(cls, agent_name: str, workspace_root: str | None) -> str: diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py index bf2ca18f..806cbcb2 100644 --- a/backend/app/services/openclaw/session_service.py +++ b/backend/app/services/openclaw/session_service.py @@ -8,9 +8,7 @@ from typing import TYPE_CHECKING from uuid import UUID from fastapi import HTTPException, status -from sqlmodel import col -from app.models.agents import Agent from app.models.boards import Board from app.schemas.gateway_api import ( GatewayResolveQuery, @@ -31,6 +29,7 @@ from app.services.openclaw.gateway_rpc import ( send_message, ) from app.services.openclaw.policies import OpenClawAuthorizationPolicy +from app.services.openclaw.shared import GatewayAgentIdentity from app.services.organizations import require_board_access if TYPE_CHECKING: @@ -124,12 +123,7 @@ class GatewaySessionService(OpenClawDBService): await require_board_access(self.session, user=user, board=board, write=False) gateway = await require_gateway_for_board(self.session, board) config = gateway_client_config(gateway) - main_agent = ( - await Agent.objects.filter_by(gateway_id=gateway.id) - .filter(col(Agent.board_id).is_(None)) - .first(self.session) - ) - main_session = main_agent.openclaw_session_id if main_agent else None + main_session = GatewayAgentIdentity.session_key(gateway) return ( board, config, diff --git a/backend/tests/test_session_keys.py b/backend/tests/test_session_keys.py new file mode 100644 index 00000000..f66216cf --- /dev/null +++ b/backend/tests/test_session_keys.py @@ -0,0 +1,47 @@ +# ruff: noqa: S101 +"""Unit tests for deterministic OpenClaw session-key helpers.""" + +from __future__ import annotations + +from uuid import UUID + +from app.services.openclaw.internal.session_keys import ( + board_agent_session_key, + board_lead_session_key, + board_scoped_session_key, + gateway_main_session_key, +) +from app.services.openclaw.shared import GatewayAgentIdentity + + +def test_gateway_main_session_key_matches_gateway_identity() -> None: + gateway_id = UUID("00000000-0000-0000-0000-000000000123") + assert gateway_main_session_key(gateway_id) == GatewayAgentIdentity.session_key_for_id(gateway_id) + + +def test_board_lead_session_key_format() -> None: + board_id = UUID("00000000-0000-0000-0000-000000000456") + assert board_lead_session_key(board_id) == f"agent:lead-{board_id}:main" + + +def test_board_agent_session_key_format() -> None: + agent_id = UUID("00000000-0000-0000-0000-000000000789") + assert board_agent_session_key(agent_id) == f"agent:mc-{agent_id}:main" + + +def test_board_scoped_session_key_selects_lead() -> None: + agent_id = UUID("00000000-0000-0000-0000-000000000001") + board_id = UUID("00000000-0000-0000-0000-000000000002") + assert ( + board_scoped_session_key(agent_id=agent_id, board_id=board_id, is_board_lead=True) + == board_lead_session_key(board_id) + ) + + +def test_board_scoped_session_key_selects_non_lead() -> None: + agent_id = UUID("00000000-0000-0000-0000-000000000001") + board_id = UUID("00000000-0000-0000-0000-000000000002") + assert ( + board_scoped_session_key(agent_id=agent_id, board_id=board_id, is_board_lead=False) + == board_agent_session_key(agent_id) + )