diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py new file mode 100644 index 00000000..35a7c193 --- /dev/null +++ b/backend/app/services/openclaw/admin_service.py @@ -0,0 +1,404 @@ +"""Gateway admin lifecycle service.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import HTTPException, status +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.time import utcnow +from app.db import crud +from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig +from app.integrations.openclaw_gateway import ( + OpenClawGatewayError, + ensure_session, + openclaw_call, + send_message, +) +from app.models.activity_events import ActivityEvent +from app.models.agents import Agent +from app.models.approvals import Approval +from app.models.gateways import Gateway +from app.models.tasks import Task +from app.schemas.gateways import GatewayTemplatesSyncResult +from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG +from app.services.openclaw.provisioning import ( + GatewayTemplateSyncOptions, + MainAgentProvisionRequest, + ProvisionOptions, + provision_main_agent, + sync_gateway_templates, +) +from app.services.openclaw.session_service import GatewayTemplateSyncQuery +from app.services.openclaw.shared import GatewayAgentIdentity + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + + from app.models.users import User + + +class AbstractGatewayMainAgentManager(ABC): + """Abstract manager for gateway-main agent naming/profile behavior.""" + + @abstractmethod + def build_main_agent_name(self, gateway: Gateway) -> str: + raise NotImplementedError + + @abstractmethod + def build_identity_profile(self) -> dict[str, str]: + raise NotImplementedError + + +class DefaultGatewayMainAgentManager(AbstractGatewayMainAgentManager): + """Default naming/profile strategy for gateway-main agents.""" + + def build_main_agent_name(self, gateway: Gateway) -> str: + return f"{gateway.name} Gateway Agent" + + def build_identity_profile(self) -> dict[str, str]: + return { + "role": "Gateway Agent", + "communication_style": "direct, concise, practical", + "emoji": ":compass:", + } + + +class GatewayAdminLifecycleService: + """Write-side gateway lifecycle service (CRUD, main agent, template sync).""" + + def __init__( + self, + session: AsyncSession, + *, + main_agent_manager: AbstractGatewayMainAgentManager | None = None, + ) -> None: + self._session = session + self._logger = logging.getLogger(__name__) + 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 + def main_agent_manager(self) -> AbstractGatewayMainAgentManager: + return self._main_agent_manager + + @main_agent_manager.setter + def main_agent_manager(self, value: AbstractGatewayMainAgentManager) -> None: + self._main_agent_manager = value + + async def require_gateway( + self, + *, + gateway_id: UUID, + organization_id: UUID, + ) -> Gateway: + gateway = ( + await Gateway.objects.by_id(gateway_id) + .filter(col(Gateway.organization_id) == organization_id) + .first(self.session) + ) + if gateway is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Gateway not found", + ) + return gateway + + async def find_main_agent(self, gateway: Gateway) -> Agent | None: + return ( + await Agent.objects.filter_by(gateway_id=gateway.id) + .filter(col(Agent.board_id).is_(None)) + .first(self.session) + ) + + @staticmethod + def extract_agent_id_from_entry(item: object) -> str | None: + if isinstance(item, str): + value = item.strip() + return value or None + if not isinstance(item, dict): + return None + for key in ("id", "agentId", "agent_id"): + raw = item.get(key) + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return None + + @staticmethod + def extract_agents_list(payload: object) -> list[object]: + if isinstance(payload, list): + return [item for item in payload] + if not isinstance(payload, dict): + return [] + agents = payload.get("agents") or [] + if not isinstance(agents, list): + return [] + return [item for item in agents] + + async def upsert_main_agent_record(self, gateway: Gateway) -> tuple[Agent, bool]: + changed = False + session_key = GatewayAgentIdentity.session_key(gateway) + agent = await self.find_main_agent(gateway) + main_agent_name = self.main_agent_manager.build_main_agent_name(gateway) + identity_profile = self.main_agent_manager.build_identity_profile() + if agent is None: + agent = Agent( + name=main_agent_name, + status="provisioning", + board_id=None, + gateway_id=gateway.id, + is_board_lead=False, + openclaw_session_id=session_key, + heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(), + identity_profile=identity_profile, + ) + self.session.add(agent) + changed = True + if agent.board_id is not None: + agent.board_id = None + changed = True + if agent.gateway_id != gateway.id: + agent.gateway_id = gateway.id + changed = True + if agent.is_board_lead: + agent.is_board_lead = False + changed = True + if agent.name != main_agent_name: + agent.name = main_agent_name + changed = True + if agent.openclaw_session_id != session_key: + agent.openclaw_session_id = session_key + changed = True + if agent.heartbeat_config is None: + agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() + changed = True + if agent.identity_profile is None: + agent.identity_profile = identity_profile + changed = True + if not agent.status: + agent.status = "provisioning" + changed = True + if changed: + agent.updated_at = utcnow() + self.session.add(agent) + return agent, changed + + async def gateway_has_main_agent_entry(self, gateway: Gateway) -> bool: + if not gateway.url: + return False + config = GatewayClientConfig(url=gateway.url, token=gateway.token) + target_id = GatewayAgentIdentity.openclaw_agent_id(gateway) + try: + payload = await openclaw_call("agents.list", config=config) + except OpenClawGatewayError: + return True + for item in self.extract_agents_list(payload): + if self.extract_agent_id_from_entry(item) == target_id: + return True + return False + + async def provision_main_agent_record( + self, + gateway: Gateway, + agent: Agent, + *, + user: User | None, + action: str, + notify: bool, + ) -> Agent: + session_key = GatewayAgentIdentity.session_key(gateway) + raw_token = generate_agent_token() + agent.agent_token_hash = hash_agent_token(raw_token) + agent.provision_requested_at = utcnow() + agent.provision_action = action + agent.updated_at = utcnow() + if agent.heartbeat_config is None: + agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + if not gateway.url: + return agent + try: + await provision_main_agent( + agent, + MainAgentProvisionRequest( + gateway=gateway, + auth_token=raw_token, + user=user, + session_key=session_key, + options=ProvisionOptions(action=action), + ), + ) + await ensure_session( + session_key, + config=GatewayClientConfig(url=gateway.url, token=gateway.token), + label=agent.name, + ) + if notify: + await send_message( + ( + f"Hello {agent.name}. Your gateway provisioning was updated.\n\n" + "Please re-read AGENTS.md, USER.md, HEARTBEAT.md, and TOOLS.md. " + "If BOOTSTRAP.md exists, run it once then delete it. " + "Begin heartbeats after startup." + ), + session_key=session_key, + config=GatewayClientConfig(url=gateway.url, token=gateway.token), + deliver=True, + ) + self.logger.info( + "gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s", + gateway.id, + agent.id, + action, + ) + except OpenClawGatewayError as exc: + self.logger.warning( + "gateway.main_agent.provision_failed_gateway gateway_id=%s agent_id=%s error=%s", + gateway.id, + agent.id, + str(exc), + ) + except (OSError, RuntimeError, ValueError) as exc: + self.logger.error( + "gateway.main_agent.provision_failed gateway_id=%s agent_id=%s error=%s", + gateway.id, + agent.id, + str(exc), + ) + except Exception as exc: # pragma: no cover - defensive fallback + self.logger.critical( + "gateway.main_agent.provision_failed_unexpected gateway_id=%s agent_id=%s " + "error_type=%s error=%s", + gateway.id, + agent.id, + exc.__class__.__name__, + str(exc), + ) + return agent + + async def ensure_main_agent( + self, + gateway: Gateway, + auth: AuthContext, + *, + action: str = "provision", + ) -> Agent: + self.logger.log( + 5, + "gateway.main_agent.ensure.start gateway_id=%s action=%s", + gateway.id, + action, + ) + agent, _ = await self.upsert_main_agent_record(gateway) + return await self.provision_main_agent_record( + gateway, + agent, + user=auth.user, + action=action, + notify=True, + ) + + async def ensure_gateway_agents_exist(self, gateways: list[Gateway]) -> None: + for gateway in gateways: + agent, gateway_changed = await self.upsert_main_agent_record(gateway) + has_gateway_entry = await self.gateway_has_main_agent_entry(gateway) + needs_provision = ( + gateway_changed or not bool(agent.agent_token_hash) or not has_gateway_entry + ) + if needs_provision: + await self.provision_main_agent_record( + gateway, + agent, + user=None, + action="provision", + notify=False, + ) + + async def clear_agent_foreign_keys(self, *, agent_id: UUID) -> None: + now = utcnow() + await crud.update_where( + self.session, + Task, + col(Task.assigned_agent_id) == agent_id, + col(Task.status) == "in_progress", + assigned_agent_id=None, + status="inbox", + in_progress_at=None, + updated_at=now, + commit=False, + ) + await crud.update_where( + self.session, + Task, + col(Task.assigned_agent_id) == agent_id, + col(Task.status) != "in_progress", + assigned_agent_id=None, + updated_at=now, + commit=False, + ) + await crud.update_where( + self.session, + ActivityEvent, + col(ActivityEvent.agent_id) == agent_id, + agent_id=None, + commit=False, + ) + await crud.update_where( + self.session, + Approval, + col(Approval.agent_id) == agent_id, + agent_id=None, + commit=False, + ) + + async def sync_templates( + self, + gateway: Gateway, + *, + query: GatewayTemplateSyncQuery, + auth: AuthContext, + ) -> GatewayTemplatesSyncResult: + self.logger.log( + 5, + "gateway.templates.sync.start gateway_id=%s include_main=%s", + gateway.id, + query.include_main, + ) + await self.ensure_gateway_agents_exist([gateway]) + result = await sync_gateway_templates( + self.session, + gateway, + GatewayTemplateSyncOptions( + user=auth.user, + include_main=query.include_main, + reset_sessions=query.reset_sessions, + rotate_tokens=query.rotate_tokens, + force_bootstrap=query.force_bootstrap, + board_id=query.board_id, + ), + ) + self.logger.info("gateway.templates.sync.success gateway_id=%s", gateway.id) + return result diff --git a/backend/app/services/openclaw/agent_service.py b/backend/app/services/openclaw/agent_service.py new file mode 100644 index 00000000..b5521690 --- /dev/null +++ b/backend/app/services/openclaw/agent_service.py @@ -0,0 +1,1406 @@ +"""Async agent lifecycle management service.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, Literal, Protocol +from uuid import UUID, uuid4 + +from fastapi import HTTPException, Request, status +from sqlalchemy import asc, or_ +from sqlmodel import col, select +from sse_starlette.sse import EventSourceResponse + +from app.core.agent_tokens import generate_agent_token, hash_agent_token +from app.core.time import utcnow +from app.db import crud +from app.db.pagination import paginate +from app.db.session import async_session_maker +from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig +from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message +from app.models.activity_events import ActivityEvent +from app.models.agents import Agent +from app.models.boards import Board +from app.models.gateways import Gateway +from app.models.organizations import Organization +from app.models.tasks import Task +from app.schemas.agents import ( + AgentCreate, + AgentHeartbeat, + AgentHeartbeatCreate, + AgentRead, + AgentUpdate, +) +from app.schemas.common import OkResponse +from app.services.activity_log import record_activity +from app.services.openclaw.constants import ( + AGENT_SESSION_PREFIX, + DEFAULT_HEARTBEAT_CONFIG, + OFFLINE_AFTER, +) +from app.services.openclaw.provisioning import ( + AgentProvisionRequest, + MainAgentProvisionRequest, + ProvisionOptions, + cleanup_agent, + provision_agent, + provision_main_agent, +) +from app.services.openclaw.shared import GatewayAgentIdentity +from app.services.organizations import ( + OrganizationContext, + get_active_membership, + has_board_access, + is_org_admin, + list_accessible_board_ids, + require_board_access, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Sequence + + from fastapi_pagination.limit_offset import LimitOffsetPage + from sqlalchemy.sql.elements import ColumnElement + from sqlmodel.ext.asyncio.session import AsyncSession + from sqlmodel.sql.expression import SelectOfScalar + + from app.models.users import User + + +class ActorContextLike(Protocol): + """Minimal actor context contract consumed by lifecycle APIs.""" + + actor_type: Literal["user", "agent"] + user: User | None + agent: Agent | None + + +@dataclass(frozen=True, slots=True) +class AgentUpdateOptions: + """Runtime options for update-and-reprovision flows.""" + + force: bool + user: User | None + context: OrganizationContext + + +@dataclass(frozen=True, slots=True) +class AgentUpdateProvisionTarget: + """Resolved target for an update provision operation.""" + + is_main_agent: bool + board: Board | None + gateway: Gateway + client_config: GatewayClientConfig + + +@dataclass(frozen=True, slots=True) +class AgentUpdateProvisionRequest: + """Provision request payload for agent updates.""" + + target: AgentUpdateProvisionTarget + raw_token: str + user: User | None + force_bootstrap: bool + + +class AbstractProvisionExecution(ABC): + """Shared async execution contract for board/main agent provisioning actions.""" + + def __init__( + self, + *, + service: AgentLifecycleService, + agent: Agent, + provision_request: AgentUpdateProvisionRequest, + action: str, + wakeup_verb: str, + raise_gateway_errors: bool, + ) -> None: + self._service = service + self._agent = agent + self._request = provision_request + self._action = action + self._wakeup_verb = wakeup_verb + self._raise_gateway_errors = raise_gateway_errors + + @property + def agent(self) -> Agent: + return self._agent + + @agent.setter + def agent(self, value: Agent) -> None: + if not isinstance(value, Agent): + msg = "agent must be an Agent model" + raise TypeError(msg) + self._agent = value + + @property + def request(self) -> AgentUpdateProvisionRequest: + return self._request + + @request.setter + def request(self, value: AgentUpdateProvisionRequest) -> None: + if not isinstance(value, AgentUpdateProvisionRequest): + msg = "request must be an AgentUpdateProvisionRequest" + raise TypeError(msg) + self._request = value + + @property + def logger(self) -> logging.Logger: + return self._service.logger + + @abstractmethod + async def _provision(self) -> None: + raise NotImplementedError + + async def execute(self) -> None: + self.logger.log( + 5, + "agent.provision.start action=%s agent_id=%s target_main=%s", + self._action, + self.agent.id, + self.request.target.is_main_agent, + ) + try: + await self._provision() + await self._service.send_wakeup_message( + self.agent, + self.request.target.client_config, + verb=self._wakeup_verb, + ) + self.agent.provision_confirm_token_hash = None + self.agent.provision_requested_at = None + self.agent.provision_action = None + self.agent.status = "online" + self.agent.updated_at = utcnow() + self._service.session.add(self.agent) + await self._service.session.commit() + record_activity( + self._service.session, + event_type=f"agent.{self._action}.direct", + message=f"{self._action.capitalize()}d directly for {self.agent.name}.", + agent_id=self.agent.id, + ) + record_activity( + self._service.session, + event_type="agent.wakeup.sent", + message=f"Wakeup message sent to {self.agent.name}.", + agent_id=self.agent.id, + ) + await self._service.session.commit() + self.logger.info( + "agent.provision.success action=%s agent_id=%s", + self._action, + self.agent.id, + ) + except OpenClawGatewayError as exc: + self._service.record_instruction_failure( + self._service.session, + self.agent, + str(exc), + self._action, + ) + await self._service.session.commit() + self.logger.error( + "agent.provision.gateway_error action=%s agent_id=%s error=%s", + self._action, + self.agent.id, + str(exc), + ) + if self._raise_gateway_errors: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway {self._action} failed: {exc}", + ) from exc + except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover + self._service.record_instruction_failure( + self._service.session, + self.agent, + str(exc), + self._action, + ) + await self._service.session.commit() + self.logger.critical( + "agent.provision.runtime_error action=%s agent_id=%s error=%s", + self._action, + self.agent.id, + str(exc), + ) + if self._raise_gateway_errors: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Unexpected error {self._action}ing agent provisioning.", + ) from exc + + +class BoardAgentProvisionExecution(AbstractProvisionExecution): + """Provision execution for board-scoped agents.""" + + async def _provision(self) -> None: + board = self.request.target.board + if board is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="board is required for non-main agent provisioning", + ) + await provision_agent( + self.agent, + AgentProvisionRequest( + board=board, + gateway=self.request.target.gateway, + auth_token=self.request.raw_token, + user=self.request.user, + options=ProvisionOptions( + action=self._action, + force_bootstrap=self.request.force_bootstrap, + reset_session=True, + ), + ), + ) + + +class MainAgentProvisionExecution(AbstractProvisionExecution): + """Provision execution for gateway-main agents.""" + + async def _provision(self) -> None: + await provision_main_agent( + self.agent, + MainAgentProvisionRequest( + gateway=self.request.target.gateway, + auth_token=self.request.raw_token, + user=self.request.user, + session_key=self.agent.openclaw_session_id, + options=ProvisionOptions( + action=self._action, + force_bootstrap=self.request.force_bootstrap, + reset_session=True, + ), + ), + ) + + +class AgentLifecycleService: + """Async service encapsulating agent lifecycle behavior for API routes.""" + + 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 + def parse_since(value: str | None) -> datetime | None: + if not value: + return None + normalized = value.strip() + if not normalized: + return None + normalized = normalized.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is not None: + return parsed.astimezone(UTC).replace(tzinfo=None) + return parsed + + @staticmethod + def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or uuid4().hex + + @classmethod + def build_session_key(cls, agent_name: str) -> str: + return f"{AGENT_SESSION_PREFIX}:{cls.slugify(agent_name)}:main" + + @classmethod + def workspace_path(cls, agent_name: str, workspace_root: str | None) -> str: + if not workspace_root: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway workspace_root is required", + ) + root = workspace_root.rstrip("/") + return f"{root}/workspace-{cls.slugify(agent_name)}" + + async def require_board( + self, + board_id: UUID | str | None, + *, + user: User | None = None, + write: bool = False, + ) -> Board: + if not board_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_id is required", + ) + board = await Board.objects.by_id(board_id).first(self.session) + if board is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found", + ) + if user is not None: + await require_board_access(self.session, user=user, board=board, write=write) + return board + + async def require_gateway( + self, + board: Board, + ) -> tuple[Gateway, GatewayClientConfig]: + if not board.gateway_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is required", + ) + gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) + if gateway is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is invalid", + ) + if gateway.organization_id != board.organization_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is invalid", + ) + if not gateway.url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway url is required", + ) + if not gateway.workspace_root: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway workspace_root is required", + ) + return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) + + @staticmethod + def gateway_client_config(gateway: Gateway) -> GatewayClientConfig: + if not gateway.url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway url is required", + ) + return GatewayClientConfig(url=gateway.url, token=gateway.token) + + @staticmethod + def is_gateway_main(agent: Agent) -> bool: + return agent.board_id is None + + @classmethod + def to_agent_read(cls, agent: Agent) -> AgentRead: + model = AgentRead.model_validate(agent, from_attributes=True) + return model.model_copy( + update={"is_gateway_main": cls.is_gateway_main(agent)}, + ) + + @staticmethod + def coerce_agent_items(items: Sequence[Any]) -> list[Agent]: + agents: list[Agent] = [] + for item in items: + if not isinstance(item, Agent): + msg = "Expected Agent items from paginated query" + raise TypeError(msg) + agents.append(item) + return agents + + async def get_main_agent_gateway(self, agent: Agent) -> Gateway | None: + if agent.board_id is not None: + return None + return await Gateway.objects.by_id(agent.gateway_id).first(self.session) + + async def ensure_gateway_session( + self, + agent_name: str, + config: GatewayClientConfig, + ) -> tuple[str, str | None]: + session_key = self.build_session_key(agent_name) + try: + await ensure_session(session_key, config=config, label=agent_name) + except OpenClawGatewayError as exc: + self.logger.warning( + "agent.session.ensure_failed agent_name=%s error=%s", + agent_name, + str(exc), + ) + return session_key, str(exc) + return session_key, None + + @classmethod + def with_computed_status(cls, agent: Agent) -> Agent: + now = utcnow() + if agent.status in {"deleting", "updating"}: + return agent + if agent.last_seen_at is None: + agent.status = "provisioning" + elif now - agent.last_seen_at > OFFLINE_AFTER: + agent.status = "offline" + return agent + + @classmethod + def serialize_agent(cls, agent: Agent) -> dict[str, object]: + return cls.to_agent_read(cls.with_computed_status(agent)).model_dump(mode="json") + + async def fetch_agent_events( + self, + board_id: UUID | None, + since: datetime, + ) -> list[Agent]: + statement = select(Agent) + if board_id: + statement = statement.where(col(Agent.board_id) == board_id) + statement = statement.where( + or_( + col(Agent.updated_at) >= since, + col(Agent.last_seen_at) >= since, + ), + ).order_by(asc(col(Agent.updated_at))) + return list(await self.session.exec(statement)) + + async def require_user_context(self, user: User | None) -> OrganizationContext: + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + member = await get_active_membership(self.session, user) + if member is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + organization = await Organization.objects.by_id(member.organization_id).first(self.session) + if organization is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return OrganizationContext(organization=organization, member=member) + + async def require_agent_access( + self, + *, + agent: Agent, + ctx: OrganizationContext, + write: bool, + ) -> None: + if agent.board_id is None: + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + gateway = await self.get_main_agent_gateway(agent) + if gateway is None or gateway.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return + + board = await Board.objects.by_id(agent.board_id).first(self.session) + if board is None or board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not await has_board_access(self.session, member=ctx.member, board=board, write=write): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + @staticmethod + def record_heartbeat(session: AsyncSession, agent: Agent) -> None: + record_activity( + session, + event_type="agent.heartbeat", + message=f"Heartbeat received from {agent.name}.", + agent_id=agent.id, + ) + + @staticmethod + def record_instruction_failure( + session: AsyncSession, + agent: Agent, + error: str, + action: str, + ) -> None: + action_label = action.replace("_", " ").capitalize() + record_activity( + session, + event_type=f"agent.{action}.failed", + message=f"{action_label} message failed: {error}", + agent_id=agent.id, + ) + + async def coerce_agent_create_payload( + self, + payload: AgentCreate, + actor: ActorContextLike, + ) -> AgentCreate: + if actor.actor_type == "user": + ctx = await self.require_user_context(actor.user) + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return payload + + if actor.actor_type == "agent": + if not actor.agent or not actor.agent.is_board_lead: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only board leads can create agents", + ) + if not actor.agent.board_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board lead must be assigned to a board", + ) + if payload.board_id and payload.board_id != actor.agent.board_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board leads can only create agents in their own board", + ) + return AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id}) + + return payload + + async def ensure_unique_agent_name( + self, + *, + board: Board, + gateway: Gateway, + requested_name: str, + ) -> None: + if not requested_name: + return + + existing = ( + await self.session.exec( + select(Agent) + .where(Agent.board_id == board.id) + .where(col(Agent.name).ilike(requested_name)), + ) + ).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An agent with this name already exists on this board.", + ) + + existing_gateway = ( + await self.session.exec( + select(Agent) + .join(Board, col(Agent.board_id) == col(Board.id)) + .where(col(Board.gateway_id) == gateway.id) + .where(col(Agent.name).ilike(requested_name)), + ) + ).first() + if existing_gateway: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An agent with this name already exists in this gateway workspace.", + ) + + desired_session_key = self.build_session_key(requested_name) + existing_session_key = ( + await self.session.exec( + select(Agent) + .join(Board, col(Agent.board_id) == col(Board.id)) + .where(col(Board.gateway_id) == gateway.id) + .where(col(Agent.openclaw_session_id) == desired_session_key), + ) + ).first() + if existing_session_key: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "This agent name would collide with an existing workspace " + "session key. Pick a different name." + ), + ) + + async def persist_new_agent( + self, + *, + data: dict[str, Any], + client_config: GatewayClientConfig, + ) -> tuple[Agent, str, str | None]: + agent = Agent.model_validate(data) + agent.status = "provisioning" + raw_token = generate_agent_token() + 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" + session_key, session_error = await self.ensure_gateway_session( + agent.name, + client_config, + ) + agent.openclaw_session_id = session_key + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + return agent, raw_token, session_error + + async def record_session_creation( + self, + *, + agent: Agent, + session_error: str | None, + ) -> None: + if session_error: + record_activity( + self.session, + event_type="agent.session.failed", + message=f"Session sync failed for {agent.name}: {session_error}", + agent_id=agent.id, + ) + else: + record_activity( + self.session, + event_type="agent.session.created", + message=f"Session created for {agent.name}.", + agent_id=agent.id, + ) + await self.session.commit() + + async def send_wakeup_message( + self, + agent: Agent, + config: GatewayClientConfig, + verb: str = "provisioned", + ) -> None: + session_key = agent.openclaw_session_id or self.build_session_key(agent.name) + await ensure_session(session_key, config=config, label=agent.name) + message = ( + f"Hello {agent.name}. Your workspace has been {verb}.\n\n" + "Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once " + "then delete it. Begin heartbeats after startup." + ) + await send_message(message, session_key=session_key, config=config, deliver=True) + + async def provision_new_agent( + self, + *, + agent: Agent, + request: AgentProvisionRequest, + client_config: GatewayClientConfig, + ) -> None: + execution = BoardAgentProvisionExecution( + service=self, + agent=agent, + provision_request=AgentUpdateProvisionRequest( + target=AgentUpdateProvisionTarget( + is_main_agent=False, + board=request.board, + gateway=request.gateway, + client_config=client_config, + ), + raw_token=request.auth_token, + user=request.user, + force_bootstrap=request.options.force_bootstrap, + ), + action="provision", + wakeup_verb="provisioned", + raise_gateway_errors=False, + ) + await execution.execute() + + async def validate_agent_update_inputs( + self, + *, + ctx: OrganizationContext, + updates: dict[str, Any], + make_main: bool | None, + ) -> None: + if make_main and not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if "status" in updates: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="status is controlled by agent heartbeat", + ) + if "board_id" in updates and updates["board_id"] is not None: + new_board = await self.require_board(updates["board_id"]) + if new_board.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if not await has_board_access( + self.session, + member=ctx.member, + board=new_board, + write=True, + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + async def apply_agent_update_mutations( + self, + *, + agent: Agent, + updates: dict[str, Any], + make_main: bool | None, + ) -> tuple[Gateway | None, Gateway | None]: + main_gateway = await self.get_main_agent_gateway(agent) + gateway_for_main: Gateway | None = None + + if make_main: + board_source = updates.get("board_id") or agent.board_id + board_for_main = await self.require_board(board_source) + gateway_for_main, _ = await self.require_gateway(board_for_main) + updates["board_id"] = None + updates["gateway_id"] = gateway_for_main.id + agent.is_board_lead = False + agent.openclaw_session_id = GatewayAgentIdentity.session_key(gateway_for_main) + main_gateway = gateway_for_main + elif make_main is not None: + if "board_id" not in updates or updates["board_id"] is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "board_id is required when converting a gateway-main agent " + "to board scope" + ), + ) + board = await self.require_board(updates["board_id"]) + if board.gateway_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is required", + ) + updates["gateway_id"] = board.gateway_id + agent.openclaw_session_id = None + + if make_main is None and "board_id" in updates: + board = await self.require_board(updates["board_id"]) + if board.gateway_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is required", + ) + updates["gateway_id"] = board.gateway_id + for key, value in updates.items(): + setattr(agent, key, value) + + if make_main is None and main_gateway is not None: + agent.board_id = None + agent.gateway_id = main_gateway.id + agent.is_board_lead = False + if make_main is False and agent.board_id is not None: + board = await self.require_board(agent.board_id) + if board.gateway_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is required", + ) + agent.gateway_id = board.gateway_id + agent.updated_at = utcnow() + if agent.heartbeat_config is None: + agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + return main_gateway, gateway_for_main + + async def resolve_agent_update_target( + self, + *, + agent: Agent, + make_main: bool | None, + main_gateway: Gateway | None, + gateway_for_main: Gateway | None, + ) -> AgentUpdateProvisionTarget: + if make_main: + if gateway_for_main is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway agent requires a gateway configuration", + ) + return AgentUpdateProvisionTarget( + is_main_agent=True, + board=None, + gateway=gateway_for_main, + client_config=self.gateway_client_config(gateway_for_main), + ) + + if make_main is None and agent.board_id is None and main_gateway is not None: + return AgentUpdateProvisionTarget( + is_main_agent=True, + board=None, + gateway=main_gateway, + client_config=self.gateway_client_config(main_gateway), + ) + + if agent.board_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_id is required for non-main agents", + ) + board = await self.require_board(agent.board_id) + gateway, client_config = await self.require_gateway(board) + return AgentUpdateProvisionTarget( + is_main_agent=False, + board=board, + gateway=gateway, + client_config=client_config, + ) + + async def ensure_agent_update_session( + self, + *, + agent: Agent, + client_config: GatewayClientConfig, + ) -> None: + session_key = agent.openclaw_session_id or self.build_session_key(agent.name) + try: + await ensure_session(session_key, config=client_config, label=agent.name) + if not agent.openclaw_session_id: + agent.openclaw_session_id = session_key + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + except OpenClawGatewayError as exc: + self.record_instruction_failure(self.session, agent, str(exc), "update") + await self.session.commit() + + @staticmethod + def mark_agent_update_pending(agent: Agent) -> str: + raw_token = generate_agent_token() + agent.agent_token_hash = hash_agent_token(raw_token) + agent.provision_requested_at = utcnow() + agent.provision_action = "update" + agent.status = "updating" + return raw_token + + async def provision_updated_agent( + self, + *, + agent: Agent, + request: AgentUpdateProvisionRequest, + ) -> None: + execution: AbstractProvisionExecution + if request.target.is_main_agent: + execution = MainAgentProvisionExecution( + service=self, + agent=agent, + provision_request=request, + action="update", + wakeup_verb="updated", + raise_gateway_errors=True, + ) + else: + execution = BoardAgentProvisionExecution( + service=self, + agent=agent, + provision_request=request, + action="update", + wakeup_verb="updated", + raise_gateway_errors=True, + ) + await execution.execute() + + @staticmethod + def heartbeat_lookup_statement(payload: AgentHeartbeatCreate) -> SelectOfScalar[Agent]: + statement = Agent.objects.filter_by(name=payload.name).statement + if payload.board_id is not None: + statement = statement.where(Agent.board_id == payload.board_id) + return statement + + async def create_agent_from_heartbeat( + self, + *, + payload: AgentHeartbeatCreate, + actor: ActorContextLike, + ) -> Agent: + if actor.actor_type == "agent": + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if actor.actor_type == "user": + ctx = await self.require_user_context(actor.user) + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + board = await self.require_board( + payload.board_id, + user=actor.user, + write=True, + ) + gateway, client_config = await self.require_gateway(board) + data: dict[str, Any] = { + "name": payload.name, + "board_id": board.id, + "gateway_id": gateway.id, + "heartbeat_config": DEFAULT_HEARTBEAT_CONFIG.copy(), + } + agent, raw_token, session_error = await self.persist_new_agent( + data=data, + client_config=client_config, + ) + await self.record_session_creation( + agent=agent, + session_error=session_error, + ) + await self.provision_new_agent( + agent=agent, + request=AgentProvisionRequest( + board=board, + gateway=gateway, + auth_token=raw_token, + user=actor.user, + options=ProvisionOptions(action="provision"), + ), + client_config=client_config, + ) + return agent + + async def handle_existing_user_heartbeat_agent( + self, + *, + agent: Agent, + user: User | None, + ) -> None: + ctx = await self.require_user_context(user) + await self.require_agent_access(agent=agent, ctx=ctx, write=True) + + if agent.agent_token_hash is not None: + return + + raw_token = generate_agent_token() + 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" + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + board = await self.require_board( + str(agent.board_id) if agent.board_id else None, + user=user, + write=True, + ) + gateway, client_config = await self.require_gateway(board) + await self.provision_new_agent( + agent=agent, + request=AgentProvisionRequest( + board=board, + gateway=gateway, + auth_token=raw_token, + user=user, + options=ProvisionOptions(action="provision"), + ), + client_config=client_config, + ) + + async def ensure_heartbeat_session_key( + self, + *, + agent: Agent, + actor: ActorContextLike, + ) -> None: + if agent.openclaw_session_id: + return + board = await self.require_board( + str(agent.board_id) if agent.board_id else None, + user=actor.user if actor.actor_type == "user" else None, + write=actor.actor_type == "user", + ) + _, client_config = await self.require_gateway(board) + session_key, session_error = await self.ensure_gateway_session( + agent.name, + client_config, + ) + agent.openclaw_session_id = session_key + self.session.add(agent) + await self.record_session_creation( + agent=agent, + session_error=session_error, + ) + + async def commit_heartbeat( + self, + *, + agent: Agent, + status_value: str | None, + ) -> AgentRead: + if status_value: + agent.status = status_value + elif agent.status == "provisioning": + agent.status = "online" + agent.last_seen_at = utcnow() + agent.updated_at = utcnow() + self.record_heartbeat(self.session, agent) + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + return self.to_agent_read(self.with_computed_status(agent)) + + async def list_agents( + self, + *, + board_id: UUID | None, + gateway_id: UUID | None, + ctx: OrganizationContext, + ) -> LimitOffsetPage[AgentRead]: + board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False) + if board_id is not None and board_id not in set(board_ids): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + base_filters: list[ColumnElement[bool]] = [] + if board_ids: + base_filters.append(col(Agent.board_id).in_(board_ids)) + if is_org_admin(ctx.member): + gateways = await Gateway.objects.filter_by( + organization_id=ctx.organization.id, + ).all(self.session) + gateway_ids = [gateway.id for gateway in gateways] + if gateway_ids: + base_filters.append( + (col(Agent.gateway_id).in_(gateway_ids)) & (col(Agent.board_id).is_(None)), + ) + if base_filters: + if len(base_filters) == 1: + statement = select(Agent).where(base_filters[0]) + else: + statement = select(Agent).where(or_(*base_filters)) + else: + statement = select(Agent).where(col(Agent.id).is_(None)) + if board_id is not None: + statement = statement.where(col(Agent.board_id) == board_id) + if gateway_id is not None: + gateway = await Gateway.objects.by_id(gateway_id).first(self.session) + if gateway is None or gateway.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + gateway_board_ids = select(Board.id).where(col(Board.gateway_id) == gateway_id) + statement = statement.where( + or_( + col(Agent.board_id).in_(gateway_board_ids), + (col(Agent.gateway_id) == gateway_id) & (col(Agent.board_id).is_(None)), + ), + ) + statement = statement.order_by(col(Agent.created_at).desc()) + + def _transform(items: Sequence[Any]) -> Sequence[Any]: + agents = self.coerce_agent_items(items) + return [self.to_agent_read(self.with_computed_status(agent)) for agent in agents] + + return await paginate(self.session, statement, transformer=_transform) + + async def stream_agents( + self, + *, + request: Request, + board_id: UUID | None, + since: str | None, + ctx: OrganizationContext, + ) -> EventSourceResponse: + since_dt = self.parse_since(since) or utcnow() + last_seen = since_dt + board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False) + allowed_ids = set(board_ids) + if board_id is not None and board_id not in allowed_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + async def event_generator() -> AsyncIterator[dict[str, str]]: + nonlocal last_seen + while True: + if await request.is_disconnected(): + break + async with async_session_maker() as stream_session: + stream_service = AgentLifecycleService(stream_session) + stream_service.logger = self.logger + if board_id is not None: + agents = await stream_service.fetch_agent_events( + board_id, + last_seen, + ) + elif allowed_ids: + agents = await stream_service.fetch_agent_events(None, last_seen) + agents = [agent for agent in agents if agent.board_id in allowed_ids] + else: + agents = [] + for agent in agents: + updated_at = agent.updated_at or agent.last_seen_at or utcnow() + last_seen = max(updated_at, last_seen) + payload = {"agent": self.serialize_agent(agent)} + yield {"event": "agent", "data": json.dumps(payload)} + await asyncio.sleep(2) + + return EventSourceResponse(event_generator(), ping=15) + + async def create_agent( + self, + *, + payload: AgentCreate, + actor: ActorContextLike, + ) -> AgentRead: + self.logger.log( + 5, + "agent.create.start actor_type=%s board_id=%s", + actor.actor_type, + payload.board_id, + ) + payload = await self.coerce_agent_create_payload(payload, actor) + + board = await self.require_board( + payload.board_id, + user=actor.user if actor.actor_type == "user" else None, + write=actor.actor_type == "user", + ) + gateway, client_config = await self.require_gateway(board) + data = payload.model_dump() + data["gateway_id"] = gateway.id + requested_name = (data.get("name") or "").strip() + await self.ensure_unique_agent_name( + board=board, + gateway=gateway, + requested_name=requested_name, + ) + agent, raw_token, session_error = await self.persist_new_agent( + data=data, + client_config=client_config, + ) + await self.record_session_creation( + agent=agent, + session_error=session_error, + ) + provision_request = AgentProvisionRequest( + board=board, + gateway=gateway, + auth_token=raw_token, + user=actor.user if actor.actor_type == "user" else None, + options=ProvisionOptions(action="provision"), + ) + await self.provision_new_agent( + agent=agent, + request=provision_request, + client_config=client_config, + ) + self.logger.info("agent.create.success agent_id=%s board_id=%s", agent.id, board.id) + return self.to_agent_read(self.with_computed_status(agent)) + + async def get_agent( + self, + *, + agent_id: str, + ctx: OrganizationContext, + ) -> AgentRead: + agent = await Agent.objects.by_id(agent_id).first(self.session) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await self.require_agent_access(agent=agent, ctx=ctx, write=False) + return self.to_agent_read(self.with_computed_status(agent)) + + async def update_agent( + self, + *, + agent_id: str, + payload: AgentUpdate, + options: AgentUpdateOptions, + ) -> AgentRead: + self.logger.log(5, "agent.update.start agent_id=%s force=%s", agent_id, options.force) + agent = await Agent.objects.by_id(agent_id).first(self.session) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await self.require_agent_access(agent=agent, ctx=options.context, write=True) + updates = payload.model_dump(exclude_unset=True) + make_main = updates.pop("is_gateway_main", None) + await self.validate_agent_update_inputs( + ctx=options.context, + updates=updates, + make_main=make_main, + ) + if not updates and not options.force and make_main is None: + return self.to_agent_read(self.with_computed_status(agent)) + main_gateway, gateway_for_main = await self.apply_agent_update_mutations( + agent=agent, + updates=updates, + make_main=make_main, + ) + target = await self.resolve_agent_update_target( + agent=agent, + make_main=make_main, + main_gateway=main_gateway, + gateway_for_main=gateway_for_main, + ) + await self.ensure_agent_update_session( + agent=agent, + client_config=target.client_config, + ) + raw_token = self.mark_agent_update_pending(agent) + self.session.add(agent) + await self.session.commit() + await self.session.refresh(agent) + provision_request = AgentUpdateProvisionRequest( + target=target, + raw_token=raw_token, + user=options.user, + force_bootstrap=options.force, + ) + await self.provision_updated_agent( + agent=agent, + request=provision_request, + ) + self.logger.info("agent.update.success agent_id=%s", agent.id) + return self.to_agent_read(self.with_computed_status(agent)) + + async def heartbeat_agent( + self, + *, + agent_id: str, + payload: AgentHeartbeat, + actor: ActorContextLike, + ) -> AgentRead: + self.logger.log( + 5, "agent.heartbeat.start agent_id=%s actor_type=%s", agent_id, actor.actor_type + ) + agent = await Agent.objects.by_id(agent_id).first(self.session) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if actor.actor_type == "user": + ctx = await self.require_user_context(actor.user) + if not is_org_admin(ctx.member): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + await self.require_agent_access(agent=agent, ctx=ctx, write=True) + return await self.commit_heartbeat( + agent=agent, + status_value=payload.status, + ) + + async def heartbeat_or_create_agent( + self, + *, + payload: AgentHeartbeatCreate, + actor: ActorContextLike, + ) -> AgentRead: + self.logger.log( + 5, + "agent.heartbeat_or_create.start actor_type=%s name=%s board_id=%s", + actor.actor_type, + payload.name, + payload.board_id, + ) + if actor.actor_type == "agent" and actor.agent: + return await self.heartbeat_agent( + agent_id=str(actor.agent.id), + payload=AgentHeartbeat(status=payload.status), + actor=actor, + ) + + agent = (await self.session.exec(self.heartbeat_lookup_statement(payload))).first() + if agent is None: + agent = await self.create_agent_from_heartbeat( + payload=payload, + actor=actor, + ) + elif actor.actor_type == "user": + await self.handle_existing_user_heartbeat_agent( + agent=agent, + user=actor.user, + ) + elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + await self.ensure_heartbeat_session_key( + agent=agent, + actor=actor, + ) + return await self.commit_heartbeat( + agent=agent, + status_value=payload.status, + ) + + async def delete_agent( + self, + *, + agent_id: str, + ctx: OrganizationContext, + ) -> OkResponse: + self.logger.log(5, "agent.delete.start agent_id=%s", agent_id) + agent = await Agent.objects.by_id(agent_id).first(self.session) + if agent is None: + return OkResponse() + await self.require_agent_access(agent=agent, ctx=ctx, write=True) + + board = await self.require_board(str(agent.board_id) if agent.board_id else None) + gateway, client_config = await self.require_gateway(board) + try: + workspace_path = await cleanup_agent(agent, gateway) + except OpenClawGatewayError as exc: + self.record_instruction_failure(self.session, agent, str(exc), "delete") + await self.session.commit() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway cleanup failed: {exc}", + ) from exc + except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover + self.record_instruction_failure(self.session, agent, str(exc), "delete") + await self.session.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Workspace cleanup failed: {exc}", + ) from exc + + record_activity( + self.session, + event_type="agent.delete.direct", + message=f"Deleted agent {agent.name}.", + agent_id=None, + ) + now = utcnow() + await crud.update_where( + self.session, + Task, + col(Task.assigned_agent_id) == agent.id, + col(Task.status) == "in_progress", + assigned_agent_id=None, + status="inbox", + in_progress_at=None, + updated_at=now, + commit=False, + ) + await crud.update_where( + self.session, + Task, + col(Task.assigned_agent_id) == agent.id, + col(Task.status) != "in_progress", + assigned_agent_id=None, + updated_at=now, + commit=False, + ) + await crud.update_where( + self.session, + ActivityEvent, + col(ActivityEvent.agent_id) == agent.id, + agent_id=None, + commit=False, + ) + await self.session.delete(agent) + await self.session.commit() + + try: + main_session = GatewayAgentIdentity.session_key(gateway) + if main_session and workspace_path: + cleanup_message = ( + "Cleanup request for deleted agent.\n\n" + f"Agent name: {agent.name}\n" + f"Agent id: {agent.id}\n" + f"Workspace path: {workspace_path}\n\n" + "Actions:\n" + "1) Remove the workspace directory.\n" + "2) Reply NO_REPLY.\n" + ) + await ensure_session(main_session, config=client_config, label="Gateway Agent") + await send_message( + cleanup_message, + session_key=main_session, + config=client_config, + deliver=False, + ) + except (OSError, OpenClawGatewayError, ValueError): + pass + self.logger.info("agent.delete.success agent_id=%s", agent_id) + return OkResponse() diff --git a/backend/app/services/openclaw/coordination_service.py b/backend/app/services/openclaw/coordination_service.py new file mode 100644 index 00000000..202b2a21 --- /dev/null +++ b/backend/app/services/openclaw/coordination_service.py @@ -0,0 +1,747 @@ +"""Gateway-main and lead coordination services.""" + +from __future__ import annotations + +import json +import logging +from abc import ABC +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, TypeVar +from uuid import UUID + +from fastapi import HTTPException, status +from sqlmodel import col, select + +from app.core.config import settings +from app.core.time import utcnow +from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig +from app.integrations.openclaw_gateway import OpenClawGatewayError, openclaw_call +from app.models.agents import Agent +from app.models.boards import Board +from app.models.gateways import Gateway +from app.schemas.gateway_coordination import ( + GatewayLeadBroadcastBoardResult, + GatewayLeadBroadcastRequest, + GatewayLeadBroadcastResponse, + GatewayLeadMessageRequest, + GatewayLeadMessageResponse, + GatewayMainAskUserRequest, + GatewayMainAskUserResponse, +) +from app.services.activity_log import record_activity +from app.services.openclaw.exceptions import ( + GatewayOperation, + map_gateway_error_message, + map_gateway_error_to_http_exception, +) +from app.services.openclaw.provisioning import ( + LeadAgentOptions, + LeadAgentRequest, + _agent_key, + _with_coordination_gateway_retry, + ensure_board_lead_agent, +) +from app.services.openclaw.shared import ( + 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") + + +class AbstractGatewayMessagingService(ABC): + """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 + async def _with_gateway_retry(fn: Callable[[], Awaitable[_T]]) -> _T: + return await _with_coordination_gateway_retry(fn) + + async def _dispatch_gateway_message( + self, + *, + session_key: str, + config: GatewayClientConfig, + agent_name: str, + message: str, + deliver: bool, + ) -> None: + async def _do_send() -> bool: + await send_gateway_agent_message( + session_key=session_key, + config=config, + agent_name=agent_name, + message=message, + deliver=deliver, + ) + return True + + await self._with_gateway_retry(_do_send) + + +class GatewayCoordinationService(AbstractGatewayMessagingService): + """Gateway-main and lead coordination workflows used by agent-facing routes.""" + + @staticmethod + def _build_gateway_lead_message( + *, + board: Board, + actor_agent_name: str, + kind: str, + content: str, + correlation_id: str | None, + reply_tags: list[str] | None, + reply_source: str | None, + ) -> str: + base_url = settings.base_url or "http://localhost:8000" + header = "GATEWAY MAIN QUESTION" if kind == "question" else "GATEWAY MAIN HANDOFF" + correlation = correlation_id.strip() if correlation_id else "" + correlation_line = f"Correlation ID: {correlation}\n" if correlation else "" + tags_json = json.dumps(reply_tags or ["gateway_main", "lead_reply"]) + source = reply_source or "lead_to_gateway_main" + return ( + f"{header}\n" + f"Board: {board.name}\n" + f"Board ID: {board.id}\n" + f"From agent: {actor_agent_name}\n" + f"{correlation_line}\n" + f"{content.strip()}\n\n" + "Reply to the gateway agent by writing a NON-chat memory item on this board:\n" + f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n" + f'Body: {{"content":"...","tags":{tags_json},"source":"{source}"}}\n' + "Do NOT reply in OpenClaw chat." + ) + + async def require_gateway_main_actor( + self, + actor_agent: Agent, + ) -> tuple[Gateway, GatewayClientConfig]: + detail = "Only the dedicated gateway agent may call this endpoint." + if actor_agent.board_id is not None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + gateway = await Gateway.objects.by_id(actor_agent.gateway_id).first(self.session) + if gateway is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + if actor_agent.openclaw_session_id != GatewayAgentIdentity.session_key(gateway): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + if not gateway.url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway url is required", + ) + return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) + + async def require_gateway_board( + self, + *, + gateway: Gateway, + board_id: UUID | str, + ) -> Board: + board = await Board.objects.by_id(board_id).first(self.session) + if board is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") + if board.gateway_id != gateway.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return board + + async def _board_agent_or_404( + self, + *, + board: Board, + agent_id: str, + ) -> Agent: + target = await Agent.objects.by_id(agent_id).first(self.session) + if target is None or (target.board_id and target.board_id != board.id): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return target + + @staticmethod + def _gateway_file_content(payload: object) -> str | None: + if isinstance(payload, str): + return payload + if isinstance(payload, dict): + content = payload.get("content") + if isinstance(content, str): + return content + file_obj = payload.get("file") + if isinstance(file_obj, dict): + nested = file_obj.get("content") + if isinstance(nested, str): + return nested + return None + + async def nudge_board_agent( + self, + *, + board: Board, + actor_agent: Agent, + target_agent_id: str, + message: str, + correlation_id: str | None = None, + ) -> None: + trace_id = resolve_trace_id(correlation_id, prefix="coord.nudge") + self.logger.log( + 5, + "gateway.coordination.nudge.start trace_id=%s board_id=%s actor_agent_id=%s " + "target_agent_id=%s", + trace_id, + board.id, + actor_agent.id, + target_agent_id, + ) + target = await self._board_agent_or_404(board=board, agent_id=target_agent_id) + if not target.openclaw_session_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Target agent has no session key", + ) + _gateway, config = await require_gateway_config_for_board(self.session, board) + try: + await self._dispatch_gateway_message( + session_key=target.openclaw_session_id or "", + config=config, + agent_name=target.name, + message=message, + deliver=True, + ) + except (OpenClawGatewayError, TimeoutError) as exc: + record_activity( + self.session, + event_type="agent.nudge.failed", + message=f"Nudge failed for {target.name}: {exc}", + agent_id=actor_agent.id, + ) + await self.session.commit() + self.logger.error( + "gateway.coordination.nudge.failed trace_id=%s board_id=%s actor_agent_id=%s " + "target_agent_id=%s error=%s", + trace_id, + board.id, + actor_agent.id, + target_agent_id, + str(exc), + ) + raise map_gateway_error_to_http_exception(GatewayOperation.NUDGE_AGENT, exc) from exc + except Exception as exc: # pragma: no cover - defensive guard + self.logger.critical( + "gateway.coordination.nudge.failed_unexpected trace_id=%s board_id=%s " + "actor_agent_id=%s target_agent_id=%s error_type=%s error=%s", + trace_id, + board.id, + actor_agent.id, + target_agent_id, + exc.__class__.__name__, + str(exc), + ) + raise + record_activity( + self.session, + event_type="agent.nudge.sent", + message=f"Nudge sent to {target.name}.", + agent_id=actor_agent.id, + ) + await self.session.commit() + self.logger.info( + "gateway.coordination.nudge.success trace_id=%s board_id=%s actor_agent_id=%s " + "target_agent_id=%s", + trace_id, + board.id, + actor_agent.id, + target_agent_id, + ) + + async def get_agent_soul( + self, + *, + board: Board, + target_agent_id: str, + correlation_id: str | None = None, + ) -> str: + trace_id = resolve_trace_id(correlation_id, prefix="coord.soul.read") + self.logger.log( + 5, + "gateway.coordination.soul_read.start trace_id=%s board_id=%s target_agent_id=%s", + trace_id, + board.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) + try: + + async def _do_get() -> object: + return await openclaw_call( + "agents.files.get", + {"agentId": _agent_key(target), "name": "SOUL.md"}, + config=config, + ) + + payload = await self._with_gateway_retry(_do_get) + except (OpenClawGatewayError, TimeoutError) as exc: + self.logger.error( + "gateway.coordination.soul_read.failed trace_id=%s board_id=%s " + "target_agent_id=%s error=%s", + trace_id, + board.id, + target_agent_id, + str(exc), + ) + raise map_gateway_error_to_http_exception(GatewayOperation.SOUL_READ, exc) from exc + except Exception as exc: # pragma: no cover - defensive guard + self.logger.critical( + "gateway.coordination.soul_read.failed_unexpected trace_id=%s board_id=%s " + "target_agent_id=%s error_type=%s error=%s", + trace_id, + board.id, + target_agent_id, + exc.__class__.__name__, + str(exc), + ) + raise + content = self._gateway_file_content(payload) + if content is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Invalid gateway response", + ) + self.logger.info( + "gateway.coordination.soul_read.success trace_id=%s board_id=%s target_agent_id=%s", + trace_id, + board.id, + target_agent_id, + ) + return content + + async def update_agent_soul( + self, + *, + board: Board, + target_agent_id: str, + content: str, + reason: str | None, + source_url: str | None, + actor_agent_id: UUID, + correlation_id: str | None = None, + ) -> None: + trace_id = resolve_trace_id(correlation_id, prefix="coord.soul.write") + self.logger.log( + 5, + "gateway.coordination.soul_write.start trace_id=%s board_id=%s target_agent_id=%s " + "actor_agent_id=%s", + trace_id, + board.id, + target_agent_id, + actor_agent_id, + ) + target = await self._board_agent_or_404(board=board, agent_id=target_agent_id) + normalized_content = content.strip() + if not normalized_content: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="content is required", + ) + + target.soul_template = normalized_content + target.updated_at = utcnow() + self.session.add(target) + await self.session.commit() + + _gateway, config = await require_gateway_config_for_board(self.session, board) + try: + + async def _do_set() -> object: + return await openclaw_call( + "agents.files.set", + { + "agentId": _agent_key(target), + "name": "SOUL.md", + "content": normalized_content, + }, + config=config, + ) + + await self._with_gateway_retry(_do_set) + except (OpenClawGatewayError, TimeoutError) as exc: + self.logger.error( + "gateway.coordination.soul_write.failed trace_id=%s board_id=%s " + "target_agent_id=%s actor_agent_id=%s error=%s", + trace_id, + board.id, + target_agent_id, + actor_agent_id, + str(exc), + ) + raise map_gateway_error_to_http_exception(GatewayOperation.SOUL_WRITE, exc) from exc + except Exception as exc: # pragma: no cover - defensive guard + self.logger.critical( + "gateway.coordination.soul_write.failed_unexpected trace_id=%s board_id=%s " + "target_agent_id=%s actor_agent_id=%s error_type=%s error=%s", + trace_id, + board.id, + target_agent_id, + actor_agent_id, + exc.__class__.__name__, + str(exc), + ) + raise + + reason_text = (reason or "").strip() + source_url_text = (source_url or "").strip() + note = f"SOUL.md updated for {target.name}." + if reason_text: + note = f"{note} Reason: {reason_text}" + if source_url_text: + note = f"{note} Source: {source_url_text}" + record_activity( + self.session, + event_type="agent.soul.updated", + message=note, + agent_id=actor_agent_id, + ) + await self.session.commit() + self.logger.info( + "gateway.coordination.soul_write.success trace_id=%s board_id=%s target_agent_id=%s " + "actor_agent_id=%s", + trace_id, + board.id, + target_agent_id, + actor_agent_id, + ) + + async def ask_user_via_gateway_main( + self, + *, + board: Board, + payload: GatewayMainAskUserRequest, + actor_agent: Agent, + ) -> GatewayMainAskUserResponse: + trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.ask_user") + self.logger.log( + 5, + "gateway.coordination.ask_user.start trace_id=%s board_id=%s actor_agent_id=%s", + trace_id, + board.id, + actor_agent.id, + ) + gateway, config = await require_gateway_config_for_board(self.session, board) + main_session_key = GatewayAgentIdentity.session_key(gateway) + + correlation = payload.correlation_id.strip() if payload.correlation_id else "" + correlation_line = f"Correlation ID: {correlation}\n" if correlation else "" + preferred_channel = (payload.preferred_channel or "").strip() + channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else "" + tags = payload.reply_tags or ["gateway_main", "user_reply"] + tags_json = json.dumps(tags) + reply_source = payload.reply_source or "user_via_gateway_main" + base_url = settings.base_url or "http://localhost:8000" + message = ( + "LEAD REQUEST: ASK USER\n" + f"Board: {board.name}\n" + f"Board ID: {board.id}\n" + f"From lead: {actor_agent.name}\n" + f"{correlation_line}" + f"{channel_line}\n" + f"{payload.content.strip()}\n\n" + "Please reach the user via your configured OpenClaw channel(s) " + "(Slack/SMS/etc).\n" + "If you cannot reach them there, post the question in Mission Control " + "board chat as a fallback.\n\n" + "When you receive the answer, reply in Mission Control by writing a " + "NON-chat memory item on this board:\n" + f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n" + f'Body: {{"content":"","tags":{tags_json},"source":"{reply_source}"}}\n' + "Do NOT reply in OpenClaw chat." + ) + try: + await self._dispatch_gateway_message( + session_key=main_session_key, + config=config, + agent_name="Gateway Agent", + message=message, + deliver=True, + ) + except (OpenClawGatewayError, TimeoutError) as exc: + record_activity( + self.session, + event_type="gateway.lead.ask_user.failed", + message=f"Lead user question failed for {board.name}: {exc}", + agent_id=actor_agent.id, + ) + await self.session.commit() + self.logger.error( + "gateway.coordination.ask_user.failed trace_id=%s board_id=%s actor_agent_id=%s " + "error=%s", + trace_id, + board.id, + actor_agent.id, + str(exc), + ) + raise map_gateway_error_to_http_exception( + GatewayOperation.ASK_USER_DISPATCH, + exc, + ) from exc + except Exception as exc: # pragma: no cover - defensive guard + self.logger.critical( + "gateway.coordination.ask_user.failed_unexpected trace_id=%s board_id=%s " + "actor_agent_id=%s error_type=%s error=%s", + trace_id, + board.id, + actor_agent.id, + exc.__class__.__name__, + str(exc), + ) + raise + + record_activity( + self.session, + event_type="gateway.lead.ask_user.sent", + message=f"Lead requested user info via gateway agent for board: {board.name}.", + agent_id=actor_agent.id, + ) + main_agent = await Agent.objects.filter_by(gateway_id=gateway.id, board_id=None).first( + self.session, + ) + await self.session.commit() + self.logger.info( + "gateway.coordination.ask_user.success trace_id=%s board_id=%s actor_agent_id=%s " + "main_agent_id=%s", + trace_id, + board.id, + actor_agent.id, + main_agent.id if main_agent else None, + ) + return GatewayMainAskUserResponse( + board_id=board.id, + main_agent_id=main_agent.id if main_agent else None, + main_agent_name=main_agent.name if main_agent else None, + ) + + async def _ensure_and_message_board_lead( + self, + *, + gateway: Gateway, + config: GatewayClientConfig, + board: Board, + message: str, + ) -> tuple[Agent, bool]: + lead, lead_created = await ensure_board_lead_agent( + self.session, + request=LeadAgentRequest( + board=board, + gateway=gateway, + config=config, + user=None, + options=LeadAgentOptions(action="provision"), + ), + ) + if not lead.openclaw_session_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Lead agent has no session key", + ) + await self._dispatch_gateway_message( + session_key=lead.openclaw_session_id or "", + config=config, + agent_name=lead.name, + message=message, + deliver=False, + ) + return lead, lead_created + + async def message_gateway_board_lead( + self, + *, + actor_agent: Agent, + board_id: UUID, + payload: GatewayLeadMessageRequest, + ) -> GatewayLeadMessageResponse: + trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.lead_message") + self.logger.log( + 5, + "gateway.coordination.lead_message.start trace_id=%s board_id=%s actor_agent_id=%s", + trace_id, + board_id, + actor_agent.id, + ) + gateway, config = await self.require_gateway_main_actor(actor_agent) + board = await self.require_gateway_board(gateway=gateway, board_id=board_id) + message = self._build_gateway_lead_message( + board=board, + actor_agent_name=actor_agent.name, + kind=payload.kind, + content=payload.content, + correlation_id=payload.correlation_id, + reply_tags=payload.reply_tags, + reply_source=payload.reply_source, + ) + + try: + lead, lead_created = await self._ensure_and_message_board_lead( + gateway=gateway, + config=config, + board=board, + message=message, + ) + except (OpenClawGatewayError, TimeoutError) as exc: + record_activity( + self.session, + event_type="gateway.main.lead_message.failed", + message=f"Lead message failed for {board.name}: {exc}", + agent_id=actor_agent.id, + ) + await self.session.commit() + self.logger.error( + "gateway.coordination.lead_message.failed trace_id=%s board_id=%s " + "actor_agent_id=%s error=%s", + trace_id, + board.id, + actor_agent.id, + str(exc), + ) + raise map_gateway_error_to_http_exception( + GatewayOperation.LEAD_MESSAGE_DISPATCH, + exc, + ) from exc + except Exception as exc: # pragma: no cover - defensive guard + self.logger.critical( + "gateway.coordination.lead_message.failed_unexpected trace_id=%s board_id=%s " + "actor_agent_id=%s error_type=%s error=%s", + trace_id, + board.id, + actor_agent.id, + exc.__class__.__name__, + str(exc), + ) + raise + + record_activity( + self.session, + event_type="gateway.main.lead_message.sent", + message=f"Sent {payload.kind} to lead for board: {board.name}.", + agent_id=actor_agent.id, + ) + await self.session.commit() + self.logger.info( + "gateway.coordination.lead_message.success trace_id=%s board_id=%s " + "actor_agent_id=%s lead_agent_id=%s", + trace_id, + board.id, + actor_agent.id, + lead.id, + ) + return GatewayLeadMessageResponse( + board_id=board.id, + lead_agent_id=lead.id, + lead_agent_name=lead.name, + lead_created=lead_created, + ) + + async def broadcast_gateway_lead_message( + self, + *, + actor_agent: Agent, + payload: GatewayLeadBroadcastRequest, + ) -> GatewayLeadBroadcastResponse: + trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.lead_broadcast") + self.logger.log( + 5, + "gateway.coordination.lead_broadcast.start trace_id=%s actor_agent_id=%s", + trace_id, + actor_agent.id, + ) + gateway, config = await self.require_gateway_main_actor(actor_agent) + statement = ( + select(Board) + .where(col(Board.gateway_id) == gateway.id) + .order_by(col(Board.created_at).desc()) + ) + if payload.board_ids: + statement = statement.where(col(Board.id).in_(payload.board_ids)) + boards = list(await self.session.exec(statement)) + + results: list[GatewayLeadBroadcastBoardResult] = [] + sent = 0 + failed = 0 + + for board in boards: + message = self._build_gateway_lead_message( + board=board, + actor_agent_name=actor_agent.name, + kind=payload.kind, + content=payload.content, + correlation_id=payload.correlation_id, + reply_tags=payload.reply_tags, + reply_source=payload.reply_source, + ) + try: + lead, _lead_created = await self._ensure_and_message_board_lead( + gateway=gateway, + config=config, + board=board, + message=message, + ) + board_result = GatewayLeadBroadcastBoardResult( + board_id=board.id, + lead_agent_id=lead.id, + lead_agent_name=lead.name, + ok=True, + ) + sent += 1 + except (HTTPException, OpenClawGatewayError, TimeoutError, ValueError) as exc: + board_result = GatewayLeadBroadcastBoardResult( + board_id=board.id, + ok=False, + error=map_gateway_error_message( + GatewayOperation.LEAD_BROADCAST_DISPATCH, + exc, + ), + ) + failed += 1 + results.append(board_result) + + record_activity( + self.session, + event_type="gateway.main.lead_broadcast.sent", + message=f"Broadcast {payload.kind} to {sent} board leads (failed: {failed}).", + agent_id=actor_agent.id, + ) + await self.session.commit() + self.logger.info( + "gateway.coordination.lead_broadcast.success trace_id=%s actor_agent_id=%s sent=%s " + "failed=%s", + trace_id, + actor_agent.id, + sent, + failed, + ) + return GatewayLeadBroadcastResponse( + ok=True, + sent=sent, + failed=failed, + results=results, + ) diff --git a/backend/app/services/openclaw/onboarding_service.py b/backend/app/services/openclaw/onboarding_service.py new file mode 100644 index 00000000..9cc8351c --- /dev/null +++ b/backend/app/services/openclaw/onboarding_service.py @@ -0,0 +1,127 @@ +"""Board onboarding gateway messaging service.""" + +from __future__ import annotations + +from app.integrations.openclaw_gateway import OpenClawGatewayError +from app.models.board_onboarding import BoardOnboardingSession +from app.models.boards import Board +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.shared import ( + GatewayAgentIdentity, + require_gateway_config_for_board, + resolve_trace_id, +) + + +class BoardOnboardingMessagingService(AbstractGatewayMessagingService): + """Gateway message dispatch helpers for onboarding routes.""" + + async def dispatch_start_prompt( + self, + *, + board: Board, + prompt: str, + correlation_id: str | None = None, + ) -> str: + trace_id = resolve_trace_id(correlation_id, prefix="onboarding.start") + self.logger.log( + 5, + "gateway.onboarding.start_dispatch.start trace_id=%s board_id=%s", + trace_id, + board.id, + ) + gateway, config = await require_gateway_config_for_board(self.session, board) + session_key = GatewayAgentIdentity.session_key(gateway) + try: + await self._dispatch_gateway_message( + session_key=session_key, + config=config, + agent_name="Gateway Agent", + message=prompt, + deliver=False, + ) + except (OpenClawGatewayError, TimeoutError) as exc: + self.logger.error( + "gateway.onboarding.start_dispatch.failed trace_id=%s board_id=%s error=%s", + trace_id, + board.id, + str(exc), + ) + raise map_gateway_error_to_http_exception( + GatewayOperation.ONBOARDING_START_DISPATCH, + exc, + ) from exc + except Exception as exc: # pragma: no cover - defensive guard + self.logger.critical( + "gateway.onboarding.start_dispatch.failed_unexpected trace_id=%s board_id=%s " + "error_type=%s error=%s", + trace_id, + board.id, + exc.__class__.__name__, + str(exc), + ) + raise + self.logger.info( + "gateway.onboarding.start_dispatch.success trace_id=%s board_id=%s session_key=%s", + trace_id, + board.id, + session_key, + ) + return session_key + + async def dispatch_answer( + self, + *, + board: Board, + onboarding: BoardOnboardingSession, + answer_text: str, + correlation_id: str | None = None, + ) -> None: + trace_id = resolve_trace_id(correlation_id, prefix="onboarding.answer") + self.logger.log( + 5, + "gateway.onboarding.answer_dispatch.start trace_id=%s board_id=%s onboarding_id=%s", + trace_id, + board.id, + onboarding.id, + ) + _gateway, config = await require_gateway_config_for_board(self.session, board) + try: + await self._dispatch_gateway_message( + session_key=onboarding.session_key, + config=config, + agent_name="Gateway Agent", + message=answer_text, + deliver=False, + ) + except (OpenClawGatewayError, TimeoutError) as exc: + self.logger.error( + "gateway.onboarding.answer_dispatch.failed trace_id=%s board_id=%s " + "onboarding_id=%s error=%s", + trace_id, + board.id, + onboarding.id, + str(exc), + ) + raise map_gateway_error_to_http_exception( + GatewayOperation.ONBOARDING_ANSWER_DISPATCH, + exc, + ) from exc + except Exception as exc: # pragma: no cover - defensive guard + self.logger.critical( + "gateway.onboarding.answer_dispatch.failed_unexpected trace_id=%s board_id=%s " + "onboarding_id=%s error_type=%s error=%s", + trace_id, + board.id, + onboarding.id, + exc.__class__.__name__, + str(exc), + ) + raise + self.logger.info( + "gateway.onboarding.answer_dispatch.success trace_id=%s board_id=%s onboarding_id=%s", + trace_id, + board.id, + onboarding.id, + ) diff --git a/backend/app/services/openclaw/services.py b/backend/app/services/openclaw/services.py index a74c21bd..bc8b7da6 100644 --- a/backend/app/services/openclaw/services.py +++ b/backend/app/services/openclaw/services.py @@ -1,2949 +1,42 @@ -"""High-level OpenClaw session, admin, agent, and coordination services.""" +"""Compatibility re-export for split OpenClaw service modules.""" -from __future__ import annotations - -import asyncio -import json -import logging -import re -from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable, Iterable -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar -from uuid import UUID, uuid4 - -from fastapi import HTTPException, Request, status -from sqlalchemy import asc, or_ -from sqlmodel import col, select -from sse_starlette.sse import EventSourceResponse - -from app.core.agent_tokens import generate_agent_token, hash_agent_token -from app.core.auth import AuthContext -from app.core.config import settings -from app.core.time import utcnow -from app.db import crud -from app.db.pagination import paginate -from app.db.session import async_session_maker -from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import ( - OpenClawGatewayError, - ensure_session, - get_chat_history, - openclaw_call, - send_message, +from app.services.openclaw.admin_service import ( + AbstractGatewayMainAgentManager, + DefaultGatewayMainAgentManager, + GatewayAdminLifecycleService, ) -from app.models.activity_events import ActivityEvent -from app.models.agents import Agent -from app.models.approvals import Approval -from app.models.board_onboarding import BoardOnboardingSession -from app.models.boards import Board -from app.models.gateways import Gateway -from app.models.organizations import Organization -from app.models.tasks import Task -from app.schemas.agents import ( - AgentCreate, - AgentHeartbeat, - AgentHeartbeatCreate, - AgentRead, - AgentUpdate, +from app.services.openclaw.agent_service import ( + AbstractProvisionExecution, + ActorContextLike, + AgentLifecycleService, + AgentUpdateOptions, + AgentUpdateProvisionRequest, + AgentUpdateProvisionTarget, + BoardAgentProvisionExecution, + MainAgentProvisionExecution, ) -from app.schemas.common import OkResponse -from app.schemas.gateway_api import ( - GatewayResolveQuery, - GatewaySessionHistoryResponse, - GatewaySessionMessageRequest, - GatewaySessionResponse, - GatewaySessionsResponse, - GatewaysStatusResponse, +from app.services.openclaw.coordination_service import ( + AbstractGatewayMessagingService, + GatewayCoordinationService, ) -from app.schemas.gateway_coordination import ( - GatewayLeadBroadcastBoardResult, - GatewayLeadBroadcastRequest, - GatewayLeadBroadcastResponse, - GatewayLeadMessageRequest, - GatewayLeadMessageResponse, - GatewayMainAskUserRequest, - GatewayMainAskUserResponse, -) -from app.schemas.gateways import GatewayTemplatesSyncResult -from app.services.activity_log import record_activity -from app.services.openclaw.constants import ( - AGENT_SESSION_PREFIX, - DEFAULT_HEARTBEAT_CONFIG, - OFFLINE_AFTER, -) -from app.services.openclaw.exceptions import ( - GatewayOperation, - map_gateway_error_message, - map_gateway_error_to_http_exception, -) -from app.services.openclaw.provisioning import ( - AgentProvisionRequest, - GatewayTemplateSyncOptions, - LeadAgentOptions, - LeadAgentRequest, - MainAgentProvisionRequest, - ProvisionOptions, - _agent_key, - _with_coordination_gateway_retry, - cleanup_agent, - ensure_board_lead_agent, - provision_agent, - provision_main_agent, - sync_gateway_templates, -) -from app.services.openclaw.shared import ( - GatewayAgentIdentity, - require_gateway_config_for_board, - resolve_trace_id, - send_gateway_agent_message, -) -from app.services.organizations import ( - OrganizationContext, - get_active_membership, - has_board_access, - is_org_admin, - list_accessible_board_ids, - require_board_access, -) - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Sequence - - from fastapi_pagination.limit_offset import LimitOffsetPage - from sqlalchemy.sql.elements import ColumnElement - from sqlmodel.ext.asyncio.session import AsyncSession - from sqlmodel.sql.expression import SelectOfScalar - - from app.models.users import User - - -_T = TypeVar("_T") - - -@dataclass(frozen=True, slots=True) -class GatewayTemplateSyncQuery: - """Sync options parsed from query args for gateway template operations.""" - - include_main: bool - reset_sessions: bool - rotate_tokens: bool - force_bootstrap: bool - board_id: UUID | None - - -class GatewaySessionService: - """Read/query gateway runtime session state for user-facing APIs.""" - - 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 - def to_resolve_query( - board_id: str | None, - gateway_url: str | None, - gateway_token: str | None, - ) -> GatewayResolveQuery: - return GatewayResolveQuery( - board_id=board_id, - gateway_url=gateway_url, - gateway_token=gateway_token, - ) - - @staticmethod - def as_object_list(value: object) -> list[object]: - if value is None: - return [] - if isinstance(value, list): - return value - if isinstance(value, (tuple, set)): - return list(value) - if isinstance(value, (str, bytes, dict)): - return [] - if isinstance(value, Iterable): - return list(value) - return [] - - async def resolve_gateway( - self, - params: GatewayResolveQuery, - *, - user: User | None = None, - ) -> tuple[Board | None, GatewayClientConfig, str | None]: - self.logger.log( - 5, - "gateway.resolve.start board_id=%s gateway_url=%s", - params.board_id, - params.gateway_url, - ) - if params.gateway_url: - return ( - None, - GatewayClientConfig(url=params.gateway_url, token=params.gateway_token), - None, - ) - if not params.board_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="board_id or gateway_url is required", - ) - board = await Board.objects.by_id(params.board_id).first(self.session) - if board is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Board not found", - ) - if user is not None: - await require_board_access(self.session, user=user, board=board, write=False) - if not board.gateway_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is required", - ) - gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) - if gateway is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is invalid", - ) - if not gateway.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway url is required", - ) - main_agent = ( - await Agent.objects.filter_by(gateway_id=gateway.id) - .filter(col(Agent.board_id).is_(None)) - .first(self.session) - ) - main_session = main_agent.openclaw_session_id if main_agent else None - return ( - board, - GatewayClientConfig(url=gateway.url, token=gateway.token), - main_session, - ) - - async def require_gateway( - self, - board_id: str | None, - *, - user: User | None = None, - ) -> tuple[Board, GatewayClientConfig, str | None]: - params = GatewayResolveQuery(board_id=board_id) - board, config, main_session = await self.resolve_gateway(params, user=user) - if board is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="board_id is required", - ) - return board, config, main_session - - async def list_sessions(self, config: GatewayClientConfig) -> list[dict[str, object]]: - sessions = await openclaw_call("sessions.list", config=config) - if isinstance(sessions, dict): - raw_items = self.as_object_list(sessions.get("sessions")) - else: - raw_items = self.as_object_list(sessions) - return [item for item in raw_items if isinstance(item, dict)] - - async def with_main_session( - self, - sessions_list: list[dict[str, object]], - *, - config: GatewayClientConfig, - main_session: str | None, - ) -> list[dict[str, object]]: - if not main_session or any(item.get("key") == main_session for item in sessions_list): - return sessions_list - try: - await ensure_session(main_session, config=config, label="Gateway Agent") - return await self.list_sessions(config) - except OpenClawGatewayError: - return sessions_list - - @staticmethod - def _require_same_org(board: Board | None, organization_id: UUID) -> None: - if board is not None and board.organization_id != organization_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - async def get_status( - self, - *, - params: GatewayResolveQuery, - organization_id: UUID, - user: User | None, - ) -> GatewaysStatusResponse: - board, config, main_session = await self.resolve_gateway(params, user=user) - self._require_same_org(board, organization_id) - try: - sessions = await openclaw_call("sessions.list", config=config) - if isinstance(sessions, dict): - sessions_list = self.as_object_list(sessions.get("sessions")) - else: - sessions_list = self.as_object_list(sessions) - main_session_entry: object | None = None - main_session_error: str | None = None - if main_session: - try: - ensured = await ensure_session( - main_session, - config=config, - label="Gateway Agent", - ) - if isinstance(ensured, dict): - main_session_entry = ensured.get("entry") or ensured - except OpenClawGatewayError as exc: - main_session_error = str(exc) - return GatewaysStatusResponse( - connected=True, - gateway_url=config.url, - sessions_count=len(sessions_list), - sessions=sessions_list, - main_session=main_session_entry, - main_session_error=main_session_error, - ) - except OpenClawGatewayError as exc: - return GatewaysStatusResponse( - connected=False, - gateway_url=config.url, - error=str(exc), - ) - - async def get_sessions( - self, - *, - board_id: str | None, - organization_id: UUID, - user: User | None, - ) -> GatewaySessionsResponse: - params = GatewayResolveQuery(board_id=board_id) - board, config, main_session = await self.resolve_gateway(params, user=user) - self._require_same_org(board, organization_id) - try: - sessions = await openclaw_call("sessions.list", config=config) - except OpenClawGatewayError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - if isinstance(sessions, dict): - sessions_list = self.as_object_list(sessions.get("sessions")) - else: - sessions_list = self.as_object_list(sessions) - - main_session_entry: object | None = None - if main_session: - try: - ensured = await ensure_session( - main_session, - config=config, - label="Gateway Agent", - ) - if isinstance(ensured, dict): - main_session_entry = ensured.get("entry") or ensured - except OpenClawGatewayError: - main_session_entry = None - return GatewaySessionsResponse(sessions=sessions_list, main_session=main_session_entry) - - async def get_session( - self, - *, - session_id: str, - board_id: str | None, - organization_id: UUID, - user: User | None, - ) -> GatewaySessionResponse: - params = GatewayResolveQuery(board_id=board_id) - board, config, main_session = await self.resolve_gateway(params, user=user) - self._require_same_org(board, organization_id) - try: - sessions_list = await self.list_sessions(config) - except OpenClawGatewayError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - sessions_list = await self.with_main_session( - sessions_list, - config=config, - main_session=main_session, - ) - session_entry = next( - (item for item in sessions_list if item.get("key") == session_id), None - ) - if session_entry is None and main_session and session_id == main_session: - try: - ensured = await ensure_session( - main_session, - config=config, - label="Gateway Agent", - ) - if isinstance(ensured, dict): - session_entry = ensured.get("entry") or ensured - except OpenClawGatewayError: - session_entry = None - if session_entry is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Session not found", - ) - return GatewaySessionResponse(session=session_entry) - - async def get_session_history( - self, - *, - session_id: str, - board_id: str | None, - organization_id: UUID, - user: User | None, - ) -> GatewaySessionHistoryResponse: - board, config, _ = await self.require_gateway(board_id, user=user) - self._require_same_org(board, organization_id) - try: - history = await get_chat_history(session_id, config=config) - except OpenClawGatewayError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - if isinstance(history, dict) and isinstance(history.get("messages"), list): - return GatewaySessionHistoryResponse(history=history["messages"]) - return GatewaySessionHistoryResponse(history=self.as_object_list(history)) - - async def send_session_message( - self, - *, - session_id: str, - payload: GatewaySessionMessageRequest, - board_id: str | None, - user: User | None, - ) -> None: - board, config, main_session = await self.require_gateway(board_id, user=user) - if user is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - await require_board_access(self.session, user=user, board=board, write=True) - try: - if main_session and session_id == main_session: - await ensure_session(main_session, config=config, label="Gateway Agent") - await send_message(payload.content, session_key=session_id, config=config) - except OpenClawGatewayError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - - -class AbstractGatewayMainAgentManager(ABC): - """Abstract manager for gateway-main agent naming/profile behavior.""" - - @abstractmethod - def build_main_agent_name(self, gateway: Gateway) -> str: - raise NotImplementedError - - @abstractmethod - def build_identity_profile(self) -> dict[str, str]: - raise NotImplementedError - - -class DefaultGatewayMainAgentManager(AbstractGatewayMainAgentManager): - """Default naming/profile strategy for gateway-main agents.""" - - def build_main_agent_name(self, gateway: Gateway) -> str: - return f"{gateway.name} Gateway Agent" - - def build_identity_profile(self) -> dict[str, str]: - return { - "role": "Gateway Agent", - "communication_style": "direct, concise, practical", - "emoji": ":compass:", - } - - -class GatewayAdminLifecycleService: - """Write-side gateway lifecycle service (CRUD, main agent, template sync).""" - - def __init__( - self, - session: AsyncSession, - *, - main_agent_manager: AbstractGatewayMainAgentManager | None = None, - ) -> None: - self._session = session - self._logger = logging.getLogger(__name__) - 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 - def main_agent_manager(self) -> AbstractGatewayMainAgentManager: - return self._main_agent_manager - - @main_agent_manager.setter - def main_agent_manager(self, value: AbstractGatewayMainAgentManager) -> None: - self._main_agent_manager = value - - async def require_gateway( - self, - *, - gateway_id: UUID, - organization_id: UUID, - ) -> Gateway: - gateway = ( - await Gateway.objects.by_id(gateway_id) - .filter(col(Gateway.organization_id) == organization_id) - .first(self.session) - ) - if gateway is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Gateway not found", - ) - return gateway - - async def find_main_agent(self, gateway: Gateway) -> Agent | None: - return ( - await Agent.objects.filter_by(gateway_id=gateway.id) - .filter(col(Agent.board_id).is_(None)) - .first(self.session) - ) - - @staticmethod - def extract_agent_id_from_entry(item: object) -> str | None: - if isinstance(item, str): - value = item.strip() - return value or None - if not isinstance(item, dict): - return None - for key in ("id", "agentId", "agent_id"): - raw = item.get(key) - if isinstance(raw, str) and raw.strip(): - return raw.strip() - return None - - @staticmethod - def extract_agents_list(payload: object) -> list[object]: - if isinstance(payload, list): - return [item for item in payload] - if not isinstance(payload, dict): - return [] - agents = payload.get("agents") or [] - if not isinstance(agents, list): - return [] - return [item for item in agents] - - async def upsert_main_agent_record(self, gateway: Gateway) -> tuple[Agent, bool]: - changed = False - session_key = GatewayAgentIdentity.session_key(gateway) - agent = await self.find_main_agent(gateway) - main_agent_name = self.main_agent_manager.build_main_agent_name(gateway) - identity_profile = self.main_agent_manager.build_identity_profile() - if agent is None: - agent = Agent( - name=main_agent_name, - status="provisioning", - board_id=None, - gateway_id=gateway.id, - is_board_lead=False, - openclaw_session_id=session_key, - heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(), - identity_profile=identity_profile, - ) - self.session.add(agent) - changed = True - if agent.board_id is not None: - agent.board_id = None - changed = True - if agent.gateway_id != gateway.id: - agent.gateway_id = gateway.id - changed = True - if agent.is_board_lead: - agent.is_board_lead = False - changed = True - if agent.name != main_agent_name: - agent.name = main_agent_name - changed = True - if agent.openclaw_session_id != session_key: - agent.openclaw_session_id = session_key - changed = True - if agent.heartbeat_config is None: - agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() - changed = True - if agent.identity_profile is None: - agent.identity_profile = identity_profile - changed = True - if not agent.status: - agent.status = "provisioning" - changed = True - if changed: - agent.updated_at = utcnow() - self.session.add(agent) - return agent, changed - - async def gateway_has_main_agent_entry(self, gateway: Gateway) -> bool: - if not gateway.url: - return False - config = GatewayClientConfig(url=gateway.url, token=gateway.token) - target_id = GatewayAgentIdentity.openclaw_agent_id(gateway) - try: - payload = await openclaw_call("agents.list", config=config) - except OpenClawGatewayError: - return True - for item in self.extract_agents_list(payload): - if self.extract_agent_id_from_entry(item) == target_id: - return True - return False - - async def provision_main_agent_record( - self, - gateway: Gateway, - agent: Agent, - *, - user: User | None, - action: str, - notify: bool, - ) -> Agent: - session_key = GatewayAgentIdentity.session_key(gateway) - raw_token = generate_agent_token() - agent.agent_token_hash = hash_agent_token(raw_token) - agent.provision_requested_at = utcnow() - agent.provision_action = action - agent.updated_at = utcnow() - if agent.heartbeat_config is None: - agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() - self.session.add(agent) - await self.session.commit() - await self.session.refresh(agent) - if not gateway.url: - return agent - try: - await provision_main_agent( - agent, - MainAgentProvisionRequest( - gateway=gateway, - auth_token=raw_token, - user=user, - session_key=session_key, - options=ProvisionOptions(action=action), - ), - ) - await ensure_session( - session_key, - config=GatewayClientConfig(url=gateway.url, token=gateway.token), - label=agent.name, - ) - if notify: - await send_message( - ( - f"Hello {agent.name}. Your gateway provisioning was updated.\n\n" - "Please re-read AGENTS.md, USER.md, HEARTBEAT.md, and TOOLS.md. " - "If BOOTSTRAP.md exists, run it once then delete it. " - "Begin heartbeats after startup." - ), - session_key=session_key, - config=GatewayClientConfig(url=gateway.url, token=gateway.token), - deliver=True, - ) - self.logger.info( - "gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s", - gateway.id, - agent.id, - action, - ) - except OpenClawGatewayError as exc: - self.logger.warning( - "gateway.main_agent.provision_failed_gateway gateway_id=%s agent_id=%s error=%s", - gateway.id, - agent.id, - str(exc), - ) - except (OSError, RuntimeError, ValueError) as exc: - self.logger.error( - "gateway.main_agent.provision_failed gateway_id=%s agent_id=%s error=%s", - gateway.id, - agent.id, - str(exc), - ) - except Exception as exc: # pragma: no cover - defensive fallback - self.logger.critical( - "gateway.main_agent.provision_failed_unexpected gateway_id=%s agent_id=%s " - "error_type=%s error=%s", - gateway.id, - agent.id, - exc.__class__.__name__, - str(exc), - ) - return agent - - async def ensure_main_agent( - self, - gateway: Gateway, - auth: AuthContext, - *, - action: str = "provision", - ) -> Agent: - self.logger.log( - 5, - "gateway.main_agent.ensure.start gateway_id=%s action=%s", - gateway.id, - action, - ) - agent, _ = await self.upsert_main_agent_record(gateway) - return await self.provision_main_agent_record( - gateway, - agent, - user=auth.user, - action=action, - notify=True, - ) - - async def ensure_gateway_agents_exist(self, gateways: list[Gateway]) -> None: - for gateway in gateways: - agent, gateway_changed = await self.upsert_main_agent_record(gateway) - has_gateway_entry = await self.gateway_has_main_agent_entry(gateway) - needs_provision = ( - gateway_changed or not bool(agent.agent_token_hash) or not has_gateway_entry - ) - if needs_provision: - await self.provision_main_agent_record( - gateway, - agent, - user=None, - action="provision", - notify=False, - ) - - async def clear_agent_foreign_keys(self, *, agent_id: UUID) -> None: - now = utcnow() - await crud.update_where( - self.session, - Task, - col(Task.assigned_agent_id) == agent_id, - col(Task.status) == "in_progress", - assigned_agent_id=None, - status="inbox", - in_progress_at=None, - updated_at=now, - commit=False, - ) - await crud.update_where( - self.session, - Task, - col(Task.assigned_agent_id) == agent_id, - col(Task.status) != "in_progress", - assigned_agent_id=None, - updated_at=now, - commit=False, - ) - await crud.update_where( - self.session, - ActivityEvent, - col(ActivityEvent.agent_id) == agent_id, - agent_id=None, - commit=False, - ) - await crud.update_where( - self.session, - Approval, - col(Approval.agent_id) == agent_id, - agent_id=None, - commit=False, - ) - - async def sync_templates( - self, - gateway: Gateway, - *, - query: GatewayTemplateSyncQuery, - auth: AuthContext, - ) -> GatewayTemplatesSyncResult: - self.logger.log( - 5, - "gateway.templates.sync.start gateway_id=%s include_main=%s", - gateway.id, - query.include_main, - ) - await self.ensure_gateway_agents_exist([gateway]) - result = await sync_gateway_templates( - self.session, - gateway, - GatewayTemplateSyncOptions( - user=auth.user, - include_main=query.include_main, - reset_sessions=query.reset_sessions, - rotate_tokens=query.rotate_tokens, - force_bootstrap=query.force_bootstrap, - board_id=query.board_id, - ), - ) - self.logger.info("gateway.templates.sync.success gateway_id=%s", gateway.id) - return result - - -class ActorContextLike(Protocol): - """Minimal actor context contract consumed by lifecycle APIs.""" - - actor_type: Literal["user", "agent"] - user: User | None - agent: Agent | None - - -@dataclass(frozen=True, slots=True) -class AgentUpdateOptions: - """Runtime options for update-and-reprovision flows.""" - - force: bool - user: User | None - context: OrganizationContext - - -@dataclass(frozen=True, slots=True) -class AgentUpdateProvisionTarget: - """Resolved target for an update provision operation.""" - - is_main_agent: bool - board: Board | None - gateway: Gateway - client_config: GatewayClientConfig - - -@dataclass(frozen=True, slots=True) -class AgentUpdateProvisionRequest: - """Provision request payload for agent updates.""" - - target: AgentUpdateProvisionTarget - raw_token: str - user: User | None - force_bootstrap: bool - - -class AbstractProvisionExecution(ABC): - """Shared async execution contract for board/main agent provisioning actions.""" - - def __init__( - self, - *, - service: AgentLifecycleService, - agent: Agent, - provision_request: AgentUpdateProvisionRequest, - action: str, - wakeup_verb: str, - raise_gateway_errors: bool, - ) -> None: - self._service = service - self._agent = agent - self._request = provision_request - self._action = action - self._wakeup_verb = wakeup_verb - self._raise_gateway_errors = raise_gateway_errors - - @property - def agent(self) -> Agent: - return self._agent - - @agent.setter - def agent(self, value: Agent) -> None: - if not isinstance(value, Agent): - msg = "agent must be an Agent model" - raise TypeError(msg) - self._agent = value - - @property - def request(self) -> AgentUpdateProvisionRequest: - return self._request - - @request.setter - def request(self, value: AgentUpdateProvisionRequest) -> None: - if not isinstance(value, AgentUpdateProvisionRequest): - msg = "request must be an AgentUpdateProvisionRequest" - raise TypeError(msg) - self._request = value - - @property - def logger(self) -> logging.Logger: - return self._service.logger - - @abstractmethod - async def _provision(self) -> None: - raise NotImplementedError - - async def execute(self) -> None: - self.logger.log( - 5, - "agent.provision.start action=%s agent_id=%s target_main=%s", - self._action, - self.agent.id, - self.request.target.is_main_agent, - ) - try: - await self._provision() - await self._service.send_wakeup_message( - self.agent, - self.request.target.client_config, - verb=self._wakeup_verb, - ) - self.agent.provision_confirm_token_hash = None - self.agent.provision_requested_at = None - self.agent.provision_action = None - self.agent.status = "online" - self.agent.updated_at = utcnow() - self._service.session.add(self.agent) - await self._service.session.commit() - record_activity( - self._service.session, - event_type=f"agent.{self._action}.direct", - message=f"{self._action.capitalize()}d directly for {self.agent.name}.", - agent_id=self.agent.id, - ) - record_activity( - self._service.session, - event_type="agent.wakeup.sent", - message=f"Wakeup message sent to {self.agent.name}.", - agent_id=self.agent.id, - ) - await self._service.session.commit() - self.logger.info( - "agent.provision.success action=%s agent_id=%s", - self._action, - self.agent.id, - ) - except OpenClawGatewayError as exc: - self._service.record_instruction_failure( - self._service.session, - self.agent, - str(exc), - self._action, - ) - await self._service.session.commit() - self.logger.error( - "agent.provision.gateway_error action=%s agent_id=%s error=%s", - self._action, - self.agent.id, - str(exc), - ) - if self._raise_gateway_errors: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Gateway {self._action} failed: {exc}", - ) from exc - except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover - self._service.record_instruction_failure( - self._service.session, - self.agent, - str(exc), - self._action, - ) - await self._service.session.commit() - self.logger.critical( - "agent.provision.runtime_error action=%s agent_id=%s error=%s", - self._action, - self.agent.id, - str(exc), - ) - if self._raise_gateway_errors: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Unexpected error {self._action}ing agent provisioning.", - ) from exc - - -class BoardAgentProvisionExecution(AbstractProvisionExecution): - """Provision execution for board-scoped agents.""" - - async def _provision(self) -> None: - board = self.request.target.board - if board is None: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="board is required for non-main agent provisioning", - ) - await provision_agent( - self.agent, - AgentProvisionRequest( - board=board, - gateway=self.request.target.gateway, - auth_token=self.request.raw_token, - user=self.request.user, - options=ProvisionOptions( - action=self._action, - force_bootstrap=self.request.force_bootstrap, - reset_session=True, - ), - ), - ) - - -class MainAgentProvisionExecution(AbstractProvisionExecution): - """Provision execution for gateway-main agents.""" - - async def _provision(self) -> None: - await provision_main_agent( - self.agent, - MainAgentProvisionRequest( - gateway=self.request.target.gateway, - auth_token=self.request.raw_token, - user=self.request.user, - session_key=self.agent.openclaw_session_id, - options=ProvisionOptions( - action=self._action, - force_bootstrap=self.request.force_bootstrap, - reset_session=True, - ), - ), - ) - - -class AgentLifecycleService: - """Async service encapsulating agent lifecycle behavior for API routes.""" - - 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 - def parse_since(value: str | None) -> datetime | None: - if not value: - return None - normalized = value.strip() - if not normalized: - return None - normalized = normalized.replace("Z", "+00:00") - try: - parsed = datetime.fromisoformat(normalized) - except ValueError: - return None - if parsed.tzinfo is not None: - return parsed.astimezone(UTC).replace(tzinfo=None) - return parsed - - @staticmethod - def slugify(value: str) -> str: - slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") - return slug or uuid4().hex - - @classmethod - def build_session_key(cls, agent_name: str) -> str: - return f"{AGENT_SESSION_PREFIX}:{cls.slugify(agent_name)}:main" - - @classmethod - def workspace_path(cls, agent_name: str, workspace_root: str | None) -> str: - if not workspace_root: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway workspace_root is required", - ) - root = workspace_root.rstrip("/") - return f"{root}/workspace-{cls.slugify(agent_name)}" - - async def require_board( - self, - board_id: UUID | str | None, - *, - user: User | None = None, - write: bool = False, - ) -> Board: - if not board_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="board_id is required", - ) - board = await Board.objects.by_id(board_id).first(self.session) - if board is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Board not found", - ) - if user is not None: - await require_board_access(self.session, user=user, board=board, write=write) - return board - - async def require_gateway( - self, - board: Board, - ) -> tuple[Gateway, GatewayClientConfig]: - if not board.gateway_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is required", - ) - gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) - if gateway is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is invalid", - ) - if gateway.organization_id != board.organization_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is invalid", - ) - if not gateway.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway url is required", - ) - if not gateway.workspace_root: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway workspace_root is required", - ) - return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) - - @staticmethod - def gateway_client_config(gateway: Gateway) -> GatewayClientConfig: - if not gateway.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway url is required", - ) - return GatewayClientConfig(url=gateway.url, token=gateway.token) - - @staticmethod - def is_gateway_main(agent: Agent) -> bool: - return agent.board_id is None - - @classmethod - def to_agent_read(cls, agent: Agent) -> AgentRead: - model = AgentRead.model_validate(agent, from_attributes=True) - return model.model_copy( - update={"is_gateway_main": cls.is_gateway_main(agent)}, - ) - - @staticmethod - def coerce_agent_items(items: Sequence[Any]) -> list[Agent]: - agents: list[Agent] = [] - for item in items: - if not isinstance(item, Agent): - msg = "Expected Agent items from paginated query" - raise TypeError(msg) - agents.append(item) - return agents - - async def get_main_agent_gateway(self, agent: Agent) -> Gateway | None: - if agent.board_id is not None: - return None - return await Gateway.objects.by_id(agent.gateway_id).first(self.session) - - async def ensure_gateway_session( - self, - agent_name: str, - config: GatewayClientConfig, - ) -> tuple[str, str | None]: - session_key = self.build_session_key(agent_name) - try: - await ensure_session(session_key, config=config, label=agent_name) - except OpenClawGatewayError as exc: - self.logger.warning( - "agent.session.ensure_failed agent_name=%s error=%s", - agent_name, - str(exc), - ) - return session_key, str(exc) - return session_key, None - - @classmethod - def with_computed_status(cls, agent: Agent) -> Agent: - now = utcnow() - if agent.status in {"deleting", "updating"}: - return agent - if agent.last_seen_at is None: - agent.status = "provisioning" - elif now - agent.last_seen_at > OFFLINE_AFTER: - agent.status = "offline" - return agent - - @classmethod - def serialize_agent(cls, agent: Agent) -> dict[str, object]: - return cls.to_agent_read(cls.with_computed_status(agent)).model_dump(mode="json") - - async def fetch_agent_events( - self, - board_id: UUID | None, - since: datetime, - ) -> list[Agent]: - statement = select(Agent) - if board_id: - statement = statement.where(col(Agent.board_id) == board_id) - statement = statement.where( - or_( - col(Agent.updated_at) >= since, - col(Agent.last_seen_at) >= since, - ), - ).order_by(asc(col(Agent.updated_at))) - return list(await self.session.exec(statement)) - - async def require_user_context(self, user: User | None) -> OrganizationContext: - if user is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - member = await get_active_membership(self.session, user) - if member is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - organization = await Organization.objects.by_id(member.organization_id).first(self.session) - if organization is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - return OrganizationContext(organization=organization, member=member) - - async def require_agent_access( - self, - *, - agent: Agent, - ctx: OrganizationContext, - write: bool, - ) -> None: - if agent.board_id is None: - if not is_org_admin(ctx.member): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - gateway = await self.get_main_agent_gateway(agent) - if gateway is None or gateway.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - return - - board = await Board.objects.by_id(agent.board_id).first(self.session) - if board is None or board.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if not await has_board_access(self.session, member=ctx.member, board=board, write=write): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - @staticmethod - def record_heartbeat(session: AsyncSession, agent: Agent) -> None: - record_activity( - session, - event_type="agent.heartbeat", - message=f"Heartbeat received from {agent.name}.", - agent_id=agent.id, - ) - - @staticmethod - def record_instruction_failure( - session: AsyncSession, - agent: Agent, - error: str, - action: str, - ) -> None: - action_label = action.replace("_", " ").capitalize() - record_activity( - session, - event_type=f"agent.{action}.failed", - message=f"{action_label} message failed: {error}", - agent_id=agent.id, - ) - - async def coerce_agent_create_payload( - self, - payload: AgentCreate, - actor: ActorContextLike, - ) -> AgentCreate: - if actor.actor_type == "user": - ctx = await self.require_user_context(actor.user) - if not is_org_admin(ctx.member): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - return payload - - if actor.actor_type == "agent": - if not actor.agent or not actor.agent.is_board_lead: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only board leads can create agents", - ) - if not actor.agent.board_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Board lead must be assigned to a board", - ) - if payload.board_id and payload.board_id != actor.agent.board_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Board leads can only create agents in their own board", - ) - return AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id}) - - return payload - - async def ensure_unique_agent_name( - self, - *, - board: Board, - gateway: Gateway, - requested_name: str, - ) -> None: - if not requested_name: - return - - existing = ( - await self.session.exec( - select(Agent) - .where(Agent.board_id == board.id) - .where(col(Agent.name).ilike(requested_name)), - ) - ).first() - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="An agent with this name already exists on this board.", - ) - - existing_gateway = ( - await self.session.exec( - select(Agent) - .join(Board, col(Agent.board_id) == col(Board.id)) - .where(col(Board.gateway_id) == gateway.id) - .where(col(Agent.name).ilike(requested_name)), - ) - ).first() - if existing_gateway: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="An agent with this name already exists in this gateway workspace.", - ) - - desired_session_key = self.build_session_key(requested_name) - existing_session_key = ( - await self.session.exec( - select(Agent) - .join(Board, col(Agent.board_id) == col(Board.id)) - .where(col(Board.gateway_id) == gateway.id) - .where(col(Agent.openclaw_session_id) == desired_session_key), - ) - ).first() - if existing_session_key: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=( - "This agent name would collide with an existing workspace " - "session key. Pick a different name." - ), - ) - - async def persist_new_agent( - self, - *, - data: dict[str, Any], - client_config: GatewayClientConfig, - ) -> tuple[Agent, str, str | None]: - agent = Agent.model_validate(data) - agent.status = "provisioning" - raw_token = generate_agent_token() - 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" - session_key, session_error = await self.ensure_gateway_session( - agent.name, - client_config, - ) - agent.openclaw_session_id = session_key - self.session.add(agent) - await self.session.commit() - await self.session.refresh(agent) - return agent, raw_token, session_error - - async def record_session_creation( - self, - *, - agent: Agent, - session_error: str | None, - ) -> None: - if session_error: - record_activity( - self.session, - event_type="agent.session.failed", - message=f"Session sync failed for {agent.name}: {session_error}", - agent_id=agent.id, - ) - else: - record_activity( - self.session, - event_type="agent.session.created", - message=f"Session created for {agent.name}.", - agent_id=agent.id, - ) - await self.session.commit() - - async def send_wakeup_message( - self, - agent: Agent, - config: GatewayClientConfig, - verb: str = "provisioned", - ) -> None: - session_key = agent.openclaw_session_id or self.build_session_key(agent.name) - await ensure_session(session_key, config=config, label=agent.name) - message = ( - f"Hello {agent.name}. Your workspace has been {verb}.\n\n" - "Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once " - "then delete it. Begin heartbeats after startup." - ) - await send_message(message, session_key=session_key, config=config, deliver=True) - - async def provision_new_agent( - self, - *, - agent: Agent, - request: AgentProvisionRequest, - client_config: GatewayClientConfig, - ) -> None: - execution = BoardAgentProvisionExecution( - service=self, - agent=agent, - provision_request=AgentUpdateProvisionRequest( - target=AgentUpdateProvisionTarget( - is_main_agent=False, - board=request.board, - gateway=request.gateway, - client_config=client_config, - ), - raw_token=request.auth_token, - user=request.user, - force_bootstrap=request.options.force_bootstrap, - ), - action="provision", - wakeup_verb="provisioned", - raise_gateway_errors=False, - ) - await execution.execute() - - async def validate_agent_update_inputs( - self, - *, - ctx: OrganizationContext, - updates: dict[str, Any], - make_main: bool | None, - ) -> None: - if make_main and not is_org_admin(ctx.member): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - if "status" in updates: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="status is controlled by agent heartbeat", - ) - if "board_id" in updates and updates["board_id"] is not None: - new_board = await self.require_board(updates["board_id"]) - if new_board.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if not await has_board_access( - self.session, - member=ctx.member, - board=new_board, - write=True, - ): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - async def apply_agent_update_mutations( - self, - *, - agent: Agent, - updates: dict[str, Any], - make_main: bool | None, - ) -> tuple[Gateway | None, Gateway | None]: - main_gateway = await self.get_main_agent_gateway(agent) - gateway_for_main: Gateway | None = None - - if make_main: - board_source = updates.get("board_id") or agent.board_id - board_for_main = await self.require_board(board_source) - gateway_for_main, _ = await self.require_gateway(board_for_main) - updates["board_id"] = None - updates["gateway_id"] = gateway_for_main.id - agent.is_board_lead = False - agent.openclaw_session_id = GatewayAgentIdentity.session_key(gateway_for_main) - main_gateway = gateway_for_main - elif make_main is not None: - if "board_id" not in updates or updates["board_id"] is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=( - "board_id is required when converting a gateway-main agent " - "to board scope" - ), - ) - board = await self.require_board(updates["board_id"]) - if board.gateway_id is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is required", - ) - updates["gateway_id"] = board.gateway_id - agent.openclaw_session_id = None - - if make_main is None and "board_id" in updates: - board = await self.require_board(updates["board_id"]) - if board.gateway_id is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is required", - ) - updates["gateway_id"] = board.gateway_id - for key, value in updates.items(): - setattr(agent, key, value) - - if make_main is None and main_gateway is not None: - agent.board_id = None - agent.gateway_id = main_gateway.id - agent.is_board_lead = False - if make_main is False and agent.board_id is not None: - board = await self.require_board(agent.board_id) - if board.gateway_id is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is required", - ) - agent.gateway_id = board.gateway_id - agent.updated_at = utcnow() - if agent.heartbeat_config is None: - agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() - self.session.add(agent) - await self.session.commit() - await self.session.refresh(agent) - return main_gateway, gateway_for_main - - async def resolve_agent_update_target( - self, - *, - agent: Agent, - make_main: bool | None, - main_gateway: Gateway | None, - gateway_for_main: Gateway | None, - ) -> AgentUpdateProvisionTarget: - if make_main: - if gateway_for_main is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway agent requires a gateway configuration", - ) - return AgentUpdateProvisionTarget( - is_main_agent=True, - board=None, - gateway=gateway_for_main, - client_config=self.gateway_client_config(gateway_for_main), - ) - - if make_main is None and agent.board_id is None and main_gateway is not None: - return AgentUpdateProvisionTarget( - is_main_agent=True, - board=None, - gateway=main_gateway, - client_config=self.gateway_client_config(main_gateway), - ) - - if agent.board_id is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="board_id is required for non-main agents", - ) - board = await self.require_board(agent.board_id) - gateway, client_config = await self.require_gateway(board) - return AgentUpdateProvisionTarget( - is_main_agent=False, - board=board, - gateway=gateway, - client_config=client_config, - ) - - async def ensure_agent_update_session( - self, - *, - agent: Agent, - client_config: GatewayClientConfig, - ) -> None: - session_key = agent.openclaw_session_id or self.build_session_key(agent.name) - try: - await ensure_session(session_key, config=client_config, label=agent.name) - if not agent.openclaw_session_id: - agent.openclaw_session_id = session_key - self.session.add(agent) - await self.session.commit() - await self.session.refresh(agent) - except OpenClawGatewayError as exc: - self.record_instruction_failure(self.session, agent, str(exc), "update") - await self.session.commit() - - @staticmethod - def mark_agent_update_pending(agent: Agent) -> str: - raw_token = generate_agent_token() - agent.agent_token_hash = hash_agent_token(raw_token) - agent.provision_requested_at = utcnow() - agent.provision_action = "update" - agent.status = "updating" - return raw_token - - async def provision_updated_agent( - self, - *, - agent: Agent, - request: AgentUpdateProvisionRequest, - ) -> None: - execution: AbstractProvisionExecution - if request.target.is_main_agent: - execution = MainAgentProvisionExecution( - service=self, - agent=agent, - provision_request=request, - action="update", - wakeup_verb="updated", - raise_gateway_errors=True, - ) - else: - execution = BoardAgentProvisionExecution( - service=self, - agent=agent, - provision_request=request, - action="update", - wakeup_verb="updated", - raise_gateway_errors=True, - ) - await execution.execute() - - @staticmethod - def heartbeat_lookup_statement(payload: AgentHeartbeatCreate) -> SelectOfScalar[Agent]: - statement = Agent.objects.filter_by(name=payload.name).statement - if payload.board_id is not None: - statement = statement.where(Agent.board_id == payload.board_id) - return statement - - async def create_agent_from_heartbeat( - self, - *, - payload: AgentHeartbeatCreate, - actor: ActorContextLike, - ) -> Agent: - if actor.actor_type == "agent": - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if actor.actor_type == "user": - ctx = await self.require_user_context(actor.user) - if not is_org_admin(ctx.member): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - board = await self.require_board( - payload.board_id, - user=actor.user, - write=True, - ) - gateway, client_config = await self.require_gateway(board) - data: dict[str, Any] = { - "name": payload.name, - "board_id": board.id, - "gateway_id": gateway.id, - "heartbeat_config": DEFAULT_HEARTBEAT_CONFIG.copy(), - } - agent, raw_token, session_error = await self.persist_new_agent( - data=data, - client_config=client_config, - ) - await self.record_session_creation( - agent=agent, - session_error=session_error, - ) - await self.provision_new_agent( - agent=agent, - request=AgentProvisionRequest( - board=board, - gateway=gateway, - auth_token=raw_token, - user=actor.user, - options=ProvisionOptions(action="provision"), - ), - client_config=client_config, - ) - return agent - - async def handle_existing_user_heartbeat_agent( - self, - *, - agent: Agent, - user: User | None, - ) -> None: - ctx = await self.require_user_context(user) - await self.require_agent_access(agent=agent, ctx=ctx, write=True) - - if agent.agent_token_hash is not None: - return - - raw_token = generate_agent_token() - 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" - self.session.add(agent) - await self.session.commit() - await self.session.refresh(agent) - board = await self.require_board( - str(agent.board_id) if agent.board_id else None, - user=user, - write=True, - ) - gateway, client_config = await self.require_gateway(board) - await self.provision_new_agent( - agent=agent, - request=AgentProvisionRequest( - board=board, - gateway=gateway, - auth_token=raw_token, - user=user, - options=ProvisionOptions(action="provision"), - ), - client_config=client_config, - ) - - async def ensure_heartbeat_session_key( - self, - *, - agent: Agent, - actor: ActorContextLike, - ) -> None: - if agent.openclaw_session_id: - return - board = await self.require_board( - str(agent.board_id) if agent.board_id else None, - user=actor.user if actor.actor_type == "user" else None, - write=actor.actor_type == "user", - ) - _, client_config = await self.require_gateway(board) - session_key, session_error = await self.ensure_gateway_session( - agent.name, - client_config, - ) - agent.openclaw_session_id = session_key - self.session.add(agent) - await self.record_session_creation( - agent=agent, - session_error=session_error, - ) - - async def commit_heartbeat( - self, - *, - agent: Agent, - status_value: str | None, - ) -> AgentRead: - if status_value: - agent.status = status_value - elif agent.status == "provisioning": - agent.status = "online" - agent.last_seen_at = utcnow() - agent.updated_at = utcnow() - self.record_heartbeat(self.session, agent) - self.session.add(agent) - await self.session.commit() - await self.session.refresh(agent) - return self.to_agent_read(self.with_computed_status(agent)) - - async def list_agents( - self, - *, - board_id: UUID | None, - gateway_id: UUID | None, - ctx: OrganizationContext, - ) -> LimitOffsetPage[AgentRead]: - board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False) - if board_id is not None and board_id not in set(board_ids): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - base_filters: list[ColumnElement[bool]] = [] - if board_ids: - base_filters.append(col(Agent.board_id).in_(board_ids)) - if is_org_admin(ctx.member): - gateways = await Gateway.objects.filter_by( - organization_id=ctx.organization.id, - ).all(self.session) - gateway_ids = [gateway.id for gateway in gateways] - if gateway_ids: - base_filters.append( - (col(Agent.gateway_id).in_(gateway_ids)) & (col(Agent.board_id).is_(None)), - ) - if base_filters: - if len(base_filters) == 1: - statement = select(Agent).where(base_filters[0]) - else: - statement = select(Agent).where(or_(*base_filters)) - else: - statement = select(Agent).where(col(Agent.id).is_(None)) - if board_id is not None: - statement = statement.where(col(Agent.board_id) == board_id) - if gateway_id is not None: - gateway = await Gateway.objects.by_id(gateway_id).first(self.session) - if gateway is None or gateway.organization_id != ctx.organization.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - gateway_board_ids = select(Board.id).where(col(Board.gateway_id) == gateway_id) - statement = statement.where( - or_( - col(Agent.board_id).in_(gateway_board_ids), - (col(Agent.gateway_id) == gateway_id) & (col(Agent.board_id).is_(None)), - ), - ) - statement = statement.order_by(col(Agent.created_at).desc()) - - def _transform(items: Sequence[Any]) -> Sequence[Any]: - agents = self.coerce_agent_items(items) - return [self.to_agent_read(self.with_computed_status(agent)) for agent in agents] - - return await paginate(self.session, statement, transformer=_transform) - - async def stream_agents( - self, - *, - request: Request, - board_id: UUID | None, - since: str | None, - ctx: OrganizationContext, - ) -> EventSourceResponse: - since_dt = self.parse_since(since) or utcnow() - last_seen = since_dt - board_ids = await list_accessible_board_ids(self.session, member=ctx.member, write=False) - allowed_ids = set(board_ids) - if board_id is not None and board_id not in allowed_ids: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - async def event_generator() -> AsyncIterator[dict[str, str]]: - nonlocal last_seen - while True: - if await request.is_disconnected(): - break - async with async_session_maker() as stream_session: - stream_service = AgentLifecycleService(stream_session) - stream_service.logger = self.logger - if board_id is not None: - agents = await stream_service.fetch_agent_events( - board_id, - last_seen, - ) - elif allowed_ids: - agents = await stream_service.fetch_agent_events(None, last_seen) - agents = [agent for agent in agents if agent.board_id in allowed_ids] - else: - agents = [] - for agent in agents: - updated_at = agent.updated_at or agent.last_seen_at or utcnow() - last_seen = max(updated_at, last_seen) - payload = {"agent": self.serialize_agent(agent)} - yield {"event": "agent", "data": json.dumps(payload)} - await asyncio.sleep(2) - - return EventSourceResponse(event_generator(), ping=15) - - async def create_agent( - self, - *, - payload: AgentCreate, - actor: ActorContextLike, - ) -> AgentRead: - self.logger.log( - 5, - "agent.create.start actor_type=%s board_id=%s", - actor.actor_type, - payload.board_id, - ) - payload = await self.coerce_agent_create_payload(payload, actor) - - board = await self.require_board( - payload.board_id, - user=actor.user if actor.actor_type == "user" else None, - write=actor.actor_type == "user", - ) - gateway, client_config = await self.require_gateway(board) - data = payload.model_dump() - data["gateway_id"] = gateway.id - requested_name = (data.get("name") or "").strip() - await self.ensure_unique_agent_name( - board=board, - gateway=gateway, - requested_name=requested_name, - ) - agent, raw_token, session_error = await self.persist_new_agent( - data=data, - client_config=client_config, - ) - await self.record_session_creation( - agent=agent, - session_error=session_error, - ) - provision_request = AgentProvisionRequest( - board=board, - gateway=gateway, - auth_token=raw_token, - user=actor.user if actor.actor_type == "user" else None, - options=ProvisionOptions(action="provision"), - ) - await self.provision_new_agent( - agent=agent, - request=provision_request, - client_config=client_config, - ) - self.logger.info("agent.create.success agent_id=%s board_id=%s", agent.id, board.id) - return self.to_agent_read(self.with_computed_status(agent)) - - async def get_agent( - self, - *, - agent_id: str, - ctx: OrganizationContext, - ) -> AgentRead: - agent = await Agent.objects.by_id(agent_id).first(self.session) - if agent is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - await self.require_agent_access(agent=agent, ctx=ctx, write=False) - return self.to_agent_read(self.with_computed_status(agent)) - - async def update_agent( - self, - *, - agent_id: str, - payload: AgentUpdate, - options: AgentUpdateOptions, - ) -> AgentRead: - self.logger.log(5, "agent.update.start agent_id=%s force=%s", agent_id, options.force) - agent = await Agent.objects.by_id(agent_id).first(self.session) - if agent is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - await self.require_agent_access(agent=agent, ctx=options.context, write=True) - updates = payload.model_dump(exclude_unset=True) - make_main = updates.pop("is_gateway_main", None) - await self.validate_agent_update_inputs( - ctx=options.context, - updates=updates, - make_main=make_main, - ) - if not updates and not options.force and make_main is None: - return self.to_agent_read(self.with_computed_status(agent)) - main_gateway, gateway_for_main = await self.apply_agent_update_mutations( - agent=agent, - updates=updates, - make_main=make_main, - ) - target = await self.resolve_agent_update_target( - agent=agent, - make_main=make_main, - main_gateway=main_gateway, - gateway_for_main=gateway_for_main, - ) - await self.ensure_agent_update_session( - agent=agent, - client_config=target.client_config, - ) - raw_token = self.mark_agent_update_pending(agent) - self.session.add(agent) - await self.session.commit() - await self.session.refresh(agent) - provision_request = AgentUpdateProvisionRequest( - target=target, - raw_token=raw_token, - user=options.user, - force_bootstrap=options.force, - ) - await self.provision_updated_agent( - agent=agent, - request=provision_request, - ) - self.logger.info("agent.update.success agent_id=%s", agent.id) - return self.to_agent_read(self.with_computed_status(agent)) - - async def heartbeat_agent( - self, - *, - agent_id: str, - payload: AgentHeartbeat, - actor: ActorContextLike, - ) -> AgentRead: - self.logger.log( - 5, "agent.heartbeat.start agent_id=%s actor_type=%s", agent_id, actor.actor_type - ) - agent = await Agent.objects.by_id(agent_id).first(self.session) - if agent is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - if actor.actor_type == "user": - ctx = await self.require_user_context(actor.user) - if not is_org_admin(ctx.member): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - await self.require_agent_access(agent=agent, ctx=ctx, write=True) - return await self.commit_heartbeat( - agent=agent, - status_value=payload.status, - ) - - async def heartbeat_or_create_agent( - self, - *, - payload: AgentHeartbeatCreate, - actor: ActorContextLike, - ) -> AgentRead: - self.logger.log( - 5, - "agent.heartbeat_or_create.start actor_type=%s name=%s board_id=%s", - actor.actor_type, - payload.name, - payload.board_id, - ) - if actor.actor_type == "agent" and actor.agent: - return await self.heartbeat_agent( - agent_id=str(actor.agent.id), - payload=AgentHeartbeat(status=payload.status), - actor=actor, - ) - - agent = (await self.session.exec(self.heartbeat_lookup_statement(payload))).first() - if agent is None: - agent = await self.create_agent_from_heartbeat( - payload=payload, - actor=actor, - ) - elif actor.actor_type == "user": - await self.handle_existing_user_heartbeat_agent( - agent=agent, - user=actor.user, - ) - elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - await self.ensure_heartbeat_session_key( - agent=agent, - actor=actor, - ) - return await self.commit_heartbeat( - agent=agent, - status_value=payload.status, - ) - - async def delete_agent( - self, - *, - agent_id: str, - ctx: OrganizationContext, - ) -> OkResponse: - self.logger.log(5, "agent.delete.start agent_id=%s", agent_id) - agent = await Agent.objects.by_id(agent_id).first(self.session) - if agent is None: - return OkResponse() - await self.require_agent_access(agent=agent, ctx=ctx, write=True) - - board = await self.require_board(str(agent.board_id) if agent.board_id else None) - gateway, client_config = await self.require_gateway(board) - try: - workspace_path = await cleanup_agent(agent, gateway) - except OpenClawGatewayError as exc: - self.record_instruction_failure(self.session, agent, str(exc), "delete") - await self.session.commit() - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Gateway cleanup failed: {exc}", - ) from exc - except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover - self.record_instruction_failure(self.session, agent, str(exc), "delete") - await self.session.commit() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Workspace cleanup failed: {exc}", - ) from exc - - record_activity( - self.session, - event_type="agent.delete.direct", - message=f"Deleted agent {agent.name}.", - agent_id=None, - ) - now = utcnow() - await crud.update_where( - self.session, - Task, - col(Task.assigned_agent_id) == agent.id, - col(Task.status) == "in_progress", - assigned_agent_id=None, - status="inbox", - in_progress_at=None, - updated_at=now, - commit=False, - ) - await crud.update_where( - self.session, - Task, - col(Task.assigned_agent_id) == agent.id, - col(Task.status) != "in_progress", - assigned_agent_id=None, - updated_at=now, - commit=False, - ) - await crud.update_where( - self.session, - ActivityEvent, - col(ActivityEvent.agent_id) == agent.id, - agent_id=None, - commit=False, - ) - await self.session.delete(agent) - await self.session.commit() - - try: - main_session = GatewayAgentIdentity.session_key(gateway) - if main_session and workspace_path: - cleanup_message = ( - "Cleanup request for deleted agent.\n\n" - f"Agent name: {agent.name}\n" - f"Agent id: {agent.id}\n" - f"Workspace path: {workspace_path}\n\n" - "Actions:\n" - "1) Remove the workspace directory.\n" - "2) Reply NO_REPLY.\n" - ) - await ensure_session(main_session, config=client_config, label="Gateway Agent") - await send_message( - cleanup_message, - session_key=main_session, - config=client_config, - deliver=False, - ) - except (OSError, OpenClawGatewayError, ValueError): - pass - self.logger.info("agent.delete.success agent_id=%s", agent_id) - return OkResponse() - - -class AbstractGatewayMessagingService(ABC): - """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 - async def _with_gateway_retry(fn: Callable[[], Awaitable[_T]]) -> _T: - return await _with_coordination_gateway_retry(fn) - - async def _dispatch_gateway_message( - self, - *, - session_key: str, - config: GatewayClientConfig, - agent_name: str, - message: str, - deliver: bool, - ) -> None: - async def _do_send() -> bool: - await send_gateway_agent_message( - session_key=session_key, - config=config, - agent_name=agent_name, - message=message, - deliver=deliver, - ) - return True - - await self._with_gateway_retry(_do_send) - - -class GatewayCoordinationService(AbstractGatewayMessagingService): - """Gateway-main and lead coordination workflows used by agent-facing routes.""" - - @staticmethod - def _build_gateway_lead_message( - *, - board: Board, - actor_agent_name: str, - kind: str, - content: str, - correlation_id: str | None, - reply_tags: list[str] | None, - reply_source: str | None, - ) -> str: - base_url = settings.base_url or "http://localhost:8000" - header = "GATEWAY MAIN QUESTION" if kind == "question" else "GATEWAY MAIN HANDOFF" - correlation = correlation_id.strip() if correlation_id else "" - correlation_line = f"Correlation ID: {correlation}\n" if correlation else "" - tags_json = json.dumps(reply_tags or ["gateway_main", "lead_reply"]) - source = reply_source or "lead_to_gateway_main" - return ( - f"{header}\n" - f"Board: {board.name}\n" - f"Board ID: {board.id}\n" - f"From agent: {actor_agent_name}\n" - f"{correlation_line}\n" - f"{content.strip()}\n\n" - "Reply to the gateway agent by writing a NON-chat memory item on this board:\n" - f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n" - f'Body: {{"content":"...","tags":{tags_json},"source":"{source}"}}\n' - "Do NOT reply in OpenClaw chat." - ) - - async def require_gateway_main_actor( - self, - actor_agent: Agent, - ) -> tuple[Gateway, GatewayClientConfig]: - detail = "Only the dedicated gateway agent may call this endpoint." - if actor_agent.board_id is not None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) - gateway = await Gateway.objects.by_id(actor_agent.gateway_id).first(self.session) - if gateway is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) - if actor_agent.openclaw_session_id != GatewayAgentIdentity.session_key(gateway): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) - if not gateway.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway url is required", - ) - return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) - - async def require_gateway_board( - self, - *, - gateway: Gateway, - board_id: UUID | str, - ) -> Board: - board = await Board.objects.by_id(board_id).first(self.session) - if board is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") - if board.gateway_id != gateway.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - return board - - async def _board_agent_or_404( - self, - *, - board: Board, - agent_id: str, - ) -> Agent: - target = await Agent.objects.by_id(agent_id).first(self.session) - if target is None or (target.board_id and target.board_id != board.id): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - return target - - @staticmethod - def _gateway_file_content(payload: object) -> str | None: - if isinstance(payload, str): - return payload - if isinstance(payload, dict): - content = payload.get("content") - if isinstance(content, str): - return content - file_obj = payload.get("file") - if isinstance(file_obj, dict): - nested = file_obj.get("content") - if isinstance(nested, str): - return nested - return None - - async def nudge_board_agent( - self, - *, - board: Board, - actor_agent: Agent, - target_agent_id: str, - message: str, - correlation_id: str | None = None, - ) -> None: - trace_id = resolve_trace_id(correlation_id, prefix="coord.nudge") - self.logger.log( - 5, - "gateway.coordination.nudge.start trace_id=%s board_id=%s actor_agent_id=%s " - "target_agent_id=%s", - trace_id, - board.id, - actor_agent.id, - target_agent_id, - ) - target = await self._board_agent_or_404(board=board, agent_id=target_agent_id) - if not target.openclaw_session_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Target agent has no session key", - ) - _gateway, config = await require_gateway_config_for_board(self.session, board) - try: - await self._dispatch_gateway_message( - session_key=target.openclaw_session_id or "", - config=config, - agent_name=target.name, - message=message, - deliver=True, - ) - except (OpenClawGatewayError, TimeoutError) as exc: - record_activity( - self.session, - event_type="agent.nudge.failed", - message=f"Nudge failed for {target.name}: {exc}", - agent_id=actor_agent.id, - ) - await self.session.commit() - self.logger.error( - "gateway.coordination.nudge.failed trace_id=%s board_id=%s actor_agent_id=%s " - "target_agent_id=%s error=%s", - trace_id, - board.id, - actor_agent.id, - target_agent_id, - str(exc), - ) - raise map_gateway_error_to_http_exception(GatewayOperation.NUDGE_AGENT, exc) from exc - except Exception as exc: # pragma: no cover - defensive guard - self.logger.critical( - "gateway.coordination.nudge.failed_unexpected trace_id=%s board_id=%s " - "actor_agent_id=%s target_agent_id=%s error_type=%s error=%s", - trace_id, - board.id, - actor_agent.id, - target_agent_id, - exc.__class__.__name__, - str(exc), - ) - raise - record_activity( - self.session, - event_type="agent.nudge.sent", - message=f"Nudge sent to {target.name}.", - agent_id=actor_agent.id, - ) - await self.session.commit() - self.logger.info( - "gateway.coordination.nudge.success trace_id=%s board_id=%s actor_agent_id=%s " - "target_agent_id=%s", - trace_id, - board.id, - actor_agent.id, - target_agent_id, - ) - - async def get_agent_soul( - self, - *, - board: Board, - target_agent_id: str, - correlation_id: str | None = None, - ) -> str: - trace_id = resolve_trace_id(correlation_id, prefix="coord.soul.read") - self.logger.log( - 5, - "gateway.coordination.soul_read.start trace_id=%s board_id=%s target_agent_id=%s", - trace_id, - board.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) - try: - - async def _do_get() -> object: - return await openclaw_call( - "agents.files.get", - {"agentId": _agent_key(target), "name": "SOUL.md"}, - config=config, - ) - - payload = await self._with_gateway_retry(_do_get) - except (OpenClawGatewayError, TimeoutError) as exc: - self.logger.error( - "gateway.coordination.soul_read.failed trace_id=%s board_id=%s " - "target_agent_id=%s error=%s", - trace_id, - board.id, - target_agent_id, - str(exc), - ) - raise map_gateway_error_to_http_exception(GatewayOperation.SOUL_READ, exc) from exc - except Exception as exc: # pragma: no cover - defensive guard - self.logger.critical( - "gateway.coordination.soul_read.failed_unexpected trace_id=%s board_id=%s " - "target_agent_id=%s error_type=%s error=%s", - trace_id, - board.id, - target_agent_id, - exc.__class__.__name__, - str(exc), - ) - raise - content = self._gateway_file_content(payload) - if content is None: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Invalid gateway response", - ) - self.logger.info( - "gateway.coordination.soul_read.success trace_id=%s board_id=%s target_agent_id=%s", - trace_id, - board.id, - target_agent_id, - ) - return content - - async def update_agent_soul( - self, - *, - board: Board, - target_agent_id: str, - content: str, - reason: str | None, - source_url: str | None, - actor_agent_id: UUID, - correlation_id: str | None = None, - ) -> None: - trace_id = resolve_trace_id(correlation_id, prefix="coord.soul.write") - self.logger.log( - 5, - "gateway.coordination.soul_write.start trace_id=%s board_id=%s target_agent_id=%s " - "actor_agent_id=%s", - trace_id, - board.id, - target_agent_id, - actor_agent_id, - ) - target = await self._board_agent_or_404(board=board, agent_id=target_agent_id) - normalized_content = content.strip() - if not normalized_content: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="content is required", - ) - - target.soul_template = normalized_content - target.updated_at = utcnow() - self.session.add(target) - await self.session.commit() - - _gateway, config = await require_gateway_config_for_board(self.session, board) - try: - - async def _do_set() -> object: - return await openclaw_call( - "agents.files.set", - { - "agentId": _agent_key(target), - "name": "SOUL.md", - "content": normalized_content, - }, - config=config, - ) - - await self._with_gateway_retry(_do_set) - except (OpenClawGatewayError, TimeoutError) as exc: - self.logger.error( - "gateway.coordination.soul_write.failed trace_id=%s board_id=%s " - "target_agent_id=%s actor_agent_id=%s error=%s", - trace_id, - board.id, - target_agent_id, - actor_agent_id, - str(exc), - ) - raise map_gateway_error_to_http_exception(GatewayOperation.SOUL_WRITE, exc) from exc - except Exception as exc: # pragma: no cover - defensive guard - self.logger.critical( - "gateway.coordination.soul_write.failed_unexpected trace_id=%s board_id=%s " - "target_agent_id=%s actor_agent_id=%s error_type=%s error=%s", - trace_id, - board.id, - target_agent_id, - actor_agent_id, - exc.__class__.__name__, - str(exc), - ) - raise - - reason_text = (reason or "").strip() - source_url_text = (source_url or "").strip() - note = f"SOUL.md updated for {target.name}." - if reason_text: - note = f"{note} Reason: {reason_text}" - if source_url_text: - note = f"{note} Source: {source_url_text}" - record_activity( - self.session, - event_type="agent.soul.updated", - message=note, - agent_id=actor_agent_id, - ) - await self.session.commit() - self.logger.info( - "gateway.coordination.soul_write.success trace_id=%s board_id=%s target_agent_id=%s " - "actor_agent_id=%s", - trace_id, - board.id, - target_agent_id, - actor_agent_id, - ) - - async def ask_user_via_gateway_main( - self, - *, - board: Board, - payload: GatewayMainAskUserRequest, - actor_agent: Agent, - ) -> GatewayMainAskUserResponse: - trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.ask_user") - self.logger.log( - 5, - "gateway.coordination.ask_user.start trace_id=%s board_id=%s actor_agent_id=%s", - trace_id, - board.id, - actor_agent.id, - ) - gateway, config = await require_gateway_config_for_board(self.session, board) - main_session_key = GatewayAgentIdentity.session_key(gateway) - - correlation = payload.correlation_id.strip() if payload.correlation_id else "" - correlation_line = f"Correlation ID: {correlation}\n" if correlation else "" - preferred_channel = (payload.preferred_channel or "").strip() - channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else "" - tags = payload.reply_tags or ["gateway_main", "user_reply"] - tags_json = json.dumps(tags) - reply_source = payload.reply_source or "user_via_gateway_main" - base_url = settings.base_url or "http://localhost:8000" - message = ( - "LEAD REQUEST: ASK USER\n" - f"Board: {board.name}\n" - f"Board ID: {board.id}\n" - f"From lead: {actor_agent.name}\n" - f"{correlation_line}" - f"{channel_line}\n" - f"{payload.content.strip()}\n\n" - "Please reach the user via your configured OpenClaw channel(s) " - "(Slack/SMS/etc).\n" - "If you cannot reach them there, post the question in Mission Control " - "board chat as a fallback.\n\n" - "When you receive the answer, reply in Mission Control by writing a " - "NON-chat memory item on this board:\n" - f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n" - f'Body: {{"content":"","tags":{tags_json},"source":"{reply_source}"}}\n' - "Do NOT reply in OpenClaw chat." - ) - try: - await self._dispatch_gateway_message( - session_key=main_session_key, - config=config, - agent_name="Gateway Agent", - message=message, - deliver=True, - ) - except (OpenClawGatewayError, TimeoutError) as exc: - record_activity( - self.session, - event_type="gateway.lead.ask_user.failed", - message=f"Lead user question failed for {board.name}: {exc}", - agent_id=actor_agent.id, - ) - await self.session.commit() - self.logger.error( - "gateway.coordination.ask_user.failed trace_id=%s board_id=%s actor_agent_id=%s " - "error=%s", - trace_id, - board.id, - actor_agent.id, - str(exc), - ) - raise map_gateway_error_to_http_exception( - GatewayOperation.ASK_USER_DISPATCH, - exc, - ) from exc - except Exception as exc: # pragma: no cover - defensive guard - self.logger.critical( - "gateway.coordination.ask_user.failed_unexpected trace_id=%s board_id=%s " - "actor_agent_id=%s error_type=%s error=%s", - trace_id, - board.id, - actor_agent.id, - exc.__class__.__name__, - str(exc), - ) - raise - - record_activity( - self.session, - event_type="gateway.lead.ask_user.sent", - message=f"Lead requested user info via gateway agent for board: {board.name}.", - agent_id=actor_agent.id, - ) - main_agent = await Agent.objects.filter_by(gateway_id=gateway.id, board_id=None).first( - self.session, - ) - await self.session.commit() - self.logger.info( - "gateway.coordination.ask_user.success trace_id=%s board_id=%s actor_agent_id=%s " - "main_agent_id=%s", - trace_id, - board.id, - actor_agent.id, - main_agent.id if main_agent else None, - ) - return GatewayMainAskUserResponse( - board_id=board.id, - main_agent_id=main_agent.id if main_agent else None, - main_agent_name=main_agent.name if main_agent else None, - ) - - async def _ensure_and_message_board_lead( - self, - *, - gateway: Gateway, - config: GatewayClientConfig, - board: Board, - message: str, - ) -> tuple[Agent, bool]: - lead, lead_created = await ensure_board_lead_agent( - self.session, - request=LeadAgentRequest( - board=board, - gateway=gateway, - config=config, - user=None, - options=LeadAgentOptions(action="provision"), - ), - ) - if not lead.openclaw_session_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Lead agent has no session key", - ) - await self._dispatch_gateway_message( - session_key=lead.openclaw_session_id or "", - config=config, - agent_name=lead.name, - message=message, - deliver=False, - ) - return lead, lead_created - - async def message_gateway_board_lead( - self, - *, - actor_agent: Agent, - board_id: UUID, - payload: GatewayLeadMessageRequest, - ) -> GatewayLeadMessageResponse: - trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.lead_message") - self.logger.log( - 5, - "gateway.coordination.lead_message.start trace_id=%s board_id=%s actor_agent_id=%s", - trace_id, - board_id, - actor_agent.id, - ) - gateway, config = await self.require_gateway_main_actor(actor_agent) - board = await self.require_gateway_board(gateway=gateway, board_id=board_id) - message = self._build_gateway_lead_message( - board=board, - actor_agent_name=actor_agent.name, - kind=payload.kind, - content=payload.content, - correlation_id=payload.correlation_id, - reply_tags=payload.reply_tags, - reply_source=payload.reply_source, - ) - - try: - lead, lead_created = await self._ensure_and_message_board_lead( - gateway=gateway, - config=config, - board=board, - message=message, - ) - except (OpenClawGatewayError, TimeoutError) as exc: - record_activity( - self.session, - event_type="gateway.main.lead_message.failed", - message=f"Lead message failed for {board.name}: {exc}", - agent_id=actor_agent.id, - ) - await self.session.commit() - self.logger.error( - "gateway.coordination.lead_message.failed trace_id=%s board_id=%s " - "actor_agent_id=%s error=%s", - trace_id, - board.id, - actor_agent.id, - str(exc), - ) - raise map_gateway_error_to_http_exception( - GatewayOperation.LEAD_MESSAGE_DISPATCH, - exc, - ) from exc - except Exception as exc: # pragma: no cover - defensive guard - self.logger.critical( - "gateway.coordination.lead_message.failed_unexpected trace_id=%s board_id=%s " - "actor_agent_id=%s error_type=%s error=%s", - trace_id, - board.id, - actor_agent.id, - exc.__class__.__name__, - str(exc), - ) - raise - - record_activity( - self.session, - event_type="gateway.main.lead_message.sent", - message=f"Sent {payload.kind} to lead for board: {board.name}.", - agent_id=actor_agent.id, - ) - await self.session.commit() - self.logger.info( - "gateway.coordination.lead_message.success trace_id=%s board_id=%s " - "actor_agent_id=%s lead_agent_id=%s", - trace_id, - board.id, - actor_agent.id, - lead.id, - ) - return GatewayLeadMessageResponse( - board_id=board.id, - lead_agent_id=lead.id, - lead_agent_name=lead.name, - lead_created=lead_created, - ) - - async def broadcast_gateway_lead_message( - self, - *, - actor_agent: Agent, - payload: GatewayLeadBroadcastRequest, - ) -> GatewayLeadBroadcastResponse: - trace_id = resolve_trace_id(payload.correlation_id, prefix="coord.lead_broadcast") - self.logger.log( - 5, - "gateway.coordination.lead_broadcast.start trace_id=%s actor_agent_id=%s", - trace_id, - actor_agent.id, - ) - gateway, config = await self.require_gateway_main_actor(actor_agent) - statement = ( - select(Board) - .where(col(Board.gateway_id) == gateway.id) - .order_by(col(Board.created_at).desc()) - ) - if payload.board_ids: - statement = statement.where(col(Board.id).in_(payload.board_ids)) - boards = list(await self.session.exec(statement)) - - results: list[GatewayLeadBroadcastBoardResult] = [] - sent = 0 - failed = 0 - - for board in boards: - message = self._build_gateway_lead_message( - board=board, - actor_agent_name=actor_agent.name, - kind=payload.kind, - content=payload.content, - correlation_id=payload.correlation_id, - reply_tags=payload.reply_tags, - reply_source=payload.reply_source, - ) - try: - lead, _lead_created = await self._ensure_and_message_board_lead( - gateway=gateway, - config=config, - board=board, - message=message, - ) - board_result = GatewayLeadBroadcastBoardResult( - board_id=board.id, - lead_agent_id=lead.id, - lead_agent_name=lead.name, - ok=True, - ) - sent += 1 - except (HTTPException, OpenClawGatewayError, TimeoutError, ValueError) as exc: - board_result = GatewayLeadBroadcastBoardResult( - board_id=board.id, - ok=False, - error=map_gateway_error_message( - GatewayOperation.LEAD_BROADCAST_DISPATCH, - exc, - ), - ) - failed += 1 - results.append(board_result) - - record_activity( - self.session, - event_type="gateway.main.lead_broadcast.sent", - message=f"Broadcast {payload.kind} to {sent} board leads (failed: {failed}).", - agent_id=actor_agent.id, - ) - await self.session.commit() - self.logger.info( - "gateway.coordination.lead_broadcast.success trace_id=%s actor_agent_id=%s sent=%s " - "failed=%s", - trace_id, - actor_agent.id, - sent, - failed, - ) - return GatewayLeadBroadcastResponse( - ok=True, - sent=sent, - failed=failed, - results=results, - ) - - -class BoardOnboardingMessagingService(AbstractGatewayMessagingService): - """Gateway message dispatch helpers for onboarding routes.""" - - async def dispatch_start_prompt( - self, - *, - board: Board, - prompt: str, - correlation_id: str | None = None, - ) -> str: - trace_id = resolve_trace_id(correlation_id, prefix="onboarding.start") - self.logger.log( - 5, - "gateway.onboarding.start_dispatch.start trace_id=%s board_id=%s", - trace_id, - board.id, - ) - gateway, config = await require_gateway_config_for_board(self.session, board) - session_key = GatewayAgentIdentity.session_key(gateway) - try: - await self._dispatch_gateway_message( - session_key=session_key, - config=config, - agent_name="Gateway Agent", - message=prompt, - deliver=False, - ) - except (OpenClawGatewayError, TimeoutError) as exc: - self.logger.error( - "gateway.onboarding.start_dispatch.failed trace_id=%s board_id=%s error=%s", - trace_id, - board.id, - str(exc), - ) - raise map_gateway_error_to_http_exception( - GatewayOperation.ONBOARDING_START_DISPATCH, - exc, - ) from exc - except Exception as exc: # pragma: no cover - defensive guard - self.logger.critical( - "gateway.onboarding.start_dispatch.failed_unexpected trace_id=%s board_id=%s " - "error_type=%s error=%s", - trace_id, - board.id, - exc.__class__.__name__, - str(exc), - ) - raise - self.logger.info( - "gateway.onboarding.start_dispatch.success trace_id=%s board_id=%s session_key=%s", - trace_id, - board.id, - session_key, - ) - return session_key - - async def dispatch_answer( - self, - *, - board: Board, - onboarding: BoardOnboardingSession, - answer_text: str, - correlation_id: str | None = None, - ) -> None: - trace_id = resolve_trace_id(correlation_id, prefix="onboarding.answer") - self.logger.log( - 5, - "gateway.onboarding.answer_dispatch.start trace_id=%s board_id=%s onboarding_id=%s", - trace_id, - board.id, - onboarding.id, - ) - _gateway, config = await require_gateway_config_for_board(self.session, board) - try: - await self._dispatch_gateway_message( - session_key=onboarding.session_key, - config=config, - agent_name="Gateway Agent", - message=answer_text, - deliver=False, - ) - except (OpenClawGatewayError, TimeoutError) as exc: - self.logger.error( - "gateway.onboarding.answer_dispatch.failed trace_id=%s board_id=%s " - "onboarding_id=%s error=%s", - trace_id, - board.id, - onboarding.id, - str(exc), - ) - raise map_gateway_error_to_http_exception( - GatewayOperation.ONBOARDING_ANSWER_DISPATCH, - exc, - ) from exc - except Exception as exc: # pragma: no cover - defensive guard - self.logger.critical( - "gateway.onboarding.answer_dispatch.failed_unexpected trace_id=%s board_id=%s " - "onboarding_id=%s error_type=%s error=%s", - trace_id, - board.id, - onboarding.id, - exc.__class__.__name__, - str(exc), - ) - raise - self.logger.info( - "gateway.onboarding.answer_dispatch.success trace_id=%s board_id=%s onboarding_id=%s", - trace_id, - board.id, - onboarding.id, - ) +from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService +from app.services.openclaw.session_service import GatewaySessionService, GatewayTemplateSyncQuery + +__all__ = [ + "AbstractGatewayMainAgentManager", + "DefaultGatewayMainAgentManager", + "GatewayAdminLifecycleService", + "AbstractProvisionExecution", + "ActorContextLike", + "AgentLifecycleService", + "AgentUpdateOptions", + "AgentUpdateProvisionRequest", + "AgentUpdateProvisionTarget", + "BoardAgentProvisionExecution", + "MainAgentProvisionExecution", + "AbstractGatewayMessagingService", + "GatewayCoordinationService", + "BoardOnboardingMessagingService", + "GatewaySessionService", + "GatewayTemplateSyncQuery", +] diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py new file mode 100644 index 00000000..d45ae70e --- /dev/null +++ b/backend/app/services/openclaw/session_service.py @@ -0,0 +1,367 @@ +"""Gateway session query service.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import HTTPException, status +from sqlmodel import col + +from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig +from app.integrations.openclaw_gateway import ( + OpenClawGatewayError, + ensure_session, + get_chat_history, + openclaw_call, + send_message, +) +from app.models.agents import Agent +from app.models.boards import Board +from app.models.gateways import Gateway +from app.schemas.gateway_api import ( + GatewayResolveQuery, + GatewaySessionHistoryResponse, + GatewaySessionMessageRequest, + GatewaySessionResponse, + GatewaySessionsResponse, + GatewaysStatusResponse, +) +from app.services.organizations import require_board_access + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + + from app.models.users import User + + +@dataclass(frozen=True, slots=True) +class GatewayTemplateSyncQuery: + """Sync options parsed from query args for gateway template operations.""" + + include_main: bool + reset_sessions: bool + rotate_tokens: bool + force_bootstrap: bool + board_id: UUID | None + + +class GatewaySessionService: + """Read/query gateway runtime session state for user-facing APIs.""" + + 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 + def to_resolve_query( + board_id: str | None, + gateway_url: str | None, + gateway_token: str | None, + ) -> GatewayResolveQuery: + return GatewayResolveQuery( + board_id=board_id, + gateway_url=gateway_url, + gateway_token=gateway_token, + ) + + @staticmethod + def as_object_list(value: object) -> list[object]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, (tuple, set)): + return list(value) + if isinstance(value, (str, bytes, dict)): + return [] + if isinstance(value, Iterable): + return list(value) + return [] + + async def resolve_gateway( + self, + params: GatewayResolveQuery, + *, + user: User | None = None, + ) -> tuple[Board | None, GatewayClientConfig, str | None]: + self.logger.log( + 5, + "gateway.resolve.start board_id=%s gateway_url=%s", + params.board_id, + params.gateway_url, + ) + if params.gateway_url: + return ( + None, + GatewayClientConfig(url=params.gateway_url, token=params.gateway_token), + None, + ) + if not params.board_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_id or gateway_url is required", + ) + board = await Board.objects.by_id(params.board_id).first(self.session) + if board is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found", + ) + if user is not None: + await require_board_access(self.session, user=user, board=board, write=False) + if not board.gateway_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is required", + ) + gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) + if gateway is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is invalid", + ) + if not gateway.url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway url is required", + ) + main_agent = ( + await Agent.objects.filter_by(gateway_id=gateway.id) + .filter(col(Agent.board_id).is_(None)) + .first(self.session) + ) + main_session = main_agent.openclaw_session_id if main_agent else None + return ( + board, + GatewayClientConfig(url=gateway.url, token=gateway.token), + main_session, + ) + + async def require_gateway( + self, + board_id: str | None, + *, + user: User | None = None, + ) -> tuple[Board, GatewayClientConfig, str | None]: + params = GatewayResolveQuery(board_id=board_id) + board, config, main_session = await self.resolve_gateway(params, user=user) + if board is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_id is required", + ) + return board, config, main_session + + async def list_sessions(self, config: GatewayClientConfig) -> list[dict[str, object]]: + sessions = await openclaw_call("sessions.list", config=config) + if isinstance(sessions, dict): + raw_items = self.as_object_list(sessions.get("sessions")) + else: + raw_items = self.as_object_list(sessions) + return [item for item in raw_items if isinstance(item, dict)] + + async def with_main_session( + self, + sessions_list: list[dict[str, object]], + *, + config: GatewayClientConfig, + main_session: str | None, + ) -> list[dict[str, object]]: + if not main_session or any(item.get("key") == main_session for item in sessions_list): + return sessions_list + try: + await ensure_session(main_session, config=config, label="Gateway Agent") + return await self.list_sessions(config) + except OpenClawGatewayError: + return sessions_list + + @staticmethod + def _require_same_org(board: Board | None, organization_id: UUID) -> None: + if board is not None and board.organization_id != organization_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + async def get_status( + self, + *, + params: GatewayResolveQuery, + organization_id: UUID, + user: User | None, + ) -> GatewaysStatusResponse: + board, config, main_session = await self.resolve_gateway(params, user=user) + self._require_same_org(board, organization_id) + try: + sessions = await openclaw_call("sessions.list", config=config) + if isinstance(sessions, dict): + sessions_list = self.as_object_list(sessions.get("sessions")) + else: + sessions_list = self.as_object_list(sessions) + main_session_entry: object | None = None + main_session_error: str | None = None + if main_session: + try: + ensured = await ensure_session( + main_session, + config=config, + label="Gateway Agent", + ) + if isinstance(ensured, dict): + main_session_entry = ensured.get("entry") or ensured + except OpenClawGatewayError as exc: + main_session_error = str(exc) + return GatewaysStatusResponse( + connected=True, + gateway_url=config.url, + sessions_count=len(sessions_list), + sessions=sessions_list, + main_session=main_session_entry, + main_session_error=main_session_error, + ) + except OpenClawGatewayError as exc: + return GatewaysStatusResponse( + connected=False, + gateway_url=config.url, + error=str(exc), + ) + + async def get_sessions( + self, + *, + board_id: str | None, + organization_id: UUID, + user: User | None, + ) -> GatewaySessionsResponse: + params = GatewayResolveQuery(board_id=board_id) + board, config, main_session = await self.resolve_gateway(params, user=user) + self._require_same_org(board, organization_id) + try: + sessions = await openclaw_call("sessions.list", config=config) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + if isinstance(sessions, dict): + sessions_list = self.as_object_list(sessions.get("sessions")) + else: + sessions_list = self.as_object_list(sessions) + + main_session_entry: object | None = None + if main_session: + try: + ensured = await ensure_session( + main_session, + config=config, + label="Gateway Agent", + ) + if isinstance(ensured, dict): + main_session_entry = ensured.get("entry") or ensured + except OpenClawGatewayError: + main_session_entry = None + return GatewaySessionsResponse(sessions=sessions_list, main_session=main_session_entry) + + async def get_session( + self, + *, + session_id: str, + board_id: str | None, + organization_id: UUID, + user: User | None, + ) -> GatewaySessionResponse: + params = GatewayResolveQuery(board_id=board_id) + board, config, main_session = await self.resolve_gateway(params, user=user) + self._require_same_org(board, organization_id) + try: + sessions_list = await self.list_sessions(config) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + sessions_list = await self.with_main_session( + sessions_list, + config=config, + main_session=main_session, + ) + session_entry = next( + (item for item in sessions_list if item.get("key") == session_id), None + ) + if session_entry is None and main_session and session_id == main_session: + try: + ensured = await ensure_session( + main_session, + config=config, + label="Gateway Agent", + ) + if isinstance(ensured, dict): + session_entry = ensured.get("entry") or ensured + except OpenClawGatewayError: + session_entry = None + if session_entry is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found", + ) + return GatewaySessionResponse(session=session_entry) + + async def get_session_history( + self, + *, + session_id: str, + board_id: str | None, + organization_id: UUID, + user: User | None, + ) -> GatewaySessionHistoryResponse: + board, config, _ = await self.require_gateway(board_id, user=user) + self._require_same_org(board, organization_id) + try: + history = await get_chat_history(session_id, config=config) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + if isinstance(history, dict) and isinstance(history.get("messages"), list): + return GatewaySessionHistoryResponse(history=history["messages"]) + return GatewaySessionHistoryResponse(history=self.as_object_list(history)) + + async def send_session_message( + self, + *, + session_id: str, + payload: GatewaySessionMessageRequest, + board_id: str | None, + user: User | None, + ) -> None: + board, config, main_session = await self.require_gateway(board_id, user=user) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + await require_board_access(self.session, user=user, board=board, write=True) + try: + if main_session and session_id == main_session: + await ensure_session(main_session, config=config, label="Gateway Agent") + await send_message(payload.content, session_key=session_id, config=config) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc diff --git a/backend/tests/test_lifecycle_services.py b/backend/tests/test_lifecycle_services.py index 67936b05..9446ae26 100644 --- a/backend/tests/test_lifecycle_services.py +++ b/backend/tests/test_lifecycle_services.py @@ -13,7 +13,9 @@ from fastapi import HTTPException, status from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig from app.integrations.openclaw_gateway import OpenClawGatewayError -from app.services.openclaw import services as lifecycle +from app.services.openclaw import coordination_service as coordination_lifecycle +from app.services.openclaw import onboarding_service as onboarding_lifecycle +from app.services.openclaw.shared import GatewayAgentIdentity @dataclass @@ -46,7 +48,7 @@ class _BoardStub: @pytest.mark.asyncio async def test_gateway_coordination_nudge_success(monkeypatch: pytest.MonkeyPatch) -> None: session = _FakeSession() - service = lifecycle.GatewayCoordinationService(session) # type: ignore[arg-type] + service = coordination_lifecycle.GatewayCoordinationService(session) # type: ignore[arg-type] board = _BoardStub(id=uuid4(), gateway_id=uuid4(), name="Roadmap") actor = _AgentStub(id=uuid4(), name="Lead Agent", board_id=board.id) target = _AgentStub( @@ -58,7 +60,7 @@ async def test_gateway_coordination_nudge_success(monkeypatch: pytest.MonkeyPatc captured: list[dict[str, Any]] = [] async def _fake_board_agent_or_404( - self: lifecycle.GatewayCoordinationService, + self: coordination_lifecycle.GatewayCoordinationService, *, board: object, agent_id: str, @@ -78,17 +80,17 @@ async def test_gateway_coordination_nudge_success(monkeypatch: pytest.MonkeyPatc return {"ok": True} monkeypatch.setattr( - lifecycle.GatewayCoordinationService, + coordination_lifecycle.GatewayCoordinationService, "_board_agent_or_404", _fake_board_agent_or_404, ) monkeypatch.setattr( - lifecycle, + coordination_lifecycle, "require_gateway_config_for_board", _fake_require_gateway_config_for_board, ) monkeypatch.setattr( - lifecycle, + coordination_lifecycle, "send_gateway_agent_message", _fake_send_gateway_agent_message, ) @@ -113,7 +115,7 @@ async def test_gateway_coordination_nudge_maps_gateway_error( monkeypatch: pytest.MonkeyPatch, ) -> None: session = _FakeSession() - service = lifecycle.GatewayCoordinationService(session) # type: ignore[arg-type] + service = coordination_lifecycle.GatewayCoordinationService(session) # type: ignore[arg-type] board = _BoardStub(id=uuid4(), gateway_id=uuid4(), name="Roadmap") actor = _AgentStub(id=uuid4(), name="Lead Agent", board_id=board.id) target = _AgentStub( @@ -124,7 +126,7 @@ async def test_gateway_coordination_nudge_maps_gateway_error( ) async def _fake_board_agent_or_404( - self: lifecycle.GatewayCoordinationService, + self: coordination_lifecycle.GatewayCoordinationService, *, board: object, agent_id: str, @@ -143,17 +145,17 @@ async def test_gateway_coordination_nudge_maps_gateway_error( raise OpenClawGatewayError("dial tcp: connection refused") monkeypatch.setattr( - lifecycle.GatewayCoordinationService, + coordination_lifecycle.GatewayCoordinationService, "_board_agent_or_404", _fake_board_agent_or_404, ) monkeypatch.setattr( - lifecycle, + coordination_lifecycle, "require_gateway_config_for_board", _fake_require_gateway_config_for_board, ) monkeypatch.setattr( - lifecycle, + coordination_lifecycle, "send_gateway_agent_message", _fake_send_gateway_agent_message, ) @@ -177,7 +179,7 @@ async def test_board_onboarding_dispatch_start_returns_session_key( monkeypatch: pytest.MonkeyPatch, ) -> None: session = _FakeSession() - service = lifecycle.BoardOnboardingMessagingService(session) # type: ignore[arg-type] + service = onboarding_lifecycle.BoardOnboardingMessagingService(session) # type: ignore[arg-type] gateway_id = uuid4() board = _BoardStub(id=uuid4(), gateway_id=gateway_id, name="Roadmap") captured: list[dict[str, Any]] = [] @@ -194,12 +196,12 @@ async def test_board_onboarding_dispatch_start_returns_session_key( return {"ok": True} monkeypatch.setattr( - lifecycle, + onboarding_lifecycle, "require_gateway_config_for_board", _fake_require_gateway_config_for_board, ) monkeypatch.setattr( - lifecycle, + coordination_lifecycle, "send_gateway_agent_message", _fake_send_gateway_agent_message, ) @@ -210,7 +212,7 @@ async def test_board_onboarding_dispatch_start_returns_session_key( correlation_id="onboarding-corr-id", ) - assert session_key == lifecycle.GatewayAgentIdentity.session_key_for_id(gateway_id) + assert session_key == GatewayAgentIdentity.session_key_for_id(gateway_id) assert len(captured) == 1 assert captured[0]["agent_name"] == "Gateway Agent" assert captured[0]["deliver"] is False @@ -221,7 +223,7 @@ async def test_board_onboarding_dispatch_answer_maps_timeout_error( monkeypatch: pytest.MonkeyPatch, ) -> None: session = _FakeSession() - service = 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") onboarding = SimpleNamespace(id=uuid4(), session_key="agent:gateway-main:main") @@ -236,12 +238,12 @@ async def test_board_onboarding_dispatch_answer_maps_timeout_error( raise TimeoutError("gateway timeout") monkeypatch.setattr( - lifecycle, + onboarding_lifecycle, "require_gateway_config_for_board", _fake_require_gateway_config_for_board, ) monkeypatch.setattr( - lifecycle, + coordination_lifecycle, "send_gateway_agent_message", _fake_send_gateway_agent_message, )