open-swe/agent/dashboard/repo_snapshots.py
Johannes du Plessis 3992d3ef5d
feat: repo-scoped dynamic sandbox snapshots (#1595)
* feat: repo-scoped dynamic sandbox snapshots

Let admins build a per-repo sandbox image from a custom Dockerfile so runs
targeting that repo boot from a snapshot with its deps pre-baked. Snapshot
selection is purely additive: repos without a `ready` repo-scoped snapshot
always fall back to the configured DEFAULT_SANDBOX_SNAPSHOT_ID.

Backend adds a repo_snapshots store module (Dockerfile + build status keyed by
owner/name), threads the resolved repo through the LangSmith sandbox creation
path, runs builds via SandboxClient.create_snapshot_from_dockerfile in a
throwaway builder sandbox, and exposes admin-only CRUD + build endpoints. The
UI adds an admin-only Agents-tab page (repo picker + Monaco Dockerfile editor +
build status/logs).

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: harden repo snapshot builds

Require REPO_SNAPSHOT_BASE_IMAGE for generated Dockerfile templates so admins
cannot accidentally build a repo snapshot from a bare Python image that lacks
Open SWE's sandbox tools. Allow stale building records to be retried by tracking
build_started_at and treating old or missing timestamps as stale.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: document repo snapshot base image config

Document REPO_SNAPSHOT_BASE_IMAGE alongside sandbox snapshot setup and convert
missing base-image configuration into a handled dashboard API error so admins see
a clear configuration message instead of an unhandled template-generation error.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 12:24:11 -07:00

429 lines
14 KiB
Python

"""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)