refactor: replace direct calls to provisioning functions with OpenClawProvisioningService methods
This commit is contained in:
@@ -30,7 +30,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.view_models import BoardGroupSnapshot
|
||||
from app.services.board_group_snapshot import build_group_snapshot
|
||||
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
|
||||
from app.services.openclaw.provisioning import sync_gateway_agent_heartbeats
|
||||
from app.services.openclaw.provisioning import OpenClawProvisioningService
|
||||
from app.services.openclaw.shared import GatewayTransportError
|
||||
from app.services.organizations import (
|
||||
OrganizationContext,
|
||||
@@ -269,7 +269,10 @@ async def _sync_gateway_heartbeats(
|
||||
failed_agent_ids.extend([agent.id for agent in gateway_agents])
|
||||
continue
|
||||
try:
|
||||
await sync_gateway_agent_heartbeats(gateway, gateway_agents)
|
||||
await OpenClawProvisioningService().sync_gateway_agent_heartbeats(
|
||||
gateway,
|
||||
gateway_agents,
|
||||
)
|
||||
except GatewayTransportError:
|
||||
failed_agent_ids.extend([agent.id for agent in gateway_agents])
|
||||
return failed_agent_ids
|
||||
|
||||
@@ -38,7 +38,7 @@ from app.services.openclaw.policies import OpenClawAuthorizationPolicy
|
||||
from app.services.openclaw.provisioning import (
|
||||
LeadAgentOptions,
|
||||
LeadAgentRequest,
|
||||
ensure_board_lead_agent,
|
||||
OpenClawProvisioningService,
|
||||
)
|
||||
from app.services.openclaw.shared import require_gateway_config_for_board
|
||||
|
||||
@@ -401,8 +401,7 @@ async def confirm_onboarding(
|
||||
session.add(onboarding)
|
||||
await session.commit()
|
||||
await session.refresh(board)
|
||||
await ensure_board_lead_agent(
|
||||
session,
|
||||
await OpenClawProvisioningService(session).ensure_board_lead_agent(
|
||||
request=LeadAgentRequest(
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
|
||||
@@ -39,7 +39,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.openclaw.provisioning import cleanup_agent
|
||||
from app.services.openclaw.provisioning import OpenClawProvisioningService
|
||||
from app.services.openclaw.shared import GatewayTransportError
|
||||
from app.services.organizations import OrganizationContext, board_access_filter
|
||||
|
||||
@@ -287,7 +287,10 @@ async def delete_board(
|
||||
if config:
|
||||
try:
|
||||
for agent in agents:
|
||||
await cleanup_agent(agent, config)
|
||||
await OpenClawProvisioningService().delete_agent_lifecycle(
|
||||
agent=agent,
|
||||
gateway=config,
|
||||
)
|
||||
except GatewayTransportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
|
||||
@@ -17,9 +17,7 @@ from app.db import crud
|
||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||
from app.integrations.openclaw_gateway import (
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
openclaw_call,
|
||||
send_message,
|
||||
)
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
@@ -30,13 +28,11 @@ from app.schemas.gateways import GatewayTemplatesSyncResult
|
||||
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
|
||||
from app.services.openclaw.provisioning import (
|
||||
GatewayTemplateSyncOptions,
|
||||
MainAgentProvisionRequest,
|
||||
ProvisionOptions,
|
||||
provision_main_agent,
|
||||
sync_gateway_templates,
|
||||
OpenClawProvisioningService,
|
||||
)
|
||||
from app.services.openclaw.session_service import GatewayTemplateSyncQuery
|
||||
from app.services.openclaw.shared import GatewayAgentIdentity
|
||||
from app.services.organizations import get_org_owner_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -132,30 +128,6 @@ class GatewayAdminLifecycleService:
|
||||
.first(self.session)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def extract_agent_id_from_entry(item: object) -> str | None:
|
||||
if isinstance(item, str):
|
||||
value = item.strip()
|
||||
return value or None
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
for key in ("id", "agentId", "agent_id"):
|
||||
raw = item.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_agents_list(payload: object) -> list[object]:
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload]
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
agents = payload.get("agents") or []
|
||||
if not isinstance(agents, list):
|
||||
return []
|
||||
return [item for item in agents]
|
||||
|
||||
async def upsert_main_agent_record(self, gateway: Gateway) -> tuple[Agent, bool]:
|
||||
changed = False
|
||||
session_key = GatewayAgentIdentity.session_key(gateway)
|
||||
@@ -210,13 +182,13 @@ class GatewayAdminLifecycleService:
|
||||
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
target_id = GatewayAgentIdentity.openclaw_agent_id(gateway)
|
||||
try:
|
||||
payload = await openclaw_call("agents.list", config=config)
|
||||
except OpenClawGatewayError:
|
||||
await openclaw_call("agents.files.list", {"agentId": target_id}, config=config)
|
||||
except OpenClawGatewayError as exc:
|
||||
message = str(exc).lower()
|
||||
if any(marker in message for marker in ("not found", "unknown agent", "no such agent")):
|
||||
return False
|
||||
return True
|
||||
for item in self.extract_agents_list(payload):
|
||||
if self.extract_agent_id_from_entry(item) == target_id:
|
||||
return True
|
||||
return False
|
||||
return True
|
||||
|
||||
async def provision_main_agent_record(
|
||||
self,
|
||||
@@ -227,7 +199,10 @@ class GatewayAdminLifecycleService:
|
||||
action: str,
|
||||
notify: bool,
|
||||
) -> Agent:
|
||||
session_key = GatewayAgentIdentity.session_key(gateway)
|
||||
template_user = user or await get_org_owner_user(
|
||||
self.session,
|
||||
organization_id=gateway.organization_id,
|
||||
)
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
agent.provision_requested_at = utcnow()
|
||||
@@ -241,33 +216,16 @@ class GatewayAdminLifecycleService:
|
||||
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 OpenClawProvisioningService().apply_agent_lifecycle(
|
||||
agent=agent,
|
||||
gateway=gateway,
|
||||
board=None,
|
||||
auth_token=raw_token,
|
||||
user=template_user,
|
||||
action=action,
|
||||
wake=notify,
|
||||
deliver_wakeup=True,
|
||||
)
|
||||
await ensure_session(
|
||||
session_key,
|
||||
config=GatewayClientConfig(url=gateway.url, token=gateway.token),
|
||||
label=agent.name,
|
||||
)
|
||||
if notify:
|
||||
await send_message(
|
||||
(
|
||||
f"Hello {agent.name}. Your gateway provisioning was updated.\n\n"
|
||||
"Please re-read AGENTS.md, USER.md, HEARTBEAT.md, and TOOLS.md. "
|
||||
"If BOOTSTRAP.md exists, run it once then delete it. "
|
||||
"Begin heartbeats after startup."
|
||||
),
|
||||
session_key=session_key,
|
||||
config=GatewayClientConfig(url=gateway.url, token=gateway.token),
|
||||
deliver=True,
|
||||
)
|
||||
self.logger.info(
|
||||
"gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s",
|
||||
gateway.id,
|
||||
@@ -388,8 +346,7 @@ class GatewayAdminLifecycleService:
|
||||
query.include_main,
|
||||
)
|
||||
await self.ensure_gateway_agents_exist([gateway])
|
||||
result = await sync_gateway_templates(
|
||||
self.session,
|
||||
result = await OpenClawProvisioningService(self.session).sync_gateway_templates(
|
||||
gateway,
|
||||
GatewayTemplateSyncOptions(
|
||||
user=auth.user,
|
||||
|
||||
@@ -49,15 +49,7 @@ from app.services.openclaw.constants import (
|
||||
OFFLINE_AFTER,
|
||||
)
|
||||
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
|
||||
from app.services.openclaw.provisioning import (
|
||||
AgentProvisionRequest,
|
||||
MainAgentProvisionRequest,
|
||||
ProvisionOptions,
|
||||
cleanup_agent,
|
||||
cleanup_main_agent,
|
||||
provision_agent,
|
||||
provision_main_agent,
|
||||
)
|
||||
from app.services.openclaw.provisioning import OpenClawProvisioningService
|
||||
from app.services.openclaw.shared import GatewayAgentIdentity
|
||||
from app.services.organizations import (
|
||||
OrganizationContext,
|
||||
@@ -176,11 +168,6 @@ class AbstractProvisionExecution(ABC):
|
||||
)
|
||||
try:
|
||||
await self._provision()
|
||||
await self._service.send_wakeup_message(
|
||||
self.agent,
|
||||
self.request.target.client_config,
|
||||
verb=self._wakeup_verb,
|
||||
)
|
||||
self.agent.provision_confirm_token_hash = None
|
||||
self.agent.provision_requested_at = None
|
||||
self.agent.provision_action = None
|
||||
@@ -256,19 +243,18 @@ class BoardAgentProvisionExecution(AbstractProvisionExecution):
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="board is required for non-main agent provisioning",
|
||||
)
|
||||
await provision_agent(
|
||||
self.agent,
|
||||
AgentProvisionRequest(
|
||||
board=board,
|
||||
gateway=self.request.target.gateway,
|
||||
auth_token=self.request.raw_token,
|
||||
user=self.request.user,
|
||||
options=ProvisionOptions(
|
||||
action=self._action,
|
||||
force_bootstrap=self.request.force_bootstrap,
|
||||
reset_session=True,
|
||||
),
|
||||
),
|
||||
await OpenClawProvisioningService().apply_agent_lifecycle(
|
||||
agent=self.agent,
|
||||
gateway=self.request.target.gateway,
|
||||
board=board,
|
||||
auth_token=self.request.raw_token,
|
||||
user=self.request.user,
|
||||
action=self._action,
|
||||
force_bootstrap=self.request.force_bootstrap,
|
||||
reset_session=True,
|
||||
wake=True,
|
||||
deliver_wakeup=True,
|
||||
wakeup_verb=self._wakeup_verb,
|
||||
)
|
||||
|
||||
|
||||
@@ -276,19 +262,18 @@ class MainAgentProvisionExecution(AbstractProvisionExecution):
|
||||
"""Provision execution for gateway-main agents."""
|
||||
|
||||
async def _provision(self) -> None:
|
||||
await provision_main_agent(
|
||||
self.agent,
|
||||
MainAgentProvisionRequest(
|
||||
gateway=self.request.target.gateway,
|
||||
auth_token=self.request.raw_token,
|
||||
user=self.request.user,
|
||||
session_key=self.agent.openclaw_session_id,
|
||||
options=ProvisionOptions(
|
||||
action=self._action,
|
||||
force_bootstrap=self.request.force_bootstrap,
|
||||
reset_session=True,
|
||||
),
|
||||
),
|
||||
await OpenClawProvisioningService().apply_agent_lifecycle(
|
||||
agent=self.agent,
|
||||
gateway=self.request.target.gateway,
|
||||
board=None,
|
||||
auth_token=self.request.raw_token,
|
||||
user=self.request.user,
|
||||
action=self._action,
|
||||
force_bootstrap=self.request.force_bootstrap,
|
||||
reset_session=True,
|
||||
wake=True,
|
||||
deliver_wakeup=True,
|
||||
wakeup_verb=self._wakeup_verb,
|
||||
)
|
||||
|
||||
|
||||
@@ -337,8 +322,25 @@ class AgentLifecycleService:
|
||||
return slug or uuid4().hex
|
||||
|
||||
@classmethod
|
||||
def build_session_key(cls, agent_name: str) -> str:
|
||||
return f"{AGENT_SESSION_PREFIX}:{cls.slugify(agent_name)}:main"
|
||||
def resolve_session_key(cls, agent: Agent) -> str:
|
||||
"""Resolve the gateway session key for an agent.
|
||||
|
||||
Notes:
|
||||
- For board-scoped agents, default to a UUID-based key to avoid name collisions.
|
||||
"""
|
||||
|
||||
existing = (agent.openclaw_session_id or "").strip()
|
||||
if agent.board_id is None:
|
||||
# Gateway-main agents must have an explicit deterministic key (set elsewhere).
|
||||
if existing:
|
||||
return existing
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway main agent session key is required",
|
||||
)
|
||||
if agent.is_board_lead:
|
||||
return f"{AGENT_SESSION_PREFIX}:lead-{agent.board_id}:main"
|
||||
return f"{AGENT_SESSION_PREFIX}:mc-{agent.id}:main"
|
||||
|
||||
@classmethod
|
||||
def workspace_path(cls, agent_name: str, workspace_root: str | None) -> str:
|
||||
@@ -439,23 +441,6 @@ class AgentLifecycleService:
|
||||
return None
|
||||
return await Gateway.objects.by_id(agent.gateway_id).first(self.session)
|
||||
|
||||
async def ensure_gateway_session(
|
||||
self,
|
||||
agent_name: str,
|
||||
config: GatewayClientConfig,
|
||||
) -> tuple[str, str | None]:
|
||||
session_key = self.build_session_key(agent_name)
|
||||
try:
|
||||
await ensure_session(session_key, config=config, label=agent_name)
|
||||
except OpenClawGatewayError as exc:
|
||||
self.logger.warning(
|
||||
"agent.session.ensure_failed agent_name=%s error=%s",
|
||||
agent_name,
|
||||
str(exc),
|
||||
)
|
||||
return session_key, str(exc)
|
||||
return session_key, None
|
||||
|
||||
@classmethod
|
||||
def with_computed_status(cls, agent: Agent) -> Agent:
|
||||
now = utcnow()
|
||||
@@ -607,30 +592,11 @@ class AgentLifecycleService:
|
||||
detail="An agent with this name already exists in this gateway workspace.",
|
||||
)
|
||||
|
||||
desired_session_key = self.build_session_key(requested_name)
|
||||
existing_session_key = (
|
||||
await self.session.exec(
|
||||
select(Agent)
|
||||
.join(Board, col(Agent.board_id) == col(Board.id))
|
||||
.where(col(Board.gateway_id) == gateway.id)
|
||||
.where(col(Agent.openclaw_session_id) == desired_session_key),
|
||||
)
|
||||
).first()
|
||||
if existing_session_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"This agent name would collide with an existing workspace "
|
||||
"session key. Pick a different name."
|
||||
),
|
||||
)
|
||||
|
||||
async def persist_new_agent(
|
||||
self,
|
||||
*,
|
||||
data: dict[str, Any],
|
||||
client_config: GatewayClientConfig,
|
||||
) -> tuple[Agent, str, str | None]:
|
||||
) -> tuple[Agent, str]:
|
||||
agent = Agent.model_validate(data)
|
||||
agent.status = "provisioning"
|
||||
raw_token = generate_agent_token()
|
||||
@@ -639,58 +605,21 @@ class AgentLifecycleService:
|
||||
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||
agent.provision_requested_at = utcnow()
|
||||
agent.provision_action = "provision"
|
||||
session_key, session_error = await self.ensure_gateway_session(
|
||||
agent.name,
|
||||
client_config,
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
agent.openclaw_session_id = self.resolve_session_key(agent)
|
||||
self.session.add(agent)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(agent)
|
||||
return agent, raw_token, session_error
|
||||
|
||||
async def record_session_creation(
|
||||
self,
|
||||
*,
|
||||
agent: Agent,
|
||||
session_error: str | None,
|
||||
) -> None:
|
||||
if session_error:
|
||||
record_activity(
|
||||
self.session,
|
||||
event_type="agent.session.failed",
|
||||
message=f"Session sync failed for {agent.name}: {session_error}",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
else:
|
||||
record_activity(
|
||||
self.session,
|
||||
event_type="agent.session.created",
|
||||
message=f"Session created for {agent.name}.",
|
||||
agent_id=agent.id,
|
||||
)
|
||||
await self.session.commit()
|
||||
|
||||
async def send_wakeup_message(
|
||||
self,
|
||||
agent: Agent,
|
||||
config: GatewayClientConfig,
|
||||
verb: str = "provisioned",
|
||||
) -> None:
|
||||
session_key = agent.openclaw_session_id or self.build_session_key(agent.name)
|
||||
await ensure_session(session_key, config=config, label=agent.name)
|
||||
message = (
|
||||
f"Hello {agent.name}. Your workspace has been {verb}.\n\n"
|
||||
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
|
||||
"then delete it. Begin heartbeats after startup."
|
||||
)
|
||||
await send_message(message, session_key=session_key, config=config, deliver=True)
|
||||
return agent, raw_token
|
||||
|
||||
async def provision_new_agent(
|
||||
self,
|
||||
*,
|
||||
agent: Agent,
|
||||
request: AgentProvisionRequest,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
auth_token: str,
|
||||
user: User | None,
|
||||
force_bootstrap: bool,
|
||||
client_config: GatewayClientConfig,
|
||||
) -> None:
|
||||
execution = BoardAgentProvisionExecution(
|
||||
@@ -699,13 +628,13 @@ class AgentLifecycleService:
|
||||
provision_request=AgentUpdateProvisionRequest(
|
||||
target=AgentUpdateProvisionTarget(
|
||||
is_main_agent=False,
|
||||
board=request.board,
|
||||
gateway=request.gateway,
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
client_config=client_config,
|
||||
),
|
||||
raw_token=request.auth_token,
|
||||
user=request.user,
|
||||
force_bootstrap=request.options.force_bootstrap,
|
||||
raw_token=auth_token,
|
||||
user=user,
|
||||
force_bootstrap=force_bootstrap,
|
||||
),
|
||||
action="provision",
|
||||
wakeup_verb="provisioned",
|
||||
@@ -852,24 +781,6 @@ class AgentLifecycleService:
|
||||
client_config=client_config,
|
||||
)
|
||||
|
||||
async def ensure_agent_update_session(
|
||||
self,
|
||||
*,
|
||||
agent: Agent,
|
||||
client_config: GatewayClientConfig,
|
||||
) -> None:
|
||||
session_key = agent.openclaw_session_id or self.build_session_key(agent.name)
|
||||
try:
|
||||
await ensure_session(session_key, config=client_config, label=agent.name)
|
||||
if not agent.openclaw_session_id:
|
||||
agent.openclaw_session_id = session_key
|
||||
self.session.add(agent)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(agent)
|
||||
except OpenClawGatewayError as exc:
|
||||
self.record_instruction_failure(self.session, agent, str(exc), "update")
|
||||
await self.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def mark_agent_update_pending(agent: Agent) -> str:
|
||||
raw_token = generate_agent_token()
|
||||
@@ -937,23 +848,14 @@ class AgentLifecycleService:
|
||||
"gateway_id": gateway.id,
|
||||
"heartbeat_config": DEFAULT_HEARTBEAT_CONFIG.copy(),
|
||||
}
|
||||
agent, raw_token, session_error = await self.persist_new_agent(
|
||||
data=data,
|
||||
client_config=client_config,
|
||||
)
|
||||
await self.record_session_creation(
|
||||
agent=agent,
|
||||
session_error=session_error,
|
||||
)
|
||||
agent, raw_token = await self.persist_new_agent(data=data)
|
||||
await self.provision_new_agent(
|
||||
agent=agent,
|
||||
request=AgentProvisionRequest(
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
auth_token=raw_token,
|
||||
user=actor.user,
|
||||
options=ProvisionOptions(action="provision"),
|
||||
),
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
auth_token=raw_token,
|
||||
user=actor.user,
|
||||
force_bootstrap=False,
|
||||
client_config=client_config,
|
||||
)
|
||||
return agent
|
||||
@@ -987,13 +889,11 @@ class AgentLifecycleService:
|
||||
gateway, client_config = await self.require_gateway(board)
|
||||
await self.provision_new_agent(
|
||||
agent=agent,
|
||||
request=AgentProvisionRequest(
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
auth_token=raw_token,
|
||||
user=user,
|
||||
options=ProvisionOptions(action="provision"),
|
||||
),
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
auth_token=raw_token,
|
||||
user=user,
|
||||
force_bootstrap=False,
|
||||
client_config=client_config,
|
||||
)
|
||||
|
||||
@@ -1003,24 +903,17 @@ class AgentLifecycleService:
|
||||
agent: Agent,
|
||||
actor: ActorContextLike,
|
||||
) -> None:
|
||||
if agent.openclaw_session_id:
|
||||
_ = actor
|
||||
if agent.board_id is None:
|
||||
return
|
||||
board = await self.require_board(
|
||||
str(agent.board_id) if agent.board_id else None,
|
||||
user=actor.user if actor.actor_type == "user" else None,
|
||||
write=actor.actor_type == "user",
|
||||
)
|
||||
_, client_config = await self.require_gateway(board)
|
||||
session_key, session_error = await self.ensure_gateway_session(
|
||||
agent.name,
|
||||
client_config,
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
desired = self.resolve_session_key(agent)
|
||||
existing = (agent.openclaw_session_id or "").strip()
|
||||
if existing == desired:
|
||||
return
|
||||
agent.openclaw_session_id = desired
|
||||
self.session.add(agent)
|
||||
await self.record_session_creation(
|
||||
agent=agent,
|
||||
session_error=session_error,
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(agent)
|
||||
|
||||
async def commit_heartbeat(
|
||||
self,
|
||||
@@ -1162,24 +1055,14 @@ class AgentLifecycleService:
|
||||
gateway=gateway,
|
||||
requested_name=requested_name,
|
||||
)
|
||||
agent, raw_token, session_error = await self.persist_new_agent(
|
||||
data=data,
|
||||
client_config=client_config,
|
||||
)
|
||||
await self.record_session_creation(
|
||||
agent, raw_token = await self.persist_new_agent(data=data)
|
||||
await self.provision_new_agent(
|
||||
agent=agent,
|
||||
session_error=session_error,
|
||||
)
|
||||
provision_request = AgentProvisionRequest(
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
auth_token=raw_token,
|
||||
user=actor.user if actor.actor_type == "user" else None,
|
||||
options=ProvisionOptions(action="provision"),
|
||||
)
|
||||
await self.provision_new_agent(
|
||||
agent=agent,
|
||||
request=provision_request,
|
||||
force_bootstrap=False,
|
||||
client_config=client_config,
|
||||
)
|
||||
self.logger.info("agent.create.success agent_id=%s board_id=%s", agent.id, board.id)
|
||||
@@ -1229,10 +1112,6 @@ class AgentLifecycleService:
|
||||
main_gateway=main_gateway,
|
||||
gateway_for_main=gateway_for_main,
|
||||
)
|
||||
await self.ensure_agent_update_session(
|
||||
agent=agent,
|
||||
client_config=target.client_config,
|
||||
)
|
||||
raw_token = self.mark_agent_update_pending(agent)
|
||||
self.session.add(agent)
|
||||
await self.session.commit()
|
||||
@@ -1345,7 +1224,10 @@ class AgentLifecycleService:
|
||||
if gateway and gateway.url:
|
||||
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
try:
|
||||
workspace_path = await cleanup_main_agent(agent, gateway)
|
||||
workspace_path = await OpenClawProvisioningService().delete_agent_lifecycle(
|
||||
agent=agent,
|
||||
gateway=gateway,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
self.record_instruction_failure(self.session, agent, str(exc), "delete")
|
||||
await self.session.commit()
|
||||
@@ -1364,7 +1246,10 @@ class AgentLifecycleService:
|
||||
board = await self.require_board(str(agent.board_id))
|
||||
gateway, client_config = await self.require_gateway(board)
|
||||
try:
|
||||
workspace_path = await cleanup_agent(agent, gateway)
|
||||
workspace_path = await OpenClawProvisioningService().delete_agent_lifecycle(
|
||||
agent=agent,
|
||||
gateway=gateway,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
self.record_instruction_failure(self.session, agent, str(exc), "delete")
|
||||
await self.session.commit()
|
||||
|
||||
@@ -39,7 +39,7 @@ from app.services.openclaw.policies import OpenClawAuthorizationPolicy
|
||||
from app.services.openclaw.provisioning import (
|
||||
LeadAgentOptions,
|
||||
LeadAgentRequest,
|
||||
ensure_board_lead_agent,
|
||||
OpenClawProvisioningService,
|
||||
)
|
||||
from app.services.openclaw.shared import (
|
||||
GatewayAgentIdentity,
|
||||
@@ -542,8 +542,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService):
|
||||
board: Board,
|
||||
message: str,
|
||||
) -> tuple[Agent, bool]:
|
||||
lead, lead_created = await ensure_board_lead_agent(
|
||||
self.session,
|
||||
lead, lead_created = await OpenClawProvisioningService(self.session).ensure_board_lead_agent(
|
||||
request=LeadAgentRequest(
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,23 @@ async def get_member(
|
||||
).first(session)
|
||||
|
||||
|
||||
async def get_org_owner_user(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
organization_id: UUID,
|
||||
) -> User | None:
|
||||
"""Return the org owner User, if one exists."""
|
||||
owner = (
|
||||
await OrganizationMember.objects.filter_by(organization_id=organization_id)
|
||||
.filter(col(OrganizationMember.role) == "owner")
|
||||
.order_by(col(OrganizationMember.created_at).asc())
|
||||
.first(session)
|
||||
)
|
||||
if owner is None:
|
||||
return None
|
||||
return await User.objects.by_id(owner.user_id).first(session)
|
||||
|
||||
|
||||
async def get_first_membership(
|
||||
session: AsyncSession,
|
||||
user_id: UUID,
|
||||
|
||||
@@ -54,7 +54,7 @@ async def _run() -> int:
|
||||
from app.models.gateways import Gateway
|
||||
from app.services.openclaw.provisioning import (
|
||||
GatewayTemplateSyncOptions,
|
||||
sync_gateway_templates,
|
||||
OpenClawProvisioningService,
|
||||
)
|
||||
|
||||
args = _parse_args()
|
||||
@@ -67,10 +67,9 @@ async def _run() -> int:
|
||||
message = f"Gateway not found: {gateway_id}"
|
||||
raise SystemExit(message)
|
||||
|
||||
result = await sync_gateway_templates(
|
||||
session,
|
||||
result = await OpenClawProvisioningService(session).sync_gateway_templates(
|
||||
gateway,
|
||||
options=GatewayTemplateSyncOptions(
|
||||
GatewayTemplateSyncOptions(
|
||||
user=None,
|
||||
include_main=bool(args.include_main),
|
||||
reset_sessions=bool(args.reset_sessions),
|
||||
|
||||
@@ -88,10 +88,18 @@ async def test_delete_gateway_main_agent_does_not_require_board_id(monkeypatch:
|
||||
async def _should_not_be_called(*_args, **_kwargs):
|
||||
raise AssertionError("require_board/require_gateway should not be called for main agents")
|
||||
|
||||
called: dict[str, int] = {"cleanup_main": 0}
|
||||
called: dict[str, int] = {"delete_lifecycle": 0}
|
||||
|
||||
async def _fake_cleanup_main_agent(_agent: object, _gateway: object) -> str | None:
|
||||
called["cleanup_main"] += 1
|
||||
async def _fake_delete_agent_lifecycle(
|
||||
_self,
|
||||
*,
|
||||
agent: object,
|
||||
gateway: object,
|
||||
delete_files: bool = True,
|
||||
delete_session: bool = True,
|
||||
) -> str | None:
|
||||
_ = (_self, agent, gateway, delete_files, delete_session)
|
||||
called["delete_lifecycle"] += 1
|
||||
return "/tmp/openclaw/workspace-gateway-x"
|
||||
|
||||
async def _fake_update_where(*_args, **_kwargs) -> None:
|
||||
@@ -100,13 +108,16 @@ async def test_delete_gateway_main_agent_does_not_require_board_id(monkeypatch:
|
||||
monkeypatch.setattr(service, "require_agent_access", _no_access_check)
|
||||
monkeypatch.setattr(service, "require_board", _should_not_be_called)
|
||||
monkeypatch.setattr(service, "require_gateway", _should_not_be_called)
|
||||
monkeypatch.setattr(agent_service, "cleanup_main_agent", _fake_cleanup_main_agent)
|
||||
monkeypatch.setattr(
|
||||
agent_service.OpenClawProvisioningService,
|
||||
"delete_agent_lifecycle",
|
||||
_fake_delete_agent_lifecycle,
|
||||
)
|
||||
monkeypatch.setattr(agent_service.crud, "update_where", _fake_update_where)
|
||||
monkeypatch.setattr(agent_service, "record_activity", lambda *_a, **_k: None)
|
||||
|
||||
result = await service.delete_agent(agent_id=str(agent.id), ctx=ctx) # type: ignore[arg-type]
|
||||
|
||||
assert result.ok is True
|
||||
assert called["cleanup_main"] == 1
|
||||
assert called["delete_lifecycle"] == 1
|
||||
assert session.deleted and session.deleted[0] == agent
|
||||
|
||||
|
||||
@@ -25,27 +25,6 @@ def test_slugify_falls_back_to_uuid_hex(monkeypatch):
|
||||
assert agent_provisioning._slugify("!!!") == "deadbeef"
|
||||
|
||||
|
||||
def test_extract_agent_id_supports_lists_and_dicts():
|
||||
assert agent_provisioning._extract_agent_id(["", " ", "abc"]) == "abc"
|
||||
assert agent_provisioning._extract_agent_id([{"agent_id": "xyz"}]) == "xyz"
|
||||
|
||||
payload = {
|
||||
"defaultAgentId": "dflt",
|
||||
"agents": [{"id": "ignored"}],
|
||||
}
|
||||
assert agent_provisioning._extract_agent_id(payload) == "dflt"
|
||||
|
||||
payload2 = {
|
||||
"agents": [{"id": ""}, {"agentId": "foo"}],
|
||||
}
|
||||
assert agent_provisioning._extract_agent_id(payload2) == "foo"
|
||||
|
||||
|
||||
def test_extract_agent_id_returns_none_for_unknown_shapes():
|
||||
assert agent_provisioning._extract_agent_id("nope") is None
|
||||
assert agent_provisioning._extract_agent_id({"agents": "not-a-list"}) is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AgentStub:
|
||||
name: str
|
||||
@@ -58,13 +37,13 @@ class _AgentStub:
|
||||
soul_template: str | None = None
|
||||
|
||||
|
||||
def test_agent_key_uses_session_key_when_present(monkeypatch):
|
||||
def test_agent_key_uses_session_key_when_present():
|
||||
agent = _AgentStub(name="Alice", openclaw_session_id="agent:alice:main")
|
||||
assert agent_provisioning._agent_key(agent) == "alice"
|
||||
|
||||
monkeypatch.setattr(agent_provisioning, "_slugify", lambda value: "slugged")
|
||||
agent2 = _AgentStub(name="Alice", openclaw_session_id=None)
|
||||
assert agent_provisioning._agent_key(agent2) == "slugged"
|
||||
agent2 = _AgentStub(name="Hello, World", openclaw_session_id=None)
|
||||
assert agent_provisioning._agent_key(agent2) == "hello-world"
|
||||
|
||||
|
||||
def test_workspace_path_preserves_tilde_in_workspace_root():
|
||||
# Mission Control accepts a user-entered workspace root (from the UI) and must
|
||||
@@ -118,9 +97,6 @@ async def test_provision_main_agent_uses_dedicated_openclaw_agent_id(monkeypatch
|
||||
captured["patched_agent_id"] = registration.agent_id
|
||||
captured["workspace_path"] = registration.workspace_path
|
||||
|
||||
async def _fake_list_supported_files(self):
|
||||
return set()
|
||||
|
||||
async def _fake_list_agent_files(self, agent_id):
|
||||
captured["files_index_agent_id"] = agent_id
|
||||
return {}
|
||||
@@ -141,11 +117,6 @@ async def test_provision_main_agent_uses_dedicated_openclaw_agent_id(monkeypatch
|
||||
"upsert_agent",
|
||||
_fake_upsert_agent,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_provisioning.OpenClawGatewayControlPlane,
|
||||
"list_supported_files",
|
||||
_fake_list_supported_files,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_provisioning.OpenClawGatewayControlPlane,
|
||||
"list_agent_files",
|
||||
@@ -158,14 +129,14 @@ async def test_provision_main_agent_uses_dedicated_openclaw_agent_id(monkeypatch
|
||||
_fake_set_agent_files,
|
||||
)
|
||||
|
||||
await agent_provisioning.provision_main_agent(
|
||||
agent,
|
||||
agent_provisioning.MainAgentProvisionRequest(
|
||||
gateway=gateway,
|
||||
auth_token="secret-token",
|
||||
user=None,
|
||||
session_key=session_key,
|
||||
),
|
||||
await agent_provisioning.OpenClawProvisioningService().apply_agent_lifecycle(
|
||||
agent=agent, # type: ignore[arg-type]
|
||||
gateway=gateway, # type: ignore[arg-type]
|
||||
board=None,
|
||||
auth_token="secret-token",
|
||||
user=None,
|
||||
action="provision",
|
||||
wake=False,
|
||||
)
|
||||
|
||||
expected_agent_id = GatewayAgentIdentity.openclaw_agent_id_for_id(gateway_id)
|
||||
@@ -173,31 +144,6 @@ async def test_provision_main_agent_uses_dedicated_openclaw_agent_id(monkeypatch
|
||||
assert captured["files_index_agent_id"] == expected_agent_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_supported_files_always_includes_default_gateway_files(monkeypatch):
|
||||
"""Provisioning should not depend solely on whatever the gateway's default agent reports."""
|
||||
|
||||
async def _fake_openclaw_call(method, params=None, config=None):
|
||||
_ = config
|
||||
if method == "agents.list":
|
||||
return {"defaultId": "main"}
|
||||
if method == "agents.files.list":
|
||||
assert params == {"agentId": "main"}
|
||||
return {"files": [{"path": "prompts/system.md", "missing": True}]}
|
||||
raise AssertionError(f"Unexpected method: {method}")
|
||||
|
||||
monkeypatch.setattr(agent_provisioning, "openclaw_call", _fake_openclaw_call)
|
||||
cp = agent_provisioning.OpenClawGatewayControlPlane(
|
||||
agent_provisioning.GatewayClientConfig(url="ws://gateway.example/ws", token=None),
|
||||
)
|
||||
supported = await cp.list_supported_files()
|
||||
|
||||
# Newer gateways may surface other file paths, but we still must include our templates.
|
||||
assert "prompts/system.md" in supported
|
||||
for required in agent_provisioning.DEFAULT_GATEWAY_FILES:
|
||||
assert required in supported
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_overwrites_user_md_on_first_provision(monkeypatch):
|
||||
"""Gateway may pre-create USER.md; we still want MC's template on first provision."""
|
||||
@@ -221,10 +167,6 @@ async def test_provision_overwrites_user_md_on_first_provision(monkeypatch):
|
||||
async def delete_agent(self, agent_id, *, delete_files=True):
|
||||
return None
|
||||
|
||||
async def list_supported_files(self):
|
||||
# Minimal set.
|
||||
return {"USER.md"}
|
||||
|
||||
async def list_agent_files(self, agent_id):
|
||||
# Pretend gateway created USER.md already.
|
||||
return {"USER.md": {"name": "USER.md", "missing": False}}
|
||||
@@ -268,3 +210,137 @@ async def test_provision_overwrites_user_md_on_first_provision(monkeypatch):
|
||||
action="provision",
|
||||
)
|
||||
assert ("USER.md", "from-mc") in cp.writes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_agent_files_update_writes_zero_size_user_md():
|
||||
"""Treat empty placeholder files as missing during update."""
|
||||
|
||||
class _ControlPlaneStub:
|
||||
def __init__(self):
|
||||
self.writes: list[tuple[str, str]] = []
|
||||
|
||||
async def ensure_agent_session(self, session_key, *, label=None):
|
||||
return None
|
||||
|
||||
async def reset_agent_session(self, session_key):
|
||||
return None
|
||||
|
||||
async def delete_agent_session(self, session_key):
|
||||
return None
|
||||
|
||||
async def upsert_agent(self, registration):
|
||||
return None
|
||||
|
||||
async def delete_agent(self, agent_id, *, delete_files=True):
|
||||
return None
|
||||
|
||||
async def list_agent_files(self, agent_id):
|
||||
return {}
|
||||
|
||||
async def set_agent_file(self, *, agent_id, name, content):
|
||||
self.writes.append((name, content))
|
||||
|
||||
async def patch_agent_heartbeats(self, entries):
|
||||
return None
|
||||
|
||||
@dataclass
|
||||
class _GatewayTiny:
|
||||
id: UUID
|
||||
name: str
|
||||
url: str
|
||||
token: str | None
|
||||
workspace_root: str
|
||||
|
||||
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
|
||||
def _agent_id(self, agent):
|
||||
return "agent-x"
|
||||
|
||||
def _build_context(self, *, agent, auth_token, user, board):
|
||||
return {}
|
||||
|
||||
gateway = _GatewayTiny(
|
||||
id=uuid4(),
|
||||
name="G",
|
||||
url="ws://x",
|
||||
token=None,
|
||||
workspace_root="/tmp",
|
||||
)
|
||||
cp = _ControlPlaneStub()
|
||||
mgr = _Manager(gateway, cp) # type: ignore[arg-type]
|
||||
|
||||
await mgr._set_agent_files(
|
||||
agent_id="agent-x",
|
||||
rendered={"USER.md": "filled"},
|
||||
existing_files={"USER.md": {"name": "USER.md", "missing": False, "size": 0}},
|
||||
action="update",
|
||||
)
|
||||
assert ("USER.md", "filled") in cp.writes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_plane_upsert_agent_create_then_update(monkeypatch):
|
||||
calls: list[tuple[str, dict[str, object] | None]] = []
|
||||
|
||||
async def _fake_openclaw_call(method, params=None, config=None):
|
||||
_ = config
|
||||
calls.append((method, params))
|
||||
if method == "agents.create":
|
||||
return {"ok": True}
|
||||
if method == "agents.update":
|
||||
return {"ok": True}
|
||||
if method == "config.get":
|
||||
return {"hash": None, "config": {"agents": {"list": []}}}
|
||||
if method == "config.patch":
|
||||
return {"ok": True}
|
||||
raise AssertionError(f"Unexpected method: {method}")
|
||||
|
||||
monkeypatch.setattr(agent_provisioning, "openclaw_call", _fake_openclaw_call)
|
||||
cp = agent_provisioning.OpenClawGatewayControlPlane(
|
||||
agent_provisioning.GatewayClientConfig(url="ws://gateway.example/ws", token=None),
|
||||
)
|
||||
await cp.upsert_agent(
|
||||
agent_provisioning.GatewayAgentRegistration(
|
||||
agent_id="board-agent-a",
|
||||
name="Board Agent A",
|
||||
workspace_path="/tmp/workspace-board-agent-a",
|
||||
heartbeat={"every": "10m", "target": "none", "includeReasoning": False},
|
||||
),
|
||||
)
|
||||
|
||||
assert calls[0][0] == "agents.create"
|
||||
assert calls[1][0] == "agents.update"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_plane_upsert_agent_handles_already_exists(monkeypatch):
|
||||
calls: list[tuple[str, dict[str, object] | None]] = []
|
||||
|
||||
async def _fake_openclaw_call(method, params=None, config=None):
|
||||
_ = config
|
||||
calls.append((method, params))
|
||||
if method == "agents.create":
|
||||
raise agent_provisioning.OpenClawGatewayError("already exists")
|
||||
if method == "agents.update":
|
||||
return {"ok": True}
|
||||
if method == "config.get":
|
||||
return {"hash": None, "config": {"agents": {"list": []}}}
|
||||
if method == "config.patch":
|
||||
return {"ok": True}
|
||||
raise AssertionError(f"Unexpected method: {method}")
|
||||
|
||||
monkeypatch.setattr(agent_provisioning, "openclaw_call", _fake_openclaw_call)
|
||||
cp = agent_provisioning.OpenClawGatewayControlPlane(
|
||||
agent_provisioning.GatewayClientConfig(url="ws://gateway.example/ws", token=None),
|
||||
)
|
||||
await cp.upsert_agent(
|
||||
agent_provisioning.GatewayAgentRegistration(
|
||||
agent_id="board-agent-a",
|
||||
name="Board Agent A",
|
||||
workspace_path="/tmp/workspace-board-agent-a",
|
||||
heartbeat={"every": "10m", "target": "none", "includeReasoning": False},
|
||||
),
|
||||
)
|
||||
|
||||
assert calls[0][0] == "agents.create"
|
||||
assert calls[1][0] == "agents.update"
|
||||
|
||||
@@ -93,4 +93,3 @@ async def test_get_auth_context_optional_returns_none_for_agent_token(
|
||||
session=_FakeSession(), # type: ignore[arg-type]
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user