Skip to content

fix(api): eager archive errors, capped count-only listing, rollup tests#5411

Merged
mmabrouk merged 10 commits into
fe-refactor/drive-surfacesfrom
pr-5400-fixes-correctness
Jul 20, 2026
Merged

fix(api): eager archive errors, capped count-only listing, rollup tests#5411
mmabrouk merged 10 commits into
fe-refactor/drive-surfacesfrom
pr-5400-fixes-correctness

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 20, 2026

Copy link
Copy Markdown
Member

Context

Part 1 of 2. These two stacked PRs implement the pre-merge fixes agreed for #5400 ("[AGE-3965] refactor(drive): virtualize surfaces + unify Files drawer" by @ardaerzin) so that PR can merge on a sound base. A four-lens backend review (Claude Fable 5) and an independent Codex review, run without seeing each other, converged on the same set of blockers. This PR carries the correctness fixes; the contract and validation fixes follow in #5412, stacked on top of this one.

The surviving story is silent failure on the archive path. POST /files/export (then /archive) built its StreamingResponse first and resolved each mount lazily inside the generator, after the 200 headers were already on the wire. A request naming a missing mount, or arriving while storage was down, returned HTTP 200 with a truncated or empty zip and no visible error. This is the exact silent-corruption class #5400 itself diagnosed and fixed for the mtime bug. Alongside it, the plain count-only listing walked the whole tree with no cap, and the recency-rollup heuristics that decide which entries collapse had no tests pinning them. This PR fixes all three, with tests.

Changes

Resolve archive mounts before streaming. The endpoint now resolves every requested mount and builds the work list eagerly, before constructing the response, so MountNotFound and MountStorageUnavailable reach the exception decorators and return a real 404 or 503 instead of a 200 with a broken zip.

Cap the plain count-only listing. The git-aware descent already stopped counting at _COUNT_CAP and set total_capped=True. The plain (non-git-aware) count-only branch enumerated the entire tree with no cap and hardcoded truncated=False, so a raw count on a huge mount walked every object. It now honors the same _COUNT_CAP and reports total_capped=True, matching the git-aware branch.

Pin the recency-rollup heuristics. _rollup_recent_entries decides which recent entries collapse into their parent, and it had no test coverage. This PR adds five tests: clone-then-edit collapse, old-plus-fresh no collapse, untimed-leaf blocks collapse, single-batch behavior, and shallow-to-deep resolution.

Scope note

The review's headline finding was a verified infinite loop in the flat paged listing (list_files_page): when the cursor stepped over a gitignored directory it appended a U+FFFF sentinel, assuming that byte sequence sorted after every key underneath, but astral-plane code points (emoji, CJK Extension B) encode from F0 up and sort after the sentinel, so a single astral-named file under an ignored directory looped forever. That fix shipped in this PR's first version. Arda then removed the flat paged view entirely in 387c62ebef (the flat listing was dropped by design), which moots the fix: its target code no longer exists. It was dropped in the rebase onto 387c62ebef.

The count-cap fix above re-adds exactly one thing that 387c62ebef deleted as dead code: the generic ObjectStore.list_objects_page store primitive. The cap needs a bounded paging primitive to count in fixed-size pages, and after the rebase that primitive now has a live caller again. No flat-view code (the service list_files_page, its route, MountFilePageResponse, or the web driveFlatFiles.ts) returns.

Verification

Live QA (recorded). A 60-second UI recording and a wire matrix were run on an isolated docker stack against the pre-387c62ebef tip and attached as a comment on this PR. Read them with that caveat: the paging-termination scenarios are now moot (the flat view is gone), while the export/404, special-character folder paths, constraint 422s, and CJK-header checks all target surviving code and remain valid.

Tests. The full API unit suite is green on the rebased tip (1373 passed). The mounts file-ops suite covers the count cap and the five rollup heuristics (uv run pytest oss/tests/pytest/unit/test_mounts_file_ops.py -q).

Independent review. Codex reviewed the pre-rebase diff at high reasoning effort; its confirmed catches were fixed and regression-tested. The paging catch it confirmed is moot with the flat view removed.

Merge order

Merge this PR into fe-refactor/drive-surfaces first, then the stacked contract PR into this branch, then #5400 into main. Merging is Mahmoud's decision. Ready for review, do not merge without Mahmoud's go.

https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 20, 2026
@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jul 20, 2026 3:48pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 688ab662-2d08-42f6-9eaf-f3b09b486e59

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The mount service adds cursor-based count-only listing, excludes folder markers from totals, and separates archive work-list construction from ZIP member streaming. The archive route passes the precomputed work list to the streaming service, with expanded unit coverage.

Changes

Mount file operations

