From 42b061f72def6fb58bd060db0c555e0921cc0475 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 10 Feb 2026 15:14:45 +0530 Subject: [PATCH] refactor: enforce architectural boundaries by updating OpenClaw service imports --- backend/app/api/board_group_memory.py | 4 +- backend/app/api/board_groups.py | 4 +- backend/app/api/board_memory.py | 8 +- backend/app/api/boards.py | 4 +- backend/app/api/tasks.py | 12 +-- backend/app/services/openclaw/__init__.py | 91 +------------------ backend/app/services/openclaw/shared.py | 32 ++++++- .../test_api_openclaw_integration_boundary.py | 28 ++++++ .../tests/test_openclaw_import_boundaries.py | 32 +++++++ 9 files changed, 111 insertions(+), 104 deletions(-) create mode 100644 backend/tests/test_api_openclaw_integration_boundary.py create mode 100644 backend/tests/test_openclaw_import_boundaries.py diff --git a/backend/app/api/board_group_memory.py b/backend/app/api/board_group_memory.py index 8593c7b3..188385b7 100644 --- a/backend/app/api/board_group_memory.py +++ b/backend/app/api/board_group_memory.py @@ -25,7 +25,6 @@ from app.core.config import settings from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import async_session_maker, get_session -from app.integrations.openclaw_gateway import OpenClawGatewayError from app.models.agents import Agent from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup @@ -35,6 +34,7 @@ from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMem from app.schemas.pagination import DefaultLimitOffsetPage from app.services.mentions import extract_mentions, matches_agent_mention from app.services.openclaw.shared import ( + GatewayTransportError, optional_gateway_config_for_board, send_gateway_agent_message, ) @@ -250,7 +250,7 @@ async def _notify_group_target( agent_name=agent.name, message=message, ) - except OpenClawGatewayError: + except GatewayTransportError: return diff --git a/backend/app/api/board_groups.py b/backend/app/api/board_groups.py index 974dc9a7..5e93ec38 100644 --- a/backend/app/api/board_groups.py +++ b/backend/app/api/board_groups.py @@ -15,7 +15,6 @@ from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate from app.db.session import get_session -from app.integrations.openclaw_gateway import OpenClawGatewayError from app.models.agents import Agent from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup @@ -32,6 +31,7 @@ from app.schemas.view_models import BoardGroupSnapshot from app.services.board_group_snapshot import build_group_snapshot from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG from app.services.openclaw.provisioning import sync_gateway_agent_heartbeats +from app.services.openclaw.shared import GatewayTransportError from app.services.organizations import ( OrganizationContext, board_access_filter, @@ -270,7 +270,7 @@ async def _sync_gateway_heartbeats( continue try: await sync_gateway_agent_heartbeats(gateway, gateway_agents) - except OpenClawGatewayError: + except GatewayTransportError: failed_agent_ids.extend([agent.id for agent in gateway_agents]) return failed_agent_ids diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py index fd3ae058..a24a2008 100644 --- a/backend/app/api/board_memory.py +++ b/backend/app/api/board_memory.py @@ -23,14 +23,14 @@ from app.core.config import settings from app.core.time import utcnow from app.db.pagination import paginate from app.db.session import async_session_maker, get_session -from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import OpenClawGatewayError from app.models.agents import Agent from app.models.board_memory import BoardMemory from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.pagination import DefaultLimitOffsetPage from app.services.mentions import extract_mentions, matches_agent_mention from app.services.openclaw.shared import ( + GatewayClientConfig, + GatewayTransportError, optional_gateway_config_for_board, send_gateway_agent_message, ) @@ -124,7 +124,7 @@ async def _send_control_command( message=command, deliver=True, ) - except OpenClawGatewayError: + except GatewayTransportError: continue @@ -215,7 +215,7 @@ async def _notify_chat_targets( agent_name=agent.name, message=message, ) - except OpenClawGatewayError: + except GatewayTransportError: continue diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 1551bfdd..1f18ef1d 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -20,7 +20,6 @@ from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate from app.db.session import get_session -from app.integrations.openclaw_gateway import OpenClawGatewayError from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.approvals import Approval @@ -41,6 +40,7 @@ from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot from app.services.board_group_snapshot import build_board_group_snapshot from app.services.board_snapshot import build_board_snapshot from app.services.openclaw.provisioning import cleanup_agent +from app.services.openclaw.shared import GatewayTransportError from app.services.organizations import OrganizationContext, board_access_filter if TYPE_CHECKING: @@ -288,7 +288,7 @@ async def delete_board( try: for agent in agents: await cleanup_agent(agent, config) - except OpenClawGatewayError as exc: + except GatewayTransportError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Gateway cleanup failed: {exc}", diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 8a7ee98b..9c064671 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -28,8 +28,6 @@ from app.core.time import utcnow from app.db import crud from app.db.pagination import paginate from app.db.session import async_session_maker, get_session -from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import OpenClawGatewayError from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.approvals import Approval @@ -44,6 +42,8 @@ from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, Ta from app.services.activity_log import record_activity from app.services.mentions import extract_mentions, matches_agent_mention from app.services.openclaw.shared import ( + GatewayClientConfig, + GatewayTransportError, optional_gateway_config_for_board, send_gateway_agent_message, ) @@ -376,7 +376,7 @@ async def _notify_agent_on_task_assign( task_id=task.id, ) await session.commit() - except OpenClawGatewayError as exc: + except GatewayTransportError as exc: record_activity( session, event_type="task.assignee_notify_failed", @@ -447,7 +447,7 @@ async def _notify_lead_on_task_create( task_id=task.id, ) await session.commit() - except OpenClawGatewayError as exc: + except GatewayTransportError as exc: record_activity( session, event_type="task.lead_notify_failed", @@ -502,7 +502,7 @@ async def _notify_lead_on_task_unassigned( task_id=task.id, ) await session.commit() - except OpenClawGatewayError as exc: + except GatewayTransportError as exc: record_activity( session, event_type="task.lead_unassigned_notify_failed", @@ -1057,7 +1057,7 @@ async def _notify_task_comment_targets( "If you are mentioned but not assigned, reply in the task " "thread but do not change task status." ) - with suppress(OpenClawGatewayError): + with suppress(GatewayTransportError): await _send_agent_task_message( session_key=agent.openclaw_session_id, config=config, diff --git a/backend/app/services/openclaw/__init__.py b/backend/app/services/openclaw/__init__.py index ac20f0fd..e2517cc5 100644 --- a/backend/app/services/openclaw/__init__.py +++ b/backend/app/services/openclaw/__init__.py @@ -1,88 +1,7 @@ -"""OpenClaw lifecycle services package.""" +"""OpenClaw lifecycle package. -from .admin_service import ( - AbstractGatewayMainAgentManager, - DefaultGatewayMainAgentManager, - GatewayAdminLifecycleService, -) -from .agent_service import ( - AbstractProvisionExecution, - ActorContextLike, - AgentLifecycleService, - AgentUpdateOptions, - AgentUpdateProvisionRequest, - AgentUpdateProvisionTarget, - BoardAgentProvisionExecution, - MainAgentProvisionExecution, -) -from .constants import DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY, DEFAULT_HEARTBEAT_CONFIG -from .coordination_service import AbstractGatewayMessagingService, GatewayCoordinationService -from .exceptions import ( - GatewayErrorPolicy, - GatewayOperation, - map_gateway_error_message, - map_gateway_error_to_http_exception, -) -from .onboarding_service import BoardOnboardingMessagingService -from .provisioning import ( - AgentProvisionRequest, - LeadAgentOptions, - LeadAgentRequest, - MainAgentProvisionRequest, - ProvisionOptions, - cleanup_agent, - ensure_board_lead_agent, - patch_gateway_agent_heartbeats, - provision_agent, - provision_main_agent, - sync_gateway_agent_heartbeats, -) -from .session_service import GatewaySessionService, GatewayTemplateSyncQuery -from .shared import ( - GatewayAgentIdentity, - optional_gateway_config_for_board, - require_gateway_config_for_board, - resolve_trace_id, - send_gateway_agent_message, -) +Import concrete modules directly (for example: ``app.services.openclaw.agent_service``) +to keep architectural boundaries explicit. +""" -__all__ = [ - "AbstractGatewayMainAgentManager", - "DefaultGatewayMainAgentManager", - "GatewayAdminLifecycleService", - "AbstractProvisionExecution", - "ActorContextLike", - "AgentLifecycleService", - "AgentUpdateOptions", - "AgentUpdateProvisionRequest", - "AgentUpdateProvisionTarget", - "BoardAgentProvisionExecution", - "MainAgentProvisionExecution", - "DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY", - "DEFAULT_HEARTBEAT_CONFIG", - "AbstractGatewayMessagingService", - "GatewayCoordinationService", - "GatewayErrorPolicy", - "GatewayOperation", - "map_gateway_error_message", - "map_gateway_error_to_http_exception", - "BoardOnboardingMessagingService", - "AgentProvisionRequest", - "LeadAgentOptions", - "LeadAgentRequest", - "MainAgentProvisionRequest", - "ProvisionOptions", - "cleanup_agent", - "ensure_board_lead_agent", - "patch_gateway_agent_heartbeats", - "provision_agent", - "provision_main_agent", - "sync_gateway_agent_heartbeats", - "GatewaySessionService", - "GatewayTemplateSyncQuery", - "GatewayAgentIdentity", - "optional_gateway_config_for_board", - "require_gateway_config_for_board", - "resolve_trace_id", - "send_gateway_agent_message", -] +__all__: list[str] = [] diff --git a/backend/app/services/openclaw/shared.py b/backend/app/services/openclaw/shared.py index 7f7ac214..3e6be651 100644 --- a/backend/app/services/openclaw/shared.py +++ b/backend/app/services/openclaw/shared.py @@ -8,8 +8,8 @@ from uuid import UUID, uuid4 from fastapi import HTTPException, status -from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import ensure_session, send_message +from app.integrations.openclaw_gateway import GatewayConfig as _GatewayClientConfig +from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message from app.models.boards import Board from app.models.gateways import Gateway from app.services.openclaw.constants import ( @@ -22,6 +22,9 @@ if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession +GatewayClientConfig = _GatewayClientConfig + + class GatewayAgentIdentity: """Naming and identity rules for Mission Control gateway-main agents.""" @@ -87,6 +90,28 @@ async def send_gateway_agent_message( await send_message(message, session_key=session_key, config=config, deliver=deliver) +async def send_gateway_agent_message_safe( + *, + session_key: str, + config: GatewayClientConfig, + agent_name: str, + message: str, + deliver: bool = False, +) -> GatewayTransportError | None: + """Best-effort gateway dispatch returning transport error when one occurs.""" + try: + await send_gateway_agent_message( + session_key=session_key, + config=config, + agent_name=agent_name, + message=message, + deliver=deliver, + ) + except GatewayTransportError as exc: + return exc + return None + + def resolve_trace_id(correlation_id: str | None, *, prefix: str) -> str: """Resolve a stable trace id from correlation id or generate a scoped fallback.""" normalized = (correlation_id or "").strip() @@ -96,3 +121,6 @@ def resolve_trace_id(correlation_id: str | None, *, prefix: str) -> str: logger = logging.getLogger(__name__) + +# Keep integration exceptions behind the OpenClaw service boundary. +GatewayTransportError = OpenClawGatewayError diff --git a/backend/tests/test_api_openclaw_integration_boundary.py b/backend/tests/test_api_openclaw_integration_boundary.py new file mode 100644 index 00000000..83ba1c68 --- /dev/null +++ b/backend/tests/test_api_openclaw_integration_boundary.py @@ -0,0 +1,28 @@ +# ruff: noqa: S101 +"""Architectural boundary tests for API/OpenClaw integration usage.""" + +from __future__ import annotations + +from pathlib import Path + + +def test_api_does_not_import_openclaw_gateway_client_directly() -> None: + """API modules should use OpenClaw services, not integration client imports.""" + repo_root = Path(__file__).resolve().parents[2] + api_root = repo_root / "backend" / "app" / "api" + + violations: list[str] = [] + for path in api_root.rglob("*.py"): + rel = path.relative_to(repo_root) + for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if line.startswith("from app.integrations.openclaw_gateway import "): + violations.append(f"{rel}:{lineno}") + elif line.startswith("import app.integrations.openclaw_gateway"): + violations.append(f"{rel}:{lineno}") + + assert not violations, ( + "Import OpenClaw integration details via service modules (for example " + "`app.services.openclaw.shared`) instead of directly from `app.api`. " + f"Violations: {', '.join(violations)}" + ) diff --git a/backend/tests/test_openclaw_import_boundaries.py b/backend/tests/test_openclaw_import_boundaries.py new file mode 100644 index 00000000..0e8f3496 --- /dev/null +++ b/backend/tests/test_openclaw_import_boundaries.py @@ -0,0 +1,32 @@ +# ruff: noqa: S101 +"""Architectural boundary tests for OpenClaw service imports.""" + +from __future__ import annotations + +from pathlib import Path + + +def test_no_openclaw_package_barrel_imports() -> None: + """Disallow `from app.services.openclaw import ...` in backend code.""" + repo_root = Path(__file__).resolve().parents[2] + backend_root = repo_root / "backend" + scan_roots = (backend_root / "app", backend_root / "tests") + + violations: list[str] = [] + for root in scan_roots: + for path in root.rglob("*.py"): + if path.name == "__init__.py": + continue + rel = path.relative_to(repo_root) + for lineno, raw_line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ): + line = raw_line.strip() + if line.startswith("from app.services.openclaw import "): + violations.append(f"{rel}:{lineno}") + + assert not violations, ( + "Use concrete OpenClaw modules (for example " + "`from app.services.openclaw.agent_service import ...`) instead of package imports. " + f"Violations: {', '.join(violations)}" + )