refactor: reorganize OpenClaw services and enhance session management

This commit is contained in:
Abhimanyu Saharan
2026-02-10 14:50:27 +05:30
parent 6f070df74b
commit 82425edd69
24 changed files with 4454 additions and 3380 deletions

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from uuid import UUID
@@ -16,20 +15,10 @@ from app.api import board_onboarding as onboarding_api
from app.api import tasks as tasks_api
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
from app.core.config import settings
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import get_session
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.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.task_dependencies import TaskDependency
from app.models.tasks import Task
from app.schemas.agents import (
@@ -45,7 +34,6 @@ from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboar
from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse
from app.schemas.gateway_coordination import (
GatewayLeadBroadcastBoardResult,
GatewayLeadBroadcastRequest,
GatewayLeadBroadcastResponse,
GatewayLeadMessageRequest,
@@ -56,8 +44,7 @@ from app.schemas.gateway_coordination import (
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.board_leads import LeadAgentOptions, LeadAgentRequest, ensure_board_lead_agent
from app.services.gateway_agents import gateway_agent_session_key
from app.services.openclaw import AgentLifecycleService, GatewayCoordinationService
from app.services.task_dependencies import (
blocked_by_dependency_ids,
dependency_status_by_id,
@@ -76,10 +63,6 @@ if TYPE_CHECKING:
from app.models.board_onboarding import BoardOnboardingSession
router = APIRouter(prefix="/agent", tags=["agent"])
_AGENT_SESSION_PREFIX = "agent:"
_SESSION_KEY_PARTS_MIN = 2
_LEAD_SESSION_KEY_MISSING = "Lead agent has no session key"
SESSION_DEP = Depends(get_session)
AGENT_CTX_DEP = Depends(get_agent_auth_context)
BOARD_DEP = Depends(get_board_or_404)
@@ -100,18 +83,6 @@ def _coerce_agent_items(items: Sequence[Any]) -> list[Agent]:
return agents
def _gateway_agent_id(agent: Agent) -> str:
session_key = agent.openclaw_session_id or ""
if session_key.startswith(_AGENT_SESSION_PREFIX):
parts = session_key.split(":")
if len(parts) >= _SESSION_KEY_PARTS_MIN and parts[1]:
return parts[1]
# Fall back to a stable slug derived from name (matches provisioning behavior).
value = agent.name.lower().strip()
value = re.sub(r"[^a-z0-9]+", "-", value).strip("-")
return value or str(agent.id)
class SoulUpdateRequest(SQLModel):
"""Payload for updating an agent SOUL document."""
@@ -147,73 +118,11 @@ def _actor(agent_ctx: AgentAuthContext) -> ActorContext:
return ActorContext(actor_type="agent", agent=agent_ctx.agent)
def _require_lead_session_key(lead: Agent) -> str:
session_key = lead.openclaw_session_id
if not session_key:
raise ValueError(_LEAD_SESSION_KEY_MISSING)
return session_key
def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None:
if agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientConfig:
if not board.gateway_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _require_gateway_main(
session: AsyncSession,
agent: Agent,
) -> tuple[Gateway, GatewayClientConfig]:
if agent.board_id is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the dedicated gateway agent may call this endpoint.",
)
gateway_id = agent.gateway_id
gateway = await Gateway.objects.by_id(gateway_id).first(session)
if gateway is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the dedicated gateway agent may call this endpoint.",
)
if agent.openclaw_session_id != gateway_agent_session_key(gateway):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the dedicated gateway agent may call this endpoint.",
)
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(
session: AsyncSession,
*,
gateway: Gateway,
board_id: UUID | str,
) -> Board:
board = await Board.objects.by_id(board_id).first(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
@router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
async def list_boards(
session: AsyncSession = SESSION_DEP,
@@ -256,8 +165,8 @@ async def list_agents(
def _transform(items: Sequence[Any]) -> Sequence[Any]:
agents = _coerce_agent_items(items)
return [
agents_api.to_agent_read(
agents_api.with_computed_status(agent),
AgentLifecycleService.to_agent_read(
AgentLifecycleService.with_computed_status(agent),
)
for agent in agents
]
@@ -560,47 +469,14 @@ async def nudge_agent(
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
target = await Agent.objects.by_id(agent_id).first(session)
if target is None or (target.board_id and target.board_id != board.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if not target.openclaw_session_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Target agent has no session key",
)
message = payload.message
config = await _gateway_config(session, board)
try:
await ensure_session(
target.openclaw_session_id,
config=config,
label=target.name,
)
await send_message(
message,
session_key=target.openclaw_session_id,
config=config,
deliver=True,
)
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="agent.nudge.failed",
message=f"Nudge failed for {target.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
record_activity(
session,
event_type="agent.nudge.sent",
message=f"Nudge sent to {target.name}.",
agent_id=agent_ctx.agent.id,
coordination = GatewayCoordinationService(session)
await coordination.nudge_board_agent(
board=board,
actor_agent=agent_ctx.agent,
target_agent_id=agent_id,
message=payload.message,
correlation_id=f"nudge:{board.id}:{agent_id}",
)
await session.commit()
return OkResponse()
@@ -631,36 +507,11 @@ async def get_agent_soul(
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead and str(agent_ctx.agent.id) != agent_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
target = await Agent.objects.by_id(agent_id).first(session)
if target is None or (target.board_id and target.board_id != board.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
config = await _gateway_config(session, board)
gateway_id = _gateway_agent_id(target)
try:
payload = await openclaw_call(
"agents.files.get",
{"agentId": gateway_id, "name": "SOUL.md"},
config=config,
)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
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
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Invalid gateway response",
coordination = GatewayCoordinationService(session)
return await coordination.get_agent_soul(
board=board,
target_agent_id=agent_id,
correlation_id=f"soul.read:{board.id}:{agent_id}",
)
@@ -676,48 +527,16 @@ async def update_agent_soul(
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
target = await Agent.objects.by_id(agent_id).first(session)
if target is None or (target.board_id and target.board_id != board.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
config = await _gateway_config(session, board)
gateway_id = _gateway_agent_id(target)
content = payload.content.strip()
if not content:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="content is required",
)
# Persist the SOUL in the DB so future reprovision/update doesn't overwrite it.
target.soul_template = content
target.updated_at = utcnow()
session.add(target)
await session.commit()
try:
await openclaw_call(
"agents.files.set",
{"agentId": gateway_id, "name": "SOUL.md", "content": content},
config=config,
)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
reason = (payload.reason or "").strip()
source_url = (payload.source_url or "").strip()
note = f"SOUL.md updated for {target.name}."
if reason:
note = f"{note} Reason: {reason}"
if source_url:
note = f"{note} Source: {source_url}"
record_activity(
session,
event_type="agent.soul.updated",
message=note,
agent_id=agent_ctx.agent.id,
coordination = GatewayCoordinationService(session)
await coordination.update_agent_soul(
board=board,
target_agent_id=agent_id,
content=payload.content,
reason=payload.reason,
source_url=payload.source_url,
actor_agent_id=agent_ctx.agent.id,
correlation_id=f"soul.write:{board.id}:{agent_id}",
)
await session.commit()
return OkResponse()
@@ -732,89 +551,14 @@ async def ask_user_via_gateway_main(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayMainAskUserResponse:
"""Route a lead's ask-user request through the dedicated gateway agent."""
import json
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not board.gateway_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board is not attached to a gateway",
)
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway is not configured for this board",
)
main_session_key = gateway_agent_session_key(gateway)
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
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: {agent_ctx.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":"<answer>","tags":{tags_json},"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
try:
await ensure_session(main_session_key, config=config, label="Gateway Agent")
await send_message(message, session_key=main_session_key, config=config, deliver=True)
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="gateway.lead.ask_user.failed",
message=f"Lead user question failed for {board.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
record_activity(
session,
event_type="gateway.lead.ask_user.sent",
message=f"Lead requested user info via gateway agent for board: {board.name}.",
agent_id=agent_ctx.agent.id,
)
main_agent = await Agent.objects.filter_by(
gateway_id=gateway.id,
board_id=None,
).first(session)
await session.commit()
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,
coordination = GatewayCoordinationService(session)
return await coordination.ask_user_via_gateway_main(
board=board,
payload=payload,
actor_agent=agent_ctx.agent,
)
@@ -829,76 +573,11 @@ async def message_gateway_board_lead(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayLeadMessageResponse:
"""Send a gateway-main message to a single board lead agent."""
import json
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
board = await _require_gateway_board(session, gateway=gateway, board_id=board_id)
lead, lead_created = await ensure_board_lead_agent(
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",
)
base_url = settings.base_url or "http://localhost:8000"
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "lead_to_gateway_main"
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"From agent: {agent_ctx.agent.name}\n"
f"{correlation_line}\n"
f"{payload.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":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
try:
await ensure_session(lead.openclaw_session_id, config=config, label=lead.name)
await send_message(message, session_key=lead.openclaw_session_id, config=config)
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="gateway.main.lead_message.failed",
message=f"Lead message failed for {board.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
record_activity(
session,
event_type="gateway.main.lead_message.sent",
message=f"Sent {payload.kind} to lead for board: {board.name}.",
agent_id=agent_ctx.agent.id,
)
await session.commit()
return GatewayLeadMessageResponse(
board_id=board.id,
lead_agent_id=lead.id,
lead_agent_name=lead.name,
lead_created=lead_created,
coordination = GatewayCoordinationService(session)
return await coordination.message_gateway_board_lead(
actor_agent=agent_ctx.agent,
board_id=board_id,
payload=payload,
)
@@ -912,92 +591,8 @@ async def broadcast_gateway_lead_message(
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> GatewayLeadBroadcastResponse:
"""Broadcast a gateway-main message to multiple board leads."""
import json
gateway, config = await _require_gateway_main(session, agent_ctx.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 session.exec(statement))
base_url = settings.base_url or "http://localhost:8000"
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "lead_to_gateway_main"
results: list[GatewayLeadBroadcastBoardResult] = []
sent = 0
failed = 0
async def _send_to_board(target_board: Board) -> GatewayLeadBroadcastBoardResult:
try:
lead, _lead_created = await ensure_board_lead_agent(
session,
request=LeadAgentRequest(
board=target_board,
gateway=gateway,
config=config,
user=None,
options=LeadAgentOptions(action="provision"),
),
)
lead_session_key = _require_lead_session_key(lead)
message = (
f"{header}\n"
f"Board: {target_board.name}\n"
f"Board ID: {target_board.id}\n"
f"From agent: {agent_ctx.agent.name}\n"
f"{correlation_line}\n"
f"{payload.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/{target_board.id}/memory\n"
f'Body: {{"content":"...","tags":{tags_json},'
f'"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
await ensure_session(lead_session_key, config=config, label=lead.name)
await send_message(message, session_key=lead_session_key, config=config)
return GatewayLeadBroadcastBoardResult(
board_id=target_board.id,
lead_agent_id=lead.id,
lead_agent_name=lead.name,
ok=True,
)
except (HTTPException, OpenClawGatewayError, ValueError) as exc:
return GatewayLeadBroadcastBoardResult(
board_id=target_board.id,
ok=False,
error=str(exc),
)
for board in boards:
board_result = await _send_to_board(board)
results.append(board_result)
if board_result.ok:
sent += 1
else:
failed += 1
record_activity(
session,
event_type="gateway.main.lead_broadcast.sent",
message=f"Broadcast {payload.kind} to {sent} board leads (failed: {failed}).",
agent_id=agent_ctx.agent.id,
)
await session.commit()
return GatewayLeadBroadcastResponse(
ok=True,
sent=sent,
failed=failed,
results=results,
coordination = GatewayCoordinationService(session)
return await coordination.broadcast_gateway_lead_message(
actor_agent=agent_ctx.agent,
payload=payload,
)

File diff suppressed because it is too large Load Diff

View File

@@ -25,17 +25,16 @@ from app.core.config import settings
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.integrations.openclaw_gateway import OpenClawGatewayError
from app.models.agents import Agent
from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw import optional_gateway_config_for_board, send_gateway_agent_message
from app.services.organizations import (
is_org_admin,
list_accessible_board_ids,
@@ -95,30 +94,6 @@ def _serialize_memory(memory: BoardGroupMemory) -> dict[str, object]:
).model_dump(mode="json")
async def _gateway_config(
session: AsyncSession,
board: Board,
) -> GatewayClientConfig | None:
if board.gateway_id is None:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _send_agent_message(
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=deliver)
async def _fetch_memory_events(
session: AsyncSession,
board_group_id: UUID,
@@ -249,7 +224,7 @@ async def _notify_group_target(
board = context.board_by_id.get(board_id)
if board is None:
return
config = await _gateway_config(context.session, board)
config = await optional_gateway_config_for_board(context.session, board)
if config is None:
return
header = _group_header(
@@ -266,7 +241,7 @@ async def _notify_group_target(
'Body: {"content":"...","tags":["chat"]}'
)
try:
await _send_agent_message(
await send_gateway_agent_message(
session_key=session_key,
config=config,
agent_name=agent.name,

View File

@@ -29,8 +29,8 @@ from app.schemas.board_groups import BoardGroupCreate, BoardGroupRead, BoardGrou
from app.schemas.common import OkResponse
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats
from app.services.board_group_snapshot import build_group_snapshot
from app.services.openclaw import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats
from app.services.organizations import (
OrganizationContext,
board_access_filter,

View File

@@ -24,13 +24,13 @@ from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.integrations.openclaw_gateway import OpenClawGatewayError
from app.models.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.gateways import Gateway
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw import optional_gateway_config_for_board, send_gateway_agent_message
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -75,30 +75,6 @@ def _serialize_memory(memory: BoardMemory) -> dict[str, object]:
).model_dump(mode="json")
async def _gateway_config(
session: AsyncSession,
board: Board,
) -> GatewayClientConfig | None:
if board.gateway_id is None:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _send_agent_message(
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=deliver)
async def _fetch_memory_events(
session: AsyncSession,
board_id: UUID,
@@ -138,7 +114,7 @@ async def _send_control_command(
if not agent.openclaw_session_id:
continue
try:
await _send_agent_message(
await send_gateway_agent_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
@@ -184,7 +160,7 @@ async def _notify_chat_targets(
) -> None:
if not memory.content:
return
config = await _gateway_config(session, board)
config = await optional_gateway_config_for_board(session, board)
if config is None:
return
@@ -230,7 +206,7 @@ async def _notify_chat_targets(
'Body: {"content":"...","tags":["chat"]}'
)
try:
await _send_agent_message(
await send_gateway_agent_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,

View File

@@ -20,8 +20,6 @@ from app.api.deps import (
from app.core.config import settings
from app.core.time import utcnow
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.board_onboarding import BoardOnboardingSession
from app.models.gateways import Gateway
from app.schemas.board_onboarding import (
@@ -35,8 +33,13 @@ from app.schemas.board_onboarding import (
BoardOnboardingUserProfile,
)
from app.schemas.boards import BoardRead
from app.services.board_leads import LeadAgentOptions, LeadAgentRequest, ensure_board_lead_agent
from app.services.gateway_agents import gateway_agent_session_key
from app.services.openclaw import (
BoardOnboardingMessagingService,
LeadAgentOptions,
LeadAgentRequest,
ensure_board_lead_agent,
require_gateway_config_for_board,
)
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -54,18 +57,6 @@ ACTOR_DEP = Depends(require_admin_or_agent)
ADMIN_AUTH_DEP = Depends(require_admin_auth)
async def _gateway_config(
session: AsyncSession,
board: Board,
) -> tuple[Gateway, GatewayClientConfig]:
if not board.gateway_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
def _parse_draft_user_profile(
draft_goal: object,
) -> BoardOnboardingUserProfile | None:
@@ -178,8 +169,7 @@ async def start_onboarding(
if onboarding:
return onboarding
gateway, config = await _gateway_config(session, board)
session_key = gateway_agent_session_key(gateway)
dispatcher = BoardOnboardingMessagingService(session)
base_url = settings.base_url or "http://localhost:8000"
prompt = (
"BOARD ONBOARDING REQUEST\n\n"
@@ -246,19 +236,11 @@ async def start_onboarding(
"working style.\n"
)
try:
await ensure_session(session_key, config=config, label="Gateway Agent")
await send_message(
prompt,
session_key=session_key,
config=config,
deliver=False,
)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
session_key = await dispatcher.dispatch_start_prompt(
board=board,
prompt=prompt,
correlation_id=f"onboarding.start:{board.id}",
)
onboarding = BoardOnboardingSession(
board_id=board.id,
@@ -289,7 +271,7 @@ async def answer_onboarding(
if onboarding is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
_, config = await _gateway_config(session, board)
dispatcher = BoardOnboardingMessagingService(session)
answer_text = payload.answer
if payload.other_text:
answer_text = f"{payload.answer}: {payload.other_text}"
@@ -299,19 +281,12 @@ async def answer_onboarding(
{"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()},
)
try:
await ensure_session(onboarding.session_key, config=config, label="Gateway Agent")
await send_message(
answer_text,
session_key=onboarding.session_key,
config=config,
deliver=False,
)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
await dispatcher.dispatch_answer(
board=board,
onboarding=onboarding,
answer_text=answer_text,
correlation_id=f"onboarding.answer:{board.id}:{onboarding.id}",
)
onboarding.messages = messages
onboarding.updated_at = utcnow()
@@ -337,10 +312,7 @@ async def agent_onboarding_update(
if board.gateway_id:
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if (
gateway
and (agent.gateway_id != gateway.id or agent.board_id is not None)
):
if gateway and (agent.gateway_id != gateway.id or agent.board_id is not None):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
onboarding = (
@@ -421,7 +393,7 @@ async def confirm_onboarding(
lead_agent = _parse_draft_lead_agent(onboarding.draft_goal)
lead_options = _lead_agent_options(lead_agent)
gateway, config = await _gateway_config(session, board)
gateway, config = await require_gateway_config_for_board(session, board)
session.add(board)
session.add(onboarding)
await session.commit()

View File

@@ -2,9 +2,8 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
@@ -21,13 +20,7 @@ from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import (
OpenClawGatewayError,
delete_session,
ensure_session,
send_message,
)
from app.integrations.openclaw_gateway import OpenClawGatewayError
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.approvals import Approval
@@ -47,7 +40,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
from app.services.board_group_snapshot import build_board_group_snapshot
from app.services.board_snapshot import build_board_snapshot
from app.services.gateway_agents import gateway_agent_session_key
from app.services.openclaw import cleanup_agent
from app.services.organizations import OrganizationContext, board_access_filter
if TYPE_CHECKING:
@@ -56,7 +49,6 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/boards", tags=["boards"])
AGENT_SESSION_PREFIX = "agent"
SESSION_DEP = Depends(get_session)
ORG_ADMIN_DEP = Depends(require_org_admin)
ORG_MEMBER_DEP = Depends(require_org_member)
@@ -70,15 +62,6 @@ INCLUDE_DONE_QUERY = Query(default=False)
PER_BOARD_TASK_LIMIT_QUERY = Query(default=5, ge=0, le=100)
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or uuid4().hex
def _build_session_key(agent_name: str) -> str:
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
async def _require_gateway(
session: AsyncSession,
gateway_id: object,
@@ -187,9 +170,9 @@ async def _apply_board_update(
async def _board_gateway(
session: AsyncSession,
board: Board,
) -> tuple[Gateway | None, GatewayClientConfig | None]:
) -> Gateway | None:
if not board.gateway_id:
return None, None
return None
config = await Gateway.objects.by_id(board.gateway_id).first(session)
if config is None:
raise HTTPException(
@@ -206,37 +189,7 @@ async def _board_gateway(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway workspace_root is required",
)
return config, GatewayClientConfig(url=config.url, token=config.token)
async def _cleanup_agent_on_gateway(
agent: Agent,
config: Gateway,
client_config: GatewayClientConfig,
) -> None:
if agent.openclaw_session_id:
await delete_session(agent.openclaw_session_id, config=client_config)
main_session = gateway_agent_session_key(config)
workspace_root = config.workspace_root
workspace_path = f"{workspace_root.rstrip('/')}/workspace-{_slugify(agent.name)}"
cleanup_message = (
"Cleanup request for deleted agent.\n\n"
f"Agent name: {agent.name}\n"
f"Agent id: {agent.id}\n"
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
f"Workspace path: {workspace_path}\n\n"
"Actions:\n"
"1) Remove the workspace directory.\n"
"2) Delete any lingering session artifacts.\n"
"Reply NO_REPLY."
)
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,
)
return config
@router.get("", response_model=DefaultLimitOffsetPage[BoardRead])
@@ -330,11 +283,11 @@ async def delete_board(
await session.exec(select(Task.id).where(Task.board_id == board.id)),
)
config, client_config = await _board_gateway(session, board)
if config and client_config:
config = await _board_gateway(session, board)
if config:
try:
for agent in agents:
await _cleanup_agent_on_gateway(agent, config, client_config)
await cleanup_agent(agent, config)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,

View File

@@ -1,32 +1,19 @@
"""Gateway inspection and session-management endpoints."""
"""Thin gateway session-inspection API wrappers."""
from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import col
from fastapi import APIRouter, Depends, Query
from app.api.deps import require_org_admin
from app.core.auth import AuthContext, get_auth_context
from app.db.session import get_session
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.integrations.openclaw_gateway_protocol import (
GATEWAY_EVENTS,
GATEWAY_METHODS,
PROTOCOL_VERSION,
)
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.schemas.common import OkResponse
from app.schemas.gateway_api import (
GatewayCommandsResponse,
@@ -37,13 +24,12 @@ from app.schemas.gateway_api import (
GatewaySessionsResponse,
GatewaysStatusResponse,
)
from app.services.organizations import OrganizationContext, require_board_access
from app.services.openclaw import GatewaySessionService
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.users import User
router = APIRouter(prefix="/gateways", tags=["gateways"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
@@ -56,7 +42,7 @@ def _query_to_resolve_input(
gateway_url: str | None = Query(default=None),
gateway_token: str | None = Query(default=None),
) -> GatewayResolveQuery:
return GatewayResolveQuery(
return GatewaySessionService.to_resolve_query(
board_id=board_id,
gateway_url=gateway_url,
gateway_token=gateway_token,
@@ -66,94 +52,6 @@ def _query_to_resolve_input(
RESOLVE_INPUT_DEP = Depends(_query_to_resolve_input)
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(
session: AsyncSession,
params: GatewayResolveQuery,
*,
user: User | None = None,
) -> tuple[Board | None, GatewayClientConfig, str | None]:
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(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(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(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(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(
session: AsyncSession,
board_id: str | None,
*,
user: User | None = None,
) -> tuple[Board, GatewayClientConfig, str | None]:
params = GatewayResolveQuery(board_id=board_id)
board, config, main_session = await _resolve_gateway(
session,
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
@router.get("/status", response_model=GatewaysStatusResponse)
async def gateways_status(
params: GatewayResolveQuery = RESOLVE_INPUT_DEP,
@@ -162,46 +60,12 @@ async def gateways_status(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaysStatusResponse:
"""Return gateway connectivity and session status."""
board, config, main_session = await _resolve_gateway(
session,
params,
service = GatewaySessionService(session)
return await service.get_status(
params=params,
organization_id=ctx.organization.id,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
sessions = await openclaw_call("sessions.list", config=config)
if isinstance(sessions, dict):
sessions_list = _as_object_list(sessions.get("sessions"))
else:
sessions_list = _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),
)
@router.get("/sessions", response_model=GatewaySessionsResponse)
@@ -212,67 +76,12 @@ async def list_gateway_sessions(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaySessionsResponse:
"""List sessions for a gateway associated with a board."""
params = GatewayResolveQuery(board_id=board_id)
board, config, main_session = await _resolve_gateway(
session,
params,
service = GatewaySessionService(session)
return await service.get_sessions(
board_id=board_id,
organization_id=ctx.organization.id,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
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 = _as_object_list(sessions.get("sessions"))
else:
sessions_list = _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 _list_sessions(config: GatewayClientConfig) -> list[dict[str, object]]:
sessions = await openclaw_call("sessions.list", config=config)
if isinstance(sessions, dict):
raw_items = _as_object_list(sessions.get("sessions"))
else:
raw_items = _as_object_list(sessions)
return [item for item in raw_items if isinstance(item, dict)]
async def _with_main_session(
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 _list_sessions(config)
except OpenClawGatewayError:
return sessions_list
@router.get("/sessions/{session_id}", response_model=GatewaySessionResponse)
@@ -284,53 +93,16 @@ async def get_gateway_session(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaySessionResponse:
"""Get a specific gateway session by key."""
params = GatewayResolveQuery(board_id=board_id)
board, config, main_session = await _resolve_gateway(
session,
params,
service = GatewaySessionService(session)
return await service.get_session(
session_id=session_id,
board_id=board_id,
organization_id=ctx.organization.id,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
sessions_list = await _list_sessions(config)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
sessions_list = await _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)
@router.get(
"/sessions/{session_id}/history",
response_model=GatewaySessionHistoryResponse,
)
@router.get("/sessions/{session_id}/history", response_model=GatewaySessionHistoryResponse)
async def get_session_history(
session_id: str,
board_id: str | None = BOARD_ID_QUERY,
@@ -339,19 +111,13 @@ async def get_session_history(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaySessionHistoryResponse:
"""Fetch chat history for a gateway session."""
board, config, _ = await _require_gateway(session, board_id, user=auth.user)
if board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
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=_as_object_list(history))
service = GatewaySessionService(session)
return await service.get_session_history(
session_id=session_id,
board_id=board_id,
organization_id=ctx.organization.id,
user=auth.user,
)
@router.post("/sessions/{session_id}/message", response_model=OkResponse)
@@ -363,23 +129,13 @@ async def send_gateway_session_message(
auth: AuthContext = AUTH_DEP,
) -> OkResponse:
"""Send a message into a specific gateway session."""
board, config, main_session = await _require_gateway(
session,
board_id,
service = GatewaySessionService(session)
await service.send_session_message(
session_id=session_id,
payload=payload,
board_id=board_id,
user=auth.user,
)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.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
return OkResponse()

View File

@@ -1,34 +1,20 @@
"""Gateway CRUD and template synchronization endpoints."""
"""Thin API wrappers for gateway CRUD and template synchronization."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, Query
from sqlmodel import col
from app.api.deps import require_org_admin
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.integrations.openclaw_gateway import 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.common import OkResponse
from app.schemas.gateways import (
GatewayCreate,
@@ -37,24 +23,12 @@ from app.schemas.gateways import (
GatewayUpdate,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG,
MainAgentProvisionRequest,
ProvisionOptions,
provision_main_agent,
)
from app.services.gateway_agents import (
gateway_agent_session_key,
gateway_openclaw_agent_id,
)
from app.services.template_sync import GatewayTemplateSyncOptions
from app.services.template_sync import sync_gateway_templates as sync_gateway_templates_service
from app.services.openclaw import GatewayAdminLifecycleService, GatewayTemplateSyncQuery
if TYPE_CHECKING:
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.users import User
from app.services.organizations import OrganizationContext
router = APIRouter(prefix="/gateways", tags=["gateways"])
@@ -67,16 +41,6 @@ ROTATE_TOKENS_QUERY = Query(default=False)
FORCE_BOOTSTRAP_QUERY = Query(default=False)
BOARD_ID_QUERY = Query(default=None)
_RUNTIME_TYPE_REFERENCES = (UUID,)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _TemplateSyncQuery:
include_main: bool
reset_sessions: bool
rotate_tokens: bool
force_bootstrap: bool
board_id: UUID | None
def _template_sync_query(
@@ -86,8 +50,8 @@ def _template_sync_query(
rotate_tokens: bool = ROTATE_TOKENS_QUERY,
force_bootstrap: bool = FORCE_BOOTSTRAP_QUERY,
board_id: UUID | None = BOARD_ID_QUERY,
) -> _TemplateSyncQuery:
return _TemplateSyncQuery(
) -> GatewayTemplateSyncQuery:
return GatewayTemplateSyncQuery(
include_main=include_main,
reset_sessions=reset_sessions,
rotate_tokens=rotate_tokens,
@@ -99,301 +63,15 @@ def _template_sync_query(
SYNC_QUERY_DEP = Depends(_template_sync_query)
def _main_agent_name(gateway: Gateway) -> str:
return f"{gateway.name} Gateway Agent"
def _gateway_identity_profile() -> dict[str, str]:
return {
"role": "Gateway Agent",
"communication_style": "direct, concise, practical",
"emoji": ":compass:",
}
async def _require_gateway(
session: AsyncSession,
*,
gateway_id: UUID,
organization_id: UUID,
) -> Gateway:
gateway = (
await Gateway.objects.by_id(gateway_id)
.filter(col(Gateway.organization_id) == organization_id)
.first(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(
session: AsyncSession,
gateway: Gateway,
) -> Agent | None:
return (
await Agent.objects.filter_by(gateway_id=gateway.id)
.filter(col(Agent.board_id).is_(None))
.first(session)
)
async def _upsert_main_agent_record(
session: AsyncSession,
gateway: Gateway,
) -> tuple[Agent, bool]:
changed = False
session_key = gateway_agent_session_key(gateway)
agent = await _find_main_agent(session, gateway)
if agent is None:
agent = Agent(
name=_main_agent_name(gateway),
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=_gateway_identity_profile(),
)
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(gateway):
agent.name = _main_agent_name(gateway)
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 = _gateway_identity_profile()
changed = True
if not agent.status:
agent.status = "provisioning"
changed = True
if changed:
agent.updated_at = utcnow()
session.add(agent)
return agent, changed
async def _ensure_gateway_agents_exist(
session: AsyncSession,
gateways: list[Gateway],
) -> None:
for gateway in gateways:
agent, gateway_changed = await _upsert_main_agent_record(session, gateway)
has_gateway_entry = await _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 _provision_main_agent_record(
session,
gateway,
agent,
user=None,
action="provision",
notify=False,
)
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
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 _gateway_has_main_agent_entry(gateway: Gateway) -> bool:
if not gateway.url:
return False
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
target_id = gateway_openclaw_agent_id(gateway)
try:
payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError:
# Avoid treating transient gateway connectivity issues as a missing agent entry.
return True
for item in _extract_agents_list(payload):
if _extract_agent_id_from_entry(item) == target_id:
return True
return False
async def _provision_main_agent_record(
session: AsyncSession,
gateway: Gateway,
agent: Agent,
*,
user: User | None,
action: str,
notify: bool,
) -> Agent:
session_key = gateway_agent_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()
session.add(agent)
await session.commit()
await 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,
)
except OpenClawGatewayError as exc:
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:
logger.warning(
"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
logger.warning(
"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(
session: AsyncSession,
gateway: Gateway,
auth: AuthContext,
*,
action: str = "provision",
) -> Agent:
agent, _ = await _upsert_main_agent_record(session, gateway)
return await _provision_main_agent_record(
session,
gateway,
agent,
user=auth.user,
action=action,
notify=True,
)
async def _clear_agent_foreign_keys(
session: AsyncSession,
*,
agent_id: UUID,
) -> None:
now = utcnow()
await crud.update_where(
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(
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(
session,
ActivityEvent,
col(ActivityEvent.agent_id) == agent_id,
agent_id=None,
commit=False,
)
await crud.update_where(
session,
Approval,
col(Approval.agent_id) == agent_id,
agent_id=None,
commit=False,
)
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
async def list_gateways(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> LimitOffsetPage[GatewayRead]:
"""List gateways for the caller's organization."""
service = GatewayAdminLifecycleService(session)
gateways = await Gateway.objects.filter_by(organization_id=ctx.organization.id).all(session)
await _ensure_gateway_agents_exist(session, gateways)
await service.ensure_gateway_agents_exist(gateways)
statement = (
Gateway.objects.filter_by(organization_id=ctx.organization.id)
.order_by(col(Gateway.created_at).desc())
@@ -410,12 +88,13 @@ async def create_gateway(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> Gateway:
"""Create a gateway and provision or refresh its main agent."""
service = GatewayAdminLifecycleService(session)
data = payload.model_dump()
gateway_id = uuid4()
data["id"] = gateway_id
data["organization_id"] = ctx.organization.id
gateway = await crud.create(session, Gateway, **data)
await _ensure_main_agent(session, gateway, auth, action="provision")
await service.ensure_main_agent(gateway, auth, action="provision")
return gateway
@@ -426,12 +105,12 @@ async def get_gateway(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> Gateway:
"""Return one gateway by id for the caller's organization."""
gateway = await _require_gateway(
session,
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
await _ensure_gateway_agents_exist(session, [gateway])
await service.ensure_gateway_agents_exist([gateway])
return gateway
@@ -444,49 +123,32 @@ async def update_gateway(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> Gateway:
"""Patch a gateway and refresh the main-agent provisioning state."""
gateway = await _require_gateway(
session,
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
updates = payload.model_dump(exclude_unset=True)
await crud.patch(session, gateway, updates)
await _ensure_main_agent(
session,
gateway,
auth,
action="update",
)
await service.ensure_main_agent(gateway, auth, action="update")
return gateway
@router.post("/{gateway_id}/templates/sync", response_model=GatewayTemplatesSyncResult)
async def sync_gateway_templates(
gateway_id: UUID,
sync_query: _TemplateSyncQuery = SYNC_QUERY_DEP,
sync_query: GatewayTemplateSyncQuery = SYNC_QUERY_DEP,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewayTemplatesSyncResult:
"""Sync templates for a gateway and optionally rotate runtime settings."""
gateway = await _require_gateway(
session,
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
await _ensure_gateway_agents_exist(session, [gateway])
return await sync_gateway_templates_service(
session,
gateway,
GatewayTemplateSyncOptions(
user=auth.user,
include_main=sync_query.include_main,
reset_sessions=sync_query.reset_sessions,
rotate_tokens=sync_query.rotate_tokens,
force_bootstrap=sync_query.force_bootstrap,
board_id=sync_query.board_id,
),
)
return await service.sync_templates(gateway, query=sync_query, auth=auth)
@router.delete("/{gateway_id}", response_model=OkResponse)
@@ -496,14 +158,14 @@ async def delete_gateway(
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
"""Delete a gateway in the caller's organization."""
gateway = await _require_gateway(
session,
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
main_agent = await _find_main_agent(session, gateway)
main_agent = await service.find_main_agent(gateway)
if main_agent is not None:
await _clear_agent_foreign_keys(session, agent_id=main_agent.id)
await service.clear_agent_foreign_keys(agent_id=main_agent.id)
await session.delete(main_agent)
duplicate_main_agents = await Agent.objects.filter_by(
@@ -513,7 +175,7 @@ async def delete_gateway(
for agent in duplicate_main_agents:
if main_agent is not None and agent.id == main_agent.id:
continue
await _clear_agent_foreign_keys(session, agent_id=agent.id)
await service.clear_agent_foreign_keys(agent_id=agent.id)
await session.delete(agent)
await session.delete(gateway)

View File

@@ -29,12 +29,11 @@ from app.db import crud
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.integrations.openclaw_gateway import OpenClawGatewayError
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.approvals import Approval
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.task_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task
@@ -44,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw import optional_gateway_config_for_board, send_gateway_agent_message
from app.services.organizations import require_board_access
from app.services.task_dependencies import (
blocked_by_dependency_ids,
@@ -301,26 +301,19 @@ def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
return TaskCommentRead.model_validate(event).model_dump(mode="json")
async def _gateway_config(
session: AsyncSession,
board: Board,
) -> GatewayClientConfig | None:
if not board.gateway_id:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _send_lead_task_message(
*,
session_key: str,
config: GatewayClientConfig,
message: str,
) -> None:
await ensure_session(session_key, config=config, label="Lead Agent")
await send_message(message, session_key=session_key, config=config, deliver=False)
await send_gateway_agent_message(
session_key=session_key,
config=config,
agent_name="Lead Agent",
message=message,
deliver=False,
)
async def _send_agent_task_message(
@@ -330,8 +323,13 @@ async def _send_agent_task_message(
agent_name: str,
message: str,
) -> None:
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=False)
await send_gateway_agent_message(
session_key=session_key,
config=config,
agent_name=agent_name,
message=message,
deliver=False,
)
async def _notify_agent_on_task_assign(
@@ -343,7 +341,7 @@ async def _notify_agent_on_task_assign(
) -> None:
if not agent.openclaw_session_id:
return
config = await _gateway_config(session, board)
config = await optional_gateway_config_for_board(session, board)
if config is None:
return
description = _truncate_snippet(task.description or "")
@@ -415,7 +413,7 @@ async def _notify_lead_on_task_create(
)
if lead is None or not lead.openclaw_session_id:
return
config = await _gateway_config(session, board)
config = await optional_gateway_config_for_board(session, board)
if config is None:
return
description = _truncate_snippet(task.description or "")
@@ -470,7 +468,7 @@ async def _notify_lead_on_task_unassigned(
)
if lead is None or not lead.openclaw_session_id:
return
config = await _gateway_config(session, board)
config = await optional_gateway_config_for_board(session, board)
if config is None:
return
description = _truncate_snippet(task.description or "")
@@ -1029,7 +1027,7 @@ async def _notify_task_comment_targets(
if request.task.board_id
else None
)
config = await _gateway_config(session, board) if board else None
config = await optional_gateway_config_for_board(session, board) if board else None
if not board or not config:
return

View File

@@ -1,159 +0,0 @@
"""Helpers for ensuring each board has a provisioned lead agent."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from sqlmodel import col, select
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.time import utcnow
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.agents import Agent
from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG,
AgentProvisionRequest,
ProvisionOptions,
provision_agent,
)
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
def lead_session_key(board: Board) -> str:
"""Return the deterministic main session key for a board lead agent."""
return f"agent:lead-{board.id}:main"
def lead_agent_name(_: Board) -> str:
"""Return the default display name for board lead agents."""
return "Lead Agent"
@dataclass(frozen=True, slots=True)
class LeadAgentOptions:
"""Optional overrides for board-lead provisioning behavior."""
agent_name: str | None = None
identity_profile: dict[str, str] | None = None
action: str = "provision"
@dataclass(frozen=True, slots=True)
class LeadAgentRequest:
"""Inputs required to ensure or provision a board lead agent."""
board: Board
gateway: Gateway
config: GatewayClientConfig
user: User | None
options: LeadAgentOptions = field(default_factory=LeadAgentOptions)
async def ensure_board_lead_agent(
session: AsyncSession,
*,
request: LeadAgentRequest,
) -> tuple[Agent, bool]:
"""Ensure a board has a lead agent; return `(agent, created)`."""
board = request.board
config_options = request.options
existing = (
await session.exec(
select(Agent)
.where(Agent.board_id == board.id)
.where(col(Agent.is_board_lead).is_(True)),
)
).first()
if existing:
desired_name = config_options.agent_name or lead_agent_name(board)
changed = False
if existing.name != desired_name:
existing.name = desired_name
changed = True
if existing.gateway_id != request.gateway.id:
existing.gateway_id = request.gateway.id
changed = True
desired_session_key = lead_session_key(board)
if not existing.openclaw_session_id:
existing.openclaw_session_id = desired_session_key
changed = True
if changed:
existing.updated_at = utcnow()
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing, False
merged_identity_profile: dict[str, Any] = {
"role": "Board Lead",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
}
if config_options.identity_profile:
merged_identity_profile.update(
{
key: value.strip()
for key, value in config_options.identity_profile.items()
if value.strip()
},
)
agent = Agent(
name=config_options.agent_name or lead_agent_name(board),
status="provisioning",
board_id=board.id,
gateway_id=request.gateway.id,
is_board_lead=True,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile=merged_identity_profile,
openclaw_session_id=lead_session_key(board),
provision_requested_at=utcnow(),
provision_action=config_options.action,
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session.add(agent)
await session.commit()
await session.refresh(agent)
try:
await provision_agent(
agent,
AgentProvisionRequest(
board=board,
gateway=request.gateway,
auth_token=raw_token,
user=request.user,
options=ProvisionOptions(action=config_options.action),
),
)
if agent.openclaw_session_id:
await ensure_session(
agent.openclaw_session_id,
config=request.config,
label=agent.name,
)
await send_message(
(
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run "
"it once "
"then delete it. Begin heartbeats after startup."
),
session_key=agent.openclaw_session_id,
config=request.config,
deliver=True,
)
except OpenClawGatewayError:
# Best-effort provisioning. The board/agent rows should still exist.
pass
return agent, True

View File

@@ -2,22 +2,20 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from sqlalchemy import case, func
from sqlmodel import col, select
from app.core.time import utcnow
from app.models.agents import Agent
from app.models.approvals import Approval
from app.models.board_memory import BoardMemory
from app.models.tasks import Task
from app.schemas.agents import AgentRead
from app.schemas.approvals import ApprovalRead
from app.schemas.board_memory import BoardMemoryRead
from app.schemas.boards import BoardRead
from app.schemas.view_models import BoardSnapshot, TaskCardRead
from app.services.openclaw import AgentLifecycleService
from app.services.task_dependencies import (
blocked_by_dependency_ids,
dependency_ids_by_task_id,
@@ -31,31 +29,6 @@ if TYPE_CHECKING:
from app.models.boards import Board
OFFLINE_AFTER = timedelta(minutes=10)
def _computed_agent_status(agent: Agent) -> str:
now = utcnow()
if agent.status in {"deleting", "updating"}:
return agent.status
if agent.last_seen_at is None:
return "provisioning"
if now - agent.last_seen_at > OFFLINE_AFTER:
return "offline"
return agent.status
def _agent_to_read(agent: Agent) -> AgentRead:
model = AgentRead.model_validate(agent, from_attributes=True)
computed_status = _computed_agent_status(agent)
is_gateway_main = agent.gateway_id is not None and agent.board_id is None
return model.model_copy(
update={
"status": computed_status,
"is_gateway_main": is_gateway_main,
},
)
def _memory_to_read(memory: BoardMemory) -> BoardMemoryRead:
return BoardMemoryRead.model_validate(memory, from_attributes=True)
@@ -125,7 +98,10 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap
.order_by(col(Agent.created_at).desc())
.all(session)
)
agent_reads = [_agent_to_read(agent) for agent in agents]
agent_reads = [
AgentLifecycleService.to_agent_read(AgentLifecycleService.with_computed_status(agent))
for agent in agents
]
agent_name_by_id = {agent.id: agent.name for agent in agents}
pending_approvals_count = int(

View File

@@ -1,31 +0,0 @@
"""Helpers for dedicated gateway-scoped agent identity/session keys."""
from __future__ import annotations
from uuid import UUID
from app.models.gateways import Gateway
_GATEWAY_AGENT_PREFIX = "agent:gateway-"
_GATEWAY_AGENT_SUFFIX = ":main"
_GATEWAY_OPENCLAW_AGENT_PREFIX = "mc-gateway-"
def gateway_agent_session_key_for_id(gateway_id: UUID) -> str:
"""Return the dedicated Mission Control gateway-agent session key for an id."""
return f"{_GATEWAY_AGENT_PREFIX}{gateway_id}{_GATEWAY_AGENT_SUFFIX}"
def gateway_agent_session_key(gateway: Gateway) -> str:
"""Return the dedicated Mission Control gateway-agent session key."""
return gateway_agent_session_key_for_id(gateway.id)
def gateway_openclaw_agent_id_for_id(gateway_id: UUID) -> str:
"""Return the dedicated OpenClaw config `agentId` for a gateway agent."""
return f"{_GATEWAY_OPENCLAW_AGENT_PREFIX}{gateway_id}"
def gateway_openclaw_agent_id(gateway: Gateway) -> str:
"""Return the dedicated OpenClaw config `agentId` for a gateway agent."""
return gateway_openclaw_agent_id_for_id(gateway.id)

View File

@@ -0,0 +1,7 @@
"""OpenClaw lifecycle services package."""
from .constants import * # noqa: F401,F403
from .exceptions import * # noqa: F401,F403
from .provisioning import * # noqa: F401,F403
from .services import * # noqa: F401,F403
from .shared import * # noqa: F401,F403

View File

@@ -0,0 +1,120 @@
"""Shared constants for lifecycle orchestration services."""
from __future__ import annotations
import random
import re
from datetime import timedelta
from typing import Any
_GATEWAY_AGENT_PREFIX = "agent:gateway-"
_GATEWAY_AGENT_SUFFIX = ":main"
_GATEWAY_OPENCLAW_AGENT_PREFIX = "mc-gateway-"
DEFAULT_HEARTBEAT_CONFIG: dict[str, Any] = {
"every": "10m",
"target": "none",
"includeReasoning": False,
}
OFFLINE_AFTER = timedelta(minutes=10)
AGENT_SESSION_PREFIX = "agent"
DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY: dict[str, bool] = {
# Suppress routine HEARTBEAT_OK delivery by default.
"showOk": False,
"showAlerts": True,
"useIndicator": True,
}
DEFAULT_IDENTITY_PROFILE = {
"role": "Generalist",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
}
IDENTITY_PROFILE_FIELDS = {
"role": "identity_role",
"communication_style": "identity_communication_style",
"emoji": "identity_emoji",
}
EXTRA_IDENTITY_PROFILE_FIELDS = {
"autonomy_level": "identity_autonomy_level",
"verbosity": "identity_verbosity",
"output_format": "identity_output_format",
"update_cadence": "identity_update_cadence",
# Per-agent charter (optional).
# Used to give agents a "purpose in life" and a distinct vibe.
"purpose": "identity_purpose",
"personality": "identity_personality",
"custom_instructions": "identity_custom_instructions",
}
DEFAULT_GATEWAY_FILES = frozenset(
{
"AGENTS.md",
"SOUL.md",
"TASK_SOUL.md",
"SELF.md",
"AUTONOMY.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOT.md",
"BOOTSTRAP.md",
"MEMORY.md",
},
)
# These files are intended to evolve within the agent workspace.
# Provision them if missing, but avoid overwriting existing content during updates.
#
# Examples:
# - SELF.md: evolving identity/preferences
# - USER.md: human-provided context + lead intake notes
# - MEMORY.md: curated long-term memory (consolidated)
PRESERVE_AGENT_EDITABLE_FILES = frozenset({"SELF.md", "USER.md", "MEMORY.md", "TASK_SOUL.md"})
HEARTBEAT_LEAD_TEMPLATE = "HEARTBEAT_LEAD.md"
HEARTBEAT_AGENT_TEMPLATE = "HEARTBEAT_AGENT.md"
SESSION_KEY_PARTS_MIN = 2
_SESSION_KEY_PARTS_MIN = SESSION_KEY_PARTS_MIN
MAIN_TEMPLATE_MAP = {
"AGENTS.md": "MAIN_AGENTS.md",
"HEARTBEAT.md": "MAIN_HEARTBEAT.md",
"USER.md": "MAIN_USER.md",
"BOOT.md": "MAIN_BOOT.md",
"TOOLS.md": "MAIN_TOOLS.md",
}
_TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$")
_NON_TRANSIENT_GATEWAY_ERROR_MARKERS = ("unsupported file",)
_TRANSIENT_GATEWAY_ERROR_MARKERS = (
"connect call failed",
"connection refused",
"errno 111",
"econnrefused",
"did not receive a valid http response",
"no route to host",
"network is unreachable",
"host is down",
"name or service not known",
"received 1012",
"service restart",
"http 503",
"http 502",
"http 504",
"temporar",
"timeout",
"timed out",
"connection closed",
"connection reset",
)
_COORDINATION_GATEWAY_TIMEOUT_S = 45.0
_COORDINATION_GATEWAY_BASE_DELAY_S = 0.5
_COORDINATION_GATEWAY_MAX_DELAY_S = 5.0
_SECURE_RANDOM = random.SystemRandom()

View File

@@ -0,0 +1,90 @@
"""OpenClaw-specific exception definitions and mapping helpers."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from fastapi import HTTPException, status
class GatewayOperation(str, Enum):
"""Typed gateway operations used for consistent HTTP error mapping."""
NUDGE_AGENT = "nudge_agent"
SOUL_READ = "soul_read"
SOUL_WRITE = "soul_write"
ASK_USER_DISPATCH = "ask_user_dispatch"
LEAD_MESSAGE_DISPATCH = "lead_message_dispatch"
LEAD_BROADCAST_DISPATCH = "lead_broadcast_dispatch"
ONBOARDING_START_DISPATCH = "onboarding_start_dispatch"
ONBOARDING_ANSWER_DISPATCH = "onboarding_answer_dispatch"
@dataclass(frozen=True, slots=True)
class GatewayErrorPolicy:
"""HTTP policy for mapping gateway operation failures."""
status_code: int
detail_template: str
_GATEWAY_ERROR_POLICIES: dict[GatewayOperation, GatewayErrorPolicy] = {
GatewayOperation.NUDGE_AGENT: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway nudge failed: {error}",
),
GatewayOperation.SOUL_READ: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway SOUL read failed: {error}",
),
GatewayOperation.SOUL_WRITE: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway SOUL update failed: {error}",
),
GatewayOperation.ASK_USER_DISPATCH: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway ask-user dispatch failed: {error}",
),
GatewayOperation.LEAD_MESSAGE_DISPATCH: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway lead message dispatch failed: {error}",
),
GatewayOperation.LEAD_BROADCAST_DISPATCH: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway lead broadcast dispatch failed: {error}",
),
GatewayOperation.ONBOARDING_START_DISPATCH: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway onboarding start dispatch failed: {error}",
),
GatewayOperation.ONBOARDING_ANSWER_DISPATCH: GatewayErrorPolicy(
status_code=status.HTTP_502_BAD_GATEWAY,
detail_template="Gateway onboarding answer dispatch failed: {error}",
),
}
def map_gateway_error_to_http_exception(
operation: GatewayOperation,
exc: Exception,
) -> HTTPException:
"""Map a gateway failure into a typed HTTP exception."""
policy = _GATEWAY_ERROR_POLICIES[operation]
return HTTPException(
status_code=policy.status_code,
detail=policy.detail_template.format(error=str(exc)),
)
def map_gateway_error_message(
operation: GatewayOperation,
exc: Exception,
) -> str:
"""Map a gateway failure into a stable error message string."""
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, str):
return detail
return str(detail)
return map_gateway_error_to_http_exception(operation, exc).detail

View File

@@ -1,106 +1,65 @@
"""Gateway-facing agent provisioning and cleanup helpers."""
"""Provisioning, template sync, and board-lead lifecycle orchestration."""
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
import hashlib
import json
import re
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from typing import TYPE_CHECKING, Any, TypeVar
from uuid import UUID, uuid4
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
from sqlalchemy import func
from sqlmodel import col, select
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
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, ensure_session, openclaw_call
from app.services.gateway_agents import (
gateway_agent_session_key,
gateway_openclaw_agent_id,
from app.integrations.openclaw_gateway import (
OpenClawGatewayError,
ensure_session,
openclaw_call,
send_message,
)
from app.models.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.boards import Board
from app.models.gateways import Gateway
from app.schemas.gateways import GatewayTemplatesSyncError, GatewayTemplatesSyncResult
from app.services.openclaw.constants import (
_COORDINATION_GATEWAY_BASE_DELAY_S,
_COORDINATION_GATEWAY_MAX_DELAY_S,
_COORDINATION_GATEWAY_TIMEOUT_S,
_NON_TRANSIENT_GATEWAY_ERROR_MARKERS,
_SECURE_RANDOM,
_SESSION_KEY_PARTS_MIN,
_TOOLS_KV_RE,
_TRANSIENT_GATEWAY_ERROR_MARKERS,
DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY,
DEFAULT_GATEWAY_FILES,
DEFAULT_HEARTBEAT_CONFIG,
DEFAULT_IDENTITY_PROFILE,
EXTRA_IDENTITY_PROFILE_FIELDS,
HEARTBEAT_AGENT_TEMPLATE,
HEARTBEAT_LEAD_TEMPLATE,
IDENTITY_PROFILE_FIELDS,
MAIN_TEMPLATE_MAP,
PRESERVE_AGENT_EDITABLE_FILES,
)
from app.services.openclaw.shared import GatewayAgentIdentity
if TYPE_CHECKING:
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.users import User
DEFAULT_HEARTBEAT_CONFIG: dict[str, Any] = {
"every": "10m",
"target": "none",
# Keep heartbeat delivery concise by default.
"includeReasoning": False,
}
DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY: dict[str, bool] = {
# Suppress routine HEARTBEAT_OK delivery by default.
"showOk": False,
"showAlerts": True,
"useIndicator": True,
}
DEFAULT_IDENTITY_PROFILE = {
"role": "Generalist",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
}
IDENTITY_PROFILE_FIELDS = {
"role": "identity_role",
"communication_style": "identity_communication_style",
"emoji": "identity_emoji",
}
EXTRA_IDENTITY_PROFILE_FIELDS = {
"autonomy_level": "identity_autonomy_level",
"verbosity": "identity_verbosity",
"output_format": "identity_output_format",
"update_cadence": "identity_update_cadence",
# Per-agent charter (optional).
# Used to give agents a "purpose in life" and a distinct vibe.
"purpose": "identity_purpose",
"personality": "identity_personality",
"custom_instructions": "identity_custom_instructions",
}
DEFAULT_GATEWAY_FILES = frozenset(
{
"AGENTS.md",
"SOUL.md",
"TASK_SOUL.md",
"SELF.md",
"AUTONOMY.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOT.md",
"BOOTSTRAP.md",
"MEMORY.md",
},
)
# These files are intended to evolve within the agent workspace.
# Provision them if missing, but avoid overwriting existing content during updates.
#
# Examples:
# - SELF.md: evolving identity/preferences
# - USER.md: human-provided context + lead intake notes
# - MEMORY.md: curated long-term memory (consolidated)
PRESERVE_AGENT_EDITABLE_FILES = frozenset({"SELF.md", "USER.md", "MEMORY.md", "TASK_SOUL.md"})
HEARTBEAT_LEAD_TEMPLATE = "HEARTBEAT_LEAD.md"
HEARTBEAT_AGENT_TEMPLATE = "HEARTBEAT_AGENT.md"
_SESSION_KEY_PARTS_MIN = 2
MAIN_TEMPLATE_MAP = {
"AGENTS.md": "MAIN_AGENTS.md",
"HEARTBEAT.md": "MAIN_HEARTBEAT.md",
"USER.md": "MAIN_USER.md",
"BOOT.md": "MAIN_BOOT.md",
"TOOLS.md": "MAIN_TOOLS.md",
}
@dataclass(frozen=True, slots=True)
class ProvisionOptions:
@@ -305,7 +264,7 @@ def _build_context(
workspace_path = _workspace_path(agent, workspace_root)
session_key = agent.openclaw_session_id or ""
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
main_session_key = gateway_agent_session_key(gateway)
main_session_key = GatewayAgentIdentity.session_key(gateway)
identity_profile: dict[str, Any] = {}
if isinstance(agent.identity_profile, dict):
identity_profile = agent.identity_profile
@@ -401,7 +360,7 @@ def _build_main_context(
"session_key": agent.openclaw_session_id or "",
"base_url": base_url,
"auth_token": auth_token,
"main_session_key": gateway_agent_session_key(gateway),
"main_session_key": GatewayAgentIdentity.session_key(gateway),
"workspace_root": gateway.workspace_root or "",
"user_name": (user.name or "") if user else "",
"user_preferred_name": preferred_name,
@@ -876,7 +835,7 @@ class GatewayMainAgentLifecycleManager(BaseAgentLifecycleManager):
"""Provisioning manager for organization gateway-main agents."""
def _agent_id(self, agent: Agent) -> str:
return gateway_openclaw_agent_id(self._gateway)
return GatewayAgentIdentity.openclaw_agent_id(self._gateway)
def _build_context(
self,
@@ -974,7 +933,7 @@ async def provision_main_agent(
gateway = request.gateway
if not gateway.url:
return
session_key = (request.session_key or gateway_agent_session_key(gateway) or "").strip()
session_key = (request.session_key or GatewayAgentIdentity.session_key(gateway) or "").strip()
if not session_key:
msg = "gateway main agent session_key is required"
raise ValueError(msg)
@@ -1008,3 +967,683 @@ async def cleanup_agent(
with suppress(OpenClawGatewayError):
await control_plane.delete_agent_session(session_key)
return None
_T = TypeVar("_T")
@dataclass(frozen=True)
class GatewayTemplateSyncOptions:
"""Runtime options controlling gateway template synchronization."""
user: User | None
include_main: bool = True
reset_sessions: bool = False
rotate_tokens: bool = False
force_bootstrap: bool = False
board_id: UUID | None = None
@dataclass(frozen=True)
class _SyncContext:
"""Shared state passed to sync helper functions."""
session: AsyncSession
gateway: Gateway
config: GatewayClientConfig
backoff: _GatewayBackoff
options: GatewayTemplateSyncOptions
def _is_transient_gateway_error(exc: Exception) -> bool:
if not isinstance(exc, OpenClawGatewayError):
return False
message = str(exc).lower()
if not message:
return False
if any(marker in message for marker in _NON_TRANSIENT_GATEWAY_ERROR_MARKERS):
return False
return ("503" in message and "websocket" in message) or any(
marker in message for marker in _TRANSIENT_GATEWAY_ERROR_MARKERS
)
def _gateway_timeout_message(
exc: OpenClawGatewayError,
*,
timeout_s: float,
context: str,
) -> str:
rounded_timeout = int(timeout_s)
timeout_text = f"{rounded_timeout} seconds"
if rounded_timeout >= 120:
timeout_text = f"{rounded_timeout // 60} minutes"
return f"Gateway unreachable after {timeout_text} ({context} timeout). Last error: {exc}"
class _GatewayBackoff:
def __init__(
self,
*,
timeout_s: float = 10 * 60,
base_delay_s: float = 0.75,
max_delay_s: float = 30.0,
jitter: float = 0.2,
timeout_context: str = "gateway operation",
) -> None:
self._timeout_s = timeout_s
self._base_delay_s = base_delay_s
self._max_delay_s = max_delay_s
self._jitter = jitter
self._timeout_context = timeout_context
self._delay_s = base_delay_s
def reset(self) -> None:
self._delay_s = self._base_delay_s
@staticmethod
async def _attempt(
fn: Callable[[], Awaitable[_T]],
) -> tuple[_T | None, OpenClawGatewayError | None]:
try:
return await fn(), None
except OpenClawGatewayError as exc:
return None, exc
async def run(self, fn: Callable[[], Awaitable[_T]]) -> _T:
# Use per-call deadlines so long-running syncs can still tolerate a later
# gateway restart without having an already-expired retry window.
deadline_s = asyncio.get_running_loop().time() + self._timeout_s
while True:
value, error = await self._attempt(fn)
if error is not None:
exc = error
if not _is_transient_gateway_error(exc):
raise exc
now = asyncio.get_running_loop().time()
remaining = deadline_s - now
if remaining <= 0:
raise TimeoutError(
_gateway_timeout_message(
exc,
timeout_s=self._timeout_s,
context=self._timeout_context,
),
) from exc
sleep_s = min(self._delay_s, remaining)
if self._jitter:
sleep_s *= 1.0 + _SECURE_RANDOM.uniform(
-self._jitter,
self._jitter,
)
sleep_s = max(0.0, min(sleep_s, remaining))
await asyncio.sleep(sleep_s)
self._delay_s = min(self._delay_s * 2.0, self._max_delay_s)
continue
self.reset()
if value is None:
msg = "Gateway retry produced no value without an error"
raise RuntimeError(msg)
return value
async def _with_gateway_retry(
fn: Callable[[], Awaitable[_T]],
*,
backoff: _GatewayBackoff,
) -> _T:
return await backoff.run(fn)
async def _with_coordination_gateway_retry(fn: Callable[[], Awaitable[_T]]) -> _T:
return await _with_gateway_retry(
fn,
backoff=_GatewayBackoff(
timeout_s=_COORDINATION_GATEWAY_TIMEOUT_S,
base_delay_s=_COORDINATION_GATEWAY_BASE_DELAY_S,
max_delay_s=_COORDINATION_GATEWAY_MAX_DELAY_S,
jitter=0.15,
timeout_context="gateway coordination",
),
)
def _parse_tools_md(content: str) -> dict[str, str]:
values: dict[str, str] = {}
for raw in content.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
match = _TOOLS_KV_RE.match(line)
if not match:
continue
values[match.group("key")] = match.group("value").strip()
return values
async def _get_agent_file(
*,
agent_gateway_id: str,
name: str,
config: GatewayClientConfig,
backoff: _GatewayBackoff | None = None,
) -> str | None:
try:
async def _do_get() -> object:
return await openclaw_call(
"agents.files.get",
{"agentId": agent_gateway_id, "name": name},
config=config,
)
payload = await (backoff.run(_do_get) if backoff else _do_get())
except OpenClawGatewayError:
return 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 _get_existing_auth_token(
*,
agent_gateway_id: str,
config: GatewayClientConfig,
backoff: _GatewayBackoff | None = None,
) -> str | None:
tools = await _get_agent_file(
agent_gateway_id=agent_gateway_id,
name="TOOLS.md",
config=config,
backoff=backoff,
)
if not tools:
return None
values = _parse_tools_md(tools)
token = values.get("AUTH_TOKEN")
if not token:
return None
token = token.strip()
return token or None
async def _paused_board_ids(session: AsyncSession, board_ids: list[UUID]) -> set[UUID]:
if not board_ids:
return set()
commands = {"/pause", "/resume"}
statement = (
select(BoardMemory.board_id, BoardMemory.content)
.where(col(BoardMemory.board_id).in_(board_ids))
.where(col(BoardMemory.is_chat).is_(True))
.where(func.lower(func.trim(col(BoardMemory.content))).in_(commands))
.order_by(col(BoardMemory.board_id), col(BoardMemory.created_at).desc())
# Postgres: DISTINCT ON (board_id) to get latest command per board.
.distinct(col(BoardMemory.board_id))
)
paused: set[UUID] = set()
for board_id, content in await session.exec(statement):
cmd = (content or "").strip().lower()
if cmd == "/pause":
paused.add(board_id)
return paused
def _append_sync_error(
result: GatewayTemplatesSyncResult,
*,
message: str,
agent: Agent | None = None,
board: Board | None = None,
) -> None:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=agent.id if agent else None,
agent_name=agent.name if agent else None,
board_id=board.id if board else None,
message=message,
),
)
async def _rotate_agent_token(session: AsyncSession, agent: Agent) -> str:
token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(token)
agent.updated_at = utcnow()
session.add(agent)
await session.commit()
await session.refresh(agent)
return token
async def _ping_gateway(ctx: _SyncContext, result: GatewayTemplatesSyncResult) -> bool:
try:
async def _do_ping() -> object:
return await openclaw_call("agents.list", config=ctx.config)
await ctx.backoff.run(_do_ping)
except (TimeoutError, OpenClawGatewayError) as exc:
_append_sync_error(result, message=str(exc))
return False
else:
return True
def _base_result(
gateway: Gateway,
*,
include_main: bool,
reset_sessions: bool,
) -> GatewayTemplatesSyncResult:
return GatewayTemplatesSyncResult(
gateway_id=gateway.id,
include_main=include_main,
reset_sessions=reset_sessions,
agents_updated=0,
agents_skipped=0,
main_updated=False,
)
def _boards_by_id(
boards: list[Board],
*,
board_id: UUID | None,
) -> dict[UUID, Board] | None:
boards_by_id = {board.id: board for board in boards}
if board_id is None:
return boards_by_id
board = boards_by_id.get(board_id)
if board is None:
return None
return {board_id: board}
async def _resolve_agent_auth_token(
ctx: _SyncContext,
result: GatewayTemplatesSyncResult,
agent: Agent,
board: Board | None,
*,
agent_gateway_id: str,
) -> tuple[str | None, bool]:
try:
auth_token = await _get_existing_auth_token(
agent_gateway_id=agent_gateway_id,
config=ctx.config,
backoff=ctx.backoff,
)
except TimeoutError as exc:
_append_sync_error(result, agent=agent, board=board, message=str(exc))
return None, True
if not auth_token:
if not ctx.options.rotate_tokens:
result.agents_skipped += 1
_append_sync_error(
result,
agent=agent,
board=board,
message=(
"Skipping agent: unable to read AUTH_TOKEN from TOOLS.md "
"(run with rotate_tokens=true to re-key)."
),
)
return None, False
auth_token = await _rotate_agent_token(ctx.session, agent)
if agent.agent_token_hash and not verify_agent_token(
auth_token,
agent.agent_token_hash,
):
if ctx.options.rotate_tokens:
auth_token = await _rotate_agent_token(ctx.session, agent)
else:
_append_sync_error(
result,
agent=agent,
board=board,
message=(
"Warning: AUTH_TOKEN in TOOLS.md does not match backend "
"token hash (agent auth may be broken)."
),
)
return auth_token, False
async def _sync_one_agent(
ctx: _SyncContext,
result: GatewayTemplatesSyncResult,
agent: Agent,
board: Board,
) -> bool:
auth_token, fatal = await _resolve_agent_auth_token(
ctx,
result,
agent,
board,
agent_gateway_id=_agent_key(agent),
)
if fatal:
return True
if not auth_token:
return False
try:
async def _do_provision() -> bool:
await provision_agent(
agent,
AgentProvisionRequest(
board=board,
gateway=ctx.gateway,
auth_token=auth_token,
user=ctx.options.user,
options=ProvisionOptions(
action="update",
force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions,
),
),
)
return True
await _with_gateway_retry(_do_provision, backoff=ctx.backoff)
result.agents_updated += 1
except TimeoutError as exc: # pragma: no cover - gateway/network dependent
result.agents_skipped += 1
_append_sync_error(result, agent=agent, board=board, message=str(exc))
return True
except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover
result.agents_skipped += 1
_append_sync_error(
result,
agent=agent,
board=board,
message=f"Failed to sync templates: {exc}",
)
return False
else:
return False
async def _sync_main_agent(
ctx: _SyncContext,
result: GatewayTemplatesSyncResult,
) -> bool:
main_session_key = GatewayAgentIdentity.session_key(ctx.gateway)
main_agent = (
await Agent.objects.all()
.filter(col(Agent.gateway_id) == ctx.gateway.id)
.filter(col(Agent.board_id).is_(None))
.first(ctx.session)
)
if main_agent is None:
_append_sync_error(
result,
message="Gateway agent record not found; " "skipping gateway agent template sync.",
)
return True
main_gateway_agent_id = GatewayAgentIdentity.openclaw_agent_id(ctx.gateway)
token, fatal = await _resolve_agent_auth_token(
ctx,
result,
main_agent,
board=None,
agent_gateway_id=main_gateway_agent_id,
)
if fatal:
return True
if not token:
_append_sync_error(
result,
agent=main_agent,
message="Skipping gateway agent: unable to read AUTH_TOKEN from TOOLS.md.",
)
return True
stop_sync = False
try:
async def _do_provision_main() -> bool:
await provision_main_agent(
main_agent,
MainAgentProvisionRequest(
gateway=ctx.gateway,
auth_token=token,
user=ctx.options.user,
session_key=main_session_key,
options=ProvisionOptions(
action="update",
force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions,
),
),
)
return True
await _with_gateway_retry(_do_provision_main, backoff=ctx.backoff)
except TimeoutError as exc: # pragma: no cover - gateway/network dependent
_append_sync_error(result, agent=main_agent, message=str(exc))
stop_sync = True
except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover
_append_sync_error(
result,
agent=main_agent,
message=f"Failed to sync gateway agent templates: {exc}",
)
else:
result.main_updated = True
return stop_sync
async def sync_gateway_templates(
session: AsyncSession,
gateway: Gateway,
options: GatewayTemplateSyncOptions,
) -> GatewayTemplatesSyncResult:
"""Synchronize AGENTS/TOOLS/etc templates to gateway-connected agents."""
result = _base_result(
gateway,
include_main=options.include_main,
reset_sessions=options.reset_sessions,
)
if not gateway.url:
_append_sync_error(
result,
message="Gateway URL is not configured for this gateway.",
)
return result
ctx = _SyncContext(
session=session,
gateway=gateway,
config=GatewayClientConfig(url=gateway.url, token=gateway.token),
backoff=_GatewayBackoff(timeout_s=10 * 60, timeout_context="template sync"),
options=options,
)
if not await _ping_gateway(ctx, result):
return result
boards = await Board.objects.filter_by(gateway_id=gateway.id).all(session)
boards_by_id = _boards_by_id(boards, board_id=options.board_id)
if boards_by_id is None:
_append_sync_error(
result,
message="Board does not belong to this gateway.",
)
return result
paused_board_ids = await _paused_board_ids(session, list(boards_by_id.keys()))
if boards_by_id:
agents = await (
Agent.objects.by_field_in("board_id", list(boards_by_id.keys()))
.order_by(col(Agent.created_at).asc())
.all(session)
)
else:
agents = []
stop_sync = False
for agent in agents:
board = boards_by_id.get(agent.board_id) if agent.board_id is not None else None
if board is None:
result.agents_skipped += 1
_append_sync_error(
result,
agent=agent,
message="Skipping agent: board not found for agent.",
)
continue
if board.id in paused_board_ids:
result.agents_skipped += 1
continue
stop_sync = await _sync_one_agent(ctx, result, agent, board)
if stop_sync:
break
if not stop_sync and options.include_main:
await _sync_main_agent(ctx, result)
return result
# Board lead lifecycle primitives consolidated from app.services.board_leads.
def lead_session_key(board: Board) -> str:
"""Return the deterministic main session key for a board lead agent."""
return f"agent:lead-{board.id}:main"
def lead_agent_name(_: Board) -> str:
"""Return the default display name for board lead agents."""
return "Lead Agent"
@dataclass(frozen=True, slots=True)
class LeadAgentOptions:
"""Optional overrides for board-lead provisioning behavior."""
agent_name: str | None = None
identity_profile: dict[str, str] | None = None
action: str = "provision"
@dataclass(frozen=True, slots=True)
class LeadAgentRequest:
"""Inputs required to ensure or provision a board lead agent."""
board: Board
gateway: Gateway
config: GatewayClientConfig
user: User | None
options: LeadAgentOptions = field(default_factory=LeadAgentOptions)
async def ensure_board_lead_agent(
session: AsyncSession,
*,
request: LeadAgentRequest,
) -> tuple[Agent, bool]:
"""Ensure a board has a lead agent; return `(agent, created)`."""
board = request.board
config_options = request.options
existing = (
await session.exec(
select(Agent)
.where(Agent.board_id == board.id)
.where(col(Agent.is_board_lead).is_(True)),
)
).first()
if existing:
desired_name = config_options.agent_name or lead_agent_name(board)
changed = False
if existing.name != desired_name:
existing.name = desired_name
changed = True
if existing.gateway_id != request.gateway.id:
existing.gateway_id = request.gateway.id
changed = True
desired_session_key = lead_session_key(board)
if not existing.openclaw_session_id:
existing.openclaw_session_id = desired_session_key
changed = True
if changed:
existing.updated_at = utcnow()
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing, False
merged_identity_profile: dict[str, Any] = {
"role": "Board Lead",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
}
if config_options.identity_profile:
merged_identity_profile.update(
{
key: value.strip()
for key, value in config_options.identity_profile.items()
if value.strip()
},
)
agent = Agent(
name=config_options.agent_name or lead_agent_name(board),
status="provisioning",
board_id=board.id,
gateway_id=request.gateway.id,
is_board_lead=True,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile=merged_identity_profile,
openclaw_session_id=lead_session_key(board),
provision_requested_at=utcnow(),
provision_action=config_options.action,
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session.add(agent)
await session.commit()
await session.refresh(agent)
try:
await provision_agent(
agent,
AgentProvisionRequest(
board=board,
gateway=request.gateway,
auth_token=raw_token,
user=request.user,
options=ProvisionOptions(action=config_options.action),
),
)
if agent.openclaw_session_id:
await ensure_session(
agent.openclaw_session_id,
config=request.config,
label=agent.name,
)
await send_message(
(
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run "
"it once then delete it. Begin heartbeats after startup."
),
session_key=agent.openclaw_session_id,
config=request.config,
deliver=True,
)
except OpenClawGatewayError:
# Best-effort provisioning. The board/agent rows should still exist.
pass
return agent, True

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
"""Shared OpenClaw lifecycle primitives."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from fastapi import HTTPException, status
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import ensure_session, send_message
from app.models.boards import Board
from app.models.gateways import Gateway
from app.services.openclaw.constants import (
_GATEWAY_AGENT_PREFIX,
_GATEWAY_AGENT_SUFFIX,
_GATEWAY_OPENCLAW_AGENT_PREFIX,
)
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
class GatewayAgentIdentity:
"""Naming and identity rules for Mission Control gateway-main agents."""
@classmethod
def session_key_for_id(cls, gateway_id: UUID) -> str:
return f"{_GATEWAY_AGENT_PREFIX}{gateway_id}{_GATEWAY_AGENT_SUFFIX}"
@classmethod
def session_key(cls, gateway: Gateway) -> str:
return cls.session_key_for_id(gateway.id)
@classmethod
def openclaw_agent_id_for_id(cls, gateway_id: UUID) -> str:
return f"{_GATEWAY_OPENCLAW_AGENT_PREFIX}{gateway_id}"
@classmethod
def openclaw_agent_id(cls, gateway: Gateway) -> str:
return cls.openclaw_agent_id_for_id(gateway.id)
async def optional_gateway_config_for_board(
session: AsyncSession,
board: Board,
) -> GatewayClientConfig | None:
"""Return gateway client config when board has a reachable configured gateway."""
if board.gateway_id is None:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
async def require_gateway_config_for_board(
session: AsyncSession,
board: Board,
) -> tuple[Gateway, GatewayClientConfig]:
"""Resolve board gateway and config, raising 422 when unavailable."""
if board.gateway_id is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board is not attached to a gateway",
)
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
if gateway is None or not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway is not configured for this board",
)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
async def send_gateway_agent_message(
*,
session_key: str,
config: GatewayClientConfig,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
"""Ensure session and dispatch a message to an agent session."""
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=deliver)
def resolve_trace_id(correlation_id: str | None, *, prefix: str) -> str:
"""Resolve a stable trace id from correlation id or generate a scoped fallback."""
normalized = (correlation_id or "").strip()
if normalized:
return normalized
return f"{prefix}:{uuid4().hex[:12]}"
logger = logging.getLogger(__name__)

View File

@@ -1,593 +0,0 @@
"""Gateway template synchronization orchestration."""
from __future__ import annotations
import asyncio
import random
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TypeVar
from uuid import UUID, uuid4
from sqlalchemy import func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
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.board_memory import BoardMemory
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.schemas.gateways import GatewayTemplatesSyncError, GatewayTemplatesSyncResult
from app.services.agent_provisioning import (
AgentProvisionRequest,
MainAgentProvisionRequest,
ProvisionOptions,
provision_agent,
provision_main_agent,
)
from app.services.gateway_agents import (
gateway_agent_session_key,
gateway_openclaw_agent_id,
)
_TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$")
SESSION_KEY_PARTS_MIN = 2
_NON_TRANSIENT_GATEWAY_ERROR_MARKERS = ("unsupported file",)
_TRANSIENT_GATEWAY_ERROR_MARKERS = (
"connect call failed",
"connection refused",
"errno 111",
"econnrefused",
"did not receive a valid http response",
"no route to host",
"network is unreachable",
"host is down",
"name or service not known",
"received 1012",
"service restart",
"http 503",
"http 502",
"http 504",
"temporar",
"timeout",
"timed out",
"connection closed",
"connection reset",
)
T = TypeVar("T")
_SECURE_RANDOM = random.SystemRandom()
_RUNTIME_TYPE_REFERENCES = (Awaitable, Callable, AsyncSession, Gateway, User, UUID)
@dataclass(frozen=True)
class GatewayTemplateSyncOptions:
"""Runtime options controlling gateway template synchronization."""
user: User | None
include_main: bool = True
reset_sessions: bool = False
rotate_tokens: bool = False
force_bootstrap: bool = False
board_id: UUID | None = None
@dataclass(frozen=True)
class _SyncContext:
"""Shared state passed to sync helper functions."""
session: AsyncSession
gateway: Gateway
config: GatewayClientConfig
backoff: _GatewayBackoff
options: GatewayTemplateSyncOptions
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or uuid4().hex
def _is_transient_gateway_error(exc: Exception) -> bool:
if not isinstance(exc, OpenClawGatewayError):
return False
message = str(exc).lower()
if not message:
return False
if any(marker in message for marker in _NON_TRANSIENT_GATEWAY_ERROR_MARKERS):
return False
return ("503" in message and "websocket" in message) or any(
marker in message for marker in _TRANSIENT_GATEWAY_ERROR_MARKERS
)
def _gateway_timeout_message(exc: OpenClawGatewayError) -> str:
return "Gateway unreachable after 10 minutes (template sync timeout). " f"Last error: {exc}"
class _GatewayBackoff:
def __init__(
self,
*,
timeout_s: float = 10 * 60,
base_delay_s: float = 0.75,
max_delay_s: float = 30.0,
jitter: float = 0.2,
) -> None:
self._timeout_s = timeout_s
self._base_delay_s = base_delay_s
self._max_delay_s = max_delay_s
self._jitter = jitter
self._delay_s = base_delay_s
def reset(self) -> None:
self._delay_s = self._base_delay_s
@staticmethod
async def _attempt(
fn: Callable[[], Awaitable[T]],
) -> tuple[T | None, OpenClawGatewayError | None]:
try:
return await fn(), None
except OpenClawGatewayError as exc:
return None, exc
async def run(self, fn: Callable[[], Awaitable[T]]) -> T:
# Use per-call deadlines so long-running syncs can still tolerate a later
# gateway restart without having an already-expired retry window.
deadline_s = asyncio.get_running_loop().time() + self._timeout_s
while True:
value, error = await self._attempt(fn)
if error is not None:
exc = error
if not _is_transient_gateway_error(exc):
raise exc
now = asyncio.get_running_loop().time()
remaining = deadline_s - now
if remaining <= 0:
raise TimeoutError(_gateway_timeout_message(exc)) from exc
sleep_s = min(self._delay_s, remaining)
if self._jitter:
sleep_s *= 1.0 + _SECURE_RANDOM.uniform(
-self._jitter,
self._jitter,
)
sleep_s = max(0.0, min(sleep_s, remaining))
await asyncio.sleep(sleep_s)
self._delay_s = min(self._delay_s * 2.0, self._max_delay_s)
continue
self.reset()
if value is None:
msg = "Gateway retry produced no value without an error"
raise RuntimeError(msg)
return value
async def _with_gateway_retry(
fn: Callable[[], Awaitable[T]],
*,
backoff: _GatewayBackoff,
) -> T:
return await backoff.run(fn)
def _gateway_agent_id(agent: Agent) -> str:
session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"):
parts = session_key.split(":")
if len(parts) >= SESSION_KEY_PARTS_MIN and parts[1]:
return parts[1]
return _slugify(agent.name)
def _parse_tools_md(content: str) -> dict[str, str]:
values: dict[str, str] = {}
for raw in content.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
match = _TOOLS_KV_RE.match(line)
if not match:
continue
values[match.group("key")] = match.group("value").strip()
return values
async def _get_agent_file(
*,
agent_gateway_id: str,
name: str,
config: GatewayClientConfig,
backoff: _GatewayBackoff | None = None,
) -> str | None:
try:
async def _do_get() -> object:
return await openclaw_call(
"agents.files.get",
{"agentId": agent_gateway_id, "name": name},
config=config,
)
payload = await (backoff.run(_do_get) if backoff else _do_get())
except OpenClawGatewayError:
return 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 _get_existing_auth_token(
*,
agent_gateway_id: str,
config: GatewayClientConfig,
backoff: _GatewayBackoff | None = None,
) -> str | None:
tools = await _get_agent_file(
agent_gateway_id=agent_gateway_id,
name="TOOLS.md",
config=config,
backoff=backoff,
)
if not tools:
return None
values = _parse_tools_md(tools)
token = values.get("AUTH_TOKEN")
if not token:
return None
token = token.strip()
return token or None
async def _paused_board_ids(session: AsyncSession, board_ids: list[UUID]) -> set[UUID]:
if not board_ids:
return set()
commands = {"/pause", "/resume"}
statement = (
select(BoardMemory.board_id, BoardMemory.content)
.where(col(BoardMemory.board_id).in_(board_ids))
.where(col(BoardMemory.is_chat).is_(True))
.where(func.lower(func.trim(col(BoardMemory.content))).in_(commands))
.order_by(col(BoardMemory.board_id), col(BoardMemory.created_at).desc())
# Postgres: DISTINCT ON (board_id) to get latest command per board.
.distinct(col(BoardMemory.board_id))
)
paused: set[UUID] = set()
for board_id, content in await session.exec(statement):
cmd = (content or "").strip().lower()
if cmd == "/pause":
paused.add(board_id)
return paused
def _append_sync_error(
result: GatewayTemplatesSyncResult,
*,
message: str,
agent: Agent | None = None,
board: Board | None = None,
) -> None:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=agent.id if agent else None,
agent_name=agent.name if agent else None,
board_id=board.id if board else None,
message=message,
),
)
async def _rotate_agent_token(session: AsyncSession, agent: Agent) -> str:
token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(token)
agent.updated_at = utcnow()
session.add(agent)
await session.commit()
await session.refresh(agent)
return token
async def _ping_gateway(ctx: _SyncContext, result: GatewayTemplatesSyncResult) -> bool:
try:
async def _do_ping() -> object:
return await openclaw_call("agents.list", config=ctx.config)
await ctx.backoff.run(_do_ping)
except (TimeoutError, OpenClawGatewayError) as exc:
_append_sync_error(result, message=str(exc))
return False
else:
return True
def _base_result(
gateway: Gateway,
*,
include_main: bool,
reset_sessions: bool,
) -> GatewayTemplatesSyncResult:
return GatewayTemplatesSyncResult(
gateway_id=gateway.id,
include_main=include_main,
reset_sessions=reset_sessions,
agents_updated=0,
agents_skipped=0,
main_updated=False,
)
def _boards_by_id(
boards: list[Board],
*,
board_id: UUID | None,
) -> dict[UUID, Board] | None:
boards_by_id = {board.id: board for board in boards}
if board_id is None:
return boards_by_id
board = boards_by_id.get(board_id)
if board is None:
return None
return {board_id: board}
async def _resolve_agent_auth_token(
ctx: _SyncContext,
result: GatewayTemplatesSyncResult,
agent: Agent,
board: Board | None,
*,
agent_gateway_id: str,
) -> tuple[str | None, bool]:
try:
auth_token = await _get_existing_auth_token(
agent_gateway_id=agent_gateway_id,
config=ctx.config,
backoff=ctx.backoff,
)
except TimeoutError as exc:
_append_sync_error(result, agent=agent, board=board, message=str(exc))
return None, True
if not auth_token:
if not ctx.options.rotate_tokens:
result.agents_skipped += 1
_append_sync_error(
result,
agent=agent,
board=board,
message=(
"Skipping agent: unable to read AUTH_TOKEN from TOOLS.md "
"(run with rotate_tokens=true to re-key)."
),
)
return None, False
auth_token = await _rotate_agent_token(ctx.session, agent)
if agent.agent_token_hash and not verify_agent_token(
auth_token,
agent.agent_token_hash,
):
if ctx.options.rotate_tokens:
auth_token = await _rotate_agent_token(ctx.session, agent)
else:
_append_sync_error(
result,
agent=agent,
board=board,
message=(
"Warning: AUTH_TOKEN in TOOLS.md does not match backend "
"token hash (agent auth may be broken)."
),
)
return auth_token, False
async def _sync_one_agent(
ctx: _SyncContext,
result: GatewayTemplatesSyncResult,
agent: Agent,
board: Board,
) -> bool:
auth_token, fatal = await _resolve_agent_auth_token(
ctx,
result,
agent,
board,
agent_gateway_id=_gateway_agent_id(agent),
)
if fatal:
return True
if not auth_token:
return False
try:
async def _do_provision() -> None:
await provision_agent(
agent,
AgentProvisionRequest(
board=board,
gateway=ctx.gateway,
auth_token=auth_token,
user=ctx.options.user,
options=ProvisionOptions(
action="update",
force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions,
),
),
)
await _with_gateway_retry(_do_provision, backoff=ctx.backoff)
result.agents_updated += 1
except TimeoutError as exc: # pragma: no cover - gateway/network dependent
result.agents_skipped += 1
_append_sync_error(result, agent=agent, board=board, message=str(exc))
return True
except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover
result.agents_skipped += 1
_append_sync_error(
result,
agent=agent,
board=board,
message=f"Failed to sync templates: {exc}",
)
return False
else:
return False
async def _sync_main_agent(
ctx: _SyncContext,
result: GatewayTemplatesSyncResult,
) -> bool:
main_session_key = gateway_agent_session_key(ctx.gateway)
main_agent = (
await Agent.objects.all()
.filter(col(Agent.gateway_id) == ctx.gateway.id)
.filter(col(Agent.board_id).is_(None))
.first(ctx.session)
)
if main_agent is None:
_append_sync_error(
result,
message=("Gateway agent record not found; " "skipping gateway agent template sync."),
)
return True
main_gateway_agent_id = gateway_openclaw_agent_id(ctx.gateway)
token, fatal = await _resolve_agent_auth_token(
ctx,
result,
main_agent,
board=None,
agent_gateway_id=main_gateway_agent_id,
)
if fatal:
return True
if not token:
_append_sync_error(
result,
agent=main_agent,
message="Skipping gateway agent: unable to read AUTH_TOKEN from TOOLS.md.",
)
return True
stop_sync = False
try:
async def _do_provision_main() -> None:
await provision_main_agent(
main_agent,
MainAgentProvisionRequest(
gateway=ctx.gateway,
auth_token=token,
user=ctx.options.user,
session_key=main_session_key,
options=ProvisionOptions(
action="update",
force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions,
),
),
)
await _with_gateway_retry(_do_provision_main, backoff=ctx.backoff)
except TimeoutError as exc: # pragma: no cover - gateway/network dependent
_append_sync_error(result, agent=main_agent, message=str(exc))
stop_sync = True
except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover
_append_sync_error(
result,
agent=main_agent,
message=f"Failed to sync gateway agent templates: {exc}",
)
else:
result.main_updated = True
return stop_sync
async def sync_gateway_templates(
session: AsyncSession,
gateway: Gateway,
options: GatewayTemplateSyncOptions,
) -> GatewayTemplatesSyncResult:
"""Synchronize AGENTS/TOOLS/etc templates to gateway-connected agents."""
result = _base_result(
gateway,
include_main=options.include_main,
reset_sessions=options.reset_sessions,
)
if not gateway.url:
_append_sync_error(
result,
message="Gateway URL is not configured for this gateway.",
)
return result
ctx = _SyncContext(
session=session,
gateway=gateway,
config=GatewayClientConfig(url=gateway.url, token=gateway.token),
backoff=_GatewayBackoff(timeout_s=10 * 60),
options=options,
)
if not await _ping_gateway(ctx, result):
return result
boards = await Board.objects.filter_by(gateway_id=gateway.id).all(session)
boards_by_id = _boards_by_id(boards, board_id=options.board_id)
if boards_by_id is None:
_append_sync_error(
result,
message="Board does not belong to this gateway.",
)
return result
paused_board_ids = await _paused_board_ids(session, list(boards_by_id.keys()))
if boards_by_id:
agents = await (
Agent.objects.by_field_in("board_id", list(boards_by_id.keys()))
.order_by(col(Agent.created_at).asc())
.all(session)
)
else:
agents = []
stop_sync = False
for agent in agents:
board = boards_by_id.get(agent.board_id) if agent.board_id is not None else None
if board is None:
result.agents_skipped += 1
_append_sync_error(
result,
agent=agent,
message="Skipping agent: board not found for agent.",
)
continue
if board.id in paused_board_ids:
result.agents_skipped += 1
continue
stop_sync = await _sync_one_agent(ctx, result, agent, board)
if stop_sync:
break
if not stop_sync and options.include_main:
await _sync_main_agent(ctx, result)
return result

View File

@@ -18,7 +18,7 @@ async def run() -> None:
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.services.gateway_agents import gateway_agent_session_key
from app.services.openclaw import GatewayAgentIdentity
await init_db()
async with async_session_maker() as session:
@@ -30,7 +30,7 @@ async def run() -> None:
main_session_key="placeholder",
workspace_root=str(demo_workspace_root),
)
gateway.main_session_key = gateway_agent_session_key(gateway)
gateway.main_session_key = GatewayAgentIdentity.session_key(gateway)
session.add(gateway)
await session.commit()
await session.refresh(gateway)

View File

@@ -52,7 +52,7 @@ def _parse_args() -> argparse.Namespace:
async def _run() -> int:
from app.db.session import async_session_maker
from app.models.gateways import Gateway
from app.services.template_sync import GatewayTemplateSyncOptions, sync_gateway_templates
from app.services.openclaw import GatewayTemplateSyncOptions, sync_gateway_templates
args = _parse_args()
gateway_id = UUID(args.gateway_id)

View File

@@ -7,11 +7,8 @@ from uuid import UUID, uuid4
import pytest
from app.services import agent_provisioning
from app.services.gateway_agents import (
gateway_agent_session_key_for_id,
gateway_openclaw_agent_id_for_id,
)
from app.services.openclaw import GatewayAgentIdentity
from app.services.openclaw import provisioning as agent_provisioning
def test_slugify_normalizes_and_trims():
@@ -81,7 +78,7 @@ class _GatewayStub:
@pytest.mark.asyncio
async def test_provision_main_agent_uses_dedicated_openclaw_agent_id(monkeypatch):
gateway_id = uuid4()
session_key = gateway_agent_session_key_for_id(gateway_id)
session_key = GatewayAgentIdentity.session_key_for_id(gateway_id)
gateway = _GatewayStub(
id=gateway_id,
name="Acme",
@@ -149,6 +146,6 @@ async def test_provision_main_agent_uses_dedicated_openclaw_agent_id(monkeypatch
),
)
expected_agent_id = gateway_openclaw_agent_id_for_id(gateway_id)
expected_agent_id = GatewayAgentIdentity.openclaw_agent_id_for_id(gateway_id)
assert captured["patched_agent_id"] == expected_agent_id
assert captured["files_index_agent_id"] == expected_agent_id

View File

@@ -0,0 +1,258 @@
# ruff: noqa: S101
"""Unit tests for lifecycle coordination and onboarding messaging services."""
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
from uuid import UUID, uuid4
import pytest
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
@dataclass
class _FakeSession:
committed: int = 0
added: list[object] = field(default_factory=list)
def add(self, value: object) -> None:
self.added.append(value)
async def commit(self) -> None:
self.committed += 1
@dataclass
class _AgentStub:
id: UUID
name: str
openclaw_session_id: str | None = None
board_id: UUID | None = None
@dataclass
class _BoardStub:
id: UUID
gateway_id: UUID | None
name: str
@pytest.mark.asyncio
async def test_gateway_coordination_nudge_success(monkeypatch: pytest.MonkeyPatch) -> None:
session = _FakeSession()
service = 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(
id=uuid4(),
name="Worker Agent",
openclaw_session_id="agent:worker:main",
board_id=board.id,
)
captured: list[dict[str, Any]] = []
async def _fake_board_agent_or_404(
self: lifecycle.GatewayCoordinationService,
*,
board: object,
agent_id: str,
) -> _AgentStub:
_ = (self, board, agent_id)
return target
async def _fake_require_gateway_config_for_board(
_session: object,
_board: object,
) -> tuple[object, GatewayClientConfig]:
gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**kwargs: Any) -> dict[str, bool]:
captured.append(kwargs)
return {"ok": True}
monkeypatch.setattr(
lifecycle.GatewayCoordinationService,
"_board_agent_or_404",
_fake_board_agent_or_404,
)
monkeypatch.setattr(
lifecycle,
"require_gateway_config_for_board",
_fake_require_gateway_config_for_board,
)
monkeypatch.setattr(
lifecycle,
"send_gateway_agent_message",
_fake_send_gateway_agent_message,
)
await service.nudge_board_agent(
board=board, # type: ignore[arg-type]
actor_agent=actor, # type: ignore[arg-type]
target_agent_id=str(target.id),
message="Please run BOOT.md",
correlation_id="nudge-corr-id",
)
assert len(captured) == 1
assert captured[0]["session_key"] == "agent:worker:main"
assert captured[0]["agent_name"] == "Worker Agent"
assert captured[0]["deliver"] is True
assert session.committed == 1
@pytest.mark.asyncio
async def test_gateway_coordination_nudge_maps_gateway_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _FakeSession()
service = 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(
id=uuid4(),
name="Worker Agent",
openclaw_session_id="agent:worker:main",
board_id=board.id,
)
async def _fake_board_agent_or_404(
self: lifecycle.GatewayCoordinationService,
*,
board: object,
agent_id: str,
) -> _AgentStub:
_ = (self, board, agent_id)
return target
async def _fake_require_gateway_config_for_board(
_session: object,
_board: object,
) -> tuple[object, GatewayClientConfig]:
gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**_kwargs: Any) -> None:
raise OpenClawGatewayError("dial tcp: connection refused")
monkeypatch.setattr(
lifecycle.GatewayCoordinationService,
"_board_agent_or_404",
_fake_board_agent_or_404,
)
monkeypatch.setattr(
lifecycle,
"require_gateway_config_for_board",
_fake_require_gateway_config_for_board,
)
monkeypatch.setattr(
lifecycle,
"send_gateway_agent_message",
_fake_send_gateway_agent_message,
)
with pytest.raises(HTTPException) as exc_info:
await service.nudge_board_agent(
board=board, # type: ignore[arg-type]
actor_agent=actor, # type: ignore[arg-type]
target_agent_id=str(target.id),
message="Please run BOOT.md",
correlation_id="nudge-corr-id",
)
assert exc_info.value.status_code == status.HTTP_502_BAD_GATEWAY
assert "Gateway nudge failed:" in str(exc_info.value.detail)
assert session.committed == 1
@pytest.mark.asyncio
async def test_board_onboarding_dispatch_start_returns_session_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _FakeSession()
service = 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]] = []
async def _fake_require_gateway_config_for_board(
_session: object,
_board: object,
) -> tuple[object, GatewayClientConfig]:
gateway = SimpleNamespace(id=gateway_id, url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**kwargs: Any) -> dict[str, bool]:
captured.append(kwargs)
return {"ok": True}
monkeypatch.setattr(
lifecycle,
"require_gateway_config_for_board",
_fake_require_gateway_config_for_board,
)
monkeypatch.setattr(
lifecycle,
"send_gateway_agent_message",
_fake_send_gateway_agent_message,
)
session_key = await service.dispatch_start_prompt(
board=board, # type: ignore[arg-type]
prompt="BOARD ONBOARDING REQUEST",
correlation_id="onboarding-corr-id",
)
assert session_key == lifecycle.GatewayAgentIdentity.session_key_for_id(gateway_id)
assert len(captured) == 1
assert captured[0]["agent_name"] == "Gateway Agent"
assert captured[0]["deliver"] is False
@pytest.mark.asyncio
async def test_board_onboarding_dispatch_answer_maps_timeout_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session = _FakeSession()
service = 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")
async def _fake_require_gateway_config_for_board(
_session: object,
_board: object,
) -> tuple[object, GatewayClientConfig]:
gateway = SimpleNamespace(id=uuid4(), url="ws://gateway.example/ws")
return gateway, GatewayClientConfig(url="ws://gateway.example/ws", token=None)
async def _fake_send_gateway_agent_message(**_kwargs: Any) -> None:
raise TimeoutError("gateway timeout")
monkeypatch.setattr(
lifecycle,
"require_gateway_config_for_board",
_fake_require_gateway_config_for_board,
)
monkeypatch.setattr(
lifecycle,
"send_gateway_agent_message",
_fake_send_gateway_agent_message,
)
with pytest.raises(HTTPException) as exc_info:
await service.dispatch_answer(
board=board, # type: ignore[arg-type]
onboarding=onboarding,
answer_text="I prefer concise updates.",
correlation_id="onboarding-answer-corr-id",
)
assert exc_info.value.status_code == status.HTTP_502_BAD_GATEWAY
assert "Gateway onboarding answer dispatch failed:" in str(exc_info.value.detail)