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>
This commit is contained in:
Johannes du Plessis 2026-06-23 12:24:11 -07:00 • committed by GitHub
parent 60663800e5
commit 3992d3ef5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1422 additions and 32 deletions

View file

@ -39,10 +39,13 @@ DEFAULT_SANDBOX_VCPUS="4" # Optional, d
DEFAULT_SANDBOX_MEM_BYTES="16106127360" # Optional, default 15 GiB
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="7200" # Optional, default 7200 (2 h); 0 disables
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS="86400" # Optional, default 86400 (24 h); 0 disables
REPO_SNAPSHOT_BASE_IMAGE="<registry>/<open-swe-sandbox-image>" # Optional; required for admin-generated repo snapshot templates
```
This is useful for pre-installing languages, frameworks, or internal tools that your repos depend on — reducing setup time per agent run. The default snapshot includes the GitHub CLI; agents invoke it as `GH_TOKEN=dummy gh <command>` and rely on the LangSmith proxy for the real credentials.
`REPO_SNAPSHOT_BASE_IMAGE` should point to the published Docker image used to create your default Open SWE sandbox snapshot (typically the image built from this repository's `Dockerfile`). The admin **Repository Snapshots** page uses it as the base image when generating per-repo Dockerfile templates. If it is not configured, template generation fails closed instead of suggesting a bare image that would be missing Open SWE's required sandbox tools.
For LangSmith sandboxes, Open SWE configures two GitHub proxy rules whenever a sandbox is created or reattached to a run:
- `github.com` / `*.github.com` receive Basic auth for git-over-HTTPS operations.

View file

@ -212,10 +212,14 @@ DEFAULT_SANDBOX_MEM_BYTES="16106127360"
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="7200"
# Optional; delete a stopped sandbox after this many seconds. Default is 86400 (24 hours). 0 disables.
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS="86400"
# Optional; required only for the admin Repository Snapshots page/template generator.
REPO_SNAPSHOT_BASE_IMAGE="<your-docker-hub>/<name-of-your-image>"
```
`DEFAULT_SANDBOX_SNAPSHOT_ID` is required when `SANDBOX_TYPE=langsmith`. The server validates this at startup and refuses to boot if it's missing. The snapshot should include the GitHub CLI from the project Dockerfile; Open SWE authenticates `git` and `gh` through the LangSmith sandbox proxy using runtime-minted GitHub App installation tokens, not deployment-stored GitHub access tokens.
`REPO_SNAPSHOT_BASE_IMAGE` should point at the same published Open SWE sandbox image you used to create the default snapshot (for example, the image built from `./Dockerfile`). The admin **Repository Snapshots** page uses it as the `FROM` line when generating per-repo Dockerfile templates. If it is not set, template generation is intentionally disabled so admins do not accidentally build repo-scoped snapshots from a bare image that lacks Open SWE's required tools (`git`, `gh`, `sfw`, language runtimes, and proxy assumptions).
## 5. Set up triggers
Open SWE can be triggered from GitHub, Linear, and/or Slack. **Configure whichever surfaces your team uses — you don't need all of them.**

View file

