diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index 1f808984..c08746a9 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -47,6 +47,11 @@ SANDBOX_CREATION_TIMEOUT = 180 SANDBOX_POLL_INTERVAL = 1.0 from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata +from .utils.github import ( + git_has_uncommitted_changes, + is_valid_git_repo, + remove_directory, +) async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 @@ -76,89 +81,96 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 raise ValueError(msg) repo_dir = f"/workspace/{repo}" + auth_url = f"https://git:{token}@github.com/{owner}/{repo}.git" + clean_url = f"https://github.com/{owner}/{repo}.git" - logger.debug("Checking if repo already exists at %s", repo_dir) + is_git_repo = await loop.run_in_executor(None, is_valid_git_repo, sandbox_backend, repo_dir) + + if not is_git_repo: + logger.warning( + "Repo directory missing or not a valid git repo at %s, removing", repo_dir + ) + try: + removed = await loop.run_in_executor(None, remove_directory, sandbox_backend, repo_dir) + if not removed: + msg = f"Failed to remove invalid directory at {repo_dir}" + logger.error(msg) + raise RuntimeError(msg) + logger.info("Removed invalid directory, will clone fresh repo") + except Exception: + logger.exception("Failed to remove invalid directory") + raise + else: + logger.info("Repo exists at %s, checking for uncommitted changes", repo_dir) + has_changes = await loop.run_in_executor( + None, git_has_uncommitted_changes, sandbox_backend, repo_dir + ) + + if has_changes: + logger.warning("Repo has uncommitted changes at %s, skipping pull", repo_dir) + return repo_dir + + logger.info("Repo is clean, pulling latest changes from %s/%s", owner, repo) + + try: + await loop.run_in_executor( + None, + sandbox_backend.execute, + f"cd {repo_dir} && git remote set-url origin {auth_url}", + ) + pull_result = await loop.run_in_executor( + None, sandbox_backend.execute, f"cd {repo_dir} && git pull origin" + ) + logger.debug("Git pull result: exit_code=%s", pull_result.exit_code) + if pull_result.exit_code != 0: + logger.warning( + "Git pull failed with exit code %s: %s", + pull_result.exit_code, + pull_result.output[:200] if pull_result.output else "", + ) + except Exception: + logger.exception("Failed to execute git pull") + raise + finally: + try: + await loop.run_in_executor( + None, + sandbox_backend.execute, + f"cd {repo_dir} && git remote set-url origin {clean_url}", + ) + except Exception: + logger.exception("Failed to restore clean remote URL") + raise + + logger.info("Repo updated at %s", repo_dir) + return repo_dir + + logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir) try: - check_result = await loop.run_in_executor( - None, sandbox_backend.execute, f"test -d {repo_dir}/.git && echo exists" - ) - logger.debug( - "Check result: exit_code=%s, output=%s", - check_result.exit_code, - check_result.output[:200] if check_result.output else "", + result = await loop.run_in_executor( + None, sandbox_backend.execute, f"git clone {auth_url} {repo_dir}" ) + logger.debug("Git clone result: exit_code=%s", result.exit_code) except Exception: - logger.exception("Failed to execute check command in sandbox") + logger.exception("Failed to execute git clone") raise - if check_result.exit_code == 0 and "exists" in check_result.output: - logger.info("Repo already exists at %s, pulling latest changes", repo_dir) - try: - status_result = await loop.run_in_executor( - None, sandbox_backend.execute, f"cd {repo_dir} && git status --porcelain" - ) - logger.debug("Git status result: exit_code=%s", status_result.exit_code) - except Exception: - logger.exception("Failed to get git status") - raise + if result.exit_code != 0: + msg = f"Failed to clone repo {owner}/{repo}: {result.output}" + logger.error(msg) + raise RuntimeError(msg) - # CRITICAL: Ensure remote URL doesn't contain token (clean up from previous runs) - clean_url = f"https://github.com/{owner}/{repo}.git" - try: - await loop.run_in_executor( - None, - sandbox_backend.execute, - f"cd {repo_dir} && git remote set-url origin {clean_url}", - ) - except Exception: - logger.exception("Failed to set remote URL") - raise + try: + await loop.run_in_executor( + None, + sandbox_backend.execute, + f"cd {repo_dir} && git remote set-url origin {clean_url}", + ) + except Exception: + logger.exception("Failed to set remote URL after clone") + raise - if status_result.exit_code == 0 and not status_result.output.strip(): - auth_url = f"https://git:{token}@github.com/{owner}/{repo}.git" - try: - pull_result = await loop.run_in_executor( - None, sandbox_backend.execute, f"cd {repo_dir} && git pull {auth_url}" - ) - logger.debug("Git pull result: exit_code=%s", pull_result.exit_code) - if pull_result.exit_code != 0: - logger.warning( - "Git pull failed with exit code %s: %s", - pull_result.exit_code, - pull_result.output[:200] if pull_result.output else "", - ) - except Exception: - logger.exception("Failed to execute git pull") - raise - else: - logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir) - clone_url = f"https://git:{token}@github.com/{owner}/{repo}.git" - try: - result = await loop.run_in_executor( - None, sandbox_backend.execute, f"git clone {clone_url} {repo_dir}" - ) - logger.debug("Git clone result: exit_code=%s", result.exit_code) - except Exception: - logger.exception("Failed to execute git clone") - raise - - if result.exit_code != 0: - msg = f"Failed to clone repo {owner}/{repo}: {result.output}" - logger.error(msg) - raise RuntimeError(msg) - - clean_url = f"https://github.com/{owner}/{repo}.git" - try: - await loop.run_in_executor( - None, - sandbox_backend.execute, - f"cd {repo_dir} && git remote set-url origin {clean_url}", - ) - except Exception: - logger.exception("Failed to set remote URL after clone") - raise - - logger.info("Repo setup complete at %s", repo_dir) + logger.info("Repo cloned successfully at %s", repo_dir) return repo_dir diff --git a/apps/agent/agent/utils/github.py b/apps/agent/agent/utils/github.py index bee5ee66..d966aa5c 100644 --- a/apps/agent/agent/utils/github.py +++ b/apps/agent/agent/utils/github.py @@ -4,9 +4,9 @@ from __future__ import annotations import logging import shlex -from typing import Any import httpx +from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol logger = logging.getLogger(__name__) @@ -15,23 +15,46 @@ HTTP_CREATED = 201 HTTP_UNPROCESSABLE_ENTITY = 422 -def _run_git(sandbox_backend: Any, repo_dir: str, command: str) -> Any: +def _run_git( + sandbox_backend: SandboxBackendProtocol, repo_dir: str, command: str +) -> ExecuteResponse: """Run a git command in the sandbox repo directory.""" return sandbox_backend.execute(f"cd {repo_dir} && {command}") -def git_has_uncommitted_changes(sandbox_backend: Any, repo_dir: str) -> bool: +def is_valid_git_repo(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool: + """Check if directory is a valid git repository.""" + git_dir = f"{repo_dir}/.git" + safe_git_dir = shlex.quote(git_dir) + result = sandbox_backend.execute(f"test -d {safe_git_dir} && echo exists") + return result.exit_code == 0 and "exists" in result.output + + +def remove_directory(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool: + """Remove a directory and all its contents.""" + safe_repo_dir = shlex.quote(repo_dir) + result = sandbox_backend.execute(f"rm -rf {safe_repo_dir}") + return result.exit_code == 0 + + +def git_has_uncommitted_changes( + sandbox_backend: SandboxBackendProtocol, repo_dir: str +) -> bool: """Check whether the repo has uncommitted changes.""" result = _run_git(sandbox_backend, repo_dir, "git status --porcelain") return result.exit_code == 0 and bool(result.output.strip()) -def git_fetch_origin(sandbox_backend: Any, repo_dir: str) -> Any: +def git_fetch_origin( + sandbox_backend: SandboxBackendProtocol, repo_dir: str +) -> ExecuteResponse: """Fetch latest from origin (best-effort).""" return _run_git(sandbox_backend, repo_dir, "git fetch origin 2>/dev/null || true") -def git_has_unpushed_commits(sandbox_backend: Any, repo_dir: str) -> bool: +def git_has_unpushed_commits( + sandbox_backend: SandboxBackendProtocol, repo_dir: str +) -> bool: """Check whether there are commits not pushed to upstream.""" git_log_cmd = ( "git log --oneline @{upstream}..HEAD 2>/dev/null " @@ -41,13 +64,15 @@ def git_has_unpushed_commits(sandbox_backend: Any, repo_dir: str) -> bool: return result.exit_code == 0 and bool(result.output.strip()) -def git_current_branch(sandbox_backend: Any, repo_dir: str) -> str: +def git_current_branch(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> str: """Get the current git branch name.""" result = _run_git(sandbox_backend, repo_dir, "git rev-parse --abbrev-ref HEAD") return result.output.strip() if result.exit_code == 0 else "" -def git_checkout_branch(sandbox_backend: Any, repo_dir: str, branch: str) -> bool: +def git_checkout_branch( + sandbox_backend: SandboxBackendProtocol, repo_dir: str, branch: str +) -> bool: """Checkout branch, creating it if needed.""" safe_branch = shlex.quote(branch) checkout_result = _run_git( @@ -60,7 +85,7 @@ def git_checkout_branch(sandbox_backend: Any, repo_dir: str, branch: str) -> boo def git_config_user( - sandbox_backend: Any, + sandbox_backend: SandboxBackendProtocol, repo_dir: str, name: str, email: str, @@ -72,18 +97,24 @@ def git_config_user( _run_git(sandbox_backend, repo_dir, f"git config user.email {safe_email}") -def git_add_all(sandbox_backend: Any, repo_dir: str) -> Any: +def git_add_all( + sandbox_backend: SandboxBackendProtocol, repo_dir: str +) -> ExecuteResponse: """Stage all changes.""" return _run_git(sandbox_backend, repo_dir, "git add -A") -def git_commit(sandbox_backend: Any, repo_dir: str, message: str) -> Any: +def git_commit( + sandbox_backend: SandboxBackendProtocol, repo_dir: str, message: str +) -> ExecuteResponse: """Commit staged changes with the given message.""" safe_message = shlex.quote(message) return _run_git(sandbox_backend, repo_dir, f"git commit -m {safe_message}") -def git_get_remote_url(sandbox_backend: Any, repo_dir: str) -> str | None: +def git_get_remote_url( + sandbox_backend: SandboxBackendProtocol, repo_dir: str +) -> str | None: """Get the origin remote URL.""" result = _run_git(sandbox_backend, repo_dir, "git remote get-url origin") if result.exit_code != 0: @@ -92,11 +123,11 @@ def git_get_remote_url(sandbox_backend: Any, repo_dir: str) -> str | None: def git_push( - sandbox_backend: Any, + sandbox_backend: SandboxBackendProtocol, repo_dir: str, branch: str, github_token: str | None = None, -) -> Any: +) -> ExecuteResponse: """Push the branch to origin, using a token if needed.""" safe_branch = shlex.quote(branch) remote_url = git_get_remote_url(sandbox_backend, repo_dir)