Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4fa19d0
[AGE-3965] fix(frontend): virtualize drive surfaces for large file trees
ardaerzin Jul 17, 2026
0be1135
[AGE-3965] fix(frontend): folder-browse chat grid, stable thumbnails …
ardaerzin Jul 17, 2026
fa68845
[AGE-3965] feat(drive): backend latest-files + git-aware listing; FE …
ardaerzin Jul 18, 2026
62b5a29
feat(drive): backend flat cursor-pagination endpoint + streaming-arch…
ardaerzin Jul 20, 2026
e280384
feat(drive): FE data layer — mounts client + entities for paged/lates…
ardaerzin Jul 20, 2026
cad05a3
feat(drive): unified Files drawer — flat infinite-scroll, skeleton/re…
ardaerzin Jul 20, 2026
656c220
feat(drive): chat file-link surfaces (conversation, markdown, runtime…
ardaerzin Jul 20, 2026
3ac6af2
refactor(drive): remove dead exports + fix RuntimeLens import order
ardaerzin Jul 20, 2026
84adb07
fix(drive): address review — validate mount_id, sanitize download hea…
ardaerzin Jul 20, 2026
da040e1
refactor(drive): move flat-file paging onto the Fern client + useInfi…
ardaerzin Jul 20, 2026
387c62e
refactor(drive): simplify Files drawer to a single tree view + full-w…
ardaerzin Jul 20, 2026
5320d51
fix(api): resolve archive mounts eagerly so bad requests 404 instead …
mmabrouk Jul 20, 2026
6616af9
fix(api): cap the plain count-only mount listing at _COUNT_CAP
mmabrouk Jul 20, 2026
d7d029c
test(api): pin _rollup_recent_entries batch-collapse behavior
mmabrouk Jul 20, 2026
78fc327
fix(api): relax mount path validation and harden archive inputs
mmabrouk Jul 20, 2026
daf3f6a
fix(api): rename download-all route to POST /files/export
mmabrouk Jul 20, 2026
a9d9d49
fix(api): constrain mount file-listing depth and order params
mmabrouk Jul 20, 2026
ce29aa0
refactor(api): name the archive-source tuple
mmabrouk Jul 20, 2026
086c0bc
docs(api): document total's per-view meaning and soften archive scope
mmabrouk Jul 20, 2026
0feba68
test(api): update path-validation test for the relaxed denylist
mmabrouk Jul 20, 2026
9b4d220
Merge pull request #5412 from Agenta-AI/pr-5400-fixes-contract
mmabrouk Jul 20, 2026
fe0fc55
Merge pull request #5411 from Agenta-AI/pr-5400-fixes-correctness
mmabrouk Jul 20, 2026
aa23d8d
fix(drive): smooth tree-pane collapse via motion + drag-to-resize
ardaerzin Jul 20, 2026
eb14814
feat(drive): motion-owned tile grid with animated column reflow + fra…
ardaerzin Jul 20, 2026
27185bb
Merge branch 'release/v0.105.7' into fe-refactor/drive-surfaces
ardaerzin Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions api/oss/src/apis/fastapi/mounts/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import List, Optional
from uuid import UUID

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -36,6 +37,22 @@ class AgentMountQueryRequest(BaseModel):
name: str = "default"


class ArchiveMount(BaseModel):
"""One mount to include in an archive. `path` scopes it to a folder within the mount ("" = the
whole mount); `prefix` places its files under `prefix/` in the zip (the folded drive layout)."""

mount_id: UUID
prefix: str = ""
path: str = ""


class MountArchiveRequest(BaseModel):
"""Zip several mounts into ONE archive (the drive folds cwd + agent-files into one tree)."""

mounts: List[ArchiveMount] = Field(default_factory=list)
filename: str = "files.zip"


# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
Expand All @@ -58,6 +75,12 @@ class MountsResponse(BaseModel):

class MountFileListResponse(BaseModel):
count: int = 0
# Entries this view would return BEFORE any limit — so a limited "latest N" listing still reports
# the true total (the UI badge). Its unit follows the view: leaf files only in the recency listing
# (order/limit set), files-plus-folders in the shallow (depth=1) and browse modes.
total: int = 0
# `total` is a FLOOR (the count-only scan hit its cap) — the UI shows "N+". False when exact.
total_capped: bool = False
files: List[MountFile] = Field(default_factory=list)


Expand Down
55 changes: 54 additions & 1 deletion api/oss/src/apis/fastapi/mounts/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from functools import wraps
from typing import Optional
from typing import Literal, Optional
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, status
Expand All @@ -10,6 +10,7 @@
from oss.src.core.access.permissions.service import check_action_access
from oss.src.apis.fastapi.shared.exceptions import FORBIDDEN_EXCEPTION

from oss.src.core.mounts.dtos import MountArchiveSource
from oss.src.core.mounts.service import MountsService
from oss.src.core.mounts.types import (
MountArtifactIdInvalid,
Expand All @@ -27,6 +28,7 @@

from oss.src.apis.fastapi.mounts.models import (
AgentMountQueryRequest,
MountArchiveRequest,
MountCreateRequest,
MountCredentialsResponse,
MountEditRequest,
Expand All @@ -43,6 +45,7 @@
download_mount_file,
merge_mount_query,
sign_mount_credentials,
stream_mounts_archive,
upload_mount_file,
)

Expand Down Expand Up @@ -189,6 +192,16 @@ def __init__(
response_model_exclude_none=True,
status_code=status.HTTP_200_OK,
)
# Registered before the "/{mount_id}/..." routes so this literal path isn't captured as an
# operation on a mount named "files".
self.router.add_api_route(
"/files/export",
self.export_mount_files,
methods=["POST"],
operation_id="export_mount_files",
response_model=None,
status_code=status.HTTP_200_OK,
)
self.router.add_api_route(
"/{mount_id}/archive",
self.archive_mount,
Expand Down Expand Up @@ -487,6 +500,14 @@ async def get_mount_files(
*,
path: Optional[str] = Query(default=None),
read: Optional[str] = Query(default=None),
order: Optional[Literal["recent", "name", "path"]] = Query(default=None),
limit: Optional[int] = Query(default=None, ge=0),
# Only depth==1 is implemented (the shallow one-level summary); reject other values loudly
# instead of silently falling through to the most expensive full-tree branch.
depth: Optional[int] = Query(default=None, ge=1, le=1),
with_counts: bool = Query(default=False),
git_aware: bool = Query(default=False),
include_gitignored: bool = Query(default=False),
):
await self._check(request, Permission.VIEW_MOUNTS)

Expand All @@ -505,9 +526,17 @@ async def get_mount_files(
project_id=UUID(request.state.project_id),
mount_id=mount_id,
path=path,
order=order,
limit=limit,
depth=depth,
with_counts=with_counts,
git_aware=git_aware,
include_gitignored=include_gitignored,
)
return MountFileListResponse(
count=len(listing.files),
total=listing.total,
total_capped=listing.total_capped,
files=listing.files,
)

Expand Down Expand Up @@ -589,6 +618,30 @@ async def download_mount_file(
path=path,
)

@intercept_exceptions()
@handle_mount_exceptions()
async def export_mount_files(
self,
request: Request,
*,
archive_request: MountArchiveRequest,
):
await self._check(request, Permission.VIEW_MOUNTS)

return await stream_mounts_archive(
mounts_service=self.mounts_service,
project_id=UUID(request.state.project_id),
mounts=[
MountArchiveSource(
mount_id=m.mount_id,
archive_prefix=m.prefix,
source_path=m.path,
)
for m in archive_request.mounts
],
filename=archive_request.filename,
)

@intercept_exceptions()
@handle_mount_exceptions()
async def delete_mount_file(
Expand Down
98 changes: 94 additions & 4 deletions api/oss/src/apis/fastapi/mounts/utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
from datetime import datetime, timezone
from mimetypes import guess_type
from posixpath import basename
from typing import Optional
from stat import S_IFREG
from typing import List, Optional
from urllib.parse import quote
from uuid import UUID

from fastapi import Response, UploadFile

from oss.src.core.mounts.dtos import MountCredentials, MountFileWritten, MountQuery
from fastapi.responses import StreamingResponse
from stream_zip import ZIP_AUTO, async_stream_zip

from oss.src.core.mounts.dtos import (
MountArchiveSource,
MountCredentials,
MountFileWritten,
MountQuery,
)
from oss.src.core.mounts.service import MountsService

# Regular-file mode for archive members (owner rw, group/other r).
_ARCHIVE_FILE_MODE = S_IFREG | 0o644


def _content_disposition_attachment(filename: str) -> str:
"""Build a safe `Content-Disposition: attachment` header value (RFC 6266).

`filename` is client-supplied (a download path's basename or the archive name), so it must never
be interpolated raw: a `"` or control char would break out of the quoted parameter and inject
further header directives. The quoted `filename` is stripped to an ASCII, printable, quote-free
fallback (the header is latin-1 encoded); `filename*` carries the exact (percent-encoded) value for
clients that honour it.
"""
safe = (
"".join(
c for c in filename if c.isascii() and c.isprintable() and c not in '"\\'
)
or "download"
)
return (
f"attachment; filename=\"{safe}\"; filename*=UTF-8''{quote(filename, safe='')}"
)


async def upload_mount_file(
*,
Expand Down Expand Up @@ -58,7 +91,64 @@ async def download_mount_file(
return Response(
content=body,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{name}"'},
headers={"Content-Disposition": _content_disposition_attachment(name)},
)


async def stream_mounts_archive(
*,
mounts_service: MountsService,
project_id: UUID,
mounts: List[MountArchiveSource],
filename: str = "files.zip",
) -> StreamingResponse:
"""STREAM a zip of EVERY file across the given mounts as a binary download ("download all").

The drive folds cwd + agent-files into one tree, so each ``(mount_id, prefix)`` is placed under
``prefix/`` in the zip. The archive is streamed member-by-member (never buffered whole), and the
service prefetches file bodies with bounded concurrency. ``ZIP_AUTO`` picks zip32/zip64 per entry
by size; very large individual files are out of product scope for "download all".
"""
work = await mounts_service.build_archive_work_list(
project_id=project_id,
mounts=mounts,
)

async def members():
async for (
zip_path,
_size,
mtime,
body,
) in mounts_service.iter_archive_members(
work=work,
):
# `mtime` is the store's LastModified as epoch MILLISECONDS (see StoreObject.mtime);
# `datetime.fromtimestamp` wants SECONDS — passing ms overflows to a year out of range
# and RAISES mid-stream (after 200 headers are sent), truncating the zip to 0 bytes.
modified_at = (
datetime.fromtimestamp(mtime / 1000, tz=timezone.utc)
if mtime
else datetime.now(tz=timezone.utc)
)

async def _data(_body=body):
yield _body

# Size from the actual bytes (not the pre-read listing) so a file changed between list
# and read can't desync the zip entry; ZIP_AUTO then picks zip32/zip64 accordingly.
yield (
zip_path,
modified_at,
_ARCHIVE_FILE_MODE,
ZIP_AUTO(len(body)),
_data(),
)

return StreamingResponse(
async_stream_zip(members()),
media_type="application/zip",
headers={"Content-Disposition": _content_disposition_attachment(filename)},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


Expand Down
22 changes: 22 additions & 0 deletions api/oss/src/core/mounts/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,32 @@ class MountFile(BaseModel):
# Object-store LastModified as epoch milliseconds; None when the store omits it. Lets the UI
# order files by recency regardless of how they were created (bash, Write tool, upload).
mtime: Optional[int] = None
# Direct-child count for a folder entry — only set when the recency view rolls a whole
# freshly-written directory (e.g. a `git clone`) up into ONE folder row instead of flooding the
# "recent files" list with its leaves. None for real files.
item_count: Optional[int] = None


class MountFileList(BaseModel):
files: List[MountFile] = Field(default_factory=list)
# Entries this view would return BEFORE any limit — so a limited "latest N" listing still reports
# the true total (the UI badge) without shipping the whole tree. Its unit follows the view: leaf
# files only in the recency listing (order/limit set), files-plus-folders in the shallow (depth=1)
# and browse modes.
total: int = 0
# `total` is a FLOOR, not exact — the count-only scan hit its cap on a very large tree, so the UI
# shows "N+". False for an exhaustive count.
total_capped: bool = False


class MountArchiveSource(BaseModel):
"""One mount to include in a download-all archive: which mount, which folder within it
(`source_path`; "" = the whole mount), and the prefix its files sit under in the zip (the folded
drive layout)."""

mount_id: UUID
source_path: str = ""
archive_prefix: str = ""


class MountFileContent(BaseModel):
Expand Down
Loading
Loading