feat: add endpoint to delete board agents and implement related service logic
This commit is contained in:
@@ -550,6 +550,23 @@ async def update_agent_soul(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.delete("/boards/{board_id}/agents/{agent_id}", response_model=OkResponse)
|
||||
async def delete_board_agent(
|
||||
agent_id: str,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete a board agent as the board lead."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
_require_board_lead(agent_ctx)
|
||||
service = AgentLifecycleService(session)
|
||||
return await service.delete_agent_as_lead(
|
||||
agent_id=agent_id,
|
||||
actor_agent=agent_ctx.agent,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/boards/{board_id}/gateway/main/ask-user",
|
||||
response_model=GatewayMainAskUserResponse,
|
||||
|
||||
@@ -1658,7 +1658,38 @@ class AgentLifecycleService(OpenClawDBService):
|
||||
if agent is None:
|
||||
return OkResponse()
|
||||
await self.require_agent_access(agent=agent, ctx=ctx, write=True)
|
||||
return await self._delete_agent_record(agent=agent)
|
||||
|
||||
async def delete_agent_as_lead(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
actor_agent: Agent,
|
||||
) -> OkResponse:
|
||||
"""Delete a board-scoped agent as the board lead."""
|
||||
self.logger.log(TRACE_LEVEL, "agent.delete.lead.start agent_id=%s", agent_id)
|
||||
lead = OpenClawAuthorizationPolicy.require_board_lead_actor(
|
||||
actor_agent=actor_agent,
|
||||
detail="Only board leads can delete agents",
|
||||
)
|
||||
agent = await Agent.objects.by_id(agent_id).first(self.session)
|
||||
if agent is None:
|
||||
return OkResponse()
|
||||
if agent.board_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Board leads cannot delete gateway main agents",
|
||||
)
|
||||
board = await self.require_board(lead.board_id)
|
||||
OpenClawAuthorizationPolicy.require_board_agent_target(target=agent, board=board)
|
||||
if agent.is_board_lead:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Board leads cannot delete lead agents",
|
||||
)
|
||||
return await self._delete_agent_record(agent=agent)
|
||||
|
||||
async def _delete_agent_record(self, *, agent: Agent) -> OkResponse:
|
||||
gateway: Gateway | None = None
|
||||
client_config: GatewayClientConfig | None = None
|
||||
workspace_path: str | None = None
|
||||
@@ -1772,5 +1803,5 @@ class AgentLifecycleService(OpenClawDBService):
|
||||
)
|
||||
except (OSError, OpenClawGatewayError, ValueError):
|
||||
pass
|
||||
self.logger.info("agent.delete.success agent_id=%s", agent_id)
|
||||
self.logger.info("agent.delete.success agent_id=%s", agent.id)
|
||||
return OkResponse()
|
||||
|
||||
175
backend/tests/test_agent_delete_lead_agent.py
Normal file
175
backend/tests/test_agent_delete_lead_agent.py
Normal file
@@ -0,0 +1,175 @@
|
||||
# ruff: noqa: S101
|
||||
"""Unit tests for lead-agent delete behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
import app.services.openclaw.provisioning_db as agent_service
|
||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSession:
|
||||
committed: int = 0
|
||||
deleted: list[object] = field(default_factory=list)
|
||||
|
||||
def add(self, _value: object) -> None:
|
||||
return None
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
async def delete(self, value: object) -> None:
|
||||
self.deleted.append(value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AgentStub:
|
||||
id: UUID
|
||||
name: str
|
||||
gateway_id: UUID
|
||||
board_id: UUID | None = None
|
||||
is_board_lead: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BoardStub:
|
||||
id: UUID
|
||||
gateway_id: UUID
|
||||
|
||||
|
||||
@dataclass
|
||||
class _GatewayStub:
|
||||
id: UUID
|
||||
url: str
|
||||
token: str | None
|
||||
workspace_root: str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_agent_as_lead_removes_board_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _FakeSession()
|
||||
service = agent_service.AgentLifecycleService(session) # type: ignore[arg-type]
|
||||
|
||||
gateway_id = uuid4()
|
||||
board = _BoardStub(id=uuid4(), gateway_id=gateway_id)
|
||||
lead = _AgentStub(
|
||||
id=uuid4(),
|
||||
name="Lead Agent",
|
||||
gateway_id=gateway_id,
|
||||
board_id=board.id,
|
||||
is_board_lead=True,
|
||||
)
|
||||
target = _AgentStub(
|
||||
id=uuid4(),
|
||||
name="Worker Agent",
|
||||
gateway_id=gateway_id,
|
||||
board_id=board.id,
|
||||
is_board_lead=False,
|
||||
)
|
||||
|
||||
async def _fake_first_agent(_session: object) -> _AgentStub:
|
||||
return target
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_service.Agent,
|
||||
"objects",
|
||||
SimpleNamespace(by_id=lambda _id: SimpleNamespace(first=_fake_first_agent)),
|
||||
)
|
||||
|
||||
async def _fake_require_board(_board_id: object, **_kwargs: object) -> _BoardStub:
|
||||
return board
|
||||
|
||||
async def _fake_require_gateway(
|
||||
_board: object,
|
||||
) -> tuple[_GatewayStub, GatewayClientConfig]:
|
||||
gateway = _GatewayStub(
|
||||
id=gateway_id,
|
||||
url="ws://gateway.example/ws",
|
||||
token=None,
|
||||
workspace_root="/tmp/openclaw",
|
||||
)
|
||||
return gateway, GatewayClientConfig(url=gateway.url, token=None)
|
||||
|
||||
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)
|
||||
return None
|
||||
|
||||
async def _fake_update_where(*_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(service, "require_board", _fake_require_board)
|
||||
monkeypatch.setattr(service, "require_gateway", _fake_require_gateway)
|
||||
monkeypatch.setattr(
|
||||
agent_service.OpenClawGatewayProvisioner,
|
||||
"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_as_lead(
|
||||
agent_id=str(target.id),
|
||||
actor_agent=lead, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert session.deleted and session.deleted[0] == target
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_agent_as_lead_rejects_gateway_main(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _FakeSession()
|
||||
service = agent_service.AgentLifecycleService(session) # type: ignore[arg-type]
|
||||
|
||||
gateway_id = uuid4()
|
||||
board_id = uuid4()
|
||||
lead = _AgentStub(
|
||||
id=uuid4(),
|
||||
name="Lead Agent",
|
||||
gateway_id=gateway_id,
|
||||
board_id=board_id,
|
||||
is_board_lead=True,
|
||||
)
|
||||
target = _AgentStub(
|
||||
id=uuid4(),
|
||||
name="Gateway Main",
|
||||
gateway_id=gateway_id,
|
||||
board_id=None,
|
||||
is_board_lead=False,
|
||||
)
|
||||
|
||||
async def _fake_first_agent(_session: object) -> _AgentStub:
|
||||
return target
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_service.Agent,
|
||||
"objects",
|
||||
SimpleNamespace(by_id=lambda _id: SimpleNamespace(first=_fake_first_agent)),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service.delete_agent_as_lead(
|
||||
agent_id=str(target.id),
|
||||
actor_agent=lead, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert "gateway main" in str(exc_info.value.detail).lower()
|
||||
Reference in New Issue
Block a user