diff --git a/api/oss/src/core/mounts/service.py b/api/oss/src/core/mounts/service.py index e33e645415..953f23c01b 100644 --- a/api/oss/src/core/mounts/service.py +++ b/api/oss/src/core/mounts/service.py @@ -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 ) @@ -959,9 +960,10 @@ 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 @@ -969,6 +971,9 @@ async def _list_pruned_files( `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 @@ -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. @@ -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) @@ -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. @@ -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)] 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) diff --git a/api/oss/tests/pytest/unit/test_mounts_file_ops.py b/api/oss/tests/pytest/unit/test_mounts_file_ops.py index eff508265d..ebda4dbd3f 100644 --- a/api/oss/tests/pytest/unit/test_mounts_file_ops.py +++ b/api/oss/tests/pytest/unit/test_mounts_file_ops.py @@ -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. diff --git a/web/packages/agenta-entities/src/drive/useSessionDrive.ts b/web/packages/agenta-entities/src/drive/useSessionDrive.ts index b281085c12..a20db63dbd 100644 --- a/web/packages/agenta-entities/src/drive/useSessionDrive.ts +++ b/web/packages/agenta-entities/src/drive/useSessionDrive.ts @@ -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 @@ -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 => { @@ -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 { @@ -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, { fromAgentMount: entry.resolved.origin === "agent", }), ), @@ -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)}`, diff --git a/web/packages/agenta-entities/tests/unit/summary-drive-path.test.ts b/web/packages/agenta-entities/tests/unit/summary-drive-path.test.ts new file mode 100644 index 0000000000..09fbdbdc7a --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/summary-drive-path.test.ts @@ -0,0 +1,31 @@ +import {describe, expect, it} from "vitest" + +import {isListableDrivePath, isSummaryDrivePath} from "../../src/drive/useSessionDrive" + +describe("isSummaryDrivePath", () => { + it("keeps ordinary user files", () => { + expect(isSummaryDrivePath("notes.md")).toBe(true) + expect(isSummaryDrivePath("src/main.py")).toBe(true) + }) + + it("drops hidden paths at any depth, so they leave the count and the list together (#6027)", () => { + expect(isSummaryDrivePath(".env")).toBe(false) + expect(isSummaryDrivePath(".claude/settings.json")).toBe(false) + expect(isSummaryDrivePath("src/.hidden/keep.txt")).toBe(false) + }) + + it("still drops what isListableDrivePath drops", () => { + expect(isSummaryDrivePath("agents/sessions/x.json")).toBe(false) + expect(isSummaryDrivePath("agent-files")).toBe(false) + }) + + it("keeps a real `agent-files` directory from the agent mount", () => { + expect(isSummaryDrivePath("agent-files", {fromAgentMount: true})).toBe(true) + }) + + it("is strictly narrower than isListableDrivePath — hidden is the only difference", () => { + // The browse explorer asks the wider question; it lists hidden files behind its own toggle. + expect(isListableDrivePath(".gitignore")).toBe(true) + expect(isSummaryDrivePath(".gitignore")).toBe(false) + }) +})