diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 4cbc5054..100f546b 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -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="/" # 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 ` 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. diff --git a/INSTALLATION.md b/INSTALLATION.md index f0a23418..419c663f 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -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="/" ``` `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.** diff --git a/agent/dashboard/repo_snapshots.py b/agent/dashboard/repo_snapshots.py new file mode 100644 index 00000000..e35329f0 --- /dev/null +++ b/agent/dashboard/repo_snapshots.py @@ -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) diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 9a4084e8..0e1cb429 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -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, diff --git a/agent/integrations/langsmith.py b/agent/integrations/langsmith.py index 04e0ada0..e8adc694 100644 --- a/agent/integrations/langsmith.py +++ b/agent/integrations/langsmith.py @@ -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, diff --git a/agent/reviewer.py b/agent/reviewer.py index 4f0d1fd3..cbadd299 100644 --- a/agent/reviewer.py +++ b/agent/reviewer.py @@ -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) diff --git a/agent/server.py b/agent/server.py index 291b8931..ac219b86 100644 --- a/agent/server.py +++ b/agent/server.py @@ -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( diff --git a/agent/utils/sandbox.py b/agent/utils/sandbox.py index c703e029..0dc3e41d 100644 --- a/agent/utils/sandbox.py +++ b/agent/utils/sandbox.py @@ -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) diff --git a/tests/test_proxy_auth.py b/tests/test_proxy_auth.py index a7eb2596..163bd1a4 100644 --- a/tests/test_proxy_auth.py +++ b/tests/test_proxy_auth.py @@ -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 diff --git a/tests/test_repo_snapshots.py b/tests/test_repo_snapshots.py new file mode 100644 index 00000000..d76538ff --- /dev/null +++ b/tests/test_repo_snapshots.py @@ -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" diff --git a/tests/test_reviewer.py b/tests/test_reviewer.py index 79ecdb13..109fc90c 100644 --- a/tests/test_reviewer.py +++ b/tests/test_reviewer.py @@ -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"}, ) diff --git a/tests/test_stale_sandbox_creating.py b/tests/test_stale_sandbox_creating.py index bb477f24..1cc2c76c 100644 --- a/tests/test_stale_sandbox_creating.py +++ b/tests/test_stale_sandbox_creating.py @@ -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 diff --git a/ui/src/components/InstructionsEditor.tsx b/ui/src/components/InstructionsEditor.tsx index 1d1da4c4..296f4f98 100644 --- a/ui/src/components/InstructionsEditor.tsx +++ b/ui/src/components/InstructionsEditor.tsx @@ -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 (
onChange(v ?? "")} options={{ @@ -55,5 +57,5 @@ export function InstructionsEditor({ theme="vs-dark" />
- ); + ) } diff --git a/ui/src/components/RepoSnapshotsPanel.tsx b/ui/src/components/RepoSnapshotsPanel.tsx new file mode 100644 index 00000000..7a905103 --- /dev/null +++ b/ui/src/components/RepoSnapshotsPanel.tsx @@ -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 = { + none: "Not built", + building: "Building…", + ready: "Ready", + failed: "Failed", +} + +const STATUS_CLASS: Record = { + 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(null) + const [addRepo, setAddRepo] = useState("") + const [selected, setSelected] = useState(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 + } + + 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 ( +
+ {githubReauth && ( +
+ Your GitHub connection expired.{" "} + + Sign in with GitHub again + {" "} + to list installed repos. +
+ )} +
+
+ +
+ setAddRepo(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleAdd() + } + }} + className="sm:flex-1" + /> + +
+ {suggestedRepos.length > 0 && ( + r.full_name)} + value={addRepo} + onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")} + > + + + + No matches + {suggestedRepos.map((r) => ( + + {r.full_name} + {r.private && ( + + private + + )} + + ))} + + + + )} +
+ +
+

Repositories

+ {(snapshots.data ?? []).length === 0 ? ( +

+ No repositories yet. +

+ ) : ( +
    + {(snapshots.data ?? []).map((s) => ( +
  • + +
  • + ))} +
+ )} +
+
+ +
+ +
+ {!selected || !active ? ( +

+ Select a repository above to edit its Dockerfile and build a + snapshot. Repos without a ready snapshot fall back to the default + sandbox image. +

+ ) : ( + <> +
+

+ {active.full_name} +

+ + {STATUS_LABEL[active.status]} + + {active.snapshot_id && ( + + snapshot {active.snapshot_id} + + )} + {active.last_built_at && ( + + built {new Date(active.last_built_at).toLocaleString()} + + )} +
+
+ + + {dirty && ( + + Save before building + + )} + +
+ + {active.status === "failed" && active.status_message && ( +

+ {active.status_message} +

+ )} + {active.build_log && ( +
+                {active.build_log}
+              
+ )} + + )} + {error &&

{error}

} +
+
+ ) +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index b357f91d..dcd477fd 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -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 | 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 | 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(`/agent-instructions/${encodeURIComponent(full_name)}`, { method: "DELETE", }), + listRepoSnapshots: () => request>("/repo-snapshots"), + createRepoSnapshot: (full_name: string) => + request("/repo-snapshots", { + method: "POST", + body: JSON.stringify({ full_name }), + }), + getRepoSnapshot: (full_name: string) => + request(`/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(`/repo-snapshots/${encodeURIComponent(full_name)}`, { + method: "PUT", + body: JSON.stringify(body), + }), + buildRepoSnapshot: (full_name: string) => + request( + `/repo-snapshots/${encodeURIComponent(full_name)}/build`, + { method: "POST" } + ), + deleteRepoSnapshot: (full_name: string) => + request(`/repo-snapshots/${encodeURIComponent(full_name)}`, { + method: "DELETE", + }), getTeamSettings: () => request("/team-settings"), saveTeamSettings: (body: TeamSettings) => request("/team-settings", { diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index ad789d34..4a12a8e1 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -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, } diff --git a/ui/src/routes/agents_.snapshots.tsx b/ui/src/routes/agents_.snapshots.tsx new file mode 100644 index 00000000..8619c404 --- /dev/null +++ b/ui/src/routes/agents_.snapshots.tsx @@ -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 ( +
+ +
+ ) + } + if (!session.data) return + if (!session.data.is_admin) return + + return ( + +
+ +
+
+ ) +} diff --git a/ui/src/routes/cloud-agents.tsx b/ui/src/routes/cloud-agents.tsx index 1b2d35d3..b0e8f52d 100644 --- a/ui/src/routes/cloud-agents.tsx +++ b/ui/src/routes/cloud-agents.tsx @@ -321,13 +321,33 @@ function CloudAgentsPage() { className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40" >
- Repository Instructions + + Repository Instructions + - Per-repo custom instructions injected into the agent's system prompt. + Per-repo custom instructions injected into the agent's system + prompt.
+ {session.data?.is_admin && ( + +
+ + Repository Snapshots + + + Build a per-repo sandbox image from a custom Dockerfile. Falls + back to the default image. + +
+ + + )} {error &&

{error}

}