"""Gateway admin lifecycle service.""" from __future__ import annotations from abc import ABC, abstractmethod import re from typing import TYPE_CHECKING from uuid import UUID from fastapi import HTTPException, status from sqlmodel import col from sqlmodel import select from app.core.auth import AuthContext from app.core.logging import TRACE_LEVEL from app.core.time import utcnow from app.db import crud from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.approvals import Approval from app.models.boards import Board from app.models.board_webhooks import BoardWebhook from app.models.gateways import Gateway from app.models.tasks import Task from app.schemas.gateways import ( GatewayAgentImportCandidate, GatewayAgentImportPreviewResponse, GatewayAgentImportReconcileSummary, GatewayAgentImportResponse, GatewayAgentReconcileError, GatewayTemplatesSyncResult, ) from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG from app.services.openclaw.db_service import OpenClawDBService from app.services.openclaw.error_messages import normalize_gateway_error_message from app.services.openclaw.gateway_compat import check_gateway_version_compatibility from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call from app.services.openclaw.internal.agent_key import agent_key as resolve_agent_key from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator from app.services.openclaw.provisioning_db import ( GatewayTemplateSyncOptions, OpenClawProvisioningService, ) 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 _LEAD_SESSION_KEY_RE = re.compile( r"^agent:lead-(?P[0-9a-fA-F-]{36}):main$" ) def _as_dict_list(value: object) -> list[dict[str, object]]: if isinstance(value, dict): for key in ("agents", "sessions", "items", "data"): nested = value.get(key) if isinstance(nested, list): return [item for item in nested if isinstance(item, dict)] return [] if isinstance(value, list): return [item for item in value if isinstance(item, dict)] return [] def _derive_session_key_for_runtime_agent( runtime_agent_id: str, session_by_agent_key: dict[str, str], ) -> str: existing = session_by_agent_key.get(runtime_agent_id) if existing: return existing return f"agent:{runtime_agent_id}:main" def _parse_lead_board_id(session_key: str) -> UUID | None: matched = _LEAD_SESSION_KEY_RE.match(session_key) if matched is None: return None board_id_raw = matched.group("board_id") try: return UUID(board_id_raw) except ValueError: return None 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(OpenClawDBService): """Write-side gateway lifecycle service (CRUD, main agent, template sync).""" def __init__( self, session: AsyncSession, *, main_agent_manager: AbstractGatewayMainAgentManager | None = None, ) -> None: super().__init__(session) self._main_agent_manager = main_agent_manager or DefaultGatewayMainAgentManager() @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) ) 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, allow_insecure_tls=gateway.allow_insecure_tls, disable_device_pairing=gateway.disable_device_pairing, ) target_id = GatewayAgentIdentity.openclaw_agent_id(gateway) try: await openclaw_call("agents.files.list", {"agentId": target_id}, config=config) except OpenClawGatewayError as exc: message = str(exc).lower() if any(marker in message for marker in ("not found", "unknown agent", "no such agent")): return False return True return True async def assert_gateway_runtime_compatible( self, *, url: str, token: str | None, allow_insecure_tls: bool = False, disable_device_pairing: bool = False, ) -> None: """Validate that a gateway runtime meets minimum supported version.""" config = GatewayClientConfig( url=url, token=token, allow_insecure_tls=allow_insecure_tls, disable_device_pairing=disable_device_pairing, ) try: result = await check_gateway_version_compatibility(config) except OpenClawGatewayError as exc: detail = normalize_gateway_error_message(str(exc)) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Gateway compatibility check failed: {detail}", ) from exc if not result.compatible: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=result.message or "Gateway runtime version is not supported.", ) async def provision_main_agent_record( self, gateway: Gateway, agent: Agent, *, user: User | None, action: str, notify: bool, ) -> Agent: orchestrator = AgentLifecycleOrchestrator(self.session) try: provisioned = await orchestrator.run_lifecycle( gateway=gateway, agent_id=agent.id, board=None, user=user, action=action, auth_token=None, force_bootstrap=False, reset_session=False, wake=notify, deliver_wakeup=True, wakeup_verb=None, clear_confirm_token=False, raise_gateway_errors=True, ) except HTTPException: self.logger.error( "gateway.main_agent.provision_failed gateway_id=%s agent_id=%s action=%s", gateway.id, agent.id, action, ) raise self.logger.info( "gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s", gateway.id, provisioned.id, action, ) return provisioned async def ensure_main_agent( self, gateway: Gateway, auth: AuthContext, *, action: str = "provision", ) -> Agent: self.logger.log( TRACE_LEVEL, "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 preview_gateway_agent_import( self, *, gateway: Gateway, ) -> GatewayAgentImportPreviewResponse: """Return runtime agents that can be imported into Pipeline metadata.""" config = GatewayClientConfig( url=gateway.url, token=gateway.token, allow_insecure_tls=gateway.allow_insecure_tls, disable_device_pairing=gateway.disable_device_pairing, ) try: runtime_agents_raw = await openclaw_call("agents.list", config=config) runtime_sessions_raw = await openclaw_call( "sessions.list", {"limit": 500}, config=config, ) except OpenClawGatewayError as exc: detail = normalize_gateway_error_message(str(exc)) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Gateway runtime query failed: {detail}", ) from exc runtime_agents = _as_dict_list(runtime_agents_raw) runtime_sessions = _as_dict_list(runtime_sessions_raw) session_by_agent_key: dict[str, str] = {} for runtime_session in runtime_sessions: key = runtime_session.get("key") if not isinstance(key, str): continue parts = key.split(":") if len(parts) < 2 or parts[0] != "agent": continue session_by_agent_key[parts[1]] = key existing_agents = await Agent.objects.filter_by(gateway_id=gateway.id).all(self.session) existing_by_session_key = { (agent.openclaw_session_id or "").strip(): agent for agent in existing_agents if (agent.openclaw_session_id or "").strip() } existing_by_runtime_key = { resolve_agent_key(agent): agent for agent in existing_agents } lead_board_ids: set[UUID] = set() normalized_runtime_agents: list[tuple[str, str | None, str]] = [] for runtime_agent in runtime_agents: runtime_id = runtime_agent.get("id") if not isinstance(runtime_id, str) or not runtime_id.strip(): continue runtime_key = runtime_id.strip() runtime_name = runtime_agent.get("name") normalized_name = runtime_name.strip() if isinstance(runtime_name, str) else None session_key = _derive_session_key_for_runtime_agent(runtime_key, session_by_agent_key) lead_board_id = _parse_lead_board_id(session_key) if lead_board_id is not None: lead_board_ids.add(lead_board_id) normalized_runtime_agents.append((runtime_key, normalized_name, session_key)) board_by_id: dict[UUID, Board] = {} if lead_board_ids: board_rows = ( await self.session.exec( select(Board).where( col(Board.id).in_(lead_board_ids), col(Board.organization_id) == gateway.organization_id, col(Board.gateway_id) == gateway.id, ) ) ).all() board_by_id = {board.id: board for board in board_rows} candidates: list[GatewayAgentImportCandidate] = [] for runtime_id, runtime_name, session_key in normalized_runtime_agents: tracked_agent = existing_by_session_key.get(session_key) if tracked_agent is None: tracked_agent = existing_by_runtime_key.get(runtime_id) lead_board_id = _parse_lead_board_id(session_key) inferred_board_id = ( lead_board_id if lead_board_id is not None and lead_board_id in board_by_id else None ) inferred_is_board_lead = inferred_board_id is not None candidates.append( GatewayAgentImportCandidate( gateway_agent_id=runtime_id, gateway_agent_name=runtime_name, session_key=session_key, existing_agent_id=tracked_agent.id if tracked_agent is not None else None, importable=tracked_agent is None, inferred_board_id=inferred_board_id, inferred_is_board_lead=inferred_is_board_lead, ) ) candidates.sort(key=lambda item: item.gateway_agent_id.lower()) discovered_count = len(candidates) importable_count = sum(1 for item in candidates if item.importable) already_tracked_count = discovered_count - importable_count return GatewayAgentImportPreviewResponse( gateway_id=gateway.id, discovered_count=discovered_count, importable_count=importable_count, already_tracked_count=already_tracked_count, candidates=candidates, ) async def import_gateway_agents( self, *, gateway: Gateway, gateway_agent_ids: list[str], reconcile_after_import: bool = False, rotate_tokens: bool = True, reset_sessions: bool = True, force_bootstrap: bool = True, ) -> GatewayAgentImportResponse: """Import selected runtime agents as Pipeline agent rows.""" preview = await self.preview_gateway_agent_import(gateway=gateway) candidates_by_runtime_id = { item.gateway_agent_id: item for item in preview.candidates } seen: set[str] = set() deduped_ids: list[str] = [] for runtime_id in gateway_agent_ids: cleaned = runtime_id.strip() if not cleaned or cleaned in seen: continue seen.add(cleaned) deduped_ids.append(cleaned) imported_agent_ids: list[UUID] = [] skipped_gateway_agent_ids: list[str] = [] for runtime_id in deduped_ids: candidate = candidates_by_runtime_id.get(runtime_id) if candidate is None or not candidate.importable: skipped_gateway_agent_ids.append(runtime_id) continue imported = Agent( name=candidate.gateway_agent_name or f"Imported {runtime_id}", board_id=candidate.inferred_board_id, gateway_id=gateway.id, status="offline", heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(), openclaw_session_id=candidate.session_key, is_board_lead=candidate.inferred_is_board_lead, ) self.session.add(imported) await self.session.flush() imported_agent_ids.append(imported.id) await self.session.commit() reconcile_summary: GatewayAgentImportReconcileSummary | None = None if reconcile_after_import and imported_agent_ids: reconcile_summary = await self.reconcile_imported_agents( gateway=gateway, agent_ids=imported_agent_ids, rotate_tokens=rotate_tokens, reset_sessions=reset_sessions, force_bootstrap=force_bootstrap, ) return GatewayAgentImportResponse( gateway_id=gateway.id, imported_count=len(imported_agent_ids), skipped_count=len(skipped_gateway_agent_ids), imported_agent_ids=imported_agent_ids, skipped_gateway_agent_ids=skipped_gateway_agent_ids, reconcile=reconcile_summary, ) async def reconcile_imported_agents( self, *, gateway: Gateway, agent_ids: list[UUID], rotate_tokens: bool, reset_sessions: bool, force_bootstrap: bool, ) -> GatewayAgentImportReconcileSummary: """Run post-import lifecycle reconcile for imported agent rows.""" if not agent_ids: return GatewayAgentImportReconcileSummary( attempted=0, updated=0, skipped=0, errors=[], ) agents = ( await self.session.exec( select(Agent).where( col(Agent.id).in_(agent_ids), col(Agent.gateway_id) == gateway.id, ) ) ).all() boards_by_id: dict[UUID, Board] = {} board_ids = [agent.board_id for agent in agents if agent.board_id is not None] if board_ids: board_rows = ( await self.session.exec( select(Board).where( col(Board.id).in_(board_ids), col(Board.gateway_id) == gateway.id, col(Board.organization_id) == gateway.organization_id, ) ) ).all() boards_by_id = {board.id: board for board in board_rows} updated = 0 skipped = 0 errors: list[GatewayAgentReconcileError] = [] main_session_key = GatewayAgentIdentity.session_key(gateway) for agent in agents: board: Board | None = None if agent.board_id is not None: board = boards_by_id.get(agent.board_id) if board is None: skipped += 1 errors.append( GatewayAgentReconcileError( agent_id=agent.id, message="Skipping reconcile: board is missing or not mapped to gateway.", ) ) continue elif (agent.openclaw_session_id or "").strip() != main_session_key: skipped += 1 errors.append( GatewayAgentReconcileError( agent_id=agent.id, message=( "Skipping reconcile: boardless imported agent is not the gateway-main " "session and cannot be reprovisioned automatically." ), ) ) continue if not rotate_tokens: skipped += 1 errors.append( GatewayAgentReconcileError( agent_id=agent.id, message=( "Skipping reconcile: rotate_tokens=false is unsupported for imported " "agents without an existing backend token hash." ), ) ) continue try: await AgentLifecycleOrchestrator(self.session).run_lifecycle( gateway=gateway, agent_id=agent.id, board=board, user=None, action="update", auth_token=None, force_bootstrap=force_bootstrap, reset_session=reset_sessions, wake=False, deliver_wakeup=False, wakeup_verb="updated", clear_confirm_token=False, raise_gateway_errors=True, ) except HTTPException as exc: skipped += 1 message = exc.detail if isinstance(exc.detail, str) else str(exc.detail) errors.append( GatewayAgentReconcileError( agent_id=agent.id, message=f"Reconcile failed: {message}", ) ) except Exception as exc: skipped += 1 errors.append( GatewayAgentReconcileError( agent_id=agent.id, message=f"Reconcile failed: {exc}", ) ) else: updated += 1 return GatewayAgentImportReconcileSummary( attempted=len(agents), updated=updated, skipped=skipped, errors=errors, ) 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, ) await crud.update_where( self.session, BoardWebhook, col(BoardWebhook.agent_id) == agent_id, agent_id=None, updated_at=now, commit=False, ) async def sync_templates( self, gateway: Gateway, *, query: GatewayTemplateSyncQuery, auth: AuthContext, ) -> GatewayTemplatesSyncResult: self.logger.log( TRACE_LEVEL, "gateway.templates.sync.start gateway_id=%s include_main=%s", gateway.id, query.include_main, ) await self.ensure_gateway_agents_exist([gateway]) result = await OpenClawProvisioningService(self.session).sync_gateway_templates( gateway, GatewayTemplateSyncOptions( user=auth.user, include_main=query.include_main, lead_only=query.lead_only, reset_sessions=query.reset_sessions, rotate_tokens=query.rotate_tokens, force_bootstrap=query.force_bootstrap, overwrite=query.overwrite, board_id=query.board_id, ), ) self.logger.info("gateway.templates.sync.success gateway_id=%s", gateway.id) return result