mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53: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_MEM_BYTES="16106127360" # Optional, default 15 GiB
|
||||||
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="7200" # Optional, default 7200 (2 h); 0 disables
|
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
|
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.
|
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:
|
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.
|
- `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"
|
DEFAULT_SANDBOX_IDLE_TTL_SECONDS="7200"
|
||||||
# Optional; delete a stopped sandbox after this many seconds. Default is 86400 (24 hours). 0 disables.
|
# Optional; delete a stopped sandbox after this many seconds. Default is 86400 (24 hours). 0 disables.
|
||||||
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS="86400"
|
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.
|
`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
|
## 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.**
|
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,
|
upsert_profile,
|
||||||
)
|
)
|
||||||
from .repo_access import require_repo_access_for_user
|
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 (
|
from .review_api import (
|
||||||
get_review,
|
get_review,
|
||||||
get_review_diff,
|
get_review_diff,
|
||||||
|
|
@ -686,6 +700,87 @@ async def api_set_enabled_review_repo(
|
||||||
return {"repos": repos}
|
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")
|
@router.get("/admin/user-mappings")
|
||||||
async def admin_list_user_mappings(
|
async def admin_list_user_mappings(
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,8 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
||||||
def create_langsmith_sandbox(
|
def create_langsmith_sandbox(
|
||||||
sandbox_id: str | None = None,
|
sandbox_id: str | None = None,
|
||||||
github_token: str | None = None,
|
github_token: str | None = None,
|
||||||
|
*,
|
||||||
|
snapshot_id: str | None = None,
|
||||||
) -> SandboxBackendProtocol:
|
) -> SandboxBackendProtocol:
|
||||||
"""Create or connect to a LangSmith sandbox without automatic cleanup.
|
"""Create or connect to a LangSmith sandbox without automatic cleanup.
|
||||||
|
|
||||||
|
|
@ -203,13 +205,15 @@ def create_langsmith_sandbox(
|
||||||
If None, creates a new sandbox.
|
If None, creates a new sandbox.
|
||||||
github_token: Optional GitHub token. Used to configure proxy auth on
|
github_token: Optional GitHub token. Used to configure proxy auth on
|
||||||
new sandboxes. Ignored when connecting to an existing sandbox.
|
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:
|
Returns:
|
||||||
SandboxBackendProtocol instance
|
SandboxBackendProtocol instance
|
||||||
"""
|
"""
|
||||||
api_key = _get_langsmith_api_key()
|
api_key = _get_langsmith_api_key()
|
||||||
(
|
(
|
||||||
snapshot_id,
|
default_snapshot_id,
|
||||||
fs_capacity_bytes,
|
fs_capacity_bytes,
|
||||||
vcpus,
|
vcpus,
|
||||||
mem_bytes,
|
mem_bytes,
|
||||||
|
|
@ -217,10 +221,12 @@ def create_langsmith_sandbox(
|
||||||
delete_after_stop_seconds,
|
delete_after_stop_seconds,
|
||||||
) = _get_sandbox_snapshot_config()
|
) = _get_sandbox_snapshot_config()
|
||||||
|
|
||||||
|
effective_snapshot_id = snapshot_id or default_snapshot_id
|
||||||
|
|
||||||
provider = LangSmithProvider(api_key=api_key)
|
provider = LangSmithProvider(api_key=api_key)
|
||||||
backend = provider.get_or_create(
|
backend = provider.get_or_create(
|
||||||
sandbox_id=sandbox_id,
|
sandbox_id=sandbox_id,
|
||||||
snapshot_id=snapshot_id,
|
snapshot_id=effective_snapshot_id,
|
||||||
fs_capacity_bytes=fs_capacity_bytes,
|
fs_capacity_bytes=fs_capacity_bytes,
|
||||||
vcpus=vcpus,
|
vcpus=vcpus,
|
||||||
mem_bytes=mem_bytes,
|
mem_bytes=mem_bytes,
|
||||||
|
|
|
||||||
|
|
@ -843,10 +843,16 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||||
github_proxy_token = github_token
|
github_proxy_token = github_token
|
||||||
github_api_token = github_token
|
github_api_token = github_token
|
||||||
repo_name_for_scope = str(repo_config.get("name") or "")
|
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(
|
sandbox_backend = await ensure_sandbox_for_thread(
|
||||||
thread_id,
|
thread_id,
|
||||||
github_proxy_token=github_proxy_token,
|
github_proxy_token=github_proxy_token,
|
||||||
github_proxy_repositories=[repo_name_for_scope] if repo_name_for_scope else None,
|
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)
|
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.agent_usage import record_agent_thread_usage
|
||||||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
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 (
|
from .dashboard.team_settings import (
|
||||||
get_team_default_model_pair,
|
get_team_default_model_pair,
|
||||||
get_team_default_repo,
|
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()
|
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(
|
async def _create_sandbox_with_proxy(
|
||||||
github_proxy_token: str | None = None,
|
github_proxy_token: str | None = None,
|
||||||
*,
|
*,
|
||||||
thread_id: str | None = None,
|
thread_id: str | None = None,
|
||||||
github_proxy_repositories: Sequence[str] | None = None,
|
github_proxy_repositories: Sequence[str] | None = None,
|
||||||
|
repo: dict[str, str] | None = None,
|
||||||
) -> SandboxBackendProtocol:
|
) -> SandboxBackendProtocol:
|
||||||
"""Create a new sandbox with GitHub proxy auth configured."""
|
"""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")
|
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
||||||
if sandbox_type == "langsmith":
|
if sandbox_type == "langsmith":
|
||||||
|
|
@ -243,6 +261,7 @@ async def _refresh_github_proxy_or_recreate(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
github_proxy_token: str | None = None,
|
github_proxy_token: str | None = None,
|
||||||
github_proxy_repositories: Sequence[str] | None = None,
|
github_proxy_repositories: Sequence[str] | None = None,
|
||||||
|
repo: dict[str, str] | None = None,
|
||||||
) -> SandboxBackendProtocol:
|
) -> SandboxBackendProtocol:
|
||||||
"""Refresh proxy credentials, recreating stale LangSmith sandboxes on failure."""
|
"""Refresh proxy credentials, recreating stale LangSmith sandboxes on failure."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -263,6 +282,7 @@ async def _refresh_github_proxy_or_recreate(
|
||||||
thread_id,
|
thread_id,
|
||||||
github_proxy_token=github_proxy_token,
|
github_proxy_token=github_proxy_token,
|
||||||
github_proxy_repositories=github_proxy_repositories,
|
github_proxy_repositories=github_proxy_repositories,
|
||||||
|
repo=repo,
|
||||||
)
|
)
|
||||||
return sandbox_backend
|
return sandbox_backend
|
||||||
|
|
||||||
|
|
@ -280,6 +300,7 @@ async def _recreate_sandbox(
|
||||||
*,
|
*,
|
||||||
github_proxy_token: str | None = None,
|
github_proxy_token: str | None = None,
|
||||||
github_proxy_repositories: Sequence[str] | None = None,
|
github_proxy_repositories: Sequence[str] | None = None,
|
||||||
|
repo: dict[str, str] | None = None,
|
||||||
) -> SandboxBackendProtocol:
|
) -> SandboxBackendProtocol:
|
||||||
"""Recreate a sandbox after a connection failure.
|
"""Recreate a sandbox after a connection failure.
|
||||||
|
|
||||||
|
|
@ -295,6 +316,7 @@ async def _recreate_sandbox(
|
||||||
github_proxy_token,
|
github_proxy_token,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
github_proxy_repositories=github_proxy_repositories,
|
github_proxy_repositories=github_proxy_repositories,
|
||||||
|
repo=repo,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -309,6 +331,7 @@ async def check_or_recreate_sandbox(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
github_proxy_token: str | None = None,
|
github_proxy_token: str | None = None,
|
||||||
github_proxy_repositories: Sequence[str] | None = None,
|
github_proxy_repositories: Sequence[str] | None = None,
|
||||||
|
repo: dict[str, str] | None = None,
|
||||||
) -> SandboxBackendProtocol:
|
) -> SandboxBackendProtocol:
|
||||||
"""Check if a cached sandbox is reachable; recreate it if not.
|
"""Check if a cached sandbox is reachable; recreate it if not.
|
||||||
|
|
||||||
|
|
@ -329,6 +352,7 @@ async def check_or_recreate_sandbox(
|
||||||
thread_id,
|
thread_id,
|
||||||
github_proxy_token=github_proxy_token,
|
github_proxy_token=github_proxy_token,
|
||||||
github_proxy_repositories=github_proxy_repositories,
|
github_proxy_repositories=github_proxy_repositories,
|
||||||
|
repo=repo,
|
||||||
)
|
)
|
||||||
return sandbox_backend
|
return sandbox_backend
|
||||||
|
|
||||||
|
|
@ -385,6 +409,7 @@ async def ensure_sandbox_for_thread(
|
||||||
*,
|
*,
|
||||||
github_proxy_token: str | None = None,
|
github_proxy_token: str | None = None,
|
||||||
github_proxy_repositories: Sequence[str] | None = None,
|
github_proxy_repositories: Sequence[str] | None = None,
|
||||||
|
repo: dict[str, str] | None = None,
|
||||||
) -> SandboxBackendProtocol:
|
) -> SandboxBackendProtocol:
|
||||||
"""Get-or-create a healthy sandbox bound to ``thread_id``.
|
"""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.
|
3. No sandbox at all → create one and persist the id.
|
||||||
4. Metadata has an id but no cache → reconnect; recreate on failure.
|
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
|
Persists the resulting ``sandbox_id`` to thread metadata, and on the
|
||||||
first creation/reconnect for this thread initializes git identity.
|
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)
|
logger.info("Using cached sandbox backend for thread %s", thread_id)
|
||||||
original_sandbox_id = sandbox_backend.id
|
original_sandbox_id = sandbox_backend.id
|
||||||
sandbox_backend = await check_or_recreate_sandbox(
|
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:
|
if sandbox_backend.id == original_sandbox_id:
|
||||||
sandbox_backend = await _refresh_github_proxy_or_recreate(
|
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:
|
elif sandbox_id is None:
|
||||||
logger.info("Creating new sandbox for thread %s", thread_id)
|
logger.info("Creating new sandbox for thread %s", thread_id)
|
||||||
|
|
@ -425,6 +452,7 @@ async def ensure_sandbox_for_thread(
|
||||||
github_proxy_token,
|
github_proxy_token,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
github_proxy_repositories=github_proxy_repositories,
|
github_proxy_repositories=github_proxy_repositories,
|
||||||
|
repo=repo,
|
||||||
)
|
)
|
||||||
logger.info("Sandbox created: %s", sandbox_backend.id)
|
logger.info("Sandbox created: %s", sandbox_backend.id)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -447,6 +475,7 @@ async def ensure_sandbox_for_thread(
|
||||||
github_proxy_token,
|
github_proxy_token,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
github_proxy_repositories=github_proxy_repositories,
|
github_proxy_repositories=github_proxy_repositories,
|
||||||
|
repo=repo,
|
||||||
)
|
)
|
||||||
created_replacement_sandbox = True
|
created_replacement_sandbox = True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -456,11 +485,11 @@ async def ensure_sandbox_for_thread(
|
||||||
if not created_replacement_sandbox:
|
if not created_replacement_sandbox:
|
||||||
original_sandbox_id = sandbox_backend.id
|
original_sandbox_id = sandbox_backend.id
|
||||||
sandbox_backend = await check_or_recreate_sandbox(
|
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:
|
if sandbox_backend.id == original_sandbox_id:
|
||||||
sandbox_backend = await _refresh_github_proxy_or_recreate(
|
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)
|
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)
|
github_token, _expires_at = await resolve_github_token(config, thread_id)
|
||||||
profile_login = resolve_github_login(config)
|
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(
|
triggering_user_identity_task = asyncio.create_task(
|
||||||
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
|
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"))
|
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
|
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
|
||||||
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
|
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_model_id = overridden_subagent_model
|
||||||
subagent_effort = overridden_subagent_effort
|
subagent_effort = overridden_subagent_effort
|
||||||
|
|
||||||
configurable = (config or {}).get("configurable") or {}
|
|
||||||
per_thread_model = configurable.get("agent_model_id")
|
per_thread_model = configurable.get("agent_model_id")
|
||||||
per_thread_effort = configurable.get("agent_effort")
|
per_thread_effort = configurable.get("agent_effort")
|
||||||
if (
|
if (
|
||||||
|
|
@ -731,7 +763,6 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True)
|
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)
|
repo_custom_instructions = await _resolve_repo_custom_instructions(prompt_default_repo)
|
||||||
|
|
||||||
observability_tools = await _load_observability_tools(
|
observability_tools = await _load_observability_tools(
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,11 @@ def _load_sandbox_factory(sandbox_type: str) -> SandboxFactory:
|
||||||
return factory
|
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.
|
"""Create or reconnect to a sandbox using the configured provider.
|
||||||
|
|
||||||
The provider is selected via the SANDBOX_TYPE environment variable.
|
The provider is selected via the SANDBOX_TYPE environment variable.
|
||||||
|
|
@ -35,12 +39,17 @@ def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sandbox_id: Optional existing sandbox ID to reconnect to.
|
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:
|
Returns:
|
||||||
A sandbox backend implementing SandboxBackendProtocol.
|
A sandbox backend implementing SandboxBackendProtocol.
|
||||||
"""
|
"""
|
||||||
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
||||||
factory = _load_sandbox_factory(sandbox_type)
|
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)
|
return factory(sandbox_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ class TestCreateSandboxWithProxy:
|
||||||
|
|
||||||
await _create_sandbox_with_proxy()
|
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")
|
mock_proxy.assert_called_once_with("sandbox-123", "ghs_install")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -215,7 +215,7 @@ class TestCreateSandboxWithProxy:
|
||||||
|
|
||||||
await _create_sandbox_with_proxy()
|
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()
|
mock_proxy.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -386,7 +386,10 @@ class TestRefreshProxyOnSandboxReuse:
|
||||||
assert sandbox is replacement_sandbox
|
assert sandbox is replacement_sandbox
|
||||||
mock_proxy.assert_called_once_with("sandbox-stale", "ghs_fresh")
|
mock_proxy.assert_called_once_with("sandbox-stale", "ghs_fresh")
|
||||||
mock_recreate.assert_awaited_once_with(
|
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
|
@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",
|
"reviewer-thread-id",
|
||||||
github_proxy_token="app-token",
|
github_proxy_token="app-token",
|
||||||
github_proxy_repositories=["repo"],
|
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(
|
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
|
return sb
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,29 @@
|
||||||
import Editor from "@monaco-editor/react";
|
import Editor from "@monaco-editor/react"
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
interface InstructionsEditorProps {
|
interface InstructionsEditorProps {
|
||||||
value: string;
|
value: string
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void
|
||||||
disabled?: boolean;
|
disabled?: boolean
|
||||||
placeholder?: string;
|
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({
|
export function InstructionsEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
language = "markdown",
|
||||||
}: InstructionsEditorProps) {
|
}: InstructionsEditorProps) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,14 +34,14 @@ export function InstructionsEditor({
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-md border border-border">
|
<div className="overflow-hidden rounded-md border border-border">
|
||||||
<Editor
|
<Editor
|
||||||
height="360px"
|
height="360px"
|
||||||
defaultLanguage="markdown"
|
language={language}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v ?? "")}
|
onChange={(v) => onChange(v ?? "")}
|
||||||
options={{
|
options={{
|
||||||
|
|
@ -55,5 +57,5 @@ export function InstructionsEditor({
|
||||||
theme="vs-dark"
|
theme="vs-dark"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
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 FindingSeverity = "low" | "medium" | "high" | "critical"
|
||||||
export type FindingConfidence = "low" | "medium" | "high"
|
export type FindingConfidence = "low" | "medium" | "high"
|
||||||
export type FindingStatus = "open" | "resolved" | "dismissed"
|
export type FindingStatus = "open" | "resolved" | "dismissed"
|
||||||
|
|
@ -582,6 +615,32 @@ export const api = {
|
||||||
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
|
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
|
||||||
method: "DELETE",
|
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"),
|
getTeamSettings: () => request<TeamSettings>("/team-settings"),
|
||||||
saveTeamSettings: (body: TeamSettings) =>
|
saveTeamSettings: (body: TeamSettings) =>
|
||||||
request<TeamSettings>("/team-settings", {
|
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 IndexRouteImport } from './routes/index'
|
||||||
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
||||||
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
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 AgentsInstructionsRouteImport } from './routes/agents_.instructions'
|
||||||
import { Route as AgentsThreadsRouteImport } from './routes/agents/threads'
|
import { Route as AgentsThreadsRouteImport } from './routes/agents/threads'
|
||||||
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
||||||
|
|
@ -87,6 +88,11 @@ const ReviewStylesRoute = ReviewStylesRouteImport.update({
|
||||||
path: '/review/styles',
|
path: '/review/styles',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentsSnapshotsRoute = AgentsSnapshotsRouteImport.update({
|
||||||
|
id: '/agents_/snapshots',
|
||||||
|
path: '/agents/snapshots',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({
|
const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({
|
||||||
id: '/agents_/instructions',
|
id: '/agents_/instructions',
|
||||||
path: '/agents/instructions',
|
path: '/agents/instructions',
|
||||||
|
|
@ -159,6 +165,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/agents/threads': typeof AgentsThreadsRoute
|
'/agents/threads': typeof AgentsThreadsRoute
|
||||||
'/agents/instructions': typeof AgentsInstructionsRoute
|
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||||
|
'/agents/snapshots': typeof AgentsSnapshotsRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
|
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
|
||||||
|
|
@ -182,6 +189,7 @@ export interface FileRoutesByTo {
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/agents/threads': typeof AgentsThreadsRoute
|
'/agents/threads': typeof AgentsThreadsRoute
|
||||||
'/agents/instructions': typeof AgentsInstructionsRoute
|
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||||
|
'/agents/snapshots': typeof AgentsSnapshotsRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
'/agents': typeof AgentsIndexRoute
|
'/agents': typeof AgentsIndexRoute
|
||||||
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
|
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
|
||||||
|
|
@ -207,6 +215,7 @@ export interface FileRoutesById {
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/agents/threads': typeof AgentsThreadsRoute
|
'/agents/threads': typeof AgentsThreadsRoute
|
||||||
'/agents_/instructions': typeof AgentsInstructionsRoute
|
'/agents_/instructions': typeof AgentsInstructionsRoute
|
||||||
|
'/agents_/snapshots': typeof AgentsSnapshotsRoute
|
||||||
'/review_/styles': typeof ReviewStylesRoute
|
'/review_/styles': typeof ReviewStylesRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
'/agents/$threadId_/plan': typeof AgentsThreadIdPlanRoute
|
'/agents/$threadId_/plan': typeof AgentsThreadIdPlanRoute
|
||||||
|
|
@ -233,6 +242,7 @@ export interface FileRouteTypes {
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/agents/threads'
|
| '/agents/threads'
|
||||||
| '/agents/instructions'
|
| '/agents/instructions'
|
||||||
|
| '/agents/snapshots'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
| '/agents/$threadId/plan'
|
| '/agents/$threadId/plan'
|
||||||
|
|
@ -256,6 +266,7 @@ export interface FileRouteTypes {
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/agents/threads'
|
| '/agents/threads'
|
||||||
| '/agents/instructions'
|
| '/agents/instructions'
|
||||||
|
| '/agents/snapshots'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
| '/agents'
|
| '/agents'
|
||||||
| '/agents/$threadId/plan'
|
| '/agents/$threadId/plan'
|
||||||
|
|
@ -280,6 +291,7 @@ export interface FileRouteTypes {
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/agents/threads'
|
| '/agents/threads'
|
||||||
| '/agents_/instructions'
|
| '/agents_/instructions'
|
||||||
|
| '/agents_/snapshots'
|
||||||
| '/review_/styles'
|
| '/review_/styles'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
| '/agents/$threadId_/plan'
|
| '/agents/$threadId_/plan'
|
||||||
|
|
@ -303,6 +315,7 @@ export interface RootRouteChildren {
|
||||||
UsageRoute: typeof UsageRoute
|
UsageRoute: typeof UsageRoute
|
||||||
AdminEvalsRoute: typeof AdminEvalsRoute
|
AdminEvalsRoute: typeof AdminEvalsRoute
|
||||||
AgentsInstructionsRoute: typeof AgentsInstructionsRoute
|
AgentsInstructionsRoute: typeof AgentsInstructionsRoute
|
||||||
|
AgentsSnapshotsRoute: typeof AgentsSnapshotsRoute
|
||||||
ReviewStylesRoute: typeof ReviewStylesRoute
|
ReviewStylesRoute: typeof ReviewStylesRoute
|
||||||
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
|
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
|
||||||
}
|
}
|
||||||
|
|
@ -386,6 +399,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ReviewStylesRouteImport
|
preLoaderRoute: typeof ReviewStylesRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/agents_/snapshots': {
|
||||||
|
id: '/agents_/snapshots'
|
||||||
|
path: '/agents/snapshots'
|
||||||
|
fullPath: '/agents/snapshots'
|
||||||
|
preLoaderRoute: typeof AgentsSnapshotsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/agents_/instructions': {
|
'/agents_/instructions': {
|
||||||
id: '/agents_/instructions'
|
id: '/agents_/instructions'
|
||||||
path: '/agents/instructions'
|
path: '/agents/instructions'
|
||||||
|
|
@ -505,6 +525,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
UsageRoute: UsageRoute,
|
UsageRoute: UsageRoute,
|
||||||
AdminEvalsRoute: AdminEvalsRoute,
|
AdminEvalsRoute: AdminEvalsRoute,
|
||||||
AgentsInstructionsRoute: AgentsInstructionsRoute,
|
AgentsInstructionsRoute: AgentsInstructionsRoute,
|
||||||
|
AgentsSnapshotsRoute: AgentsSnapshotsRoute,
|
||||||
ReviewStylesRoute: ReviewStylesRoute,
|
ReviewStylesRoute: ReviewStylesRoute,
|
||||||
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
|
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"
|
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
</Link>
|
</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>
|
</SettingsSection>
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue