"""Per-repository sandbox snapshots built from custom Dockerfiles. Each record holds an admin-authored Dockerfile (edited in the dashboard) and the id of the LangSmith snapshot most recently built from it. When a run targets a repo that has a ``ready`` snapshot, the sandbox boots from it instead of the global ``DEFAULT_SANDBOX_SNAPSHOT_ID``. Repos without a ready snapshot always fall back to that configured default, so this is purely additive. Builds run server-side via ``SandboxClient.create_snapshot_from_dockerfile``, which uploads the Dockerfile context to a throwaway LangSmith builder sandbox, runs BuildKit there, and captures the result. Nothing is executed on the host. """ from __future__ import annotations import logging import os import tempfile from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal from langgraph_sdk import get_client from pydantic import BaseModel, Field, field_validator from .review_styles import normalize_repo_full_name logger = logging.getLogger(__name__) REPO_SNAPSHOTS_NAMESPACE: list[str] = ["repo_snapshots"] BuildStatus = Literal["none", "building", "ready", "failed"] class RepoSnapshotConfigError(RuntimeError): pass DOCKERFILE_MAX_CHARS = 100_000 BUILD_LOG_MAX_CHARS = 20_000 # Build sizing defaults. The builder sandbox must hold the build context, the # intermediate layers, and the final image, so default generously. DEFAULT_BUILD_FS_CAPACITY_BYTES = 32 * 1024**3 DEFAULT_BUILD_VCPUS = 2 DEFAULT_BUILD_MEM_BYTES = 8 * 1024**3 DEFAULT_BUILD_TIMEOUT_SECONDS = 30 * 60 DEFAULT_STALE_BUILD_SECONDS = 6 * 60 * 60 _MIN_FS_CAPACITY_BYTES = 1 * 1024**3 _MAX_FS_CAPACITY_BYTES = 128 * 1024**3 _MIN_MEM_BYTES = 1 * 1024**3 _MAX_MEM_BYTES = 64 * 1024**3 _MIN_VCPUS = 1 _MAX_VCPUS = 16 def _default_base_image() -> str: """Base image used to seed generated Dockerfile templates.""" image = os.environ.get("REPO_SNAPSHOT_BASE_IMAGE", "").strip() if not image: raise RepoSnapshotConfigError( "REPO_SNAPSHOT_BASE_IMAGE must be set to the published Open SWE sandbox image" ) return image def generate_dockerfile_template(full_name: str) -> str: """Return a starter Dockerfile for a repo, extending the Open SWE base image.""" base = _default_base_image() return ( f"# Dockerfile for {full_name}\n" "#\n" "# This image becomes the sandbox snapshot for runs targeting this repo.\n" "# It MUST keep the tools Open SWE relies on (git, gh, the language\n" "# toolchain, sfw), so extend the Open SWE base image rather than starting\n" "# from a bare OS image. Add only repo-specific dependencies below.\n" f"FROM {base}\n" "\n" "# Example: pre-install system + project dependencies so they are baked\n" "# into the snapshot and runs start with everything already available.\n" "# RUN apt-get update && apt-get install -y --no-install-recommends \\\n" "# postgresql-client \\\n" "# && rm -rf /var/lib/apt/lists/*\n" "\n" "WORKDIR /workspace\n" ) class RepoSnapshotCreate(BaseModel): full_name: str = Field(..., description="GitHub repo in owner/name form") @field_validator("full_name", mode="before") @classmethod def _valid_full_name(cls, v: str) -> str: return normalize_repo_full_name(v) class RepoSnapshotUpdate(BaseModel): dockerfile: str = Field(default="") fs_capacity_bytes: int | None = None vcpus: int | None = None mem_bytes: int | None = None target: str | None = None build_args: dict[str, str] | None = None @field_validator("dockerfile") @classmethod def _dockerfile_len(cls, v: str) -> str: if len(v) > DOCKERFILE_MAX_CHARS: raise ValueError(f"dockerfile must be at most {DOCKERFILE_MAX_CHARS} characters") return v @field_validator("fs_capacity_bytes") @classmethod def _fs_capacity(cls, v: int | None) -> int | None: if v is None: return None if not _MIN_FS_CAPACITY_BYTES <= v <= _MAX_FS_CAPACITY_BYTES: raise ValueError("fs_capacity_bytes out of range") return v @field_validator("vcpus") @classmethod def _vcpus(cls, v: int | None) -> int | None: if v is None: return None if not _MIN_VCPUS <= v <= _MAX_VCPUS: raise ValueError("vcpus out of range") return v @field_validator("mem_bytes") @classmethod def _mem_bytes(cls, v: int | None) -> int | None: if v is None: return None if not _MIN_MEM_BYTES <= v <= _MAX_MEM_BYTES: raise ValueError("mem_bytes out of range") return v def _client(): return get_client() def _now_iso() -> str: return datetime.now(UTC).isoformat() def _parse_iso(value: object) -> datetime | None: if not isinstance(value, str) or not value.strip(): return None try: parsed = datetime.fromisoformat(value) except ValueError: return None if parsed.tzinfo is None: return parsed.replace(tzinfo=UTC) return parsed.astimezone(UTC) def _stale_build_seconds() -> int: raw = os.environ.get("REPO_SNAPSHOT_STALE_BUILD_SECONDS") if not raw: return DEFAULT_STALE_BUILD_SECONDS try: value = int(raw) except ValueError: return DEFAULT_STALE_BUILD_SECONDS return max(value, 0) def is_repo_snapshot_build_stale(record: dict[str, Any]) -> bool: if record.get("status") != "building": return False started_at = _parse_iso(record.get("build_started_at")) if started_at is None: return True return (datetime.now(UTC) - started_at).total_seconds() > _stale_build_seconds() async def _get_value(key: str) -> dict[str, Any] | None: try: item = await _client().store.get_item(REPO_SNAPSHOTS_NAMESPACE, key) except Exception as e: # noqa: BLE001 logger.debug("store get_item failed for %s: %s", key, e) return None if item is None: return None value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) return value if isinstance(value, dict) else None def _default_record(full_name: str, created_by: str) -> dict[str, Any]: owner, name = full_name.split("/", 1) return { "full_name": full_name, "owner": owner, "name": name, "dockerfile": generate_dockerfile_template(full_name), "snapshot_id": None, "snapshot_name": None, "status": "none", "status_message": None, "build_log": None, "fs_capacity_bytes": DEFAULT_BUILD_FS_CAPACITY_BYTES, "vcpus": DEFAULT_BUILD_VCPUS, "mem_bytes": DEFAULT_BUILD_MEM_BYTES, "target": None, "build_args": None, "build_started_at": None, "last_built_at": None, "created_by": created_by, "created_at": _now_iso(), "updated_at": _now_iso(), } async def get_repo_snapshot(full_name: str) -> dict[str, Any] | None: return await _get_value(normalize_repo_full_name(full_name)) async def list_repo_snapshots() -> list[dict[str, Any]]: try: result = await _client().store.search_items(REPO_SNAPSHOTS_NAMESPACE, limit=1000) except Exception as e: # noqa: BLE001 logger.debug("store search_items failed for repo_snapshots: %s", e) return [] items = result.get("items") if isinstance(result, dict) else getattr(result, "items", []) out: list[dict[str, Any]] = [] for item in items or []: value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) if isinstance(value, dict): out.append(value) out.sort(key=lambda r: r.get("full_name", "")) return out async def create_repo_snapshot(full_name: str, created_by: str) -> dict[str, Any]: full_name = normalize_repo_full_name(full_name) existing = await get_repo_snapshot(full_name) if existing: return existing value = _default_record(full_name, created_by) await _client().store.put_item(REPO_SNAPSHOTS_NAMESPACE, full_name, value) return value async def update_repo_snapshot(full_name: str, update: RepoSnapshotUpdate) -> dict[str, Any]: full_name = normalize_repo_full_name(full_name) existing = await get_repo_snapshot(full_name) or _default_record(full_name, "") value = {**existing, "dockerfile": update.dockerfile, "updated_at": _now_iso()} if update.fs_capacity_bytes is not None: value["fs_capacity_bytes"] = update.fs_capacity_bytes if update.vcpus is not None: value["vcpus"] = update.vcpus if update.mem_bytes is not None: value["mem_bytes"] = update.mem_bytes value["target"] = update.target value["build_args"] = update.build_args await _client().store.put_item(REPO_SNAPSHOTS_NAMESPACE, full_name, value) return value async def delete_repo_snapshot(full_name: str) -> bool: full_name = normalize_repo_full_name(full_name) existing = await get_repo_snapshot(full_name) if not existing: return False try: await _client().store.delete_item(REPO_SNAPSHOTS_NAMESPACE, full_name) except Exception as e: # noqa: BLE001 logger.warning("Failed to delete repo snapshot %s: %s", full_name, e) return False return True async def mark_repo_snapshot_building(full_name: str) -> dict[str, Any]: """Set a repo snapshot's status to ``building`` and return the record.""" full_name = normalize_repo_full_name(full_name) existing = await get_repo_snapshot(full_name) if existing is None: raise ValueError(f"no repo snapshot record for {full_name}") value = { **existing, "status": "building", "status_message": None, "build_log": None, "build_started_at": _now_iso(), "updated_at": _now_iso(), } await _client().store.put_item(REPO_SNAPSHOTS_NAMESPACE, full_name, value) return value async def resolve_repo_snapshot_id(owner: str | None, name: str | None) -> str | None: """Return a repo's ready snapshot id, or ``None`` to fall back to the default. Never raises: any lookup failure resolves to ``None`` so sandbox creation falls back to the configured ``DEFAULT_SANDBOX_SNAPSHOT_ID``. """ if not owner or not name: return None try: record = await _get_value(f"{owner}/{name}") except Exception: # noqa: BLE001 logger.debug("repo snapshot lookup failed for %s/%s", owner, name, exc_info=True) return None if not record or record.get("status") != "ready": return None snapshot_id = record.get("snapshot_id") return snapshot_id if isinstance(snapshot_id, str) and snapshot_id else None async def _set_status( full_name: str, status: BuildStatus, *, status_message: str | None = None, extra: dict[str, Any] | None = None, ) -> None: existing = await get_repo_snapshot(full_name) if existing is None: return value = { **existing, "status": status, "status_message": status_message, "updated_at": _now_iso(), } if status != "building": value["build_started_at"] = None if extra: value.update(extra) await _client().store.put_item(REPO_SNAPSHOTS_NAMESPACE, full_name, value) def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[str, str]: """Build a snapshot from the record's Dockerfile. Runs in a worker thread. Returns ``(snapshot_id, build_log_tail)``. Raises on build failure. """ from langsmith.sandbox import SandboxClient from agent.integrations.langsmith import _get_langsmith_api_key api_key = _get_langsmith_api_key() if not api_key: raise RuntimeError("LANGSMITH_API_KEY is not configured") logs: list[str] = [] def _on_log(line: str) -> None: logs.append(line) timeout = int( os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS) ) client = SandboxClient(api_key=api_key) try: with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir: dockerfile_path = Path(context_dir) / "Dockerfile" dockerfile_path.write_text(record.get("dockerfile") or "") build_args = ( record.get("build_args") if isinstance(record.get("build_args"), dict) else None ) target = record.get("target") if isinstance(record.get("target"), str) else None snapshot = client.create_snapshot_from_dockerfile( snapshot_name, dockerfile="Dockerfile", fs_capacity_bytes=int( record.get("fs_capacity_bytes") or DEFAULT_BUILD_FS_CAPACITY_BYTES ), context=context_dir, build_args=build_args or None, target=target or None, on_build_log=_on_log, vcpus=int(record.get("vcpus") or DEFAULT_BUILD_VCPUS), mem_bytes=int(record.get("mem_bytes") or DEFAULT_BUILD_MEM_BYTES), timeout=timeout, ) finally: client.close() log_tail = "".join(logs)[-BUILD_LOG_MAX_CHARS:] return snapshot.id, log_tail async def run_snapshot_build(full_name: str) -> None: """Build (or rebuild) the snapshot for a repo and persist the result. Intended to run as a FastAPI background task. The status is set to ``building`` before kicking off the (blocking) build in a worker thread. """ import asyncio full_name = normalize_repo_full_name(full_name) record = await get_repo_snapshot(full_name) if record is None: logger.warning("Cannot build snapshot for %s: no record", full_name) return owner, name = full_name.split("/", 1) timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") snapshot_name = f"openswe-{owner}-{name}-{timestamp}".replace("/", "-").lower() try: snapshot_id, log_tail = await asyncio.to_thread(_build_snapshot_sync, record, snapshot_name) except Exception as e: # noqa: BLE001 logger.warning("Snapshot build failed for %s: %s", full_name, e, exc_info=True) await _set_status( full_name, "failed", status_message=str(e)[:1000], ) return await _set_status( full_name, "ready", status_message=None, extra={ "snapshot_id": snapshot_id, "snapshot_name": snapshot_name, "build_log": log_tail, "last_built_at": _now_iso(), }, ) logger.info("Built snapshot %s for repo %s", snapshot_id, full_name)