Skip to content

[feat] Add a mounts move endpoint so files and folders rename server-side - #6883

Open
ashrafchowdury wants to merge 5 commits into
mainfrom
feat/api-mount-file-move
Open

ashrafchowdury wants to merge 5 commits into
mainfrom
feat/api-mount-file-move

Conversation

@ashrafchowdury

Copy link
Copy Markdown
Contributor

Context

Renaming a file in the Files pane downloads its bytes to the browser, uploads them under the new name and deletes the old key. Every byte round-trips through the client, and folders cannot be renamed at all (a just-created empty folder is faked by create + delete). The mounts API had no move: folder create, upload, write, download, list and delete only.

Changes

One new endpoint, POST /mounts/{mount_id}/files/move?path=<from>&to=<full new path>, permission EDIT_MOUNTS. It renames or moves a file or a whole folder server-side. to is the complete new path (a rename is path=a/b.md&to=a/c.md, a move is to=x/b.md), never a folder to drop into.

Object stores have no rename, so the service enumerates the keys the path stands for (the same enumeration delete_path used, now shared as _path_keys), copies each to its new key with S3 CopyObject, then deletes the old keys. Copies run first and the delete last, so a failed copy leaves the source intact; a failed copy also cancels its in-flight siblings so nothing lands after the error.

Responses:

200 {"source": "drafts", "destination": "final", "count": 2}   # count = keys moved, like delete
404 source missing
409 something already exists at the destination (never overwritten)   # new MountFileConflict
422 same path, destination inside the source, or an invalid path

Two store-level details: SeaweedFS refuses an empty trailing-slash key (a folder marker) as a copy source, so markers are re-created with the same zero-byte write create_folder does. And delete_keys now returns the count the store actually removed instead of len(keys); delete_path reports that honest count, and a move whose source keys survive raises instead of claiming success.

The existence checks use one bounded page (list_objects_page(max_keys=1)) rather than a recursive listing, so checking whether src is free no longer walks a 50k-file src-old/ next to it.

Not in this PR: a copy flag (Duplicate keeps its client path), cross-mount moves, a sessions-router mirror. The web client regenerates from /openapi.json in the follow-up that points the Files pane's rename at this route.

Tests

  • Unit (test_mounts_file_ops.py): file rename; folder move carrying the marker and descendants while srcs/ stays put; missing, occupied, same-path and into-itself refusals with nothing moved; src-old/ sorting between src and src/ fools neither side; a store that refuses to delete makes the move raise. Full API unit suite green.
  • Acceptance (test_mounts_basics.py, against the local EE dev stack): folder move shows up in the listing, 409 on an occupied name, 404 on a missing source, 422 on ... 35/35 in the file.

@vercel

vercel Bot commented Sep 16, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 16, 2026 12:29pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Summary

Summary by CodeRabbit

  • New Features

    • Added the ability to move or rename files and folders within a mount.
    • Move operations report the source, destination, and number of moved items.
    • Added conflict responses when the destination already exists.
  • Bug Fixes

    • Prevented invalid moves, including path traversal, self-moves, and descendant destinations.
    • Improved handling of missing sources and incomplete cleanup.
  • Tests

    • Added coverage for file and folder moves, conflicts, invalid paths, missing sources, and partial failures.

Walkthrough

Changes

Mounts now support moving files and folders through a new API endpoint. The service validates paths, copies source keys, deletes source keys, and reports conflicts or incomplete deletion. Object-store copying and failed-key reporting are supported. Unit and acceptance tests cover the operation.

Mount file move