Layer / File(s) Summary
Listing counts and cursor pagination
api/oss/src/core/mounts/service.py, api/oss/src/core/store/storage.py, api/oss/tests/pytest/unit/test_mounts_file_ops.py
Count-only listings use cursor pages, exclude folder markers, and apply capped totals. Tests cover deterministic paging, rollup behavior, and count boundaries.
Archive work planning and streaming
api/oss/src/core/mounts/service.py, api/oss/src/apis/fastapi/mounts/utils.py, api/oss/tests/pytest/unit/test_mounts_file_ops.py
Archive metadata is precomputed into ordered work entries, then streamed through the FastAPI route; missing mounts and prefixed ZIP paths are tested.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FastAPIArchive as FastAPI archive route
  participant MountsService
  participant ObjectStore
  participant ZipStream as ZIP stream
  FastAPIArchive->>MountsService: build_archive_work_list(project_id, mounts)
  MountsService->>ObjectStore: list archive source objects
  ObjectStore-->>MountsService: ordered file metadata
  MountsService-->>FastAPIArchive: archive work list
  FastAPIArchive->>MountsService: iter_archive_members(work)
  MountsService->>ObjectStore: prefetch file bytes
  ObjectStore-->>MountsService: file bytes
  MountsService-->>ZipStream: ordered archive members
Loading

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: archive error handling, capped count-only listing, and added rollup tests.
Description check ✅ Passed The description is detailed and directly matches the archive, count cap, and rollup test changes in the PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr-5400-fixes-correctness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk

Copy link
Copy Markdown
Member Author

Live QA recording: paging termination, special-char folders, download-all. Details in the PR body.

pr5400-fixes-qa.mp4

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ab6f001d-359e-429f-8229-7baf550e7cd9

📥 Commits

Reviewing files that changed from the base of the PR and between da040e1 and c93b418.

📒 Files selected for processing (3)
  • api/oss/src/apis/fastapi/mounts/utils.py
  • api/oss/src/core/mounts/service.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py

Comment on lines +1254 to +1266
async def build_archive_work_list(
self,
*,
project_id: UUID,
mounts: List[Tuple[UUID, str, str]],
concurrency: int = _ARCHIVE_READ_CONCURRENCY,
) -> AsyncIterator[Tuple[str, int, Optional[int], bytes]]:
"""Yield ``(zip_path, size, mtime, raw_bytes)`` for the files in the given mounts, in order —
the basis for a STREAMING archive. Each mount is a ``(mount_id, zip_prefix, source_path)``:
) -> List[Tuple[str, str, int, Optional[int]]]:
"""Build the ordered archive work list for the given mounts.

Each mount is a ``(mount_id, zip_prefix, source_path)``:
``source_path`` scopes it to a FOLDER within the mount ("" = the whole mount, for "download
all"); ``zip_prefix`` places its files under ``prefix/`` in the zip (e.g. "agent-files" for
the folded agent mount). Folder markers are skipped. Reads up to ``concurrency`` files AHEAD
(bounded ordered prefetch) — never buffering the zip whole nor hammering the store.
the folded agent mount). Folder markers are skipped. Each work item is a
``(zip_path, storage_key, size, mtime)`` tuple.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Return a named DTO instead of raw tuples for the archive work list.

build_archive_work_list returns List[Tuple[str, str, int, Optional[int]]] and iter_archive_members (Lines 1297-1305) consumes/yields the same positional-tuple shape. Define a named DTO (e.g. ArchiveWorkItem with zip_path, storage_key, size, mtime) in api/oss/src/core/mounts/dtos.py and use it here; downstream unpacking in api/oss/src/apis/fastapi/mounts/utils.py and test_zip_paths_include_mount_prefix would switch to attribute access.

As per coding guidelines: "Do not return raw dicts or tuples from service methods or clients; define named DTOs in core/{domain}/dtos.py instead."

Source: Coding guidelines

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-production-3668.up.railway.app/w
Project agenta-oss-pr-5411
Image tag pr-5411-555bc8c
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-07-20T15:57:27.722Z

mmabrouk added 9 commits July 20, 2026 17:16
…of a broken 200

The download-all archive resolved each mount lazily inside the streaming
generator, after the 200 headers were sent, so MountNotFound / MountStorageUnavailable
never reached @handle_mount_exceptions and a bad mount id yielded a corrupt zip.
Split iter_archive_members into build_archive_work_list (resolve + list, awaited
before the StreamingResponse is built) and iter_archive_members (prefetch/yield only),
so those errors surface as real 404/503 through the decorators.

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
The raw (non-git-aware) count-only path ran a full uncapped list_objects_v2 and
hardcoded truncated=False, so a huge tree was enumerated whole. Page it bounded at
_COUNT_CAP and report total_capped=True when more remain, matching the git-aware
branch's bounded-count contract.

