Pipeline/backend/app/api/gateways.py

803 lines
27 KiB
Python
Raw Normal View History

"""Thin API wrappers for gateway CRUD and template synchronization."""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, Query, Request
from sqlmodel import col
from sse_starlette.sse import EventSourceResponse
from app.api.deps import require_org_admin, require_org_member
from app.core.auth import AuthContext, get_auth_context
from app.core.config import settings
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.models.agents import Agent
from app.models.gateways import Gateway
from app.models.skills import GatewayInstalledSkill
from app.schemas.common import OkResponse
from app.schemas.gateways import (
2026-05-20 04:13:32 -05:00
GatewayAgentImportPreviewResponse,
GatewayAgentImportRequest,
GatewayAgentImportResponse,
GatewayCreate,
GatewayRead,
GatewayTemplatesSyncResult,
GatewayUpdate,
)
from app.schemas.gateway_ops import (
CronJobRead,
CronStatusResponse,
HealthSnapshotRead,
SystemHealthResponse,
)
from app.schemas.runtime_usage import (
ProviderUsageResponse,
ProviderUsageScrapeResult,
RuntimeUsageResponse,
)
from app.db.session import async_session_maker
from app.services.activity_log import record_activity
from app.services.openclaw.cron_status import (
compute_job_status,
fetch_cron_jobs,
)
from app.services.openclaw.system_health import (
DEFAULT_HISTORY_WINDOW_HOURS,
fetch_health,
get_history,
)
from app.services.openclaw.runtime_activity import (
HISTORY_FETCH_LIMIT,
POLL_HISTORY_SESSIONS_MAX,
fetch_recent_events,
)
from app.services.openclaw.runtime_usage import get_runtime_usage
from app.services.openclaw.usage_scrapers import get_provider_usage
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.openclaw.admin_service import GatewayAdminLifecycleService
from app.services.openclaw.session_service import GatewayTemplateSyncQuery
if TYPE_CHECKING:
from fastapi_pagination.limit_offset import LimitOffsetPage
from sqlmodel.ext.asyncio.session import AsyncSession
from app.services.organizations import OrganizationContext
router = APIRouter(prefix="/gateways", tags=["gateways"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin)
ORG_MEMBER_DEP = Depends(require_org_member)
INCLUDE_MAIN_QUERY = Query(default=True)
RESET_SESSIONS_QUERY = Query(default=False)
ROTATE_TOKENS_QUERY = Query(default=False)
FORCE_BOOTSTRAP_QUERY = Query(default=False)
OVERWRITE_QUERY = Query(default=False)
LEAD_ONLY_QUERY = Query(default=False)
BOARD_ID_QUERY = Query(default=None)
_RUNTIME_TYPE_REFERENCES = (UUID,)
def _template_sync_query(
*,
include_main: bool = INCLUDE_MAIN_QUERY,
lead_only: bool = LEAD_ONLY_QUERY,
reset_sessions: bool = RESET_SESSIONS_QUERY,
rotate_tokens: bool = ROTATE_TOKENS_QUERY,
force_bootstrap: bool = FORCE_BOOTSTRAP_QUERY,
overwrite: bool = OVERWRITE_QUERY,
board_id: UUID | None = BOARD_ID_QUERY,
) -> GatewayTemplateSyncQuery:
return GatewayTemplateSyncQuery(
include_main=include_main,
lead_only=lead_only,
reset_sessions=reset_sessions,
rotate_tokens=rotate_tokens,
force_bootstrap=force_bootstrap,
overwrite=overwrite,
board_id=board_id,
)
SYNC_QUERY_DEP = Depends(_template_sync_query)
@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."""
statement = (
Gateway.objects.filter_by(organization_id=ctx.organization.id)
.order_by(col(Gateway.created_at).desc())
.statement
)
return await paginate(session, statement)
@router.post("", response_model=GatewayRead)
async def create_gateway(
payload: GatewayCreate,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> Gateway:
"""Create a gateway and provision or refresh its main agent."""
service = GatewayAdminLifecycleService(session)
await service.assert_gateway_runtime_compatible(
url=payload.url,
token=payload.token,
allow_insecure_tls=payload.allow_insecure_tls,
disable_device_pairing=payload.disable_device_pairing,
)
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 service.ensure_main_agent(gateway, auth, action="provision")
return gateway
@router.get("/{gateway_id}", response_model=GatewayRead)
async def get_gateway(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> Gateway:
"""Return one gateway by id for the caller's organization."""
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
return gateway
@router.patch("/{gateway_id}", response_model=GatewayRead)
async def update_gateway(
gateway_id: UUID,
payload: GatewayUpdate,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> Gateway:
"""Patch a gateway and refresh the main-agent provisioning state."""
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
updates = payload.model_dump(exclude_unset=True)
if (
"url" in updates
or "token" in updates
or "allow_insecure_tls" in updates
or "disable_device_pairing" in updates
):
raw_next_url = updates.get("url", gateway.url)
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
next_token = updates.get("token", gateway.token)
next_allow_insecure_tls = bool(
updates.get("allow_insecure_tls", gateway.allow_insecure_tls),
)
next_disable_device_pairing = bool(
updates.get("disable_device_pairing", gateway.disable_device_pairing),
)
if next_url:
await service.assert_gateway_runtime_compatible(
url=next_url,
token=next_token,
allow_insecure_tls=next_allow_insecure_tls,
disable_device_pairing=next_disable_device_pairing,
)
await crud.patch(session, gateway, updates)
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: 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."""
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
return await service.sync_templates(gateway, query=sync_query, auth=auth)
2026-05-20 04:13:32 -05:00
@router.get(
"/{gateway_id}/agents/import-preview",
response_model=GatewayAgentImportPreviewResponse,
)
async def preview_import_gateway_agents(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewayAgentImportPreviewResponse:
"""Preview existing gateway runtime agents that can be imported."""
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
return await service.preview_gateway_agent_import(gateway=gateway)
@router.post(
"/{gateway_id}/agents/import",
response_model=GatewayAgentImportResponse,
)
async def import_gateway_agents(
gateway_id: UUID,
payload: GatewayAgentImportRequest,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewayAgentImportResponse:
"""Import selected existing gateway runtime agents into Pipeline."""
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
return await service.import_gateway_agents(
gateway=gateway,
gateway_agent_ids=payload.gateway_agent_ids,
reconcile_after_import=payload.reconcile_after_import,
rotate_tokens=payload.rotate_tokens,
reset_sessions=payload.reset_sessions,
force_bootstrap=payload.force_bootstrap,
)
@router.get(
"/{gateway_id}/runtime-usage",
response_model=RuntimeUsageResponse,
summary="Gateway runtime usage",
description=(
"Return model usage, token counts, estimated spend, burn rate, and "
"time-remaining predictions for the specified gateway. "
"Visible to all organisation members."
),
)
async def get_gateway_runtime_usage(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> RuntimeUsageResponse:
"""Aggregate runtime usage from the gateway's usage.cost / usage.status RPC methods."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
account_key = gateway.name.lower().replace(" ", "-") if gateway.name else "default"
return await get_runtime_usage(
gateway_id=gateway.id,
config=config,
account_key=account_key,
)
@router.get(
"/{gateway_id}/provider-usage",
response_model=ProviderUsageResponse,
summary="Gateway provider-native usage (opt-in scraper)",
description=(
"Return provider-native subscription usage data scraped from the CLI "
"(e.g. ``claude /usage``). Returns an empty results list when "
"USAGE_SCRAPER_ENABLED=false (the default). "
"Enable with USAGE_SCRAPER_ENABLED=true and ensure the required "
"prerequisites (tmux, claude binary) are accessible from the backend process."
),
)
async def get_gateway_provider_usage(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ProviderUsageResponse:
"""Scrape provider-native usage for the specified gateway (opt-in)."""
service = GatewayAdminLifecycleService(session)
await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
now = utcnow()
if not settings.usage_scraper_enabled:
return ProviderUsageResponse(
gateway_id=gateway_id,
generated_at=now,
scraper_enabled=False,
results=[],
)
enabled_providers = [
p.strip()
for p in settings.usage_scraper_providers.split(",")
if p.strip()
]
scrape_results = await get_provider_usage(
gateway_id=str(gateway_id),
enabled_providers=enabled_providers,
tmux_socket=settings.usage_scraper_tmux_socket,
include_raw=settings.usage_scraper_include_raw,
)
results = [
ProviderUsageScrapeResult(
provider=r.provider,
source_name=r.source_name,
scraped_at=r.scraped_at,
fresh=r.fresh,
freshness_ttl_seconds=r.freshness_ttl_seconds,
current_pct=r.parsed.current_pct,
remaining_ms=r.parsed.remaining_ms,
remaining_label=r.parsed.remaining_label,
weekly_messages_used=r.parsed.weekly_messages_used,
weekly_messages_limit=r.parsed.weekly_messages_limit,
weekly_tokens_used=r.parsed.weekly_tokens_used,
weekly_cost_usd=r.parsed.weekly_cost_usd,
raw_text=r.parsed.raw_text,
error=r.error or r.parsed.error,
)
for r in scrape_results
]
return ProviderUsageResponse(
gateway_id=gateway_id,
generated_at=now,
scraper_enabled=True,
results=results,
)
@router.get(
"/{gateway_id}/cron",
response_model=CronStatusResponse,
summary="Gateway cron job status",
description="Return the list of cron jobs configured on the gateway with their last-run status.",
)
async def get_gateway_cron(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> CronStatusResponse:
"""Read cron job status from the gateway (read-only)."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
jobs = await fetch_cron_jobs(config)
return CronStatusResponse(
gateway_id=gateway_id,
generated_at=utcnow(),
jobs=[
CronJobRead(
name=j.name,
schedule=j.schedule,
enabled=j.enabled,
status=compute_job_status(j),
last_run=j.last_run,
next_run=j.next_run,
last_duration_ms=j.last_duration_ms,
last_error=j.last_error,
)
for j in jobs
],
)
@router.get(
"/{gateway_id}/health",
response_model=SystemHealthResponse,
summary="Gateway system health",
description="Return current CPU, RAM, disk, and uptime stats plus a 24-hour history.",
)
async def get_gateway_health(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> SystemHealthResponse:
"""Read system health from the gateway and append to the rolling history."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
snapshot = await fetch_health(str(gateway_id), config, record=True)
history = get_history(str(gateway_id))
def _snap_read(s) -> HealthSnapshotRead:
return HealthSnapshotRead(
recorded_at=s.recorded_at,
cpu_pct=s.cpu_pct,
memory_pct=s.memory_pct,
memory_used_bytes=s.memory_used_bytes,
memory_total_bytes=s.memory_total_bytes,
disk_pct=s.disk_pct,
disk_used_bytes=s.disk_used_bytes,
disk_total_bytes=s.disk_total_bytes,
uptime_seconds=s.uptime_seconds,
load_avg_1m=s.load_avg_1m,
load_avg_5m=s.load_avg_5m,
load_avg_15m=s.load_avg_15m,
hostname=s.hostname,
platform=s.platform,
)
return SystemHealthResponse(
gateway_id=gateway_id,
generated_at=utcnow(),
current=_snap_read(snapshot),
history=[_snap_read(s) for s in history],
history_window_hours=DEFAULT_HISTORY_WINDOW_HOURS,
)
# ---------------------------------------------------------------------------
# Admin: logs and config (org-admin gate)
# ---------------------------------------------------------------------------
_SECRET_KEY_PATTERNS = frozenset(
{"token", "secret", "password", "key", "credential", "auth", "bearer", "apikey", "api_key"}
)
def _mask_config_value(key: str, value: object) -> object:
"""Return the value with secrets replaced by a masked string."""
if isinstance(value, str) and any(p in key.lower() for p in _SECRET_KEY_PATTERNS):
return f"••••{value[-4:]}" if len(value) > 4 else "••••"
return value
@router.get(
"/{gateway_id}/logs",
summary="Tail gateway logs (admin)",
description="Return the most recent log lines from the gateway process. Admin-only.",
)
async def get_gateway_logs(
gateway_id: UUID,
lines: int = Query(default=100, ge=1, le=500, description="Number of lines to return"),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> dict:
"""Return recent log lines from the gateway (read-only)."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
try:
raw = await openclaw_call("logs.tail", {"lines": lines}, config=config)
except Exception:
raw = None
if isinstance(raw, list):
log_lines = [str(line) for line in raw if line is not None]
elif isinstance(raw, str):
log_lines = raw.splitlines()
elif isinstance(raw, dict):
nested = raw.get("lines") or raw.get("logs") or raw.get("output") or []
log_lines = [str(l) for l in nested] if isinstance(nested, list) else str(raw).splitlines()
else:
log_lines = []
return {
"gateway_id": str(gateway_id),
"generated_at": utcnow().isoformat(),
"lines": log_lines,
"lines_requested": lines,
}
@router.get(
"/{gateway_id}/config",
summary="Read gateway config (admin)",
description="Return the current gateway configuration with secrets masked. Admin-only.",
)
async def get_gateway_config(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> dict:
"""Return the gateway configuration with sensitive values masked."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
try:
raw = await openclaw_call("config.get", config=config)
except Exception:
raw = None
if isinstance(raw, dict):
masked = {k: _mask_config_value(k, v) for k, v in raw.items()}
else:
masked = {}
return {
"gateway_id": str(gateway_id),
"generated_at": utcnow().isoformat(),
"config": masked,
"available": bool(masked),
}
@router.patch(
"/{gateway_id}/config",
summary="Update gateway config (admin)",
description=(
"Apply partial config updates to the gateway using config.patch + config.apply. "
"Secrets must be submitted in full — not masked values. "
"Each call is audit-logged. Admin-only."
),
)
async def patch_gateway_config(
gateway_id: UUID,
payload: dict,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> dict:
"""Apply config changes and write an audit event."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
gw_config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
if not payload:
from fastapi import HTTPException as _HTTPException
raise _HTTPException(status_code=400, detail="No config keys provided.")
# Strip any masked placeholder values (user accidentally sent ••••xxxx)
clean_payload = {k: v for k, v in payload.items() if not str(v).startswith("••••")}
if not clean_payload:
from fastapi import HTTPException as _HTTPException
raise _HTTPException(status_code=400, detail="All values appear to be masked placeholders.")
try:
await openclaw_call("config.patch", clean_payload, config=gw_config)
await openclaw_call("config.apply", config=gw_config)
except Exception as exc:
from fastapi import HTTPException as _HTTPException
raise _HTTPException(status_code=502, detail=f"Gateway config update failed: {exc}") from exc
# Audit: record what changed (mask secret values in the log message)
changed_keys = list(clean_payload.keys())
masked_summary = ", ".join(
f"{k}={_mask_config_value(k, v)}" for k, v in clean_payload.items()
)
actor = str(ctx.member.user_id)
async with async_session_maker() as audit_session:
record_activity(
audit_session,
event_type="gateway.config.updated",
message=f"Gateway config updated by user {actor} — changed: {masked_summary}",
)
await audit_session.commit()
return {
"gateway_id": str(gateway_id),
"updated_at": utcnow().isoformat(),
"changed_keys": changed_keys,
"applied": True,
}
@router.get(
"/{gateway_id}/runtime-activity",
summary="Recent gateway runtime messages (REST snapshot)",
description=(
"Return the most recent messages from all active sessions on the gateway, "
"normalised and redacted. Use the streaming endpoint for a live feed."
),
)
async def get_gateway_runtime_activity(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> dict:
"""Non-streaming snapshot of recent gateway activity."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
events = await fetch_recent_events(
config,
max_sessions=POLL_HISTORY_SESSIONS_MAX,
history_limit=HISTORY_FETCH_LIMIT,
)
return {
"gateway_id": str(gateway_id),
"generated_at": utcnow().isoformat(),
"events": [e.to_dict() for e in events],
}
_RUNTIME_ACTIVITY_POLL_SECONDS = 4
_RUNTIME_ACTIVITY_SEEN_MAX = 1000
@router.get(
"/{gateway_id}/runtime-activity/stream",
summary="Live gateway runtime activity (SSE)",
description=(
"Stream normalised gateway session messages as server-sent events. "
"Each event is of type ``runtime_message``. The stream polls the "
"gateway every few seconds and emits only new messages."
),
)
async def stream_gateway_runtime_activity(
request: Request,
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> EventSourceResponse:
"""SSE stream of live gateway session messages."""
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
config = GatewayClientConfig(
url=gateway.url,
token=gateway.token,
allow_insecure_tls=gateway.allow_insecure_tls,
disable_device_pairing=gateway.disable_device_pairing,
)
async def event_generator():
from collections import deque
seen_ids: set[str] = set()
seen_queue: deque[str] = deque()
initial = True
while True:
if await request.is_disconnected():
break
events = await fetch_recent_events(
config,
since_ids=None if initial else seen_ids,
max_sessions=POLL_HISTORY_SESSIONS_MAX,
history_limit=HISTORY_FETCH_LIMIT,
)
initial = False
for event in events:
if event.event_id in seen_ids:
continue
seen_ids.add(event.event_id)
seen_queue.append(event.event_id)
if len(seen_queue) > _RUNTIME_ACTIVITY_SEEN_MAX:
oldest = seen_queue.popleft()
seen_ids.discard(oldest)
yield {
"event": "runtime_message",
"data": json.dumps(event.to_dict()),
}
await asyncio.sleep(_RUNTIME_ACTIVITY_POLL_SECONDS)
return EventSourceResponse(event_generator(), ping=15)
@router.delete("/{gateway_id}", response_model=OkResponse)
async def delete_gateway(
gateway_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
"""Delete a gateway in the caller's organization."""
service = GatewayAdminLifecycleService(session)
gateway = await service.require_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
main_agent = await service.find_main_agent(gateway)
if main_agent is not None:
await service.clear_agent_foreign_keys(agent_id=main_agent.id)
await session.delete(main_agent)
duplicate_main_agents = await Agent.objects.filter_by(
gateway_id=gateway.id,
board_id=None,
).all(session)
for agent in duplicate_main_agents:
if main_agent is not None and agent.id == main_agent.id:
continue
await service.clear_agent_foreign_keys(agent_id=agent.id)
await session.delete(agent)
# NOTE: The migration declares `ondelete="CASCADE"` for gateway_installed_skills.gateway_id,
# but some backends/test environments (e.g. SQLite without FK pragma) may not
# enforce cascades. Delete rows explicitly to guarantee cleanup semantics.
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()