Layer / File(s) Summary
Move API contract and routing
api/oss/src/apis/fastapi/mounts/models.py, api/oss/src/apis/fastapi/mounts/router.py, api/oss/src/core/mounts/dtos.py, api/oss/src/core/mounts/types.py
Adds move response models, the POST /mounts/{mount_id}/files/move route, permission handling, and HTTP 409 handling for occupied destinations.
Move orchestration and storage operations
api/oss/src/core/mounts/service.py, api/oss/src/core/store/storage.py, api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
Adds path discovery and move logic. The service copies source keys, removes the source, rejects invalid destinations, rolls back partial copies, and detects incomplete deletion. ObjectStore adds server-side copying and failed-key reporting.
Move behavior validation
api/oss/tests/pytest/unit/test_mounts_file_ops.py, api/oss/tests/pytest/acceptance/mounts/test_mounts_basics.py
Tests file and folder moves, traversal rejection, occupied and missing paths, prefix handling, rollback, and failed source deletion.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MountsRouter
  participant MountsService
  participant ObjectStore
  Client->>MountsRouter: Submit move request
  MountsRouter->>MountsService: Call move_path
  MountsService->>ObjectStore: Copy source keys
  MountsService->>ObjectStore: Delete source keys
  ObjectStore-->>MountsService: Return failed key names
  MountsService-->>MountsRouter: Return MountFileMoved
  MountsRouter-->>Client: Return MountFileMovedResponse
Loading

Merge Risk: 🟡 Moderate · up to f0589

Large folder moves can exhaust worker memory, and a failed move can remove another writer's destination object during rollback. These correctness and availability risks should be addressed before release.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.32% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a mounts move endpoint that performs server-side renames for files and folders.
Description check ✅ Passed The description directly explains the new move endpoint, server-side object-store behavior, response codes, validation, and test coverage.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/api-mount-file-move

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 71baceb8-daf4-42ec-92a0-941c0bdc20c0

📥 Commits

Reviewing files that changed from the base of the PR and between fa60f71 and 7c97e66.

📒 Files selected for processing (8)
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/mounts/types.py
  • api/oss/src/core/store/storage.py
  • api/oss/tests/pytest/acceptance/mounts/test_mounts_basics.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.

Comment thread api/oss/src/apis/fastapi/mounts/router.py
Comment thread api/oss/src/core/mounts/service.py
Comment thread api/oss/src/core/mounts/service.py Outdated
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-pr-6883.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-6883-3aa0191
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-09-17T12:37:37.415Z

@ashrafchowdury
ashrafchowdury changed the base branch from release/v0.118.2 to main September 16, 2026 10:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 7de9910a-3620-47ad-acaa-59dff74e7fcf

📥 Commits

Reviewing files that changed from the base of the PR and between 7c97e66 and 7110015.

📒 Files selected for processing (4)
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/store/storage.py
  • api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.

Comment thread api/oss/src/core/mounts/service.py
Comment thread api/oss/src/core/mounts/service.py
Comment thread api/oss/src/core/mounts/service.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Preserve the delete_prefix count and failure semantics. · test_mounts_file_ops.py:281-283

api/oss/tests/pytest/unit/test_mounts_file_ops.py:281-283
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the delete_prefix count and failure semantics.

FakeMountStorage.delete_prefix() declares -> int, but it returns the List[str] from delete_keys(). delete_keys() returns keys that the store refused, so returning len(objects) would count failed deletions as successful. Match the production implementation by subtracting the failed keys.

Proposed fix
 async def delete_prefix(self, *, bucket: str, prefix: str) -> int:
     objects = await self.list_objects_v2(bucket=bucket, prefix=prefix)
-    return await self.delete_keys(bucket=bucket, keys=[o.key for o in objects])
+    failed = await self.delete_keys(
+        bucket=bucket, keys=[o.key for o in objects]
+    )
+    return len(objects) - len(failed)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: ad5424ab-b543-4862-ba15-61a0e8795809

📥 Commits

Reviewing files that changed from the base of the PR and between 713d023 and f05893d.

📒 Files selected for processing (9)
  • api/oss/src/apis/fastapi/mounts/models.py
  • api/oss/src/apis/fastapi/mounts/router.py
  • api/oss/src/core/mounts/dtos.py
  • api/oss/src/core/mounts/service.py
  • api/oss/src/core/mounts/types.py
  • api/oss/src/core/store/storage.py
  • api/oss/tests/pytest/acceptance/mounts/test_mounts_basics.py
  • api/oss/tests/pytest/unit/mounts/test_protected_mount_policy.py
  • api/oss/tests/pytest/unit/test_mounts_file_ops.py

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment on lines +1630 to +1633
copies = [
asyncio.create_task(_copy(key, target))
for key, target in zip(source_keys, targets)
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1520,1665p' api/oss/src/core/mounts/service.py
rg -n '_LIST_CONCURRENCY|async def list_objects|def list_objects|_path_keys' api/oss/src/core/mounts/service.py api/oss/src/core/store/storage.py

Repository: Agenta-AI/agenta

Length of output: 6670


🏁 Script executed:

sed -n '410,515p' api/oss/src/core/store/storage.py
rg -n --glob '*.py' 'move_path\(|MountFileMoved|/move|move.*path|path.*move' api/oss/src
sed -n '900,1030p' api/oss/src/core/mounts/service.py
sed -n '1125,1195p' api/oss/src/core/mounts/service.py

Repository: Agenta-AI/agenta

Length of output: 17755


🏁 Script executed:

sed -n '240,280p' api/oss/src/apis/fastapi/mounts/router.py
sed -n '600,655p' api/oss/src/apis/fastapi/mounts/router.py
sed -n '80,130p' api/oss/src/apis/fastapi/mounts/models.py
rg -n --glob '*.py' '_MAX_.*(FILE|OBJECT|KEY)|max.*(file|object|key)|move.*(limit|cap)|limit.*move' api/oss/src/core/mounts api/oss/src/apis/fastapi/mounts

Repository: Agenta-AI/agenta

Length of output: 5519


Bound task creation for large folder moves.

move_path creates one live asyncio.Task for every source key before awaiting completion. The semaphore limits storage calls inside _copy; it does not limit task allocation. _path_keys recursively materializes every source object, and the /files/move endpoint has no move-specific object limit. A sufficiently large folder can therefore exhaust worker memory through retained task and coroutine state.

Process source-target pairs in chunks or use a fixed worker pool so task creation remains bounded by _LIST_CONCURRENCY. Avoid adding another full pairs list, since source_keys and targets are already materialized.

Proposed chunked copy
-        copies = [
-            asyncio.create_task(_copy(key, target))
-            for key, target in zip(source_keys, targets)
-        ]
         try:
-            await asyncio.gather(*copies)
+            for offset in range(0, len(source_keys), _LIST_CONCURRENCY):
+                copies = [
+                    asyncio.create_task(_copy(key, target))
+                    for key, target in zip(
+                        source_keys[offset : offset + _LIST_CONCURRENCY],
+                        targets[offset : offset + _LIST_CONCURRENCY],
+                    )
+                ]
+                await asyncio.gather(*copies)
         except BaseException:
             # gather leaves the siblings running; stop them so nothing lands after the failure.
             for task in copies:
                 task.cancel()

…lder

Object stores have no rename, so a move is: enumerate the path's keys (the exact key, or a
folder's marker and everything under it — the same walk delete_path does, now shared as
_path_keys), server-side copy each to its new key, then delete the old ones. Copies first,
delete last, so a failed copy leaves the source intact. A folder marker is re-created rather
than copied: SeaweedFS refuses a trailing-slash key as a copy source.

`path` and `to` are both full mount-relative paths, validated like every file op; the same
path or a folder into itself is 422, a missing source 404, an occupied destination 409
(MountFileConflict — the store never overwrites). EDIT_MOUNTS, like the other writes.

ObjectStore gains copy_object over miniopy's CopySource. Unit tests cover a file rename, a
folder move with marker and descendants (a prefix sibling stays), and every refusal;
acceptance tests run the route end to end.
… failure, surface failed deletes

The destination conflict check listed whole sibling subtrees (prefix without a slash); one
bounded page per check now settles it, and the shared key enumeration uses the same for its
exact-key membership. A failed copy cancels the in-flight siblings so nothing lands after
the error. delete_keys returns the count the store actually removed, and a move whose
source keys survive raises instead of reporting success.
…ed deletes retry once

delete_keys now returns the keys the store refused instead of a count, so move_path can retry
them and delete_path reports the real count. The protected-mount fake store gains the bounded
page listing move_path uses.
…mmediate child's

The shallow lister keeps the marker's mtime, and the with_counts pass folds in the newest
child it already reads, so a fresh folder sorts as new in the Files pane.
@ashrafchowdury
ashrafchowdury force-pushed the feat/api-mount-file-move branch from f05893d to d76f4b5 Compare September 17, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant