Skip to content

Commit bd69575

Browse files
authored
Create files.py
1 parent 9dc1d69 commit bd69575

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

‎app/controllers/files.py‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Upload and artifact business logic for a conversation workspace.
2+
3+
The workspace is the agent's cwd, so it holds two kinds of file: what
4+
the user uploaded (tracked in ``conversation_files``) and what the
5+
agent produced (everything else). That distinction, the name
6+
sanitizing, and the size limit are the rules this module owns.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import contextlib
12+
import re
13+
import uuid
14+
from collections.abc import Awaitable, Callable
15+
from dataclasses import dataclass
16+
from datetime import UTC, datetime
17+
from pathlib import Path
18+
19+
from sqlalchemy.orm import Session
20+
21+
from ..infra.config import conversation_workspace
22+
from ..models import Conversation, ConversationFile, User, new_id
23+
from ..models.db import commit_now
24+
from .errors import InvalidRequest, NotFound, PayloadTooLarge
25+
26+
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
27+
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
28+
29+
30+
@dataclass(frozen=True)
31+
class ArtifactInfo:
32+
"""One agent-produced file on disk (no DB row backs these)."""
33+
34+
name: str
35+
size: int
36+
modified_at: datetime
37+
38+
39+
def safe_filename(name: str) -> str:
40+
"""Strip path components and unsafe characters; keep the suffix."""
41+
base = Path(name).name or "upload"
42+
cleaned = _SAFE_NAME.sub("_", base).strip("._") or "upload"
43+
return cleaned[:200]
44+
45+
46+
async def store_upload(
47+
db: Session,
48+
user: User,
49+
conversation: Conversation,
50+
*,
51+
filename: str | None,
52+
read_chunk: Callable[[int], Awaitable[bytes]],
53+
) -> ConversationFile:
54+
"""Stream an upload into the conversation's agent workspace.
55+
56+
The file lands in the sandbox cwd so the agent can open it by name.
57+
``read_chunk`` is the transport's reader (an ``UploadFile.read``),
58+
which keeps this free of any HTTP type while still streaming rather
59+
than buffering the whole body. A file over the limit is removed
60+
again -- a partial upload must not linger in the agent's cwd.
61+
"""
62+
clean = safe_filename(filename or "upload")
63+
stored_name = f"{uuid.uuid4().hex[:8]}_{clean}"
64+
dest = conversation_workspace(conversation.id) / stored_name
65+
size = 0
66+
with dest.open("wb") as out:
67+
while chunk := await read_chunk(1024 * 1024):
68+
size += len(chunk)
69+
if size > MAX_UPLOAD_BYTES:
70+
dest.unlink(missing_ok=True)
71+
raise PayloadTooLarge("file too large")
72+
out.write(chunk)
73+
row = ConversationFile(
74+
id=new_id("file"),
75+
conversation_id=conversation.id,
76+
user_id=user.id,
77+
filename=clean,
78+
stored_name=stored_name,
79+
size=size,
80+
path=str(dest),
81+
)
82+
db.add(row)
83+
db.flush()
84+
commit_now(db)
85+
return row
86+
87+
88+
def delete_upload(db: Session, conversation: Conversation, file_id: str) -> None:
89+
"""Forget an upload and unlink it from the workspace."""
90+
row = db.get(ConversationFile, file_id)
91+
if row is None or row.conversation_id != conversation.id:
92+
raise NotFound("file not found")
93+
with contextlib.suppress(OSError):
94+
Path(row.path).unlink()
95+
db.delete(row)
96+
commit_now(db)
97+
98+
99+
def list_artifacts(db: Session, conversation: Conversation) -> list[ArtifactInfo]:
100+
"""Files the agent produced in its workspace.
101+
102+
The workspace doubles as the upload dir, so user uploads are
103+
excluded -- everything else is an agent output.
104+
"""
105+
stored = {
106+
row[0]
107+
for row in db.query(ConversationFile.stored_name).filter(
108+
ConversationFile.conversation_id == conversation.id
109+
)
110+
}
111+
artifacts = []
112+
for entry in sorted(conversation_workspace(conversation.id).iterdir()):
113+
if not entry.is_file() or entry.name in stored:
114+
continue
115+
stat = entry.stat()
116+
artifacts.append(
117+
ArtifactInfo(
118+
name=entry.name,
119+
size=stat.st_size,
120+
modified_at=datetime.fromtimestamp(stat.st_mtime, UTC),
121+
)
122+
)
123+
return artifacts
124+
125+
126+
def artifact_path(conversation: Conversation, name: str) -> Path:
127+
"""On-disk path of one agent-produced file.
128+
129+
Only a basename is accepted, so a crafted name cannot walk out of
130+
the workspace.
131+
"""
132+
if not name or Path(name).name != name:
133+
raise InvalidRequest("invalid file name")
134+
path = conversation_workspace(conversation.id) / name
135+
if not path.is_file():
136+
raise NotFound("artifact not found")
137+
return path

0 commit comments

Comments
 (0)