Re-adds the generic ObjectStore.list_objects_page pagination primitive (removed
alongside the flat Files view); this bounded count is now its sole consumer.

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
Adds pure-function tests for the recent-files rollup: clone-then-edit collapses to
the top directory, an old-plus-fresh directory does not collapse, an untimed leaf
blocks collapse, a single-batch history produces no rollup, and shallow-to-deep
resolution picks repo/ over repo/web/. Pins current behavior; no algorithm change.

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
Switch validate_file_path from a character allowlist to a denylist so real
folder names (route groups, npm scopes, `c++`, `#`, `~`, non-ASCII) that the
lazy-browse flow round-trips no longer 422 on every expand. Reject only unsafe
input: absolute paths, empty/`.`/`..` segments, NUL and control characters.

Harden the archive path in the same pass (opposite direction): validate
ArchiveMount prefix/path, sanitize `..` out of zip entry names to prevent
zip-slip, and make the Content-Disposition fallback ASCII-only so a non-latin-1
filename stops 500ing on Starlette's latin-1 header encoding.

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
"archive" is this router's soft-delete lifecycle verb (archive_mount two
registrations below), and the collision is what forced the route-ordering
hack. Rename the download-all zip route to the non-lifecycle /files/export
and update the operation_id, handler, and all three frontend touchpoints
(driveMedia streaming + buffered paths, and the hand-added client method,
which stays hand-written until the Fern regen ticket).

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
`depth` advertised ge=1 but only depth==1 is implemented; depth=2 silently
fell through to the most expensive full-tree browse branch. Constrain it to
le=1 so unsupported depths 422. Type `order` as a Literal so a typo 422s at the
boundary instead of returning the flat view unsorted.

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
Replace the archive path's anonymous `(mount_id, prefix, path)` triples with a
MountArchiveSource DTO, per the typed-DTO convention. Thread it through the
service, archive utils, router, and unit tests.

(The companion MountFilePage DTO from the original change is dropped: the flat
paged listing it named was removed with the Files drawer's flat view.)

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
`total` had three undocumented computed meanings; state the one contract
explicitly (entries the queried view returns before any limit: leaf files in
flat/recency modes, files-plus-folders in shallow/browse). Also stop the
download-all docstring from promising >4 GB files, which are out of product
scope.

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
test_mounts_service.py still asserted the old allowlist rejected angle
brackets; under the denylist they are legitimate filename characters. Assert
they (and route-group names) are accepted, and pin control-char rejection.

Claude-Session: https://claude.ai/code/session_01PTkjdoAPbhSEFR3ubpgzBU
@mmabrouk
mmabrouk force-pushed the pr-5400-fixes-correctness branch from c93b418 to d7d029c Compare July 20, 2026 15:29
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Jul 20, 2026
@mmabrouk mmabrouk changed the title fix(api): stop paged drive listing hanging and fix archive/count edges fix(api): eager archive errors, capped count-only listing, rollup tests Jul 20, 2026
@mmabrouk

Copy link
Copy Markdown
Member Author

Rebased both PRs onto Arda's latest tip 387c62ebef. The one casualty is the pager infinite-loop fix that was #5411's original headline: 387c62ebef removed the flat paged listing entirely (by design), so its target code no longer exists and that fix is dropped as moot. Everything else is intact — the eager archive-error resolution (bad mounts now 404/503 instead of a broken 200), the count-only _COUNT_CAP cap, the rollup heuristic tests, and the whole contract PR (#5412: path-validation relax, archive hardening, export-route rename, depth/order guards, DTO naming). One thing to note in the diff: the count-cap fix re-adds exactly one primitive that 387c62ebef deleted as dead code — the generic ObjectStore.list_objects_page store method — because the cap counts in bounded pages and needs it; it now has a live caller again, and no flat-view code comes back with it. Full API unit suite is green on the rebased tip.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
api/oss/src/core/mounts/service.py (1)

1089-1104: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Return a named DTO instead of raw tuples for the archive work list.

build_archive_work_list returns List[Tuple[str, str, int, Optional[int]]] and iter_archive_members consumes the same positional-tuple shape. Define a named DTO (e.g. ArchiveWorkItem subclassing Pydantic's BaseModel) in api/oss/src/core/mounts/dtos.py and use it here.

As per coding guidelines: "Service methods must return typed DTOs (Pydantic BaseModel subclasses), not raw dicts, tuples, or Any; use Optional[DTO] for missing entities and List[DTO] for collections."

Also applies to: 1130-1140

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ed44346-4d94-4896-aa12-66ec6599f080

📥 Commits

Reviewing files that changed from the base of the PR and between c93b418 and d7d029c.

📒 Files selected for processing (4)
  • api/oss/src/apis/fastapi/mounts/utils.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/store/storage.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • api/oss/src/apis/fastapi/mounts/utils.py

fix(api): relax drive path validation, harden archive, rename export route
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Jul 20, 2026
@mmabrouk
mmabrouk merged commit fe0fc55 into fe-refactor/drive-surfaces Jul 20, 2026
26 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Report Something isn't working size:L This PR changes 100-499 lines, ignoring generated files. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants