refactor: streamline agent lifecycle management with new DB service helpers

This commit is contained in:
Abhimanyu Saharan
2026-02-11 01:13:10 +05:30
parent f4161494d9
commit f1038acf44
17 changed files with 377 additions and 350 deletions

View File

@@ -33,10 +33,7 @@ from app.models.users import User
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.mentions import extract_mentions, matches_agent_mention from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw.shared import ( from app.services.openclaw.gateway_dispatch import GatewayDispatchService
optional_gateway_config_for_board,
send_gateway_agent_message_safe,
)
from app.services.organizations import ( from app.services.organizations import (
is_org_admin, is_org_admin,
list_accessible_board_ids, list_accessible_board_ids,
@@ -206,6 +203,7 @@ def _group_header(*, is_broadcast: bool, mentioned: bool) -> str:
@dataclass(frozen=True) @dataclass(frozen=True)
class _NotifyGroupContext: class _NotifyGroupContext:
session: AsyncSession session: AsyncSession
dispatch: GatewayDispatchService
group: BoardGroup group: BoardGroup
board_by_id: dict[UUID, Board] board_by_id: dict[UUID, Board]
mentions: set[str] mentions: set[str]
@@ -226,7 +224,7 @@ async def _notify_group_target(
board = context.board_by_id.get(board_id) board = context.board_by_id.get(board_id)
if board is None: if board is None:
return return
config = await optional_gateway_config_for_board(context.session, board) config = await context.dispatch.optional_gateway_config_for_board(board)
if config is None: if config is None:
return return
header = _group_header( header = _group_header(
@@ -242,7 +240,7 @@ async def _notify_group_target(
f"POST {context.base_url}/api/v1/boards/{board.id}/group-memory\n" f"POST {context.base_url}/api/v1/boards/{board.id}/group-memory\n"
'Body: {"content":"...","tags":["chat"]}' 'Body: {"content":"...","tags":["chat"]}'
) )
error = await send_gateway_agent_message_safe( error = await context.dispatch.try_send_agent_message(
session_key=session_key, session_key=session_key,
config=config, config=config,
agent_name=agent.name, agent_name=agent.name,
@@ -294,6 +292,7 @@ async def _notify_group_memory_targets(
context = _NotifyGroupContext( context = _NotifyGroupContext(
session=session, session=session,
dispatch=GatewayDispatchService(session),
group=group, group=group,
board_by_id=board_by_id, board_by_id=board_by_id,
mentions=mentions, mentions=mentions,

View File

@@ -30,8 +30,8 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot from app.schemas.view_models import BoardGroupSnapshot
from app.services.board_group_snapshot import build_group_snapshot from app.services.board_group_snapshot import build_group_snapshot
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
from app.services.openclaw.shared import GatewayTransportError
from app.services.organizations import ( from app.services.organizations import (
OrganizationContext, OrganizationContext,
board_access_filter, board_access_filter,
@@ -273,7 +273,7 @@ async def _sync_gateway_heartbeats(
gateway, gateway,
gateway_agents, gateway_agents,
) )
except GatewayTransportError: except OpenClawGatewayError:
failed_agent_ids.extend([agent.id for agent in gateway_agents]) failed_agent_ids.extend([agent.id for agent in gateway_agents])
return failed_agent_ids return failed_agent_ids

View File

@@ -28,11 +28,8 @@ from app.models.board_memory import BoardMemory
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.mentions import extract_mentions, matches_agent_mention from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw.shared import ( from app.services.openclaw.gateway_dispatch import GatewayDispatchService
GatewayClientConfig, from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
optional_gateway_config_for_board,
send_gateway_agent_message_safe,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -102,6 +99,7 @@ async def _send_control_command(
session: AsyncSession, session: AsyncSession,
board: Board, board: Board,
actor: ActorContext, actor: ActorContext,
dispatch: GatewayDispatchService,
config: GatewayClientConfig, config: GatewayClientConfig,
command: str, command: str,
) -> None: ) -> None:
@@ -115,7 +113,7 @@ async def _send_control_command(
continue continue
if not agent.openclaw_session_id: if not agent.openclaw_session_id:
continue continue
error = await send_gateway_agent_message_safe( error = await dispatch.try_send_agent_message(
session_key=agent.openclaw_session_id, session_key=agent.openclaw_session_id,
config=config, config=config,
agent_name=agent.name, agent_name=agent.name,
@@ -161,7 +159,8 @@ async def _notify_chat_targets(
) -> None: ) -> None:
if not memory.content: if not memory.content:
return return
config = await optional_gateway_config_for_board(session, board) dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if config is None: if config is None:
return return
@@ -174,6 +173,7 @@ async def _notify_chat_targets(
session=session, session=session,
board=board, board=board,
actor=actor, actor=actor,
dispatch=dispatch,
config=config, config=config,
command=command, command=command,
) )
@@ -206,7 +206,7 @@ async def _notify_chat_targets(
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n" f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
'Body: {"content":"...","tags":["chat"]}' 'Body: {"content":"...","tags":["chat"]}'
) )
error = await send_gateway_agent_message_safe( error = await dispatch.try_send_agent_message(
session_key=agent.openclaw_session_id, session_key=agent.openclaw_session_id,
config=config, config=config,
agent_name=agent.name, agent_name=agent.name,

View File

@@ -33,6 +33,7 @@ from app.schemas.board_onboarding import (
BoardOnboardingUserProfile, BoardOnboardingUserProfile,
) )
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService
from app.services.openclaw.policies import OpenClawAuthorizationPolicy from app.services.openclaw.policies import OpenClawAuthorizationPolicy
from app.services.openclaw.provisioning_db import ( from app.services.openclaw.provisioning_db import (
@@ -40,7 +41,6 @@ from app.services.openclaw.provisioning_db import (
LeadAgentRequest, LeadAgentRequest,
OpenClawProvisioningService, OpenClawProvisioningService,
) )
from app.services.openclaw.shared import require_gateway_config_for_board
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -396,7 +396,7 @@ async def confirm_onboarding(
lead_agent = _parse_draft_lead_agent(onboarding.draft_goal) lead_agent = _parse_draft_lead_agent(onboarding.draft_goal)
lead_options = _lead_agent_options(lead_agent) lead_options = _lead_agent_options(lead_agent)
gateway, config = await require_gateway_config_for_board(session, board) gateway, config = await GatewayDispatchService(session).require_gateway_config_for_board(board)
session.add(board) session.add(board)
session.add(onboarding) session.add(onboarding)
await session.commit() await session.commit()

View File

@@ -39,8 +39,8 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
from app.services.board_group_snapshot import build_board_group_snapshot from app.services.board_group_snapshot import build_board_group_snapshot
from app.services.board_snapshot import build_board_snapshot from app.services.board_snapshot import build_board_snapshot
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
from app.services.openclaw.shared import GatewayTransportError
from app.services.organizations import OrganizationContext, board_access_filter from app.services.organizations import OrganizationContext, board_access_filter
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -291,7 +291,7 @@ async def delete_board(
agent=agent, agent=agent,
gateway=config, gateway=config,
) )
except GatewayTransportError as exc: except OpenClawGatewayError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway cleanup failed: {exc}", detail=f"Gateway cleanup failed: {exc}",

View File

@@ -40,12 +40,9 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.mentions import extract_mentions, matches_agent_mention from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw.shared import ( from app.services.openclaw.gateway_dispatch import GatewayDispatchService
GatewayClientConfig, from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
GatewayTransportError, from app.services.openclaw.gateway_rpc import OpenClawGatewayError
optional_gateway_config_for_board,
send_gateway_agent_message_safe,
)
from app.services.organizations import require_board_access from app.services.organizations import require_board_access
from app.services.task_dependencies import ( from app.services.task_dependencies import (
blocked_by_dependency_ids, blocked_by_dependency_ids,
@@ -305,11 +302,12 @@ def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
async def _send_lead_task_message( async def _send_lead_task_message(
*, *,
dispatch: GatewayDispatchService,
session_key: str, session_key: str,
config: GatewayClientConfig, config: GatewayClientConfig,
message: str, message: str,
) -> GatewayTransportError | None: ) -> OpenClawGatewayError | None:
return await send_gateway_agent_message_safe( return await dispatch.try_send_agent_message(
session_key=session_key, session_key=session_key,
config=config, config=config,
agent_name="Lead Agent", agent_name="Lead Agent",
@@ -320,12 +318,13 @@ async def _send_lead_task_message(
async def _send_agent_task_message( async def _send_agent_task_message(
*, *,
dispatch: GatewayDispatchService,
session_key: str, session_key: str,
config: GatewayClientConfig, config: GatewayClientConfig,
agent_name: str, agent_name: str,
message: str, message: str,
) -> GatewayTransportError | None: ) -> OpenClawGatewayError | None:
return await send_gateway_agent_message_safe( return await dispatch.try_send_agent_message(
session_key=session_key, session_key=session_key,
config=config, config=config,
agent_name=agent_name, agent_name=agent_name,
@@ -343,7 +342,8 @@ async def _notify_agent_on_task_assign(
) -> None: ) -> None:
if not agent.openclaw_session_id: if not agent.openclaw_session_id:
return return
config = await optional_gateway_config_for_board(session, board) dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if config is None: if config is None:
return return
description = _truncate_snippet(task.description or "") description = _truncate_snippet(task.description or "")
@@ -361,6 +361,7 @@ async def _notify_agent_on_task_assign(
+ ("\n\nTake action: open the task and begin work. " "Post updates as task comments.") + ("\n\nTake action: open the task and begin work. " "Post updates as task comments.")
) )
error = await _send_agent_task_message( error = await _send_agent_task_message(
dispatch=dispatch,
session_key=agent.openclaw_session_id, session_key=agent.openclaw_session_id,
config=config, config=config,
agent_name=agent.name, agent_name=agent.name,
@@ -415,7 +416,8 @@ async def _notify_lead_on_task_create(
) )
if lead is None or not lead.openclaw_session_id: if lead is None or not lead.openclaw_session_id:
return return
config = await optional_gateway_config_for_board(session, board) dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if config is None: if config is None:
return return
description = _truncate_snippet(task.description or "") description = _truncate_snippet(task.description or "")
@@ -433,6 +435,7 @@ async def _notify_lead_on_task_create(
+ "\n\nTake action: triage, assign, or plan next steps." + "\n\nTake action: triage, assign, or plan next steps."
) )
error = await _send_lead_task_message( error = await _send_lead_task_message(
dispatch=dispatch,
session_key=lead.openclaw_session_id, session_key=lead.openclaw_session_id,
config=config, config=config,
message=message, message=message,
@@ -470,7 +473,8 @@ async def _notify_lead_on_task_unassigned(
) )
if lead is None or not lead.openclaw_session_id: if lead is None or not lead.openclaw_session_id:
return return
config = await optional_gateway_config_for_board(session, board) dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if config is None: if config is None:
return return
description = _truncate_snippet(task.description or "") description = _truncate_snippet(task.description or "")
@@ -488,6 +492,7 @@ async def _notify_lead_on_task_unassigned(
+ "\n\nTake action: assign a new owner or adjust the plan." + "\n\nTake action: assign a new owner or adjust the plan."
) )
error = await _send_lead_task_message( error = await _send_lead_task_message(
dispatch=dispatch,
session_key=lead.openclaw_session_id, session_key=lead.openclaw_session_id,
config=config, config=config,
message=message, message=message,
@@ -1029,8 +1034,11 @@ async def _notify_task_comment_targets(
if request.task.board_id if request.task.board_id
else None else None
) )
config = await optional_gateway_config_for_board(session, board) if board else None if board is None:
if not board or not config: return
dispatch = GatewayDispatchService(session)
config = await dispatch.optional_gateway_config_for_board(board)
if not config:
return return
snippet = _truncate_snippet(request.message) snippet = _truncate_snippet(request.message)
@@ -1057,6 +1065,7 @@ async def _notify_task_comment_targets(
"thread but do not change task status." "thread but do not change task status."
) )
await _send_agent_task_message( await _send_agent_task_message(
dispatch=dispatch,
session_key=agent.openclaw_session_id, session_key=agent.openclaw_session_id,
config=config, config=config,
agent_name=agent.name, agent_name=agent.name,

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
@@ -10,7 +9,6 @@ from uuid import UUID
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlmodel import col from sqlmodel import col
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext from app.core.auth import AuthContext
from app.core.time import utcnow from app.core.time import utcnow
from app.db import crud from app.db import crud
@@ -21,6 +19,12 @@ from app.models.gateways import Gateway
from app.models.tasks import Task from app.models.tasks import Task
from app.schemas.gateways import GatewayTemplatesSyncResult from app.schemas.gateways import GatewayTemplatesSyncResult
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
from app.services.openclaw.db_agent_state import (
mark_provision_complete,
mark_provision_requested,
mint_agent_token,
)
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
@@ -64,7 +68,7 @@ class DefaultGatewayMainAgentManager(AbstractGatewayMainAgentManager):
} }
class GatewayAdminLifecycleService: class GatewayAdminLifecycleService(OpenClawDBService):
"""Write-side gateway lifecycle service (CRUD, main agent, template sync).""" """Write-side gateway lifecycle service (CRUD, main agent, template sync)."""
def __init__( def __init__(
@@ -73,26 +77,9 @@ class GatewayAdminLifecycleService:
*, *,
main_agent_manager: AbstractGatewayMainAgentManager | None = None, main_agent_manager: AbstractGatewayMainAgentManager | None = None,
) -> None: ) -> None:
self._session = session super().__init__(session)
self._logger = logging.getLogger(__name__)
self._main_agent_manager = main_agent_manager or DefaultGatewayMainAgentManager() self._main_agent_manager = main_agent_manager or DefaultGatewayMainAgentManager()
@property
def session(self) -> AsyncSession:
return self._session
@session.setter
def session(self, value: AsyncSession) -> None:
self._session = value
@property
def logger(self) -> logging.Logger:
return self._logger
@logger.setter
def logger(self, value: logging.Logger) -> None:
self._logger = value
@property @property
def main_agent_manager(self) -> AbstractGatewayMainAgentManager: def main_agent_manager(self) -> AbstractGatewayMainAgentManager:
return self._main_agent_manager return self._main_agent_manager
@@ -206,16 +193,13 @@ class GatewayAdminLifecycleService:
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Organization owner not found (required for gateway agent USER.md rendering).", detail="Organization owner not found (required for gateway agent USER.md rendering).",
) )
raw_token = generate_agent_token() raw_token = mint_agent_token(agent)
agent.agent_token_hash = hash_agent_token(raw_token) mark_provision_requested(
agent.provision_requested_at = utcnow() agent,
agent.provision_action = action action=action,
agent.updated_at = utcnow() status="updating" if action == "update" else "provisioning",
if agent.heartbeat_config is None: )
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() await self.add_commit_refresh(agent)
self.session.add(agent)
await self.session.commit()
await self.session.refresh(agent)
if not gateway.url: if not gateway.url:
return agent return agent
@@ -253,13 +237,8 @@ class GatewayAdminLifecycleService:
detail=f"Unexpected error {action}ing gateway provisioning.", detail=f"Unexpected error {action}ing gateway provisioning.",
) from exc ) from exc
agent.status = "online" mark_provision_complete(agent, status="online")
agent.provision_requested_at = None await self.add_commit_refresh(agent)
agent.provision_action = None
agent.updated_at = utcnow()
self.session.add(agent)
await self.session.commit()
await self.session.refresh(agent)
self.logger.info( self.logger.info(
"gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s", "gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s",

View File

@@ -3,10 +3,9 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from abc import ABC from abc import ABC
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, TypeVar from typing import TypeVar
from uuid import UUID from uuid import UUID
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -27,11 +26,13 @@ from app.schemas.gateway_coordination import (
GatewayMainAskUserResponse, GatewayMainAskUserResponse,
) )
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.exceptions import ( from app.services.openclaw.exceptions import (
GatewayOperation, GatewayOperation,
map_gateway_error_message, map_gateway_error_message,
map_gateway_error_to_http_exception, map_gateway_error_to_http_exception,
) )
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
from app.services.openclaw.internal.agent_key import agent_key from app.services.openclaw.internal.agent_key import agent_key
@@ -42,43 +43,14 @@ from app.services.openclaw.provisioning_db import (
LeadAgentRequest, LeadAgentRequest,
OpenClawProvisioningService, OpenClawProvisioningService,
) )
from app.services.openclaw.shared import ( from app.services.openclaw.shared import GatewayAgentIdentity
GatewayAgentIdentity,
require_gateway_config_for_board,
resolve_trace_id,
send_gateway_agent_message,
)
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
_T = TypeVar("_T") _T = TypeVar("_T")
class AbstractGatewayMessagingService(ABC): class AbstractGatewayMessagingService(OpenClawDBService, ABC):
"""Shared gateway messaging primitives with retry semantics.""" """Shared gateway messaging primitives with retry semantics."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
self._logger = logging.getLogger(__name__)
@property
def session(self) -> AsyncSession:
return self._session
@session.setter
def session(self, value: AsyncSession) -> None:
self._session = value
@property
def logger(self) -> logging.Logger:
return self._logger
@logger.setter
def logger(self, value: logging.Logger) -> None:
self._logger = value
@staticmethod @staticmethod
async def _with_gateway_retry(fn: Callable[[], Awaitable[_T]]) -> _T: async def _with_gateway_retry(fn: Callable[[], Awaitable[_T]]) -> _T:
return await with_coordination_gateway_retry(fn) return await with_coordination_gateway_retry(fn)
@@ -93,7 +65,7 @@ class AbstractGatewayMessagingService(ABC):
deliver: bool, deliver: bool,
) -> None: ) -> None:
async def _do_send() -> bool: async def _do_send() -> bool:
await send_gateway_agent_message( await GatewayDispatchService(self.session).send_agent_message(
session_key=session_key, session_key=session_key,
config=config, config=config,
agent_name=agent_name, agent_name=agent_name,
@@ -198,7 +170,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
message: str, message: str,
correlation_id: str | None = None, correlation_id: str | None = None,
) -> None: ) -> None:
trace_id = resolve_trace_id(correlation_id, prefix="coord.nudge") trace_id = GatewayDispatchService.resolve_trace_id(correlation_id, prefix="coord.nudge")
self.logger.log( self.logger.log(
5, 5,
"gateway.coordination.nudge.start trace_id=%s board_id=%s actor_agent_id=%s " "gateway.coordination.nudge.start trace_id=%s board_id=%s actor_agent_id=%s "
@@ -214,7 +186,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Target agent has no session key", detail="Target agent has no session key",
) )
_gateway, config = await require_gateway_config_for_board(self.session, board) _gateway, config = await GatewayDispatchService(
self.session
).require_gateway_config_for_board(board)
try: try:
await self._dispatch_gateway_message( await self._dispatch_gateway_message(
session_key=target.openclaw_session_id or "", session_key=target.openclaw_session_id or "",
@@ -276,7 +250,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
target_agent_id: str, target_agent_id: str,
correlation_id: str | None = None, correlation_id: str | None = None,
) -> str: ) -> str:
trace_id = resolve_trace_id(correlation_id, prefix="coord.soul.read") trace_id = GatewayDispatchService.resolve_trace_id(correlation_id, prefix="coord.soul.read")
self.logger.log( self.logger.log(
5, 5,
"gateway.coordination.soul_read.start trace_id=%s board_id=%s target_agent_id=%s", "gateway.coordination.soul_read.start trace_id=%s board_id=%s target_agent_id=%s",
@@ -285,7 +259,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
target_agent_id, target_agent_id,
) )
target = await self._board_agent_or_404(board=board, agent_id=target_agent_id) target = await self._board_agent_or_404(board=board, agent_id=target_agent_id)
_gateway, config = await require_gateway_config_for_board(self.session, board) _gateway, config = await GatewayDispatchService(
self.session
).require_gateway_config_for_board(board)
try: try:
async def _do_get() -> object: async def _do_get() -> object:
@@ -342,7 +318,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
actor_agent_id: UUID, actor_agent_id: UUID,
correlation_id: str | None = None, correlation_id: str | None = None,
) -> None: ) -> None:
trace_id = resolve_trace_id(correlation_id, prefix="coord.soul.write") trace_id = GatewayDispatchService.resolve_trace_id(
correlation_id, prefix="coord.soul.write"
)
self.logger.log( self.logger.log(
5, 5,
"gateway.coordination.soul_write.start trace_id=%s board_id=%s target_agent_id=%s " "gateway.coordination.soul_write.start trace_id=%s board_id=%s target_agent_id=%s "
@@ -365,7 +343,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
self.session.add(target) self.session.add(target)
await self.session.commit() await self.session.commit()
_gateway, config = await require_gateway_config_for_board(self.session, board) _gateway, config = await GatewayDispatchService(
self.session
).require_gateway_config_for_board(board)
try: try:
async def _do_set() -> object: async def _do_set() -> object:
@@ -434,7 +414,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
payload: GatewayMainAskUserRequest, payload: GatewayMainAskUserRequest,
actor_agent: Agent, actor_agent: Agent,
) -> GatewayMainAskUserResponse: ) -> GatewayMainAskUserResponse:
trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.ask_user") trace_id = GatewayDispatchService.resolve_trace_id(
payload.correlation_id, prefix="coord.ask_user"
)
self.logger.log( self.logger.log(
5, 5,
"gateway.coordination.ask_user.start trace_id=%s board_id=%s actor_agent_id=%s", "gateway.coordination.ask_user.start trace_id=%s board_id=%s actor_agent_id=%s",
@@ -442,7 +424,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
board.id, board.id,
actor_agent.id, actor_agent.id,
) )
gateway, config = await require_gateway_config_for_board(self.session, board) gateway, config = await GatewayDispatchService(
self.session
).require_gateway_config_for_board(board)
main_session_key = GatewayAgentIdentity.session_key(gateway) main_session_key = GatewayAgentIdentity.session_key(gateway)
correlation = payload.correlation_id.strip() if payload.correlation_id else "" correlation = payload.correlation_id.strip() if payload.correlation_id else ""
@@ -575,7 +559,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
board_id: UUID, board_id: UUID,
payload: GatewayLeadMessageRequest, payload: GatewayLeadMessageRequest,
) -> GatewayLeadMessageResponse: ) -> GatewayLeadMessageResponse:
trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.lead_message") trace_id = GatewayDispatchService.resolve_trace_id(
payload.correlation_id, prefix="coord.lead_message"
)
self.logger.log( self.logger.log(
5, 5,
"gateway.coordination.lead_message.start trace_id=%s board_id=%s actor_agent_id=%s", "gateway.coordination.lead_message.start trace_id=%s board_id=%s actor_agent_id=%s",
@@ -662,7 +648,9 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
actor_agent: Agent, actor_agent: Agent,
payload: GatewayLeadBroadcastRequest, payload: GatewayLeadBroadcastRequest,
) -> GatewayLeadBroadcastResponse: ) -> GatewayLeadBroadcastResponse:
trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.lead_broadcast") trace_id = GatewayDispatchService.resolve_trace_id(
payload.correlation_id, prefix="coord.lead_broadcast"
)
self.logger.log( self.logger.log(
5, 5,
"gateway.coordination.lead_broadcast.start trace_id=%s actor_agent_id=%s", "gateway.coordination.lead_broadcast.start trace_id=%s actor_agent_id=%s",

View File

@@ -0,0 +1,57 @@
"""Shared DB mutation helpers for OpenClaw agent lifecycle services."""
from __future__ import annotations
from typing import Literal
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.time import utcnow
from app.models.agents import Agent
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
def ensure_heartbeat_config(agent: Agent) -> None:
"""Ensure an agent has a heartbeat_config dict populated."""
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
def mint_agent_token(agent: Agent) -> str:
"""Generate a new raw token and update the agent's token hash."""
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
return raw_token
def mark_provision_requested(
agent: Agent,
*,
action: str,
status: str | None = None,
) -> None:
"""Mark an agent as pending provisioning/update."""
ensure_heartbeat_config(agent)
agent.provision_requested_at = utcnow()
agent.provision_action = action
if status is not None:
agent.status = status
agent.updated_at = utcnow()
def mark_provision_complete(
agent: Agent,
*,
status: Literal["online", "offline", "provisioning", "updating", "deleting"] = "online",
clear_confirm_token: bool = False,
) -> None:
"""Clear provisioning fields after a successful gateway lifecycle run."""
if clear_confirm_token:
agent.provision_confirm_token_hash = None
agent.status = status
agent.provision_requested_at = None
agent.provision_action = None
agent.updated_at = utcnow()

View File

@@ -0,0 +1,47 @@
"""Shared DB-backed service base classes for OpenClaw.
These helpers are intentionally small: they reduce boilerplate (session + logger) across
OpenClaw services without adding new architectural layers.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
class OpenClawDBService:
"""Base class for OpenClaw services that require an AsyncSession."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
# Use the concrete subclass module for logger naming.
self._logger = logging.getLogger(self.__class__.__module__)
@property
def session(self) -> AsyncSession:
return self._session
@session.setter
def session(self, value: AsyncSession) -> None:
self._session = value
@property
def logger(self) -> logging.Logger:
return self._logger
@logger.setter
def logger(self, value: logging.Logger) -> None:
self._logger = value
async def add_commit_refresh(self, model: object) -> None:
"""Persist a model, committing the current transaction and refreshing when supported."""
self.session.add(model)
await self.session.commit()
refresh = getattr(self.session, "refresh", None)
if callable(refresh):
await refresh(model)

View File

@@ -0,0 +1,89 @@
"""DB-backed gateway config resolution and message dispatch helpers.
This module exists to keep `app.api.*` thin: APIs should call OpenClaw services, not
directly orchestrate gateway RPC calls.
"""
from __future__ import annotations
from uuid import uuid4
from fastapi import HTTPException, status
from app.models.boards import Board
from app.models.gateways import Gateway
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, ensure_session, send_message
class GatewayDispatchService(OpenClawDBService):
"""Resolve gateway config for boards and dispatch messages to agent sessions."""
async def optional_gateway_config_for_board(
self,
board: Board,
) -> GatewayClientConfig | None:
if board.gateway_id is None:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def require_gateway_config_for_board(
self,
board: Board,
) -> tuple[Gateway, GatewayClientConfig]:
if board.gateway_id is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board is not attached to a gateway",
)
gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session)
if gateway is None or not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway is not configured for this board",
)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
async def send_agent_message(
self,
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=deliver)
async def try_send_agent_message(
self,
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
deliver: bool = False,
) -> OpenClawGatewayError | None:
try:
await self.send_agent_message(
session_key=session_key,
config=config,
agent_name=agent_name,
message=message,
deliver=deliver,
)
except OpenClawGatewayError as exc:
return exc
return None
@staticmethod
def resolve_trace_id(correlation_id: str | None, *, prefix: str) -> str:
normalized = (correlation_id or "").strip()
if normalized:
return normalized
return f"{prefix}:{uuid4().hex[:12]}"

View File

@@ -6,12 +6,9 @@ from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board from app.models.boards import Board
from app.services.openclaw.coordination_service import AbstractGatewayMessagingService from app.services.openclaw.coordination_service import AbstractGatewayMessagingService
from app.services.openclaw.exceptions import GatewayOperation, map_gateway_error_to_http_exception from app.services.openclaw.exceptions import GatewayOperation, map_gateway_error_to_http_exception
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_rpc import OpenClawGatewayError from app.services.openclaw.gateway_rpc import OpenClawGatewayError
from app.services.openclaw.shared import ( from app.services.openclaw.shared import GatewayAgentIdentity
GatewayAgentIdentity,
require_gateway_config_for_board,
resolve_trace_id,
)
class BoardOnboardingMessagingService(AbstractGatewayMessagingService): class BoardOnboardingMessagingService(AbstractGatewayMessagingService):
@@ -24,14 +21,18 @@ class BoardOnboardingMessagingService(AbstractGatewayMessagingService):
prompt: str, prompt: str,
correlation_id: str | None = None, correlation_id: str | None = None,
) -> str: ) -> str:
trace_id = resolve_trace_id(correlation_id, prefix="onboarding.start") trace_id = GatewayDispatchService.resolve_trace_id(
correlation_id, prefix="onboarding.start"
)
self.logger.log( self.logger.log(
5, 5,
"gateway.onboarding.start_dispatch.start trace_id=%s board_id=%s", "gateway.onboarding.start_dispatch.start trace_id=%s board_id=%s",
trace_id, trace_id,
board.id, board.id,
) )
gateway, config = await require_gateway_config_for_board(self.session, board) gateway, config = await GatewayDispatchService(
self.session
).require_gateway_config_for_board(board)
session_key = GatewayAgentIdentity.session_key(gateway) session_key = GatewayAgentIdentity.session_key(gateway)
try: try:
await self._dispatch_gateway_message( await self._dispatch_gateway_message(
@@ -78,7 +79,9 @@ class BoardOnboardingMessagingService(AbstractGatewayMessagingService):
answer_text: str, answer_text: str,
correlation_id: str | None = None, correlation_id: str | None = None,
) -> None: ) -> None:
trace_id = resolve_trace_id(correlation_id, prefix="onboarding.answer") trace_id = GatewayDispatchService.resolve_trace_id(
correlation_id, prefix="onboarding.answer"
)
self.logger.log( self.logger.log(
5, 5,
"gateway.onboarding.answer_dispatch.start trace_id=%s board_id=%s onboarding_id=%s", "gateway.onboarding.answer_dispatch.start trace_id=%s board_id=%s onboarding_id=%s",
@@ -86,7 +89,9 @@ class BoardOnboardingMessagingService(AbstractGatewayMessagingService):
board.id, board.id,
onboarding.id, onboarding.id,
) )
_gateway, config = await require_gateway_config_for_board(self.session, board) _gateway, config = await GatewayDispatchService(
self.session
).require_gateway_config_for_board(board)
try: try:
await self._dispatch_gateway_message( await self._dispatch_gateway_message(
session_key=onboarding.session_key, session_key=onboarding.session_key,

View File

@@ -10,7 +10,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -22,7 +21,7 @@ from sqlalchemy import asc, func, or_
from sqlmodel import col, select from sqlmodel import col, select
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token from app.core.agent_tokens import verify_agent_token
from app.core.time import utcnow from app.core.time import utcnow
from app.db import crud from app.db import crud
from app.db.pagination import paginate from app.db.pagination import paginate
@@ -50,6 +49,12 @@ from app.services.openclaw.constants import (
DEFAULT_HEARTBEAT_CONFIG, DEFAULT_HEARTBEAT_CONFIG,
OFFLINE_AFTER, OFFLINE_AFTER,
) )
from app.services.openclaw.db_agent_state import (
mark_provision_complete,
mark_provision_requested,
mint_agent_token,
)
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import ( from app.services.openclaw.gateway_rpc import (
OpenClawGatewayError, OpenClawGatewayError,
@@ -120,17 +125,13 @@ class LeadAgentRequest:
options: LeadAgentOptions = field(default_factory=LeadAgentOptions) options: LeadAgentOptions = field(default_factory=LeadAgentOptions)
class OpenClawProvisioningService: class OpenClawProvisioningService(OpenClawDBService):
"""DB-backed provisioning workflows (bulk template sync, lead-agent record).""" """DB-backed provisioning workflows (bulk template sync, lead-agent record)."""
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session super().__init__(session)
self._gateway = OpenClawGatewayProvisioner() self._gateway = OpenClawGatewayProvisioner()
@property
def session(self) -> AsyncSession:
return self._session
@staticmethod @staticmethod
def lead_session_key(board: Board) -> str: def lead_session_key(board: Board) -> str:
return f"agent:lead-{board.id}:main" return f"agent:lead-{board.id}:main"
@@ -191,21 +192,16 @@ class OpenClawProvisioningService:
agent = Agent( agent = Agent(
name=config_options.agent_name or self.lead_agent_name(board), name=config_options.agent_name or self.lead_agent_name(board),
status="provisioning",
board_id=board.id, board_id=board.id,
gateway_id=request.gateway.id, gateway_id=request.gateway.id,
is_board_lead=True, is_board_lead=True,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(), heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile=merged_identity_profile, identity_profile=merged_identity_profile,
openclaw_session_id=self.lead_session_key(board), openclaw_session_id=self.lead_session_key(board),
provision_requested_at=utcnow(),
provision_action=config_options.action,
) )
raw_token = generate_agent_token() raw_token = mint_agent_token(agent)
agent.agent_token_hash = hash_agent_token(raw_token) mark_provision_requested(agent, action=config_options.action, status="provisioning")
self.session.add(agent) await self.add_commit_refresh(agent)
await self.session.commit()
await self.session.refresh(agent)
# Strict behavior: provisioning errors surface to the caller. The DB row exists # Strict behavior: provisioning errors surface to the caller. The DB row exists
# so a later retry can succeed with the same deterministic identity/session key. # so a later retry can succeed with the same deterministic identity/session key.
@@ -220,13 +216,8 @@ class OpenClawProvisioningService:
deliver_wakeup=True, deliver_wakeup=True,
) )
agent.status = "online" mark_provision_complete(agent, status="online")
agent.provision_requested_at = None await self.add_commit_refresh(agent)
agent.provision_action = None
agent.updated_at = utcnow()
self.session.add(agent)
await self.session.commit()
await self.session.refresh(agent)
return agent, True return agent, True
@@ -433,8 +424,7 @@ def _append_sync_error(
async def _rotate_agent_token(session: AsyncSession, agent: Agent) -> str: async def _rotate_agent_token(session: AsyncSession, agent: Agent) -> str:
token = generate_agent_token() token = mint_agent_token(agent)
agent.agent_token_hash = hash_agent_token(token)
agent.updated_at = utcnow() agent.updated_at = utcnow()
session.add(agent) session.add(agent)
await session.commit() await session.commit()
@@ -692,28 +682,11 @@ class AgentUpdateProvisionRequest:
force_bootstrap: bool force_bootstrap: bool
class AgentLifecycleService: class AgentLifecycleService(OpenClawDBService):
"""Async service encapsulating agent lifecycle behavior for API routes.""" """Async service encapsulating agent lifecycle behavior for API routes."""
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session super().__init__(session)
self._logger = logging.getLogger(__name__)
@property
def session(self) -> AsyncSession:
return self._session
@session.setter
def session(self, value: AsyncSession) -> None:
self._session = value
@property
def logger(self) -> logging.Logger:
return self._logger
@logger.setter
def logger(self, value: logging.Logger) -> None:
self._logger = value
@staticmethod @staticmethod
def parse_since(value: str | None) -> datetime | None: def parse_since(value: str | None) -> datetime | None:
@@ -1013,17 +986,10 @@ class AgentLifecycleService:
data: dict[str, Any], data: dict[str, Any],
) -> tuple[Agent, str]: ) -> tuple[Agent, str]:
agent = Agent.model_validate(data) agent = Agent.model_validate(data)
agent.status = "provisioning" raw_token = mint_agent_token(agent)
raw_token = generate_agent_token() mark_provision_requested(agent, action="provision", status="provisioning")
agent.agent_token_hash = hash_agent_token(raw_token)
if agent.heartbeat_config is None:
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
agent.provision_requested_at = utcnow()
agent.provision_action = "provision"
agent.openclaw_session_id = self.resolve_session_key(agent) agent.openclaw_session_id = self.resolve_session_key(agent)
self.session.add(agent) await self.add_commit_refresh(agent)
await self.session.commit()
await self.session.refresh(agent)
return agent, raw_token return agent, raw_token
async def _apply_gateway_provisioning( async def _apply_gateway_provisioning(
@@ -1078,11 +1044,7 @@ class AgentLifecycleService:
deliver_wakeup=True, deliver_wakeup=True,
wakeup_verb=wakeup_verb, wakeup_verb=wakeup_verb,
) )
agent.provision_confirm_token_hash = None mark_provision_complete(agent, status="online", clear_confirm_token=True)
agent.provision_requested_at = None
agent.provision_action = None
agent.status = "online"
agent.updated_at = utcnow()
self.session.add(agent) self.session.add(agent)
await self.session.commit() await self.session.commit()
record_activity( record_activity(
@@ -1301,11 +1263,8 @@ class AgentLifecycleService:
@staticmethod @staticmethod
def mark_agent_update_pending(agent: Agent) -> str: def mark_agent_update_pending(agent: Agent) -> str:
raw_token = generate_agent_token() raw_token = mint_agent_token(agent)
agent.agent_token_hash = hash_agent_token(raw_token) mark_provision_requested(agent, action="update", status="updating")
agent.provision_requested_at = utcnow()
agent.provision_action = "update"
agent.status = "updating"
return raw_token return raw_token
async def provision_updated_agent( async def provision_updated_agent(
@@ -1379,15 +1338,9 @@ class AgentLifecycleService:
if agent.agent_token_hash is not None: if agent.agent_token_hash is not None:
return return
raw_token = generate_agent_token() raw_token = mint_agent_token(agent)
agent.agent_token_hash = hash_agent_token(raw_token) mark_provision_requested(agent, action="provision", status="provisioning")
if agent.heartbeat_config is None: await self.add_commit_refresh(agent)
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
agent.provision_requested_at = utcnow()
agent.provision_action = "provision"
self.session.add(agent)
await self.session.commit()
await self.session.refresh(agent)
board = await self.require_board( board = await self.require_board(
str(agent.board_id) if agent.board_id else None, str(agent.board_id) if agent.board_id else None,
user=user, user=user,

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -22,6 +21,7 @@ from app.schemas.gateway_api import (
GatewaySessionsResponse, GatewaySessionsResponse,
GatewaysStatusResponse, GatewaysStatusResponse,
) )
from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import ( from app.services.openclaw.gateway_rpc import (
OpenClawGatewayError, OpenClawGatewayError,
@@ -50,28 +50,11 @@ class GatewayTemplateSyncQuery:
board_id: UUID | None board_id: UUID | None
class GatewaySessionService: class GatewaySessionService(OpenClawDBService):
"""Read/query gateway runtime session state for user-facing APIs.""" """Read/query gateway runtime session state for user-facing APIs."""
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session super().__init__(session)
self._logger = logging.getLogger(__name__)
@property
def session(self) -> AsyncSession:
return self._session
@session.setter
def session(self, value: AsyncSession) -> None:
self._session = value
@property
def logger(self) -> logging.Logger:
return self._logger
@logger.setter
def logger(self, value: logging.Logger) -> None:
self._logger = value
@staticmethod @staticmethod
def to_resolve_query( def to_resolve_query(

View File

@@ -2,29 +2,14 @@
from __future__ import annotations from __future__ import annotations
import logging from uuid import UUID
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from fastapi import HTTPException, status
from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.services.openclaw.constants import ( from app.services.openclaw.constants import (
_GATEWAY_AGENT_PREFIX, _GATEWAY_AGENT_PREFIX,
_GATEWAY_AGENT_SUFFIX, _GATEWAY_AGENT_SUFFIX,
_GATEWAY_OPENCLAW_AGENT_PREFIX, _GATEWAY_OPENCLAW_AGENT_PREFIX,
) )
from app.services.openclaw.gateway_rpc import GatewayConfig as _GatewayClientConfig
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, ensure_session, send_message
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
GatewayClientConfig = _GatewayClientConfig
# Keep integration exceptions behind the OpenClaw service boundary.
GatewayTransportError = OpenClawGatewayError
class GatewayAgentIdentity: class GatewayAgentIdentity:
@@ -45,81 +30,3 @@ class GatewayAgentIdentity:
@classmethod @classmethod
def openclaw_agent_id(cls, gateway: Gateway) -> str: def openclaw_agent_id(cls, gateway: Gateway) -> str:
return cls.openclaw_agent_id_for_id(gateway.id) return cls.openclaw_agent_id_for_id(gateway.id)
async def optional_gateway_config_for_board(
session: AsyncSession,
board: Board,
) -> GatewayClientConfig | None:
"""Return gateway client config when board has a reachable configured gateway."""
if board.gateway_id is None:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def require_gateway_config_for_board(
session: AsyncSession,
board: Board,
) -> tuple[Gateway, GatewayClientConfig]:
"""Resolve board gateway and config, raising 422 when unavailable."""
if board.gateway_id is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board is not attached to a gateway",
)
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway is not configured for this board",
)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
async def send_gateway_agent_message(
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
"""Ensure session and dispatch a message to an agent session."""
await ensure_session(session_key, config=config, label=agent_name)
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()
if normalized:
return normalized
return f"{prefix}:{uuid4().hex[:12]}"
logger = logging.getLogger(__name__)

View File

@@ -30,24 +30,23 @@ def test_api_does_not_import_openclaw_gateway_client_directly() -> None:
def test_api_uses_safe_gateway_dispatch_helper() -> None: def test_api_uses_safe_gateway_dispatch_helper() -> None:
"""API modules should use `send_gateway_agent_message_safe`, not direct send.""" """API modules should not call low-level gateway RPC helpers directly."""
repo_root = Path(__file__).resolve().parents[2] repo_root = Path(__file__).resolve().parents[2]
api_root = repo_root / "backend" / "app" / "api" api_root = repo_root / "backend" / "app" / "api"
direct_send_pattern = re.compile(r"\bsend_gateway_agent_message\b")
forbidden = {"ensure_session", "send_message", "openclaw_call"}
violations: list[str] = [] violations: list[str] = []
for path in api_root.rglob("*.py"): for path in api_root.rglob("*.py"):
rel = path.relative_to(repo_root) rel = path.relative_to(repo_root)
for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.strip() line = raw_line.strip()
if not direct_send_pattern.search(line): if not line.startswith("from app.services.openclaw.gateway_rpc import "):
continue continue
if "send_gateway_agent_message_safe" in line: if any(re.search(rf"\\b{name}\\b", line) for name in forbidden):
continue violations.append(f"{rel}:{lineno}")
violations.append(f"{rel}:{lineno}")
assert not violations, ( assert not violations, (
"Use `send_gateway_agent_message_safe` from `app.services.openclaw.shared` " "Use OpenClaw service modules (for example `app.services.openclaw.gateway_dispatch`) "
"for API-level gateway notification dispatch. " "instead of calling low-level gateway RPC helpers from `app.api`."
f"Violations: {', '.join(violations)}" f"Violations: {', '.join(violations)}"
) )

View File

@@ -69,15 +69,17 @@ async def test_gateway_coordination_nudge_success(monkeypatch: pytest.MonkeyPatc
return target return target
async def _fake_require_gateway_config_for_board( async def _fake_require_gateway_config_for_board(
_session: object, self: coordination_lifecycle.GatewayDispatchService,
_board: object, _board: object,
) -> tuple[object, GatewayClientConfig]: ) -> tuple[object, GatewayClientConfig]:
_ = self
gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws") gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None) return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**kwargs: Any) -> dict[str, bool]: async def _fake_send_agent_message(self, **kwargs: Any) -> None:
_ = self
captured.append(kwargs) captured.append(kwargs)
return {"ok": True} return None
monkeypatch.setattr( monkeypatch.setattr(
coordination_lifecycle.GatewayCoordinationService, coordination_lifecycle.GatewayCoordinationService,
@@ -85,14 +87,14 @@ async def test_gateway_coordination_nudge_success(monkeypatch: pytest.MonkeyPatc
_fake_board_agent_or_404, _fake_board_agent_or_404,
) )
monkeypatch.setattr( monkeypatch.setattr(
coordination_lifecycle, coordination_lifecycle.GatewayDispatchService,
"require_gateway_config_for_board", "require_gateway_config_for_board",
_fake_require_gateway_config_for_board, _fake_require_gateway_config_for_board,
) )
monkeypatch.setattr( monkeypatch.setattr(
coordination_lifecycle, coordination_lifecycle.GatewayDispatchService,
"send_gateway_agent_message", "send_agent_message",
_fake_send_gateway_agent_message, _fake_send_agent_message,
) )
await service.nudge_board_agent( await service.nudge_board_agent(
@@ -135,13 +137,15 @@ async def test_gateway_coordination_nudge_maps_gateway_error(
return target return target
async def _fake_require_gateway_config_for_board( async def _fake_require_gateway_config_for_board(
_session: object, self: coordination_lifecycle.GatewayDispatchService,
_board: object, _board: object,
) -> tuple[object, GatewayClientConfig]: ) -> tuple[object, GatewayClientConfig]:
_ = self
gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws") gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None) return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**_kwargs: Any) -> None: async def _fake_send_agent_message(self, **_kwargs: Any) -> None:
_ = self
raise OpenClawGatewayError("dial tcp: connection refused") raise OpenClawGatewayError("dial tcp: connection refused")
monkeypatch.setattr( monkeypatch.setattr(
@@ -150,14 +154,14 @@ async def test_gateway_coordination_nudge_maps_gateway_error(
_fake_board_agent_or_404, _fake_board_agent_or_404,
) )
monkeypatch.setattr( monkeypatch.setattr(
coordination_lifecycle, coordination_lifecycle.GatewayDispatchService,
"require_gateway_config_for_board", "require_gateway_config_for_board",
_fake_require_gateway_config_for_board, _fake_require_gateway_config_for_board,
) )
monkeypatch.setattr( monkeypatch.setattr(
coordination_lifecycle, coordination_lifecycle.GatewayDispatchService,
"send_gateway_agent_message", "send_agent_message",
_fake_send_gateway_agent_message, _fake_send_agent_message,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
@@ -185,25 +189,27 @@ async def test_board_onboarding_dispatch_start_returns_session_key(
captured: list[dict[str, Any]] = [] captured: list[dict[str, Any]] = []
async def _fake_require_gateway_config_for_board( async def _fake_require_gateway_config_for_board(
_session: object, self: onboarding_lifecycle.GatewayDispatchService,
_board: object, _board: object,
) -> tuple[object, GatewayClientConfig]: ) -> tuple[object, GatewayClientConfig]:
_ = self
gateway = SimpleNamespace(id=gateway_id, url="ws://gateway.example/ws") gateway = SimpleNamespace(id=gateway_id, url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None) return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**kwargs: Any) -> dict[str, bool]: async def _fake_send_agent_message(self, **kwargs: Any) -> None:
_ = self
captured.append(kwargs) captured.append(kwargs)
return {"ok": True} return None
monkeypatch.setattr( monkeypatch.setattr(
onboarding_lifecycle, onboarding_lifecycle.GatewayDispatchService,
"require_gateway_config_for_board", "require_gateway_config_for_board",
_fake_require_gateway_config_for_board, _fake_require_gateway_config_for_board,
) )
monkeypatch.setattr( monkeypatch.setattr(
coordination_lifecycle, coordination_lifecycle.GatewayDispatchService,
"send_gateway_agent_message", "send_agent_message",
_fake_send_gateway_agent_message, _fake_send_agent_message,
) )
session_key = await service.dispatch_start_prompt( session_key = await service.dispatch_start_prompt(
@@ -224,28 +230,34 @@ async def test_board_onboarding_dispatch_answer_maps_timeout_error(
) -> None: ) -> None:
session = _FakeSession() session = _FakeSession()
service = onboarding_lifecycle.BoardOnboardingMessagingService(session) # type: ignore[arg-type] service = onboarding_lifecycle.BoardOnboardingMessagingService(session) # type: ignore[arg-type]
board = _BoardStub(id=uuid4(), gateway_id=uuid4(), name="Roadmap") gateway_id = uuid4()
onboarding = SimpleNamespace(id=uuid4(), session_key="agent:gateway-main:main") board = _BoardStub(id=uuid4(), gateway_id=gateway_id, name="Roadmap")
onboarding = SimpleNamespace(
id=uuid4(),
session_key=GatewayAgentIdentity.session_key_for_id(gateway_id),
)
async def _fake_require_gateway_config_for_board( async def _fake_require_gateway_config_for_board(
_session: object, self: onboarding_lifecycle.GatewayDispatchService,
_board: object, _board: object,
) -> tuple[object, GatewayClientConfig]: ) -> tuple[object, GatewayClientConfig]:
gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws") _ = self
gateway = SimpleNamespace(id=gateway_id, url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None) return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**_kwargs: Any) -> None: async def _fake_send_agent_message(self, **_kwargs: Any) -> None:
_ = self
raise TimeoutError("gateway timeout") raise TimeoutError("gateway timeout")
monkeypatch.setattr( monkeypatch.setattr(
onboarding_lifecycle, onboarding_lifecycle.GatewayDispatchService,
"require_gateway_config_for_board", "require_gateway_config_for_board",
_fake_require_gateway_config_for_board, _fake_require_gateway_config_for_board,
) )
monkeypatch.setattr( monkeypatch.setattr(
coordination_lifecycle, coordination_lifecycle.GatewayDispatchService,
"send_gateway_agent_message", "send_agent_message",
_fake_send_gateway_agent_message, _fake_send_agent_message,
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info: