feat: implement cascading delete for gateway and associated installed skills

This commit is contained in:
Abhimanyu Saharan
2026-02-14 03:04:49 +05:30
committed by Abhimanyu Saharan
parent 577c0d2839
commit da6cc2544b
5 changed files with 67 additions and 5 deletions

View File

@@ -14,6 +14,7 @@ from app.db import crud
from app.db.pagination import paginate
from app.db.session import get_session
from app.models.agents import Agent
from app.models.gateway_installed_skills import GatewayInstalledSkill
from app.models.gateways import Gateway
from app.schemas.common import OkResponse
from app.schemas.gateways import (
@@ -175,6 +176,12 @@ async def delete_gateway(
await service.clear_agent_foreign_keys(agent_id=agent.id)
await session.delete(agent)
installed_skills = await GatewayInstalledSkill.objects.filter_by(
gateway_id=gateway.id,
).all(session)
for installed_skill in installed_skills:
await session.delete(installed_skill)
await session.delete(gateway)
await session.commit()
return OkResponse()

View File

@@ -73,6 +73,7 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(
["gateway_id"],
["gateways.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["skill_id"],

View File

@@ -15,6 +15,7 @@ from sqlmodel import SQLModel, col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin
from app.api.gateways import router as gateways_router
from app.api.skills_marketplace import (
PackSkillCandidate,
_collect_pack_skills_from_repo,
@@ -44,6 +45,7 @@ def _build_test_app(
) -> FastAPI:
app = FastAPI()
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(gateways_router)
api_v1.include_router(skills_marketplace_router)
app.include_router(api_v1)
@@ -171,6 +173,58 @@ async def test_install_skill_dispatches_instruction_and_persists_installation(
await engine.dispose()
@pytest.mark.asyncio
async def test_delete_gateway_removes_installed_skill_rows() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
try:
async with session_maker() as session:
organization, gateway = await _seed_base(session)
skill = MarketplaceSkill(
organization_id=organization.id,
name="Deploy Helper",
source_url="https://example.com/skills/deploy-helper.git",
)
session.add(skill)
await session.commit()
await session.refresh(skill)
session.add(
GatewayInstalledSkill(
gateway_id=gateway.id,
skill_id=skill.id,
),
)
await session.commit()
app = _build_test_app(session_maker, organization=organization)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.delete(f"/api/v1/gateways/{gateway.id}")
assert response.status_code == 200
assert response.json() == {"ok": True}
async with session_maker() as session:
deleted_gateway = await session.get(Gateway, gateway.id)
assert deleted_gateway is None
remaining_installs = (
await session.exec(
select(GatewayInstalledSkill).where(
col(GatewayInstalledSkill.gateway_id) == gateway.id,
),
)
).all()
assert remaining_installs == []
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_list_marketplace_skills_marks_installed_cards() -> None:
engine = await _make_engine()

View File

@@ -7,6 +7,6 @@ type EditMarketplaceSkillPageProps = {
export default async function EditMarketplaceSkillPage({
params,
}: EditMarketplaceSkillPageProps) {
const { skillId } = await params;
redirect(`/skills/packs/${skillId}/edit`);
await params;
redirect("/skills/marketplace");
}

View File

@@ -80,9 +80,9 @@ const toPackLabel = (packUrl: string): string => {
}
};
const toPackDetailHref = (packUrl: string): string => {
const toPacksHref = (packUrl: string): string => {
const params = new URLSearchParams({ source_url: packUrl });
return `/skills/packs/detail?${params.toString()}`;
return `/skills/packs?${params.toString()}`;
};
export function MarketplaceSkillsTable({
@@ -143,7 +143,7 @@ export function MarketplaceSkillsTable({
const packUrl = toPackUrl(row.original.source_url);
return (
<Link
href={toPackDetailHref(packUrl)}
href={toPacksHref(packUrl)}
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
>
{truncate(toPackLabel(packUrl), 40)}