refactor: streamline agent lifecycle management with new DB service helpers
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
57
backend/app/services/openclaw/db_agent_state.py
Normal file
57
backend/app/services/openclaw/db_agent_state.py
Normal 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()
|
||||||
47
backend/app/services/openclaw/db_service.py
Normal file
47
backend/app/services/openclaw/db_service.py
Normal 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)
|
||||||
89
backend/app/services/openclaw/gateway_dispatch.py
Normal file
89
backend/app/services/openclaw/gateway_dispatch.py
Normal 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]}"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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__)
|
|
||||||
|
|||||||
@@ -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)}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user