Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 45 additions & 13 deletions api/oss/src/core/mounts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,9 @@ def _is_git_plumbing(path: str) -> bool:

def _is_hidden_path(path: str) -> bool:
"""A dot-prefixed (hidden) file or folder anywhere in the path — `.claude/…`, `.gitignore`, etc.
Mirrors the web `isHiddenPath`. Dropped from the RECENCY view only (it is meant to read like
"what did I just work on", not dotfile plumbing); the browsable tree still lists them (dimmed)."""
Mirrors the web `isHiddenPath`. Dropped from the curated FLAT view (count + recency): those read
like "what is in my drive / what did I just work on", not dotfile plumbing. The browsable tree
and `depth=1` levels still list them (dimmed), behind the UI's "show hidden" toggle."""
return any(
segment.startswith(".") for segment in path.strip("/").split("/") if segment
)
Expand Down Expand Up @@ -959,16 +960,20 @@ async def _list_pruned_files(
mount_base: str,
cap: Optional[int] = None,
) -> Tuple[List[StoreObject], List[Tuple[str, "pathspec.PathSpec"]], bool]:
"""Enumerate a mount's FILES by descending the tree LEVEL BY LEVEL, skipping `.git` and
gitignored DIRECTORIES at the store layer — so a dependency dump (`node_modules`, tens of
thousands of objects) is never enumerated at all. The flat `recursive=True` listing cannot
"""Enumerate a mount's FILES by descending the tree LEVEL BY LEVEL, skipping every directory
the curated view discards — `.git`, gitignored, runner-internal (`agents/`) and hidden
(dot-prefixed) — at the store layer, so a dependency dump (`node_modules`, tens of thousands
of objects) is never enumerated at all. The flat `recursive=True` listing cannot
exclude a prefix, so it must scan every object; this walks only what survives, listing sibling
directories concurrently (bounded by `_LIST_CONCURRENCY`) so wall-clock tracks the tree DEPTH,
not the object count. Each level's `.gitignore` files are read before that level's children are
pruned, so the repo's own rules drive the prune.

`cap` early-stops the descent once that many files are collected — for a bounded COUNT of a
pathologically large (non-ignored) tree, so the cost never runs away regardless of contents.
Because the prunes above run DURING the walk, what `cap` budgets is (near enough) the files
the caller will actually count, so a drive only reports "N+" when it genuinely holds that
many VISIBLE files.

Returns (kept StoreObjects, specs, truncated). `truncated` is True when the `cap` stopped the
walk early (the real count is higher). The caller still applies FILE-level gitignore for
Expand Down Expand Up @@ -998,17 +1003,27 @@ async def _shallow(prefix: str):
subdir_prefixes: List[str] = []
for level_files, level_subdirs in listings:
for obj in level_files:
kept.append(obj)
rel = (
obj.key[len(mount_base) :]
if obj.key.startswith(mount_base)
else obj.key
)
# Read BEFORE the prune below: `.gitignore` is itself a hidden file.
if rel == ".gitignore" or rel.endswith("/.gitignore"):
dir_rel = (
"" if rel == ".gitignore" else rel[: -len("/.gitignore")]
)
gitignore_reads.append((dir_rel, obj.key))
# Charge `cap` only for files the caller can actually count, so a root full of
# dotfiles can't report "N+" over an exactly countable drive. Gitignored files
# still pass here; their specs are not in scope until the level is read.
if (
_is_git_plumbing(rel)
or _is_internal_mount_path(rel)
or _is_hidden_path(rel)
):
continue
kept.append(obj)
subdir_prefixes.extend(level_subdirs)

# Bounded COUNT: enough to know it's "more than the cap" — stop before descending further.
Expand All @@ -1033,7 +1048,16 @@ async def _shallow(prefix: str):
).rstrip("/")
if not dir_rel:
continue
if _is_git_plumbing(dir_rel) or _path_gitignored(dir_rel, True, specs):
# Every directory whose files the curated view would discard anyway. Pruning them
# HERE, not after the walk, is what keeps `cap` a budget of COUNTABLE files: a big
# `.claude/` or `agents/` tree would otherwise spend the budget and then be filtered
# out, reporting a needless "N+" on a drive that could be counted exactly.
if (
_is_git_plumbing(dir_rel)
or _is_internal_mount_path(dir_rel)
or _is_hidden_path(dir_rel)
or _path_gitignored(dir_rel, True, specs)
):
continue
visited.add(sub_prefix)
frontier.append(sub_prefix)
Expand Down Expand Up @@ -1069,6 +1093,8 @@ async def list_files(
output) are pruned, runner-internal artifacts are hidden, and — for perf — the flat/recency
modes descend level-by-level pruning ignored DIRECTORIES at the store layer instead of
enumerating a `node_modules` dump. Pruning drives both the count and the tree in that mode.
The curated FLAT view (count + recency) additionally drops dot-prefixed paths; the browse and
`depth=1` views keep them, so the explorer can still show them behind its own toggle.

`include_gitignored` (git_aware only) surfaces `.gitignore`-matched files again — the UI's
"show git-ignored files" toggle — while STILL hiding `.git` plumbing and runner internals.
Expand Down Expand Up @@ -1264,23 +1290,29 @@ async def _count_children(sub_rel: str) -> Tuple[str, int]:
for o in store_files
]
if git_aware:
# Whole-directory pruning happened at the store level; a `.git` file or a gitignored
# FILE inside a KEPT directory (e.g. a stray `*.pyc`) still needs dropping here.
# The descent already pruned these as whole DIRECTORIES. What is left to drop is the
# matching FILE sitting in a KEPT directory — a stray `*.pyc`, a root `.gitignore` or
# `.env`, a `.agenta-*` marker — which no directory prune can reach.
#
# Hidden files leave the curated flat view entirely: this is the "N files" badge and
# the recency list it labels, which are about user content, not plumbing. The
# browsable tree still lists them (dimmed, behind the UI's "show hidden" toggle),
# which is why they drop here and not in the browse/`depth=1` views.
files = [f for f in files if not _is_git_plumbing(f.path)]
if specs:
files = [
f for f in files if not _path_gitignored(f.path, False, specs)
]
files = [f for f in files if not _is_internal_mount_path(f.path)]
files = [f for f in files if not _is_hidden_path(f.path)]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
total = len(files)
if count_only:
return MountFileList(files=[], total=total, total_capped=truncated)
if order == "recent":
if git_aware:
# Drop dotfile plumbing (`.claude/…`, `.gitignore`) — the recency list reads as
# "what did I just work on" — then roll a fresh directory into one folder row.
visible = [f for f in files if not _is_hidden_path(f.path)]
entries = _rollup_recent_entries(visible, limit)
# Hidden/internal plumbing is already gone above; roll a fresh directory into
# one folder row so the list reads as "what did I just work on".
entries = _rollup_recent_entries(files, limit)
return MountFileList(files=entries, total=total)
# RAW recency: newest object-store mtime first, no rollup/hidden pruning.
files.sort(key=lambda f: f.mtime or 0, reverse=True)
Expand Down
190 changes: 188 additions & 2 deletions api/oss/tests/pytest/unit/test_mounts_file_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,12 +632,198 @@ async def test_git_aware_recency_descent_prunes_gitignored_dirs(self):
listing = await service.list_files(
project_id=pid, mount_id=mid, order="path", limit=100, git_aware=True
)
# `.gitignore` itself is a dotfile, so the curated flat view drops it as hidden plumbing.
assert {f.path for f in listing.files} == {
".gitignore",
"api/main.py",
"web/index.ts",
}
assert listing.total == 3
assert listing.total == 2

async def test_git_aware_flat_listing_excludes_hidden_paths(self):
# Dot-prefixed files and directories are plumbing, not user content: the curated flat view
# leaves them out of both the listing and its `total` (#6027).
mount = _make_mount()
service, pid, mid = _make_service(mount)
for path in [
"notes.md",
".env",
".claude/settings.json",
"src/.hidden/keep.txt",
"src/main.py",
]:
await service.write_file(
project_id=pid, mount_id=mid, path=path, content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, order="path", limit=100, git_aware=True
)

assert {f.path for f in listing.files} == {"notes.md", "src/main.py"}
assert listing.total == 2

async def test_git_aware_count_only_excludes_hidden_paths(self):
# The "N files" badge reads the count-only total, so it must agree with the list above.
mount = _make_mount()
service, pid, mid = _make_service(mount)
for path in ["notes.md", ".env", ".claude/settings.json"]:
await service.write_file(
project_id=pid, mount_id=mid, path=path, content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, limit=0, git_aware=True
)

assert listing.total == 1
assert listing.files == []

async def test_hidden_tree_does_not_spend_the_count_budget(self, monkeypatch):
# `cap` bounds the DESCENT, but the number it produces is the VISIBLE file count — so the
# two have to be measured in the same unit. A hidden directory is pruned during the walk,
# not counted and then filtered away afterwards; otherwise a drive with a big `.claude/`
# reports a needless "N+" when its real files could have been counted exactly.
monkeypatch.setattr(mounts_service_module, "_COUNT_CAP", 5)
mount = _make_mount()
service, pid, mid = _make_service(mount)
for i in range(10):
await service.write_file(
project_id=pid, mount_id=mid, path=f".claude/f{i}.json", content=b"x"
)
for path in ["a.txt", "b.txt"]:
await service.write_file(
project_id=pid, mount_id=mid, path=path, content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, limit=0, git_aware=True
)

assert listing.total == 2
assert listing.total_capped is False

async def test_root_dotfiles_do_not_spend_the_count_budget(self, monkeypatch):
# The directory prune cannot reach a hidden file sitting at a KEPT directory's root, so the
# level scan has to skip it too. Otherwise five root dotfiles exhaust the budget and the
# drive reports "1+" for a count it could state exactly.
monkeypatch.setattr(mounts_service_module, "_COUNT_CAP", 5)
mount = _make_mount()
service, pid, mid = _make_service(mount)
for path in [
".env",
".dockerignore",
".coderabbit.yaml",
".prettierrc",
".npmrc",
"a.txt",
]:
await service.write_file(
project_id=pid, mount_id=mid, path=path, content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, limit=0, git_aware=True
)

assert listing.total == 1
assert listing.total_capped is False

async def test_gitignore_still_applies_when_it_is_pruned_from_the_count(self):
# `.gitignore` is a hidden file, so the level scan skips it — but its RULES must still be
# read first, or the files it ignores would start counting.
mount = _make_mount()
service, pid, mid = _make_service(mount)
for path, body in [
(".gitignore", b"ignored.txt\n"),
("ignored.txt", b"x"),
("kept.txt", b"x"),
]:
await service.write_file(
project_id=pid, mount_id=mid, path=path, content=body
)

listing = await service.list_files(
project_id=pid, mount_id=mid, order="path", limit=100, git_aware=True
)

assert {f.path for f in listing.files} == {"kept.txt"}
assert listing.total == 1

async def test_internal_tree_does_not_spend_the_count_budget(self, monkeypatch):
# Same rule for the runner-owned `agents/` namespace, which can hold a whole transcript
# workspace: pruned during the walk, so it never crowds out the real files.
monkeypatch.setattr(mounts_service_module, "_COUNT_CAP", 5)
mount = _make_mount()
service, pid, mid = _make_service(mount)
for i in range(10):
await service.write_file(
project_id=pid,
mount_id=mid,
path=f"agents/sessions/s{i}.json",
content=b"x",
)
await service.write_file(
project_id=pid, mount_id=mid, path="a.txt", content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, limit=0, git_aware=True
)

assert listing.total == 1
assert listing.total_capped is False

async def test_count_still_caps_on_a_genuinely_large_visible_tree(
self, monkeypatch
):
# The budget still bites when the files really are visible — the prunes narrow what counts,
# they do not remove the bound.
monkeypatch.setattr(mounts_service_module, "_COUNT_CAP", 5)
mount = _make_mount()
service, pid, mid = _make_service(mount)
for i in range(12):
await service.write_file(
project_id=pid, mount_id=mid, path=f"docs/f{i}.md", content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, limit=0, git_aware=True
)

assert listing.total_capped is True
assert listing.total >= 5

