mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 05:43:14 +00:00
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:
parent
60663800e5
commit
3992d3ef5d
18 changed files with 1422 additions and 32 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
|
|
|||
429
agent/dashboard/repo_snapshots.py
Normal file
429
agent/dashboard/repo_snapshots.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
311
tests/test_repo_snapshots.py
Normal file
311
tests/test_repo_snapshots.py
Normal 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"
|
||||
|
|
@ -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"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
349
ui/src/components/RepoSnapshotsPanel.tsx
Normal file
349
ui/src/components/RepoSnapshotsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
37
ui/src/routes/agents_.snapshots.tsx
Normal file
37
ui/src/routes/agents_.snapshots.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue