Spaces:
Build error
Build error
import os | |
from typing import Any | |
from fastapi import ( | |
APIRouter, | |
Depends, | |
HTTPException, | |
Request, | |
status, | |
) | |
from fastapi.responses import FileResponse, JSONResponse | |
from pathspec import PathSpec | |
from pathspec.patterns import GitWildMatchPattern | |
from starlette.background import BackgroundTask | |
from openhands.core.exceptions import AgentRuntimeUnavailableError | |
from openhands.core.logger import openhands_logger as logger | |
from openhands.events.action import ( | |
FileReadAction, | |
) | |
from openhands.events.observation import ( | |
ErrorObservation, | |
FileReadObservation, | |
) | |
from openhands.runtime.base import Runtime | |
from openhands.server.dependencies import get_dependencies | |
from openhands.server.file_config import ( | |
FILES_TO_IGNORE, | |
) | |
from openhands.server.shared import ( | |
ConversationStoreImpl, | |
config, | |
) | |
from openhands.server.user_auth import get_user_id | |
from openhands.server.utils import get_conversation, get_conversation_store | |
from openhands.storage.conversation.conversation_store import ConversationStore | |
from openhands.utils.async_utils import call_sync_from_async | |
from openhands.server.session.conversation import ServerConversation | |
app = APIRouter(prefix='/api/conversations/{conversation_id}', dependencies=get_dependencies()) | |
async def list_files( | |
conversation: ServerConversation = Depends(get_conversation), | |
path: str | None = None | |
) -> list[str] | JSONResponse: | |
"""List files in the specified path. | |
This function retrieves a list of files from the agent's runtime file store, | |
excluding certain system and hidden files/directories. | |
To list files: | |
```sh | |
curl http://localhost:3000/api/conversations/{conversation_id}/list-files | |
``` | |
Args: | |
request (Request): The incoming request object. | |
path (str, optional): The path to list files from. Defaults to None. | |
Returns: | |
list: A list of file names in the specified path. | |
Raises: | |
HTTPException: If there's an error listing the files. | |
""" | |
if not conversation.runtime: | |
return JSONResponse( | |
status_code=status.HTTP_404_NOT_FOUND, | |
content={'error': 'Runtime not yet initialized'}, | |
) | |
runtime: Runtime = conversation.runtime | |
try: | |
file_list = await call_sync_from_async(runtime.list_files, path) | |
except AgentRuntimeUnavailableError as e: | |
logger.error(f'Error listing files: {e}') | |
return JSONResponse( | |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
content={'error': f'Error listing files: {e}'}, | |
) | |
if path: | |
file_list = [os.path.join(path, f) for f in file_list] | |
file_list = [f for f in file_list if f not in FILES_TO_IGNORE] | |
async def filter_for_gitignore(file_list: list[str], base_path: str) -> list[str]: | |
gitignore_path = os.path.join(base_path, '.gitignore') | |
try: | |
read_action = FileReadAction(gitignore_path) | |
observation = await call_sync_from_async(runtime.run_action, read_action) | |
spec = PathSpec.from_lines( | |
GitWildMatchPattern, observation.content.splitlines() | |
) | |
except Exception as e: | |
logger.warning(e) | |
return file_list | |
file_list = [entry for entry in file_list if not spec.match_file(entry)] | |
return file_list | |
try: | |
file_list = await filter_for_gitignore(file_list, '') | |
except AgentRuntimeUnavailableError as e: | |
logger.error(f'Error filtering files: {e}') | |
return JSONResponse( | |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
content={'error': f'Error filtering files: {e}'}, | |
) | |
return file_list | |
# NOTE: We use response_model=None for endpoints that can return multiple response types | |
# (like FileResponse | JSONResponse). This is because FastAPI's response_model expects a | |
# Pydantic model, but Starlette response classes like FileResponse are not Pydantic models. | |
# Instead, we document the possible responses using the 'responses' parameter and maintain | |
# proper type annotations for mypy. | |
async def select_file(file: str, conversation: ServerConversation = Depends(get_conversation)) -> FileResponse | JSONResponse: | |
"""Retrieve the content of a specified file. | |
To select a file: | |
```sh | |
curl http://localhost:3000/api/conversations/{conversation_id}select-file?file=<file_path> | |
``` | |
Args: | |
file (str): The path of the file to be retrieved. | |
Expect path to be absolute inside the runtime. | |
request (Request): The incoming request object. | |
Returns: | |
dict: A dictionary containing the file content. | |
Raises: | |
HTTPException: If there's an error opening the file. | |
""" | |
runtime: Runtime = conversation.runtime | |
file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file) | |
read_action = FileReadAction(file) | |
try: | |
observation = await call_sync_from_async(runtime.run_action, read_action) | |
except AgentRuntimeUnavailableError as e: | |
logger.error(f'Error opening file {file}: {e}') | |
return JSONResponse( | |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
content={'error': f'Error opening file: {e}'}, | |
) | |
if isinstance(observation, FileReadObservation): | |
content = observation.content | |
return JSONResponse(content={'code': content}) | |
elif isinstance(observation, ErrorObservation): | |
logger.error(f'Error opening file {file}: {observation}') | |
if 'ERROR_BINARY_FILE' in observation.message: | |
return JSONResponse( | |
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, | |
content={'error': f'Unable to open binary file: {file}'}, | |
) | |
return JSONResponse( | |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
content={'error': f'Error opening file: {observation}'}, | |
) | |
else: | |
# Handle unexpected observation types | |
return JSONResponse( | |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
content={'error': f'Unexpected observation type: {type(observation)}'}, | |
) | |
def zip_current_workspace(conversation: ServerConversation = Depends(get_conversation)) -> FileResponse | JSONResponse: | |
try: | |
logger.debug('Zipping workspace') | |
runtime: Runtime = conversation.runtime | |
path = runtime.config.workspace_mount_path_in_sandbox | |
try: | |
zip_file_path = runtime.copy_from(path) | |
except AgentRuntimeUnavailableError as e: | |
logger.error(f'Error zipping workspace: {e}') | |
return JSONResponse( | |
status_code=500, | |
content={'error': f'Error zipping workspace: {e}'}, | |
) | |
return FileResponse( | |
path=zip_file_path, | |
filename='workspace.zip', | |
media_type='application/zip', | |
background=BackgroundTask(lambda: os.unlink(zip_file_path)), | |
) | |
except Exception as e: | |
logger.error(f'Error zipping workspace: {e}') | |
raise HTTPException( | |
status_code=500, | |
detail='Failed to zip workspace', | |
) | |
async def git_changes( | |
conversation: ServerConversation = Depends(get_conversation), | |
conversation_store: ConversationStore = Depends(get_conversation_store), | |
user_id: str = Depends(get_user_id), | |
) -> list[dict[str, str]] | JSONResponse: | |
runtime: Runtime = conversation.runtime | |
cwd = await get_cwd( | |
conversation_store, | |
conversation.sid, | |
runtime.config.workspace_mount_path_in_sandbox, | |
) | |
logger.info(f'Getting git changes in {cwd}') | |
try: | |
changes = await call_sync_from_async(runtime.get_git_changes, cwd) | |
if changes is None: | |
return JSONResponse( | |
status_code=404, | |
content={'error': 'Not a git repository'}, | |
) | |
return changes | |
except AgentRuntimeUnavailableError as e: | |
logger.error(f'Runtime unavailable: {e}') | |
return JSONResponse( | |
status_code=500, | |
content={'error': f'Error getting changes: {e}'}, | |
) | |
except Exception as e: | |
logger.error(f'Error getting changes: {e}') | |
return JSONResponse( | |
status_code=500, | |
content={'error': str(e)}, | |
) | |
async def git_diff( | |
path: str, | |
conversation_store: Any = Depends(get_conversation_store), | |
conversation: ServerConversation = Depends(get_conversation), | |
) -> dict[str, Any] | JSONResponse: | |
runtime: Runtime = conversation.runtime | |
cwd = await get_cwd( | |
conversation_store, | |
conversation.sid, | |
runtime.config.workspace_mount_path_in_sandbox, | |
) | |
try: | |
diff = await call_sync_from_async(runtime.get_git_diff, path, cwd) | |
return diff | |
except AgentRuntimeUnavailableError as e: | |
logger.error(f'Error getting diff: {e}') | |
return JSONResponse( | |
status_code=500, | |
content={'error': f'Error getting diff: {e}'}, | |
) | |
async def get_cwd( | |
conversation_store: ConversationStore, | |
conversation_id: str, | |
workspace_mount_path_in_sandbox: str, | |
) -> str: | |
metadata = await conversation_store.get_metadata(conversation_id) | |
cwd = workspace_mount_path_in_sandbox | |
if metadata and metadata.selected_repository: | |
repo_dir = metadata.selected_repository.split('/')[-1] | |
cwd = os.path.join(cwd, repo_dir) | |
return cwd | |