async def test_raw_flat_listing_keeps_hidden_paths(self):
# The hidden-file pruning is part of the CURATED view only — the raw contract still lists
# everything that is stored, so other API consumers see the mount as it really is.
mount = _make_mount()
service, pid, mid = _make_service(mount)
for path in ["notes.md", ".env"]:
await service.write_file(
project_id=pid, mount_id=mid, path=path, content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, order="path", limit=100, git_aware=False
)

assert {f.path for f in listing.files} == {"notes.md", ".env"}

async def test_git_aware_shallow_listing_keeps_hidden_paths(self):
# `depth=1` backs the browsable explorer, which shows hidden entries (dimmed) behind its own
# toggle — so the pruning above must NOT reach this view.
mount = _make_mount()
service, pid, mid = _make_service(mount)
for path in ["notes.md", ".env"]:
await service.write_file(
project_id=pid, mount_id=mid, path=path, content=b"x"
)

listing = await service.list_files(
project_id=pid, mount_id=mid, depth=1, git_aware=True
)

assert {f.path for f in listing.files} == {"notes.md", ".env"}

async def test_raw_listing_keeps_git_and_ignored_by_default(self):
# Default (git_aware=False) is the plain-endpoint contract: EVERY object under the prefix, incl.
Expand Down
20 changes: 16 additions & 4 deletions web/packages/agenta-entities/src/drive/useSessionDrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import {
} from "@agenta/entities/session"

import {agentMountQueryFamily} from "./agentDrive"
import {cleanPath, driveFileStats, isInternalDrivePath, relativeTime} from "./driveTree"
import {
cleanPath,
driveFileStats,
isHiddenPath,
isInternalDrivePath,
relativeTime,
} from "./driveTree"

/** The agent's durable mount is symlinked into the session cwd under this name (runner:
* `AGENT_FILES_LINK_NAME`). Its files live in a SEPARATE mount/prefix, so the drive folds them in
Expand Down Expand Up @@ -78,6 +84,10 @@ export const isListableDrivePath = (path: string, opts?: {fromAgentMount?: boole
return opts?.fromAgentMount === true || rel !== AGENT_FILES_DIR
}

/** {@link isListableDrivePath} minus hidden paths; the explorer asks the wider one (#6027). */
export const isSummaryDrivePath = (path: string, opts?: {fromAgentMount?: boolean}): boolean =>
isListableDrivePath(path, opts) && !isHiddenPath(path)

/** True when a listing holds BOTH agent and session files — the only time the origin tags/filter
* carry information (a single-origin drive doesn't need them). */
export const driveHasMixedOrigins = (files: {path: string}[]): boolean => {
Expand Down Expand Up @@ -379,6 +389,8 @@ const SUMMARY_LATEST_LIMIT = 5
* - The COUNT is a BOUNDED `limit=0` scan per mount (`total`/`total_capped`): the backend stops
* after a cap and reports "N+", so the "N files" badge never blocks on enumerating a huge tree.
*
* Counts and lists exclude hidden paths (see {@link isSummaryDrivePath}).
*
* Returns the same {@link SessionDriveData} shape so consumers are unchanged.
*/
export function useSessionDriveSummary(sessionId: string, artifactId?: string): SessionDriveData {
Expand Down Expand Up @@ -410,7 +422,7 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
.filter(
(entry): entry is {resolved: DriveToolPath; at: number} =>
entry.resolved !== null &&
isListableDrivePath(entry.resolved.path, {
isSummaryDrivePath(entry.resolved.path, {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fromAgentMount: entry.resolved.origin === "agent",
}),
),
Expand Down Expand Up @@ -484,9 +496,9 @@ export function useSessionDriveSummary(sessionId: string, artifactId?: string):
// `agent-files` from the session mount is the fold marker and goes, the same name from the
// agent mount is a real directory and stays.
const rootEntries: MountFile[] = [
...(rootQuery.data ?? []).filter((f) => isListableDrivePath(f.path)),
...(rootQuery.data ?? []).filter((f) => isSummaryDrivePath(f.path)),
...(agentRootQuery.data ?? [])
.filter((f) => isListableDrivePath(f.path, {fromAgentMount: true}))
.filter((f) => isSummaryDrivePath(f.path, {fromAgentMount: true}))
.map((f) => ({
...f,
path: `${agentPrefix}${cleanPath(f.path)}`,
Expand Down
Loading
Loading