mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-28 16:38:17 -05:00
fix(platform): address workspace file handling issues
Backend fixes: - Extract _create_streaming_response helper to deduplicate Response creation - Fix orphaned storage file when race condition occurs with overwrite=False - Fix soft-delete breaking unique path constraint by modifying path on delete - Normalize path prefixes in list_files/get_file_count with include_all_sessions - Remove unused exists() method from storage backend abstraction Frontend fixes: - Remove unnecessary transformUrl wrapper, use resolveWorkspaceUrl directly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,18 @@ router = fastapi.APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _create_streaming_response(content: bytes, file) -> Response:
|
||||
"""Create a streaming response for file content."""
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=file.mimeType,
|
||||
headers={
|
||||
"Content-Disposition": _sanitize_filename_for_header(file.name),
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _create_file_download_response(file) -> Response:
|
||||
"""
|
||||
Create a download response for a workspace file.
|
||||
@@ -56,14 +68,7 @@ async def _create_file_download_response(file) -> Response:
|
||||
# For local storage, stream the file directly
|
||||
if file.storagePath.startswith("local://"):
|
||||
content = await storage.retrieve(file.storagePath)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=file.mimeType,
|
||||
headers={
|
||||
"Content-Disposition": _sanitize_filename_for_header(file.name),
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
return _create_streaming_response(content, file)
|
||||
|
||||
# For GCS, try to redirect to signed URL, fall back to streaming
|
||||
try:
|
||||
@@ -71,26 +76,12 @@ async def _create_file_download_response(file) -> Response:
|
||||
# If we got back an API path (fallback), stream directly instead
|
||||
if url.startswith("/api/"):
|
||||
content = await storage.retrieve(file.storagePath)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=file.mimeType,
|
||||
headers={
|
||||
"Content-Disposition": _sanitize_filename_for_header(file.name),
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
return _create_streaming_response(content, file)
|
||||
return fastapi.responses.RedirectResponse(url=url, status_code=302)
|
||||
except Exception:
|
||||
# Fall back to streaming directly from GCS
|
||||
content = await storage.retrieve(file.storagePath)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=file.mimeType,
|
||||
headers={
|
||||
"Content-Disposition": _sanitize_filename_for_header(file.name),
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
return _create_streaming_response(content, file)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -235,6 +235,9 @@ async def soft_delete_workspace_file(
|
||||
"""
|
||||
Soft-delete a workspace file.
|
||||
|
||||
The path is modified to include a deletion timestamp to free up the original
|
||||
path for new files while preserving the record for potential recovery.
|
||||
|
||||
Args:
|
||||
file_id: The file ID
|
||||
workspace_id: Optional workspace ID for validation
|
||||
@@ -247,11 +250,17 @@ async def soft_delete_workspace_file(
|
||||
if file is None:
|
||||
return None
|
||||
|
||||
deleted_at = datetime.now(timezone.utc)
|
||||
# Modify path to free up the unique constraint for new files at original path
|
||||
# Format: {original_path}__deleted__{timestamp}
|
||||
deleted_path = f"{file.path}__deleted__{int(deleted_at.timestamp())}"
|
||||
|
||||
updated = await UserWorkspaceFile.prisma().update(
|
||||
where={"id": file_id},
|
||||
data={
|
||||
"isDeleted": True,
|
||||
"deletedAt": datetime.now(timezone.utc),
|
||||
"deletedAt": deleted_at,
|
||||
"path": deleted_path,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -240,6 +240,11 @@ class WorkspaceManager:
|
||||
source_session_id=source_session_id,
|
||||
)
|
||||
else:
|
||||
# Clean up the orphaned storage file before raising
|
||||
try:
|
||||
await storage.delete(storage_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up orphaned storage file: {e}")
|
||||
raise ValueError(f"File already exists at path: {path}")
|
||||
|
||||
logger.info(
|
||||
@@ -274,8 +279,11 @@ class WorkspaceManager:
|
||||
"""
|
||||
# Determine the effective path prefix
|
||||
if include_all_sessions:
|
||||
# Use provided path as-is (or None for all files)
|
||||
effective_path = path
|
||||
# Normalize path to ensure leading slash (stored paths are normalized)
|
||||
if path is not None and not path.startswith("/"):
|
||||
effective_path = f"/{path}"
|
||||
else:
|
||||
effective_path = path
|
||||
elif path is not None:
|
||||
# Resolve the provided path with session scoping
|
||||
effective_path = self._resolve_path(path)
|
||||
@@ -389,8 +397,11 @@ class WorkspaceManager:
|
||||
"""
|
||||
# Determine the effective path prefix (same logic as list_files)
|
||||
if include_all_sessions:
|
||||
# Use provided path as-is (or None for all files)
|
||||
effective_path = path
|
||||
# Normalize path to ensure leading slash (stored paths are normalized)
|
||||
if path is not None and not path.startswith("/"):
|
||||
effective_path = f"/{path}"
|
||||
else:
|
||||
effective_path = path
|
||||
elif path is not None:
|
||||
# Resolve the provided path with session scoping
|
||||
effective_path = self._resolve_path(path)
|
||||
|
||||
@@ -86,19 +86,6 @@ class WorkspaceStorageBackend(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, storage_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists at the storage path.
|
||||
|
||||
Args:
|
||||
storage_path: The storage path to check
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class GCSWorkspaceStorage(WorkspaceStorageBackend):
|
||||
"""Google Cloud Storage implementation for workspace storage."""
|
||||
@@ -265,25 +252,6 @@ class GCSWorkspaceStorage(WorkspaceStorageBackend):
|
||||
return f"/api/workspace/files/{file_id}/download"
|
||||
raise
|
||||
|
||||
async def exists(self, storage_path: str) -> bool:
|
||||
"""Check if file exists in GCS."""
|
||||
if not storage_path.startswith("gcs://"):
|
||||
return False
|
||||
|
||||
path = storage_path[6:]
|
||||
parts = path.split("/", 1)
|
||||
if len(parts) != 2:
|
||||
return False
|
||||
|
||||
bucket_name, blob_name = parts
|
||||
|
||||
try:
|
||||
client = await self._get_async_client()
|
||||
await client.download_metadata(bucket_name, blob_name)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class LocalWorkspaceStorage(WorkspaceStorageBackend):
|
||||
"""Local filesystem implementation for workspace storage (self-hosted deployments)."""
|
||||
@@ -410,14 +378,6 @@ class LocalWorkspaceStorage(WorkspaceStorageBackend):
|
||||
else:
|
||||
raise ValueError(f"Invalid storage path format: {storage_path}")
|
||||
|
||||
async def exists(self, storage_path: str) -> bool:
|
||||
"""Check if file exists locally."""
|
||||
try:
|
||||
file_path = self._parse_storage_path(storage_path)
|
||||
return file_path.exists()
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# Global storage backend instance
|
||||
_workspace_storage: Optional[WorkspaceStorageBackend] = None
|
||||
|
||||
@@ -38,6 +38,14 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
* Uses the generated API URL helper and routes through the Next.js proxy
|
||||
* which handles authentication and proper backend routing.
|
||||
*/
|
||||
/**
|
||||
* URL transformer for ReactMarkdown.
|
||||
* Converts workspace:// URLs to proxy URLs that route through Next.js to the backend.
|
||||
* workspace://abc123 -> /api/proxy/api/workspace/files/abc123/download
|
||||
*
|
||||
* This is needed because ReactMarkdown sanitizes URLs and only allows
|
||||
* http, https, mailto, and tel protocols by default.
|
||||
*/
|
||||
function resolveWorkspaceUrl(src: string): string {
|
||||
if (src.startsWith("workspace://")) {
|
||||
const fileId = src.replace("workspace://", "");
|
||||
@@ -49,16 +57,6 @@ function resolveWorkspaceUrl(src: string): string {
|
||||
return src;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL transformer for ReactMarkdown.
|
||||
* Transforms workspace:// URLs to backend API download URLs before rendering.
|
||||
* This is needed because ReactMarkdown sanitizes URLs and only allows
|
||||
* http, https, mailto, and tel protocols by default.
|
||||
*/
|
||||
function transformUrl(url: string): string {
|
||||
return resolveWorkspaceUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the image URL is a workspace file (AI cannot see these yet).
|
||||
* After URL transformation, workspace files have URLs like /api/proxy/api/workspace/files/...
|
||||
@@ -114,7 +112,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
<ReactMarkdown
|
||||
skipHtml={true}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
urlTransform={transformUrl}
|
||||
urlTransform={resolveWorkspaceUrl}
|
||||
components={{
|
||||
code: ({ children, className, ...props }: CodeProps) => {
|
||||
const isInline = !className?.includes("language-");
|
||||
|
||||
Reference in New Issue
Block a user