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:
Nicholas Tindle
2026-01-28 01:25:19 -06:00
parent c4d83505c0
commit de9ef2366e
5 changed files with 49 additions and 80 deletions

View File

@@ -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(

View File

@@ -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,
},
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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-");