mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-18 02:32:04 -05:00
Implements persistent User Workspace storage for CoPilot, enabling
blocks to save and retrieve files across sessions. Files are stored in
session-scoped virtual paths (`/sessions/{session_id}/`).
Fixes SECRT-1833
### Changes 🏗️
**Database & Storage:**
- Add `UserWorkspace` and `UserWorkspaceFile` Prisma models
- Implement `WorkspaceStorageBackend` abstraction (GCS for cloud, local
filesystem for self-hosted)
- Add `workspace_id` and `session_id` fields to `ExecutionContext`
**Backend API:**
- Add REST endpoints: `GET/POST /api/workspace/files`, `GET/DELETE
/api/workspace/files/{id}`, `GET /api/workspace/files/{id}/download`
- Add CoPilot tools: `list_workspace_files`, `read_workspace_file`,
`write_workspace_file`
- Integrate workspace storage into `store_media_file()` - returns
`workspace://file-id` references
**Block Updates:**
- Refactor all file-handling blocks to use unified `ExecutionContext`
parameter
- Update media-generating blocks to persist outputs to workspace
(AIImageGenerator, AIImageCustomizer, FluxKontext, TalkingHead, FAL
video, Bannerbear, etc.)
**Frontend:**
- Render `workspace://` image references in chat via proxy endpoint
- Add "AI cannot see this image" overlay indicator
**CoPilot Context Mapping:**
- Session = Agent (graph_id) = Run (graph_exec_id)
- Files scoped to `/sessions/{session_id}/`
### Checklist 📋
#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [ ] Create CoPilot session, generate image with AIImageGeneratorBlock
- [ ] Verify image returns `workspace://file-id` (not base64)
- [ ] Verify image renders in chat with visibility indicator
- [ ] Verify workspace files persist across sessions
- [ ] Test list/read/write workspace files via CoPilot tools
- [ ] Test local storage backend for self-hosted deployments
#### For configuration changes:
- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
🤖 Generated with [Claude Code](https://claude.ai/code)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Introduces a new persistent file-storage surface area (DB tables,
storage backends, download API, and chat tools) and rewires
`store_media_file()`/block execution context across many blocks, so
regressions could impact file handling, access control, or storage
costs.
>
> **Overview**
> Adds a **persistent per-user Workspace** (new
`UserWorkspace`/`UserWorkspaceFile` models plus `WorkspaceManager` +
`WorkspaceStorageBackend` with GCS/local implementations) and wires it
into the API via a new `/api/workspace/files/{file_id}/download` route
(including header-sanitized `Content-Disposition`) and shutdown
lifecycle hooks.
>
> Extends `ExecutionContext` to carry execution identity +
`workspace_id`/`session_id`, updates executor tooling to clone
node-specific contexts, and updates `run_block` (CoPilot) to create a
session-scoped workspace and synthetic graph/run/node IDs.
>
> Refactors `store_media_file()` to require `execution_context` +
`return_format` and to support `workspace://` references; migrates many
media/file-handling blocks and related tests to the new API and to
persist generated media as `workspace://...` (or fall back to data URIs
outside CoPilot), and adds CoPilot chat tools for
listing/reading/writing/deleting workspace files with safeguards against
context bloat.
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6abc70f793. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
277 lines
7.2 KiB
Python
277 lines
7.2 KiB
Python
"""
|
|
Database CRUD operations for User Workspace.
|
|
|
|
This module provides functions for managing user workspaces and workspace files.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from prisma.models import UserWorkspace, UserWorkspaceFile
|
|
from prisma.types import UserWorkspaceFileWhereInput
|
|
|
|
from backend.util.json import SafeJson
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def get_or_create_workspace(user_id: str) -> UserWorkspace:
|
|
"""
|
|
Get user's workspace, creating one if it doesn't exist.
|
|
|
|
Uses upsert to handle race conditions when multiple concurrent requests
|
|
attempt to create a workspace for the same user.
|
|
|
|
Args:
|
|
user_id: The user's ID
|
|
|
|
Returns:
|
|
UserWorkspace instance
|
|
"""
|
|
workspace = await UserWorkspace.prisma().upsert(
|
|
where={"userId": user_id},
|
|
data={
|
|
"create": {"userId": user_id},
|
|
"update": {}, # No updates needed if exists
|
|
},
|
|
)
|
|
|
|
return workspace
|
|
|
|
|
|
async def get_workspace(user_id: str) -> Optional[UserWorkspace]:
|
|
"""
|
|
Get user's workspace if it exists.
|
|
|
|
Args:
|
|
user_id: The user's ID
|
|
|
|
Returns:
|
|
UserWorkspace instance or None
|
|
"""
|
|
return await UserWorkspace.prisma().find_unique(where={"userId": user_id})
|
|
|
|
|
|
async def create_workspace_file(
|
|
workspace_id: str,
|
|
file_id: str,
|
|
name: str,
|
|
path: str,
|
|
storage_path: str,
|
|
mime_type: str,
|
|
size_bytes: int,
|
|
checksum: Optional[str] = None,
|
|
metadata: Optional[dict] = None,
|
|
) -> UserWorkspaceFile:
|
|
"""
|
|
Create a new workspace file record.
|
|
|
|
Args:
|
|
workspace_id: The workspace ID
|
|
file_id: The file ID (same as used in storage path for consistency)
|
|
name: User-visible filename
|
|
path: Virtual path (e.g., "/documents/report.pdf")
|
|
storage_path: Actual storage path (GCS or local)
|
|
mime_type: MIME type of the file
|
|
size_bytes: File size in bytes
|
|
checksum: Optional SHA256 checksum
|
|
metadata: Optional additional metadata
|
|
|
|
Returns:
|
|
Created UserWorkspaceFile instance
|
|
"""
|
|
# Normalize path to start with /
|
|
if not path.startswith("/"):
|
|
path = f"/{path}"
|
|
|
|
file = await UserWorkspaceFile.prisma().create(
|
|
data={
|
|
"id": file_id,
|
|
"workspaceId": workspace_id,
|
|
"name": name,
|
|
"path": path,
|
|
"storagePath": storage_path,
|
|
"mimeType": mime_type,
|
|
"sizeBytes": size_bytes,
|
|
"checksum": checksum,
|
|
"metadata": SafeJson(metadata or {}),
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
f"Created workspace file {file.id} at path {path} "
|
|
f"in workspace {workspace_id}"
|
|
)
|
|
return file
|
|
|
|
|
|
async def get_workspace_file(
|
|
file_id: str,
|
|
workspace_id: Optional[str] = None,
|
|
) -> Optional[UserWorkspaceFile]:
|
|
"""
|
|
Get a workspace file by ID.
|
|
|
|
Args:
|
|
file_id: The file ID
|
|
workspace_id: Optional workspace ID for validation
|
|
|
|
Returns:
|
|
UserWorkspaceFile instance or None
|
|
"""
|
|
where_clause: dict = {"id": file_id, "isDeleted": False}
|
|
if workspace_id:
|
|
where_clause["workspaceId"] = workspace_id
|
|
|
|
return await UserWorkspaceFile.prisma().find_first(where=where_clause)
|
|
|
|
|
|
async def get_workspace_file_by_path(
|
|
workspace_id: str,
|
|
path: str,
|
|
) -> Optional[UserWorkspaceFile]:
|
|
"""
|
|
Get a workspace file by its virtual path.
|
|
|
|
Args:
|
|
workspace_id: The workspace ID
|
|
path: Virtual path
|
|
|
|
Returns:
|
|
UserWorkspaceFile instance or None
|
|
"""
|
|
# Normalize path
|
|
if not path.startswith("/"):
|
|
path = f"/{path}"
|
|
|
|
return await UserWorkspaceFile.prisma().find_first(
|
|
where={
|
|
"workspaceId": workspace_id,
|
|
"path": path,
|
|
"isDeleted": False,
|
|
}
|
|
)
|
|
|
|
|
|
async def list_workspace_files(
|
|
workspace_id: str,
|
|
path_prefix: Optional[str] = None,
|
|
include_deleted: bool = False,
|
|
limit: Optional[int] = None,
|
|
offset: int = 0,
|
|
) -> list[UserWorkspaceFile]:
|
|
"""
|
|
List files in a workspace.
|
|
|
|
Args:
|
|
workspace_id: The workspace ID
|
|
path_prefix: Optional path prefix to filter (e.g., "/documents/")
|
|
include_deleted: Whether to include soft-deleted files
|
|
limit: Maximum number of files to return
|
|
offset: Number of files to skip
|
|
|
|
Returns:
|
|
List of UserWorkspaceFile instances
|
|
"""
|
|
where_clause: UserWorkspaceFileWhereInput = {"workspaceId": workspace_id}
|
|
|
|
if not include_deleted:
|
|
where_clause["isDeleted"] = False
|
|
|
|
if path_prefix:
|
|
# Normalize prefix
|
|
if not path_prefix.startswith("/"):
|
|
path_prefix = f"/{path_prefix}"
|
|
where_clause["path"] = {"startswith": path_prefix}
|
|
|
|
return await UserWorkspaceFile.prisma().find_many(
|
|
where=where_clause,
|
|
order={"createdAt": "desc"},
|
|
take=limit,
|
|
skip=offset,
|
|
)
|
|
|
|
|
|
async def count_workspace_files(
|
|
workspace_id: str,
|
|
path_prefix: Optional[str] = None,
|
|
include_deleted: bool = False,
|
|
) -> int:
|
|
"""
|
|
Count files in a workspace.
|
|
|
|
Args:
|
|
workspace_id: The workspace ID
|
|
path_prefix: Optional path prefix to filter (e.g., "/sessions/abc123/")
|
|
include_deleted: Whether to include soft-deleted files
|
|
|
|
Returns:
|
|
Number of files
|
|
"""
|
|
where_clause: dict = {"workspaceId": workspace_id}
|
|
if not include_deleted:
|
|
where_clause["isDeleted"] = False
|
|
|
|
if path_prefix:
|
|
# Normalize prefix
|
|
if not path_prefix.startswith("/"):
|
|
path_prefix = f"/{path_prefix}"
|
|
where_clause["path"] = {"startswith": path_prefix}
|
|
|
|
return await UserWorkspaceFile.prisma().count(where=where_clause)
|
|
|
|
|
|
async def soft_delete_workspace_file(
|
|
file_id: str,
|
|
workspace_id: Optional[str] = None,
|
|
) -> Optional[UserWorkspaceFile]:
|
|
"""
|
|
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
|
|
|
|
Returns:
|
|
Updated UserWorkspaceFile instance or None if not found
|
|
"""
|
|
# First verify the file exists and belongs to workspace
|
|
file = await get_workspace_file(file_id, workspace_id)
|
|
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": deleted_at,
|
|
"path": deleted_path,
|
|
},
|
|
)
|
|
|
|
logger.info(f"Soft-deleted workspace file {file_id}")
|
|
return updated
|
|
|
|
|
|
async def get_workspace_total_size(workspace_id: str) -> int:
|
|
"""
|
|
Get the total size of all files in a workspace.
|
|
|
|
Args:
|
|
workspace_id: The workspace ID
|
|
|
|
Returns:
|
|
Total size in bytes
|
|
"""
|
|
files = await list_workspace_files(workspace_id)
|
|
return sum(file.sizeBytes for file in files)
|