"""Repositories for the Sandbox Gateway. Every tenant-scoped read joins back to ``workspaces.organization_id`` so a workspace or session id from another tenant resolves to ``None``. """ from __future__ import annotations from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domain.base import utcnow from app.domain.sandbox.models import ( CommandRun, NetworkPolicy, PreviewUrl, SandboxProviderKind, SandboxResourceLimit, SandboxSession, SandboxTemplate, SessionStatus, Snapshot, Workspace, WorkspaceStatus, ) from app.infrastructure.db.models import ( CommandRunRow, PreviewUrlRow, SandboxResourceLimitRow, SandboxSessionRow, SandboxTemplateRow, SnapshotRow, WorkspaceRow, ) def _template(row: SandboxTemplateRow) -> SandboxTemplate: return SandboxTemplate( id=row.id, name=row.name, provider=row.provider, image=row.image, description=row.description, default_cpu=row.default_cpu, default_memory_mb=row.default_memory_mb, default_disk_mb=row.default_disk_mb, is_system=row.is_system, created_at=row.created_at, ) def _workspace(row: WorkspaceRow) -> Workspace: return Workspace( id=row.id, organization_id=row.organization_id, project_id=row.project_id, template_id=row.template_id, provider=row.provider, external_workspace_id=row.external_workspace_id, status=WorkspaceStatus(row.status), created_at=row.created_at, ) def _session(row: SandboxSessionRow) -> SandboxSession: return SandboxSession( id=row.id, workspace_id=row.workspace_id, external_session_id=row.external_session_id, status=SessionStatus(row.status), started_at=row.started_at, stopped_at=row.stopped_at, last_active_at=row.last_active_at, ) class SandboxTemplateRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def list(self) -> list[SandboxTemplate]: stmt = select(SandboxTemplateRow).order_by(SandboxTemplateRow.name) return [_template(r) for r in (await self.session.execute(stmt)).scalars()] async def get(self, template_id: str) -> SandboxTemplate | None: row = await self.session.get(SandboxTemplateRow, template_id) return _template(row) if row else None async def get_by_name(self, name: str) -> SandboxTemplate | None: stmt = select(SandboxTemplateRow).where(SandboxTemplateRow.name == name) row = (await self.session.execute(stmt)).scalar_one_or_none() return _template(row) if row else None async def create(self, template: SandboxTemplate) -> SandboxTemplate: row = SandboxTemplateRow( id=template.id, name=template.name, provider=str(template.provider), image=template.image, description=template.description, default_cpu=template.default_cpu, default_memory_mb=template.default_memory_mb, default_disk_mb=template.default_disk_mb, is_system=template.is_system, created_at=template.created_at, ) self.session.add(row) await self.session.flush() return _template(row) class WorkspaceRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get(self, workspace_id: str) -> Workspace | None: """Unscoped lookup. Platform components only — tenant paths use :meth:`get_for_org` so a foreign id resolves to ``None``.""" row = await self.session.get(WorkspaceRow, workspace_id) return _workspace(row) if row else None async def get_for_org(self, organization_id: str, workspace_id: str) -> Workspace | None: stmt = select(WorkspaceRow).where( WorkspaceRow.id == workspace_id, WorkspaceRow.organization_id == organization_id, ) row = (await self.session.execute(stmt)).scalar_one_or_none() return _workspace(row) if row else None async def list_for_project( self, project_id: str, *, limit: int = 100, offset: int = 0 ) -> list[Workspace]: stmt = ( select(WorkspaceRow) .where(WorkspaceRow.project_id == project_id) .order_by(WorkspaceRow.created_at.desc()) .limit(min(limit, 500)) .offset(offset) ) return [_workspace(r) for r in (await self.session.execute(stmt)).scalars()] async def create(self, workspace: Workspace) -> Workspace: row = WorkspaceRow( id=workspace.id, organization_id=workspace.organization_id, project_id=workspace.project_id, template_id=workspace.template_id, provider=str(workspace.provider), external_workspace_id=workspace.external_workspace_id, status=str(workspace.status), created_at=workspace.created_at, ) self.session.add(row) await self.session.flush() return _workspace(row) async def set_status(self, workspace_id: str, status: WorkspaceStatus) -> None: row = await self.session.get(WorkspaceRow, workspace_id) if row: row.status = str(status) await self.session.flush() class SandboxSessionRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get(self, session_id: str) -> SandboxSession | None: row = await self.session.get(SandboxSessionRow, session_id) return _session(row) if row else None async def get_for_org(self, organization_id: str, session_id: str) -> SandboxSession | None: """Resolve a session through its workspace, which carries the tenant.""" stmt = ( select(SandboxSessionRow) .join(WorkspaceRow, WorkspaceRow.id == SandboxSessionRow.workspace_id) .where( SandboxSessionRow.id == session_id, WorkspaceRow.organization_id == organization_id, ) ) row = (await self.session.execute(stmt)).scalar_one_or_none() return _session(row) if row else None async def list_for_workspace(self, workspace_id: str) -> list[SandboxSession]: stmt = ( select(SandboxSessionRow) .where(SandboxSessionRow.workspace_id == workspace_id) .order_by(SandboxSessionRow.started_at.desc()) ) return [_session(r) for r in (await self.session.execute(stmt)).scalars()] async def list_stale_active( self, *, idle_before: datetime, limit: int = 200 ) -> list[SandboxSession]: """Active sessions untouched since ``idle_before``. The gateway reaper's query. Only ``starting``/``running``/``paused`` count as active — a stopped session is already gone and must not be stopped twice. """ stmt = ( select(SandboxSessionRow) .where( SandboxSessionRow.status.in_( [ str(SessionStatus.STARTING), str(SessionStatus.RUNNING), str(SessionStatus.PAUSED), ] ), SandboxSessionRow.last_active_at < idle_before, ) .order_by(SandboxSessionRow.last_active_at.asc()) .limit(min(limit, 500)) ) return [_session(r) for r in (await self.session.execute(stmt)).scalars()] async def list_active_external_ids(self, *, limit: int = 5_000) -> set[str]: """External ids of every session that is not stopped. The orphan reaper's query: a container whose id is not in this set has no live row pointing at it, so it is holding resources for nobody. """ stmt = ( select(SandboxSessionRow.external_session_id) .where( SandboxSessionRow.status.in_( [ str(SessionStatus.STARTING), str(SessionStatus.RUNNING), str(SessionStatus.PAUSED), ] ), SandboxSessionRow.external_session_id.is_not(None), ) .limit(min(limit, 20_000)) ) rows = (await self.session.execute(stmt)).scalars().all() return {str(value) for value in rows if value} async def create(self, sandbox_session: SandboxSession) -> SandboxSession: row = SandboxSessionRow( id=sandbox_session.id, workspace_id=sandbox_session.workspace_id, external_session_id=sandbox_session.external_session_id, status=str(sandbox_session.status), started_at=sandbox_session.started_at, stopped_at=sandbox_session.stopped_at, last_active_at=sandbox_session.last_active_at, ) self.session.add(row) await self.session.flush() return _session(row) async def set_status( self, session_id: str, status: SessionStatus, *, stopped: bool = False ) -> None: row = await self.session.get(SandboxSessionRow, session_id) if row: row.status = str(status) row.last_active_at = utcnow() if stopped: row.stopped_at = utcnow() await self.session.flush() class CommandRunRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def list_for_session(self, session_id: str) -> list[CommandRun]: stmt = ( select(CommandRunRow) .where(CommandRunRow.session_id == session_id) .order_by(CommandRunRow.created_at) ) return [ CommandRun( id=r.id, session_id=r.session_id, command=r.command, exit_code=r.exit_code, stdout_ref=r.stdout_ref, stderr_ref=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated, created_at=r.created_at, ) for r in (await self.session.execute(stmt)).scalars() ] async def create(self, run: CommandRun) -> CommandRun: row = CommandRunRow( id=run.id, session_id=run.session_id, command=run.command, exit_code=run.exit_code, stdout_ref=run.stdout_ref, stderr_ref=run.stderr_ref, duration_ms=run.duration_ms, truncated=run.truncated, created_at=run.created_at, ) self.session.add(row) await self.session.flush() return run class SnapshotRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def list_for_workspace(self, workspace_id: str) -> list[Snapshot]: stmt = ( select(SnapshotRow) .where(SnapshotRow.workspace_id == workspace_id) .order_by(SnapshotRow.created_at.desc()) ) return [ Snapshot( id=r.id, workspace_id=r.workspace_id, name=r.name, external_snapshot_id=r.external_snapshot_id, created_at=r.created_at, ) for r in (await self.session.execute(stmt)).scalars() ] async def create(self, snapshot: Snapshot) -> Snapshot: row = SnapshotRow( id=snapshot.id, workspace_id=snapshot.workspace_id, name=snapshot.name, external_snapshot_id=snapshot.external_snapshot_id, created_at=snapshot.created_at, ) self.session.add(row) await self.session.flush() return snapshot class PreviewUrlRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def list_for_workspace(self, workspace_id: str) -> list[PreviewUrl]: stmt = select(PreviewUrlRow).where(PreviewUrlRow.workspace_id == workspace_id) return [ PreviewUrl( id=r.id, workspace_id=r.workspace_id, port=r.port, url=r.url, created_at=r.created_at, ) for r in (await self.session.execute(stmt)).scalars() ] async def create(self, preview: PreviewUrl) -> PreviewUrl: row = PreviewUrlRow( id=preview.id, workspace_id=preview.workspace_id, port=preview.port, url=preview.url, created_at=preview.created_at, ) self.session.add(row) await self.session.flush() return preview class ResourceLimitRepository: def __init__(self, session: AsyncSession) -> None: self.session = session async def get_for_scope(self, scope: str, scope_id: str | None) -> SandboxResourceLimit | None: stmt = select(SandboxResourceLimitRow).where( SandboxResourceLimitRow.scope == scope, SandboxResourceLimitRow.scope_id == scope_id, ) row = (await self.session.execute(stmt)).scalar_one_or_none() if row is None: return None return SandboxResourceLimit( id=row.id, scope=row.scope, scope_id=row.scope_id, cpu=row.cpu, memory_mb=row.memory_mb, disk_mb=row.disk_mb, timeout_seconds=row.timeout_seconds, max_output_bytes=row.max_output_bytes, network_policy=NetworkPolicy(row.network_policy), ) async def upsert(self, limit: SandboxResourceLimit) -> SandboxResourceLimit: stmt = select(SandboxResourceLimitRow).where( SandboxResourceLimitRow.scope == limit.scope, SandboxResourceLimitRow.scope_id == limit.scope_id, ) row = (await self.session.execute(stmt)).scalar_one_or_none() if row is None: row = SandboxResourceLimitRow( id=limit.id, scope=limit.scope, scope_id=limit.scope_id, cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb, timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes, network_policy=str(limit.network_policy), ) self.session.add(row) else: row.cpu = limit.cpu row.memory_mb = limit.memory_mb row.disk_mb = limit.disk_mb row.timeout_seconds = limit.timeout_seconds row.max_output_bytes = limit.max_output_bytes row.network_policy = str(limit.network_policy) await self.session.flush() return limit __all__ = [ "CommandRunRepository", "PreviewUrlRepository", "ResourceLimitRepository", "SandboxProviderKind", "SandboxSessionRepository", "SandboxTemplateRepository", "SnapshotRepository", "WorkspaceRepository", ]