@ -0,0 +1,429 @@
"""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)

View file

@ -68,6 +68,20 @@ from .profiles import (
upsert_profile,
)
from .repo_access import require_repo_access_for_user
from .repo_snapshots import (
RepoSnapshotConfigError,
RepoSnapshotCreate,
RepoSnapshotUpdate,
create_repo_snapshot,
delete_repo_snapshot,
generate_dockerfile_template,
get_repo_snapshot,
is_repo_snapshot_build_stale,
list_repo_snapshots,
mark_repo_snapshot_building,
run_snapshot_build,
update_repo_snapshot,
)
from .review_api import (
get_review,
get_review_diff,
@ -686,6 +700,87 @@ async def api_set_enabled_review_repo(
return {"repos": repos}
@router.get("/repo-snapshots")
async def api_list_repo_snapshots(
_admin: dict[str, Any] = _ADMIN_DEP,
) -> list[dict[str, Any]]:
return await list_repo_snapshots()
@router.get("/repo-snapshots/template")
async def api_repo_snapshot_template(
full_name: str,
_admin: dict[str, Any] = _ADMIN_DEP,
) -> dict[str, str]:
try:
return {"dockerfile": generate_dockerfile_template(normalize_repo_full_name(full_name))}
except RepoSnapshotConfigError as e:
raise HTTPException(500, str(e)) from e
@router.post("/repo-snapshots")
async def api_create_repo_snapshot(
body: RepoSnapshotCreate,
_admin: dict[str, Any] = _ADMIN_DEP,
) -> dict[str, Any]:
try:
return await create_repo_snapshot(body.full_name, _admin["sub"])
except RepoSnapshotConfigError as e:
raise HTTPException(500, str(e)) from e
@router.get("/repo-snapshots/{full_name:path}")
async def api_get_repo_snapshot(
full_name: str,
_admin: dict[str, Any] = _ADMIN_DEP,
) -> dict[str, Any]:
record = await get_repo_snapshot(normalize_repo_full_name(full_name))
if not record:
raise HTTPException(404, "repo snapshot not found")
return record
@router.put("/repo-snapshots/{full_name:path}")
async def api_update_repo_snapshot(
full_name: str,
body: RepoSnapshotUpdate,
_admin: dict[str, Any] = _ADMIN_DEP,
) -> dict[str, Any]:
return await update_repo_snapshot(normalize_repo_full_name(full_name), body)
@router.post("/repo-snapshots/{full_name:path}/build")
async def api_build_repo_snapshot(
full_name: str,
background_tasks: BackgroundTasks,
_admin: dict[str, Any] = _ADMIN_DEP,
) -> dict[str, Any]:
full_name = normalize_repo_full_name(full_name)
record = await get_repo_snapshot(full_name)
if not record:
raise HTTPException(404, "repo snapshot not found")
if not (record.get("dockerfile") or "").strip():
raise HTTPException(400, "dockerfile is empty")
if record.get("status") == "building" and not is_repo_snapshot_build_stale(record):
raise HTTPException(409, "a build is already in progress")
record = await mark_repo_snapshot_building(full_name)
background_tasks.add_task(run_snapshot_build, full_name)
return record
@router.delete("/repo-snapshots/{full_name:path}")
async def api_delete_repo_snapshot(
full_name: str,
_admin: dict[str, Any] = _ADMIN_DEP,
) -> Response:
full_name = normalize_repo_full_name(full_name)
record = await get_repo_snapshot(full_name)
if not record:
raise HTTPException(404, "repo snapshot not found")
await delete_repo_snapshot(full_name)
return Response(status_code=204)
@router.get("/admin/user-mappings")
async def admin_list_user_mappings(
page: int = 1,

View file

@ -191,6 +191,8 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
def create_langsmith_sandbox(
sandbox_id: str | None = None,
github_token: str | None = None,
*,
snapshot_id: str | None = None,
) -> SandboxBackendProtocol:
"""Create or connect to a LangSmith sandbox without automatic cleanup.
@ -203,13 +205,15 @@ def create_langsmith_sandbox(
If None, creates a new sandbox.
github_token: Optional GitHub token. Used to configure proxy auth on
new sandboxes. Ignored when connecting to an existing sandbox.
snapshot_id: Optional repo-scoped snapshot to boot from. When omitted,
falls back to DEFAULT_SANDBOX_SNAPSHOT_ID.
Returns:
SandboxBackendProtocol instance
"""
api_key = _get_langsmith_api_key()
(
snapshot_id,
default_snapshot_id,
fs_capacity_bytes,
vcpus,
mem_bytes,
@ -217,10 +221,12 @@ def create_langsmith_sandbox(
delete_after_stop_seconds,
) = _get_sandbox_snapshot_config()
effective_snapshot_id = snapshot_id or default_snapshot_id
provider = LangSmithProvider(api_key=api_key)
backend = provider.get_or_create(
sandbox_id=sandbox_id,
snapshot_id=snapshot_id,
snapshot_id=effective_snapshot_id,
fs_capacity_bytes=fs_capacity_bytes,
vcpus=vcpus,
mem_bytes=mem_bytes,

View file

@ -843,10 +843,16 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
github_proxy_token = github_token
github_api_token = github_token
repo_name_for_scope = str(repo_config.get("name") or "")
repo_for_snapshot = (
{"owner": str(repo_config["owner"]), "name": str(repo_config["name"])}
if repo_config.get("owner") and repo_config.get("name")
else None
)
sandbox_backend = await ensure_sandbox_for_thread(
thread_id,
github_proxy_token=github_proxy_token,
github_proxy_repositories=[repo_name_for_scope] if repo_name_for_scope else None,
repo=repo_for_snapshot,
)
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)

View file

@ -41,6 +41,7 @@ from .dashboard.agent_overrides import (
)
from .dashboard.agent_usage import record_agent_thread_usage
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.repo_snapshots import resolve_repo_snapshot_id
from .dashboard.team_settings import (
get_team_default_model_pair,
get_team_default_repo,
@ -190,14 +191,31 @@ async def _resolve_proxy_token(github_proxy_token: str | None) -> tuple[str | No
return await get_github_app_installation_token_with_expiry()
async def _resolve_snapshot_id_for_repo(repo: dict[str, str] | None) -> str | None:
"""Resolve a repo's ready snapshot id; ``None`` falls back to the default.
Never raises: any failure resolves to ``None`` so sandbox creation falls
back to the configured ``DEFAULT_SANDBOX_SNAPSHOT_ID``.
"""
if not repo:
return None
try:
return await resolve_repo_snapshot_id(repo.get("owner"), repo.get("name"))
except Exception: # noqa: BLE001
logger.debug("Failed to resolve repo-scoped snapshot", exc_info=True)
return None
async def _create_sandbox_with_proxy(
github_proxy_token: str | None = None,
*,
thread_id: str | None = None,
github_proxy_repositories: Sequence[str] | None = None,
repo: dict[str, str] | None = None,
) -> SandboxBackendProtocol:
"""Create a new sandbox with GitHub proxy auth configured."""
sandbox_backend = await asyncio.to_thread(create_sandbox)
snapshot_id = await _resolve_snapshot_id_for_repo(repo)
sandbox_backend = await asyncio.to_thread(create_sandbox, snapshot_id=snapshot_id)
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
if sandbox_type == "langsmith":
@ -243,6 +261,7 @@ async def _refresh_github_proxy_or_recreate(
thread_id: str,
github_proxy_token: str | None = None,
github_proxy_repositories: Sequence[str] | None = None,
repo: dict[str, str] | None = None,
) -> SandboxBackendProtocol:
"""Refresh proxy credentials, recreating stale LangSmith sandboxes on failure."""
try:
@ -263,6 +282,7 @@ async def _refresh_github_proxy_or_recreate(
thread_id,
github_proxy_token=github_proxy_token,
github_proxy_repositories=github_proxy_repositories,
repo=repo,
)
return sandbox_backend
@ -280,6 +300,7 @@ async def _recreate_sandbox(
*,
github_proxy_token: str | None = None,
github_proxy_repositories: Sequence[str] | None = None,
repo: dict[str, str] | None = None,
) -> SandboxBackendProtocol:
"""Recreate a sandbox after a connection failure.
@ -295,6 +316,7 @@ async def _recreate_sandbox(
github_proxy_token,
thread_id=thread_id,
github_proxy_repositories=github_proxy_repositories,
repo=repo,
),
)
except Exception:
@ -309,6 +331,7 @@ async def check_or_recreate_sandbox(
thread_id: str,
github_proxy_token: str | None = None,
github_proxy_repositories: Sequence[str] | None = None,
repo: dict[str, str] | None = None,
) -> SandboxBackendProtocol:
"""Check if a cached sandbox is reachable; recreate it if not.
@ -329,6 +352,7 @@ async def check_or_recreate_sandbox(
thread_id,
github_proxy_token=github_proxy_token,
github_proxy_repositories=github_proxy_repositories,
repo=repo,
)
return sandbox_backend
@ -385,6 +409,7 @@ async def ensure_sandbox_for_thread(
*,
github_proxy_token: str | None = None,
github_proxy_repositories: Sequence[str] | None = None,
repo: dict[str, str] | None = None,
) -> SandboxBackendProtocol:
"""Get-or-create a healthy sandbox bound to ``thread_id``.
@ -396,7 +421,9 @@ async def ensure_sandbox_for_thread(
3. No sandbox at all → create one and persist the id.
4. Metadata has an id but no cache → reconnect; recreate on failure.
For LangSmith sandboxes, also refreshes the GitHub App proxy auth.
For LangSmith sandboxes, also refreshes the GitHub App proxy auth. When
``repo`` has a ``ready`` repo-scoped snapshot, newly created sandboxes boot
from it; otherwise the configured ``DEFAULT_SANDBOX_SNAPSHOT_ID`` is used.
Persists the resulting ``sandbox_id`` to thread metadata, and on the
first creation/reconnect for this thread initializes git identity.
"""
@ -411,11 +438,11 @@ async def ensure_sandbox_for_thread(
logger.info("Using cached sandbox backend for thread %s", thread_id)
original_sandbox_id = sandbox_backend.id
sandbox_backend = await check_or_recreate_sandbox(
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories, repo
)
if sandbox_backend.id == original_sandbox_id:
sandbox_backend = await _refresh_github_proxy_or_recreate(
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories, repo
)
elif sandbox_id is None:
logger.info("Creating new sandbox for thread %s", thread_id)
@ -425,6 +452,7 @@ async def ensure_sandbox_for_thread(
github_proxy_token,
thread_id=thread_id,
github_proxy_repositories=github_proxy_repositories,
repo=repo,
)
logger.info("Sandbox created: %s", sandbox_backend.id)
except Exception:
@ -447,6 +475,7 @@ async def ensure_sandbox_for_thread(
github_proxy_token,
thread_id=thread_id,
github_proxy_repositories=github_proxy_repositories,
repo=repo,
)
created_replacement_sandbox = True
except Exception:
@ -456,11 +485,11 @@ async def ensure_sandbox_for_thread(
if not created_replacement_sandbox:
original_sandbox_id = sandbox_backend.id
sandbox_backend = await check_or_recreate_sandbox(
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories, repo
)
if sandbox_backend.id == original_sandbox_id:
sandbox_backend = await _refresh_github_proxy_or_recreate(
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories, repo
)
sandbox_backend = set_sandbox_backend(thread_id, sandbox_backend)
@ -589,10 +618,14 @@ async def get_agent(config: RunnableConfig) -> Pregel:
github_token, _expires_at = await resolve_github_token(config, thread_id)
profile_login = resolve_github_login(config)
configurable = (config or {}).get("configurable") or {}
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
triggering_user_identity_task = asyncio.create_task(
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
)
sandbox_task = asyncio.create_task(ensure_sandbox_for_thread(thread_id))
sandbox_task = asyncio.create_task(
ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo)
)
team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent"))
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
@ -646,7 +679,6 @@ async def get_agent(config: RunnableConfig) -> Pregel:
subagent_model_id = overridden_subagent_model
subagent_effort = overridden_subagent_effort
configurable = (config or {}).get("configurable") or {}
per_thread_model = configurable.get("agent_model_id")
per_thread_effort = configurable.get("agent_effort")
if (
@ -731,7 +763,6 @@ async def get_agent(config: RunnableConfig) -> Pregel:
except Exception:
logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True)
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
repo_custom_instructions = await _resolve_repo_custom_instructions(prompt_default_repo)
observability_tools = await _load_observability_tools(

View file

@ -27,7 +27,11 @@ def _load_sandbox_factory(sandbox_type: str) -> SandboxFactory:
return factory
def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
def create_sandbox(
sandbox_id: str | None = None,
*,
snapshot_id: str | None = None,
) -> SandboxBackendProtocol:
"""Create or reconnect to a sandbox using the configured provider.
The provider is selected via the SANDBOX_TYPE environment variable.
@ -35,12 +39,17 @@ def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
Args:
sandbox_id: Optional existing sandbox ID to reconnect to.
snapshot_id: Optional snapshot to boot a new sandbox from. Only the
langsmith provider honors this; others ignore it. When omitted the
langsmith provider falls back to DEFAULT_SANDBOX_SNAPSHOT_ID.
Returns:
A sandbox backend implementing SandboxBackendProtocol.
"""
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
factory = _load_sandbox_factory(sandbox_type)
if sandbox_type == "langsmith" and snapshot_id is not None:
return factory(sandbox_id, snapshot_id=snapshot_id)
return factory(sandbox_id)

View file

@ -198,7 +198,7 @@ class TestCreateSandboxWithProxy:
await _create_sandbox_with_proxy()
mock_create.assert_called_once_with()
mock_create.assert_called_once_with(snapshot_id=None)
mock_proxy.assert_called_once_with("sandbox-123", "ghs_install")
@pytest.mark.asyncio
@ -215,7 +215,7 @@ class TestCreateSandboxWithProxy:
await _create_sandbox_with_proxy()
mock_create.assert_called_once_with()
mock_create.assert_called_once_with(snapshot_id=None)
mock_proxy.assert_not_called()
@pytest.mark.asyncio
@ -386,7 +386,10 @@ class TestRefreshProxyOnSandboxReuse:
assert sandbox is replacement_sandbox
mock_proxy.assert_called_once_with("sandbox-stale", "ghs_fresh")
mock_recreate.assert_awaited_once_with(
"thread-123", github_proxy_token=None, github_proxy_repositories=None
"thread-123",
github_proxy_token=None,
github_proxy_repositories=None,
repo=None,
)
@pytest.mark.asyncio

View file

@ -0,0 +1,311 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import BackgroundTasks, HTTPException
from agent.dashboard import routes
from agent.dashboard.repo_snapshots import (
RepoSnapshotConfigError,
RepoSnapshotUpdate,
create_repo_snapshot,
generate_dockerfile_template,
is_repo_snapshot_build_stale,
mark_repo_snapshot_building,
resolve_repo_snapshot_id,
run_snapshot_build,
update_repo_snapshot,
)
def test_generate_dockerfile_template_uses_base_image() -> None:
with patch.dict("os.environ", {"REPO_SNAPSHOT_BASE_IMAGE": "ghcr.io/acme/base:1"}):
template = generate_dockerfile_template("acme/repo")
assert "FROM ghcr.io/acme/base:1" in template
assert "acme/repo" in template
def test_generate_dockerfile_template_requires_base_image() -> None:
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(RepoSnapshotConfigError, match="REPO_SNAPSHOT_BASE_IMAGE"):
generate_dockerfile_template("acme/repo")
@pytest.mark.asyncio
async def test_template_endpoint_returns_configuration_error() -> None:
with patch.object(
routes,
"generate_dockerfile_template",
side_effect=RepoSnapshotConfigError("base image missing"),
):
with pytest.raises(HTTPException) as exc:
await routes.api_repo_snapshot_template("acme/repo", _admin={"sub": "octo"})
assert exc.value.status_code == 500
assert "base image missing" in exc.value.detail
@pytest.mark.asyncio
async def test_create_endpoint_returns_configuration_error() -> None:
body = routes.RepoSnapshotCreate(full_name="acme/repo")
with patch.object(
routes,
"create_repo_snapshot",
new_callable=AsyncMock,
side_effect=RepoSnapshotConfigError("base image missing"),
):
with pytest.raises(HTTPException) as exc:
await routes.api_create_repo_snapshot(body, _admin={"sub": "octo"})
assert exc.value.status_code == 500
assert "base image missing" in exc.value.detail
@pytest.mark.asyncio
async def test_resolve_returns_snapshot_id_when_ready() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
return_value={"status": "ready", "snapshot_id": "snap-123"},
):
result = await resolve_repo_snapshot_id("acme", "repo")
assert result == "snap-123"
@pytest.mark.asyncio
async def test_resolve_returns_none_when_not_ready() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
return_value={"status": "building", "snapshot_id": "snap-123"},
):
result = await resolve_repo_snapshot_id("acme", "repo")
assert result is None
@pytest.mark.asyncio
async def test_resolve_returns_none_without_record() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
return_value=None,
):
result = await resolve_repo_snapshot_id("acme", "repo")
assert result is None
@pytest.mark.asyncio
async def test_resolve_returns_none_without_owner_or_name() -> None:
assert await resolve_repo_snapshot_id(None, "repo") is None
assert await resolve_repo_snapshot_id("acme", None) is None
@pytest.mark.asyncio
async def test_resolve_swallows_errors() -> None:
with patch(
"agent.dashboard.repo_snapshots._get_value",
new_callable=AsyncMock,
side_effect=RuntimeError("store down"),
):
assert await resolve_repo_snapshot_id("acme", "repo") is None
@pytest.mark.asyncio
async def test_create_repo_snapshot_puts_new_record() -> None:
mock_put = AsyncMock()
with (
patch.dict("os.environ", {"REPO_SNAPSHOT_BASE_IMAGE": "ghcr.io/acme/base:1"}),
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value=None,
),
patch("agent.dashboard.repo_snapshots._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await create_repo_snapshot("acme/repo", "octo")
assert record["full_name"] == "acme/repo"
assert record["status"] == "none"
assert "FROM" in record["dockerfile"]
mock_put.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_repo_snapshot_persists_fields() -> None:
mock_put = AsyncMock()
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "status": "none"},
),
patch("agent.dashboard.repo_snapshots._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await update_repo_snapshot(
"acme/repo",
RepoSnapshotUpdate(dockerfile="FROM python:3.12-slim\n", vcpus=4),
)
assert record["dockerfile"] == "FROM python:3.12-slim\n"
assert record["vcpus"] == 4
mock_put.assert_awaited_once()
@pytest.mark.asyncio
async def test_mark_building_sets_status() -> None:
mock_put = AsyncMock()
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "status": "ready"},
),
patch("agent.dashboard.repo_snapshots._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await mark_repo_snapshot_building("acme/repo")
assert record["status"] == "building"
assert record["build_started_at"]
mock_put.assert_awaited_once()
def test_building_record_without_started_at_is_stale() -> None:
assert is_repo_snapshot_build_stale({"status": "building"}) is True
def test_recent_building_record_is_not_stale() -> None:
record = {"status": "building", "build_started_at": datetime.now(UTC).isoformat()}
assert is_repo_snapshot_build_stale(record) is False
def test_old_building_record_is_stale() -> None:
started = datetime.now(UTC) - timedelta(hours=7)
record = {"status": "building", "build_started_at": started.isoformat()}
assert is_repo_snapshot_build_stale(record) is True
@pytest.mark.asyncio
async def test_build_endpoint_blocks_non_stale_build() -> None:
record = {
"full_name": "acme/repo",
"status": "building",
"dockerfile": "FROM x",
"build_started_at": datetime.now(UTC).isoformat(),
}
with patch.object(routes, "get_repo_snapshot", new_callable=AsyncMock, return_value=record):
with pytest.raises(HTTPException) as exc:
await routes.api_build_repo_snapshot(
"acme/repo", BackgroundTasks(), _admin={"sub": "octo"}
)
assert exc.value.status_code == 409
@pytest.mark.asyncio
async def test_build_endpoint_allows_stale_build_retry() -> None:
stale_started = (datetime.now(UTC) - timedelta(hours=7)).isoformat()
stale = {
"full_name": "acme/repo",
"status": "building",
"dockerfile": "FROM x",
"build_started_at": stale_started,
}
building = {**stale, "build_started_at": datetime.now(UTC).isoformat()}
with (
patch.object(routes, "get_repo_snapshot", new_callable=AsyncMock, return_value=stale),
patch.object(
routes,
"mark_repo_snapshot_building",
new_callable=AsyncMock,
return_value=building,
) as mark_building,
):
result = await routes.api_build_repo_snapshot(
"acme/repo", BackgroundTasks(), _admin={"sub": "octo"}
)
assert result is building
mark_building.assert_awaited_once_with("acme/repo")
@pytest.mark.asyncio
async def test_run_snapshot_build_success_marks_ready() -> None:
statuses: list[tuple[str, dict | None]] = []
async def fake_set_status(full_name, status, *, status_message=None, extra=None):
statuses.append((status, extra))
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "dockerfile": "FROM x"},
),
patch(
"agent.dashboard.repo_snapshots._build_snapshot_sync",
return_value=("snap-new", "build log"),
),
patch("agent.dashboard.repo_snapshots._set_status", side_effect=fake_set_status),
):
await run_snapshot_build("acme/repo")
assert statuses[-1][0] == "ready"
assert statuses[-1][1]["snapshot_id"] == "snap-new"
@pytest.mark.asyncio
async def test_run_snapshot_build_failure_marks_failed() -> None:
statuses: list[str] = []
async def fake_set_status(full_name, status, *, status_message=None, extra=None):
statuses.append(status)
with (
patch(
"agent.dashboard.repo_snapshots.get_repo_snapshot",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "dockerfile": "FROM x"},
),
patch(
"agent.dashboard.repo_snapshots._build_snapshot_sync",
side_effect=RuntimeError("boom"),
),
patch("agent.dashboard.repo_snapshots._set_status", side_effect=fake_set_status),
):
await run_snapshot_build("acme/repo")
assert statuses[-1] == "failed"
def test_create_langsmith_sandbox_uses_repo_snapshot_override() -> None:
from agent.integrations import langsmith
fake_backend = MagicMock()
fake_backend.id = "box-1"
provider = MagicMock()
provider.get_or_create.return_value = fake_backend
with (
patch.dict("os.environ", {"DEFAULT_SANDBOX_SNAPSHOT_ID": "env-default"}, clear=True),
patch.object(langsmith, "LangSmithProvider", return_value=provider),
patch.object(langsmith, "_update_thread_sandbox_metadata"),
):
langsmith.create_langsmith_sandbox(snapshot_id="repo-snap")
assert provider.get_or_create.call_args.kwargs["snapshot_id"] == "repo-snap"
def test_create_langsmith_sandbox_falls_back_to_default() -> None:
from agent.integrations import langsmith
fake_backend = MagicMock()
fake_backend.id = "box-2"
provider = MagicMock()
provider.get_or_create.return_value = fake_backend
with (
patch.dict("os.environ", {"DEFAULT_SANDBOX_SNAPSHOT_ID": "env-default"}, clear=True),
patch.object(langsmith, "LangSmithProvider", return_value=provider),
patch.object(langsmith, "_update_thread_sandbox_metadata"),
):
langsmith.create_langsmith_sandbox()
assert provider.get_or_create.call_args.kwargs["snapshot_id"] == "env-default"

View file

@ -273,6 +273,7 @@ async def test_reviewer_reuses_app_token_for_sandbox_proxy() -> None:
"reviewer-thread-id",
github_proxy_token="app-token",
github_proxy_repositories=["repo"],
repo={"owner": "acme", "name": "repo"},
)

View file

@ -68,7 +68,11 @@ async def test_fresh_sandbox_creating_waits_for_other_worker() -> None:
]
async def passthrough(
sb, _thread_id, _github_proxy_token=None, _github_proxy_repositories=None
sb,
_thread_id,
_github_proxy_token=None,
_github_proxy_repositories=None,
_repo=None,
):
return sb

View file

@ -1,27 +1,29 @@
import Editor from "@monaco-editor/react";
import { useEffect, useState } from "react";
import Editor from "@monaco-editor/react"
import { useEffect, useState } from "react"
import { Textarea } from "@/components/ui/textarea";
import { Textarea } from "@/components/ui/textarea"
interface InstructionsEditorProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
language?: string
}
/** Monaco-backed markdown editor that falls back to a textarea before mount (SSR-safe). */
/** Monaco-backed code editor that falls back to a textarea before mount (SSR-safe). */
export function InstructionsEditor({
value,
onChange,
disabled,
placeholder,
language = "markdown",
}: InstructionsEditorProps) {
const [mounted, setMounted] = useState(false);
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true);
}, []);
setMounted(true)
}, [])
if (!mounted) {
return (
@ -32,14 +34,14 @@ export function InstructionsEditor({
placeholder={placeholder}
disabled={disabled}
/>
);
)
}
return (
<div className="overflow-hidden rounded-md border border-border">
<Editor
height="360px"
defaultLanguage="markdown"
language={language}
value={value}
onChange={(v) => onChange(v ?? "")}
options={{
@ -55,5 +57,5 @@ export function InstructionsEditor({
theme="vs-dark"
/>
</div>
);
)
}

View file

@ -0,0 +1,349 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import type { RepoSnapshot, RepoSnapshotStatus } from "@/lib/api"
import { Button } from "@/components/ui/button"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { InstructionsEditor } from "@/components/InstructionsEditor"
import { ApiError, api, isGithubReauthError, loginUrl } from "@/lib/api"
import { normalizeRepoFullName } from "@/lib/repo"
function formatMutationError(e: Error): string {
return isGithubReauthError(e)
? "GitHub token expired — sign in again using the link above."
: e.message
}
const STATUS_LABEL: Record<RepoSnapshotStatus, string> = {
none: "Not built",
building: "Building…",
ready: "Ready",
failed: "Failed",
}
const STATUS_CLASS: Record<RepoSnapshotStatus, string> = {
none: "text-muted-foreground",
building: "text-amber-500",
ready: "text-emerald-500",
failed: "text-destructive",
}
export function RepoSnapshotsPanel() {
const qc = useQueryClient()
const [error, setError] = useState<string | null>(null)
const [addRepo, setAddRepo] = useState("")
const [selected, setSelected] = useState<string | null>(null)
const [draft, setDraft] = useState("")
const snapshots = useQuery({
queryKey: ["repoSnapshots"],
queryFn: api.listRepoSnapshots,
})
const repos = useQuery({
queryKey: ["repos"],
queryFn: async () => {
try {
return await api.repos()
} catch (e) {
if (e instanceof ApiError && e.status === 401)
return { installations: [], repositories: [] }
throw e
}
},
})
const detail = useQuery({
queryKey: ["repoSnapshot", selected],
queryFn: () => api.getRepoSnapshot(selected!),
enabled: !!selected,
// Poll while a build is running so status + logs update live.
refetchInterval: (query) =>
query.state.data?.status === "building" ? 4000 : false,
})
useEffect(() => {
if (detail.data) setDraft(detail.data.dockerfile)
}, [detail.data?.dockerfile, detail.data?.full_name])
const create = useMutation({
mutationFn: (full_name: string) => api.createRepoSnapshot(full_name),
onSuccess: (record) => {
void qc.invalidateQueries({ queryKey: ["repoSnapshots"] })
setSelected(record.full_name)
setError(null)
},
onError: (e: Error) => setError(formatMutationError(e)),
})
const save = useMutation({
mutationFn: ({ full_name, value }: { full_name: string; value: string }) =>
api.saveRepoSnapshot(full_name, { dockerfile: value }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["repoSnapshots"] })
void qc.invalidateQueries({ queryKey: ["repoSnapshot", selected] })
setError(null)
},
onError: (e: Error) => setError(formatMutationError(e)),
})
const build = useMutation({
mutationFn: (full_name: string) => api.buildRepoSnapshot(full_name),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["repoSnapshots"] })
void qc.invalidateQueries({ queryKey: ["repoSnapshot", selected] })
setError(null)
},
onError: (e: Error) => setError(formatMutationError(e)),
})
const remove = useMutation({
mutationFn: (full_name: string) => api.deleteRepoSnapshot(full_name),
onSuccess: (_data, full_name) => {
void qc.invalidateQueries({ queryKey: ["repoSnapshots"] })
if (selected === full_name) {
setSelected(null)
setDraft("")
}
setError(null)
},
onError: (e: Error) => setError(formatMutationError(e)),
})
if (snapshots.isLoading) {
return <Skeleton className="h-40" />
}
const configured = new Set((snapshots.data ?? []).map((s) => s.full_name))
const suggestedRepos = (repos.data?.repositories ?? []).filter(
(r) => !configured.has(r.full_name)
)
const normalizedAddRepo = normalizeRepoFullName(addRepo)
const canAdd =
normalizedAddRepo !== null && !configured.has(normalizedAddRepo)
const active: RepoSnapshot | null =
detail.data ?? snapshots.data?.find((s) => s.full_name === selected) ?? null
const dirty = active != null && draft !== active.dockerfile
const building = active?.status === "building"
const handleAdd = () => {
if (!normalizedAddRepo || !canAdd) return
void create
.mutateAsync(normalizedAddRepo)
.then(() => setAddRepo(""))
.catch(() => undefined)
}
const githubReauth =
(repos.isError && isGithubReauthError(repos.error)) ||
(error !== null && /github token|re-login required/i.test(error))
return (
<div className="flex flex-col gap-6 p-4">
{githubReauth && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
Your GitHub connection expired.{" "}
<a
href={loginUrl()}
className="font-medium underline underline-offset-2"
>
Sign in with GitHub again
</a>{" "}
to list installed repos.
</div>
)}
<section className="space-y-4">
<div className="space-y-2">
<Label htmlFor="add-snapshot-repo">Add repository</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Input
id="add-snapshot-repo"
placeholder="owner/repo"
value={addRepo}
onChange={(e) => setAddRepo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAdd()
}
}}
className="sm:flex-1"
/>
<Button
size="sm"
className="shrink-0 sm:w-auto"
disabled={!canAdd || create.isPending}
onClick={handleAdd}
>
Add
</Button>
</div>
{suggestedRepos.length > 0 && (
<Combobox
items={suggestedRepos.map((r) => r.full_name)}
value={addRepo}
onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")}
>
<ComboboxInput
placeholder="Search installed repos…"
showClear
className="w-full"
/>
<ComboboxContent className="min-w-[var(--anchor-width)]">
<ComboboxList className="max-h-48">
<ComboboxEmpty>No matches</ComboboxEmpty>
{suggestedRepos.map((r) => (
<ComboboxItem key={r.full_name} value={r.full_name}>
<span className="truncate">{r.full_name}</span>
{r.private && (
<span className="ml-auto text-[10px] text-muted-foreground">
private
</span>
)}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">Repositories</p>
{(snapshots.data ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">
No repositories yet.
</p>
) : (
<ul className="flex flex-wrap gap-2">
{(snapshots.data ?? []).map((s) => (
<li key={s.full_name}>
<button
type="button"
className={`inline-flex max-w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors hover:bg-muted ${
selected === s.full_name
? "border-primary bg-muted font-medium"
: "border-border"
}`}
onClick={() => setSelected(s.full_name)}
>
<span className="truncate">{s.full_name}</span>
<span className={`text-[10px] ${STATUS_CLASS[s.status]}`}>
{STATUS_LABEL[s.status]}
</span>
</button>
</li>
))}
</ul>
)}
</div>
</section>
<div className="border-t border-border" />
<section className="space-y-3">
{!selected || !active ? (
<p className="text-xs text-muted-foreground">
Select a repository above to edit its Dockerfile and build a
snapshot. Repos without a ready snapshot fall back to the default
sandbox image.
</p>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-foreground">
{active.full_name}
</p>
<span className={`text-xs ${STATUS_CLASS[active.status]}`}>
{STATUS_LABEL[active.status]}
</span>
{active.snapshot_id && (
<span className="text-[10px] text-muted-foreground">
snapshot {active.snapshot_id}
</span>
)}
{active.last_built_at && (
<span className="text-[10px] text-muted-foreground">
built {new Date(active.last_built_at).toLocaleString()}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={!dirty || save.isPending || building}
onClick={() =>
void save.mutateAsync({
full_name: active.full_name,
value: draft,
})
}
>
Save Dockerfile
</Button>
<Button
size="sm"
variant="secondary"
disabled={dirty || building || build.isPending}
onClick={() => void build.mutateAsync(active.full_name)}
>
{building ? "Building…" : "Build snapshot"}
</Button>
{dirty && (
<span className="self-center text-xs text-muted-foreground">
Save before building
</span>
)}
<Button
size="sm"
variant="destructive"
className="ml-auto"
disabled={remove.isPending}
onClick={() => {
if (
!window.confirm(
`Remove the snapshot config for ${active.full_name}? Runs will fall back to the default sandbox image.`
)
) {
return
}
void remove.mutateAsync(active.full_name)
}}
>
Remove
</Button>
</div>
<InstructionsEditor
value={draft}
onChange={setDraft}
language="dockerfile"
disabled={building}
placeholder="FROM ..."
/>
{active.status === "failed" && active.status_message && (
<p className="text-xs text-destructive">
{active.status_message}
</p>
)}
{active.build_log && (
<pre className="max-h-64 overflow-auto rounded-md border border-border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap">
{active.build_log}
</pre>
)}
</>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</section>
</div>
)
}

View file

@ -320,6 +320,39 @@ export interface AgentInstructions {
updated_at?: string
}
export type RepoSnapshotStatus = "none" | "building" | "ready" | "failed"
export interface RepoSnapshot {
full_name: string
owner?: string
name?: string
dockerfile: string
snapshot_id: string | null
snapshot_name: string | null
status: RepoSnapshotStatus
status_message: string | null
build_log: string | null
fs_capacity_bytes?: number
vcpus?: number
mem_bytes?: number
target?: string | null
build_args?: Record<string, string> | null
build_started_at?: string | null
last_built_at?: string | null
created_by?: string
created_at?: string
updated_at?: string
}
export interface RepoSnapshotUpdateBody {
dockerfile: string
fs_capacity_bytes?: number | null
vcpus?: number | null
mem_bytes?: number | null
target?: string | null
build_args?: Record<string, string> | null
}
export type FindingSeverity = "low" | "medium" | "high" | "critical"
export type FindingConfidence = "low" | "medium" | "high"
export type FindingStatus = "open" | "resolved" | "dismissed"
@ -582,6 +615,32 @@ export const api = {
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
listRepoSnapshots: () => request<Array<RepoSnapshot>>("/repo-snapshots"),
createRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>("/repo-snapshots", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>(`/repo-snapshots/${encodeURIComponent(full_name)}`),
getRepoSnapshotTemplate: (full_name: string) =>
request<{ dockerfile: string }>(
`/repo-snapshots/template?full_name=${encodeURIComponent(full_name)}`
),
saveRepoSnapshot: (full_name: string, body: RepoSnapshotUpdateBody) =>
request<RepoSnapshot>(`/repo-snapshots/${encodeURIComponent(full_name)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
buildRepoSnapshot: (full_name: string) =>
request<RepoSnapshot>(
`/repo-snapshots/${encodeURIComponent(full_name)}/build`,
{ method: "POST" }
),
deleteRepoSnapshot: (full_name: string) =>
request<void>(`/repo-snapshots/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
getTeamSettings: () => request<TeamSettings>("/team-settings"),
saveTeamSettings: (body: TeamSettings) =>
request<TeamSettings>("/team-settings", {

View file

@ -20,6 +20,7 @@ import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
import { Route as AgentsSnapshotsRouteImport } from './routes/agents_.snapshots'
import { Route as AgentsInstructionsRouteImport } from './routes/agents_.instructions'
import { Route as AgentsThreadsRouteImport } from './routes/agents/threads'
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
@ -87,6 +88,11 @@ const ReviewStylesRoute = ReviewStylesRouteImport.update({
path: '/review/styles',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsSnapshotsRoute = AgentsSnapshotsRouteImport.update({
id: '/agents_/snapshots',
path: '/agents/snapshots',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({
id: '/agents_/instructions',
path: '/agents/instructions',
@ -159,6 +165,7 @@ export interface FileRoutesByFullPath {
'/agents/$threadId': typeof AgentsThreadIdRoute
'/agents/threads': typeof AgentsThreadsRoute
'/agents/instructions': typeof AgentsInstructionsRoute
'/agents/snapshots': typeof AgentsSnapshotsRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
@ -182,6 +189,7 @@ export interface FileRoutesByTo {
'/agents/$threadId': typeof AgentsThreadIdRoute
'/agents/threads': typeof AgentsThreadsRoute
'/agents/instructions': typeof AgentsInstructionsRoute
'/agents/snapshots': typeof AgentsSnapshotsRoute
'/review/styles': typeof ReviewStylesRoute
'/agents': typeof AgentsIndexRoute
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
@ -207,6 +215,7 @@ export interface FileRoutesById {
'/agents/$threadId': typeof AgentsThreadIdRoute
'/agents/threads': typeof AgentsThreadsRoute
'/agents_/instructions': typeof AgentsInstructionsRoute
'/agents_/snapshots': typeof AgentsSnapshotsRoute
'/review_/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/agents/$threadId_/plan': typeof AgentsThreadIdPlanRoute
@ -233,6 +242,7 @@ export interface FileRouteTypes {
| '/agents/$threadId'
| '/agents/threads'
| '/agents/instructions'
| '/agents/snapshots'
| '/review/styles'
| '/agents/'
| '/agents/$threadId/plan'
@ -256,6 +266,7 @@ export interface FileRouteTypes {
| '/agents/$threadId'
| '/agents/threads'
| '/agents/instructions'
| '/agents/snapshots'
| '/review/styles'
| '/agents'
| '/agents/$threadId/plan'
@ -280,6 +291,7 @@ export interface FileRouteTypes {
| '/agents/$threadId'
| '/agents/threads'
| '/agents_/instructions'
| '/agents_/snapshots'
| '/review_/styles'
| '/agents/'
| '/agents/$threadId_/plan'
@ -303,6 +315,7 @@ export interface RootRouteChildren {
UsageRoute: typeof UsageRoute
AdminEvalsRoute: typeof AdminEvalsRoute
AgentsInstructionsRoute: typeof AgentsInstructionsRoute
AgentsSnapshotsRoute: typeof AgentsSnapshotsRoute
ReviewStylesRoute: typeof ReviewStylesRoute
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
}
@ -386,6 +399,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ReviewStylesRouteImport
parentRoute: typeof rootRouteImport
}
'/agents_/snapshots': {
id: '/agents_/snapshots'
path: '/agents/snapshots'
fullPath: '/agents/snapshots'
preLoaderRoute: typeof AgentsSnapshotsRouteImport
parentRoute: typeof rootRouteImport
}
'/agents_/instructions': {
id: '/agents_/instructions'
path: '/agents/instructions'
@ -505,6 +525,7 @@ const rootRouteChildren: RootRouteChildren = {
UsageRoute: UsageRoute,
AdminEvalsRoute: AdminEvalsRoute,
AgentsInstructionsRoute: AgentsInstructionsRoute,
AgentsSnapshotsRoute: AgentsSnapshotsRoute,
ReviewStylesRoute: ReviewStylesRoute,
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
}

View file

@ -0,0 +1,37 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"
import { AppShell } from "@/components/AppShell"
import { RepoSnapshotsPanel } from "@/components/RepoSnapshotsPanel"
import { Skeleton } from "@/components/ui/skeleton"
import { useSession } from "@/lib/session"
export const Route = createFileRoute("/agents_/snapshots")({
component: RepoSnapshotsPage,
})
function RepoSnapshotsPage() {
const session = useSession()
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
)
}
if (!session.data) return <Navigate to="/login" />
if (!session.data.is_admin) return <Navigate to="/my-settings" />
return (
<AppShell
user={session.data}
title="Repository Snapshots"
description="Build a per-repo sandbox image from a custom Dockerfile. Repos without a ready snapshot fall back to the default sandbox image."
backTo={{ to: "/cloud-agents", label: "Back to Open SWE Agent" }}
>
<div className="rounded-lg border border-border bg-card">
<RepoSnapshotsPanel />
</div>
</AppShell>
)
}

View file

@ -321,13 +321,33 @@ function CloudAgentsPage() {
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">Repository Instructions</span>
<span className="text-xs font-medium text-foreground">
Repository Instructions
</span>
<span className="text-xs text-muted-foreground">
Per-repo custom instructions injected into the agent's system prompt.
Per-repo custom instructions injected into the agent's system
prompt.
</span>
</div>
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
{session.data?.is_admin && (
<Link
to="/agents/snapshots"
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">
Repository Snapshots
</span>
<span className="text-xs text-muted-foreground">
Build a per-repo sandbox image from a custom Dockerfile. Falls
back to the default image.
</span>
</div>
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
)}
</SettingsSection>
{error && <p className="text-xs text-destructive">{error}</p>}