diff --git a/Dockerfile b/Dockerfile index 2e550d913..1c34a4a79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,7 +92,12 @@ HEALTHCHECK --interval=10s --timeout=2s --start-period=3s --retries=3 \ # process by design, see api/security.py (the allowed_roots trust set is # in-memory; multi-worker would split it). ENTRYPOINT ["/srv/.venv/bin/python", "-m", "api"] -CMD ["--port", "8080"] +# --host is explicit because the CLI defaults to loopback (an unauthenticated +# API that serves any scanned root should not reach the network by default). +# In a container that default would make the port unreachable from the host: +# here 0.0.0.0 is the container's own namespace, and only published ports get +# out. Any `command:` override must repeat this — see docker-compose.dev.yml. +CMD ["--port", "8080", "--host", "0.0.0.0"] # Populated by CI via --build-arg. ARG GIT_SHA=dev diff --git a/api/__main__.py b/api/__main__.py index d06435706..f416bb7ae 100644 --- a/api/__main__.py +++ b/api/__main__.py @@ -1,9 +1,10 @@ """api CLI entrypoint. - python -m api Serve on :8080 (single uvicorn process). - python -m api --port 8000 Override port. - python -m api --reload Auto-reload on source changes (dev only). - python -m api --version Print version. + python -m api Serve on 127.0.0.1:8080 (single process). + python -m api --port 8000 Override port. + python -m api --host 0.0.0.0 Expose on the network (see --host below). + python -m api --reload Auto-reload on source changes (dev only). + python -m api --version Print version. SINGLE PROCESS by design — see api/security.py (the allowed_roots trust set is in-memory; multi-worker would split it). No --workers flag. @@ -27,7 +28,16 @@ def _build_parser() -> argparse.ArgumentParser: ) p.add_argument("--version", action="version", version=f"codecity {__version__}") p.add_argument("--port", type=int, default=8080, help="HTTP port (default 8080).") - p.add_argument("--host", default="0.0.0.0", help="Bind host (default 0.0.0.0).") + # Loopback by default: the API is unauthenticated, and once a scan registers + # a root, /api/file serves anything under it. Binding every interface would + # hand the whole scanned tree to anyone on the same network. Containers pass + # --host 0.0.0.0 explicitly, since there the bind is the container's own + # namespace and only published ports are reachable. + p.add_argument( + "--host", + default="127.0.0.1", + help="Bind host (default 127.0.0.1; use 0.0.0.0 to expose on the network).", + ) p.add_argument("--reload", action="store_true", help="Auto-reload (dev only).") return p diff --git a/api/config.py b/api/config.py index 85d5a134b..626f270e6 100644 --- a/api/config.py +++ b/api/config.py @@ -14,6 +14,10 @@ MAX_FILE_BYTES = 100 * 1024 * 1024 # Bodies under this skip gzip — framing overhead exceeds the savings. GZIP_MIN_BYTES = 256 +# Paths accepted per POST /api/images or /api/fingerprints; the rest of the +# body is ignored. Served over /api/config so the client chunks to the same +# number instead of hardcoding its own guess and silently losing the tail. +MAX_BATCH_PATHS = 64 # Root for every on-disk cache — the single source of truth for where codecity # stores things. cache.py hangs its manifest/file-stat/git-history subdirs off diff --git a/api/models/responses.py b/api/models/responses.py index 229a668ba..0fda90c13 100644 --- a/api/models/responses.py +++ b/api/models/responses.py @@ -38,10 +38,7 @@ class HealthResponse(BaseModel): class ConfigResponse(BaseModel): allowLocalRepos: bool - - -class CacheClearResponse(BaseModel): - deleted: int + maxBatchPaths: int class CommitDetailResponse(BaseModel): diff --git a/api/routers/file.py b/api/routers/file.py index 6fca2fbfb..fe1a86874 100644 --- a/api/routers/file.py +++ b/api/routers/file.py @@ -18,7 +18,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel -from api.config import MAX_FILE_BYTES +from api.config import MAX_BATCH_PATHS, MAX_FILE_BYTES from api.models.responses import ( ImageBatchEntry, FileTooLargeResponse, @@ -31,9 +31,9 @@ router = APIRouter(prefix="/api", tags=["file"]) -# Server-side bounds on one batch response; oversized or non-image paths are -# omitted and the client falls back to the streaming GET /api/file. -_MAX_BATCH_PATHS = 64 +# Server-side bound on one batch response; oversized or non-image paths are +# omitted and the client falls back to the streaming GET /api/file. The path +# count lives in config.py because /api/config publishes it to the client. _MAX_BATCH_IMAGE_BYTES = 8 * 1024 * 1024 _SHA_RE = re.compile(r"[0-9a-f]{40}") @@ -124,7 +124,7 @@ def get_images(req: PathBatchRequest) -> dict[str, ImageBatchEntry]: never batched (they stream their poster frame), so this is images only. """ out: dict[str, ImageBatchEntry] = {} - for path in req.paths[:_MAX_BATCH_PATHS]: + for path in req.paths[:MAX_BATCH_PATHS]: sha = (req.shas or {}).get(path) try: target = TRUST.assert_inside(Path(path), must_exist=sha is None) @@ -157,7 +157,7 @@ def get_fingerprints(req: PathBatchRequest) -> dict[str, FingerprintEntry]: unreadable paths are silently omitted. Raw binary bytes never leave the server — only the head is read, and only the fingerprint image returned.""" out: dict[str, FingerprintEntry] = {} - for path in req.paths[:_MAX_BATCH_PATHS]: + for path in req.paths[:MAX_BATCH_PATHS]: try: target = TRUST.assert_inside(Path(path)) except (NoRootsRegisteredError, OutsideRootError, OSError, RuntimeError): diff --git a/api/routers/manifest.py b/api/routers/manifest.py index 98f5790de..c8a52616e 100644 --- a/api/routers/manifest.py +++ b/api/routers/manifest.py @@ -1,10 +1,9 @@ """The manifest routes: GET /api/manifest (SSE stream), GET -/api/manifest/signature, GET /api/timeline (SSE stream), DELETE -/api/manifest/cache. +/api/manifest/signature, GET /api/timeline (SSE stream). Source classification/resolution lives in api.services.source; these are the thin HTTP handlers over it. A ResolveError carries a status + message: the -signature/cache routes turn it into an HTTPException, while the manifest and +signature route turns it into an HTTPException, while the manifest and timeline SSE routes turn it into an `error` event (EventSource can't read 4xx bodies).""" @@ -32,10 +31,8 @@ TimelineProgressEvent, ) from api.models.manifest import SignatureResponse -from api.models.responses import CacheClearResponse from api.security import TRUST from api.services.cache import ( - cache_clear_all, cache_clear_timeline, cache_load_manifest, cache_load_ref_manifest, @@ -49,11 +46,9 @@ CloneError, HostUnreachableError, RepoNotFoundError, - clone_dir_for, ensure_clone, fetch_lfs_history, hydrate_blobs, - remove_clone, ) from api.services.gitobj import resolve_ref from api.services.scan import ( @@ -247,34 +242,6 @@ def _run() -> None: return EventSourceResponse(gen()) -@router.delete("/manifest/cache", response_model=CacheClearResponse) -def clear_cache( - src: str = Query(...), - branch: str | None = Query(None), -) -> CacheClearResponse: - if not src: - raise HTTPException(400, "missing 'src' query param") - kind = classify(src) - if kind is SourceKind.INVALID: - raise HTTPException(400, "unrecognized source: pass a local path or a git URL") - if kind is SourceKind.REMOTE: - abs_root = clone_dir_for(src, branch) - else: - # Non-strict resolve so a recents entry for a since-deleted path - # still drops its cache. - abs_root = Path(src).resolve(strict=False) - # Full clean slate for this source: every per-root cache (manifest, - # file-stat, git-history). For a REMOTE source also delete the clone working - # tree so a re-add re-clones from scratch — the recovery path for a corrupt - # clone. Hold the clone lock so we never rmtree a clone a concurrent request - # is mid-clone into. - deleted = cache_clear_all(abs_root) - if kind is SourceKind.REMOTE: - with TRUST.clone_lock: - remove_clone(src, branch) - return CacheClearResponse(deleted=deleted) - - def _sse(event: "ScanEvent | TimelineEvent", payload: dict[str, Any]) -> dict[str, Any]: """sse-starlette event dict: {'event': name, 'data': json-string}. Both StrEnums serialize to their wire string ('manifest-complete', 'timeline- diff --git a/api/routers/meta.py b/api/routers/meta.py index 1105ffcd0..0c97813ef 100644 --- a/api/routers/meta.py +++ b/api/routers/meta.py @@ -6,7 +6,7 @@ from fastapi import APIRouter -from api.config import local_repos_allowed +from api.config import MAX_BATCH_PATHS, local_repos_allowed from api.models.responses import ConfigResponse, HealthResponse router = APIRouter(prefix="/api", tags=["meta"]) @@ -19,4 +19,7 @@ def health() -> HealthResponse: @router.get("/config", response_model=ConfigResponse) def config() -> ConfigResponse: - return ConfigResponse(allowLocalRepos=local_repos_allowed()) + return ConfigResponse( + allowLocalRepos=local_repos_allowed(), + maxBatchPaths=MAX_BATCH_PATHS, + ) diff --git a/api/services/cache.py b/api/services/cache.py index 85a44df06..07b1c6340 100644 --- a/api/services/cache.py +++ b/api/services/cache.py @@ -393,10 +393,9 @@ def _manifest_cache_path(abs_root: Path, content_signature: str) -> Path: def _ref_manifest_cache_path(abs_root: Path, ref_sha: str) -> Path: - # `__ref-` (not `__`) so cache_clear_manifests's `{repo_key}__*.json.gz` - # glob still sweeps these alongside content-signature entries, while the - # prefix keeps a ref-sha visually distinct from a content signature in - # directory listings. + # `__ref-` prefix keeps a ref-sha visually distinct from a content + # signature in directory listings, while staying inside the + # `{repo_key}__*.json.gz` shape the other per-root globs match. return CACHE_ROOT / "manifests" / f"{repo_key(abs_root)}__ref-{ref_sha}.json.gz" @@ -462,6 +461,83 @@ def _save_gz_manifest(path: Path, manifest: "Manifest") -> None: ) +# Entries are keyed by repo CONTENT, so without a cap this directory grows for +# the life of the install. Sized by how far back a re-read is plausible; these +# are pure performance caches, so evicting costs a rescan, never correctness. +_KEEP_CONTENT_MANIFESTS = 5 +_KEEP_REF_MANIFESTS = 20 +_KEEP_TIMELINE_BUNDLES = 3 + + +def _entry_family(name_rest: str) -> str: + """Which retention pool a `{repo_key}__` cache file belongs to.""" + if name_rest.startswith("ref-"): + return "ref" + if name_rest.startswith("timeline-"): + return "timeline" + return "content" + + +_FAMILY_KEEP = { + "content": _KEEP_CONTENT_MANIFESTS, + "ref": _KEEP_REF_MANIFESTS, + "timeline": _KEEP_TIMELINE_BUNDLES, +} + + +def prune_manifest_cache(abs_root: Path, *, protect: Path | None = None) -> int: + """Drop this root's oldest manifest-dir entries, per family. Returns the + count deleted. + + `protect` is never evicted. Callers pass the path they just wrote: mtime + resolution is only one second on some filesystems, so a burst of saves can + tie and sort the newest entry into the tail — which would delete the very + manifest the caller is about to read back. + + Ordering is by mtime rather than access time, which macOS and most Linux + mounts do not track (relatime), so this is write-recency, not LRU: an old + signature that keeps being re-read still ages out. That is the right trade + here, since re-reading it only ever saved a rescan. + + Best-effort, like the rest of this module: a failed unlink (or a file a + concurrent request removed first) must never break the response.""" + manifests_dir = CACHE_ROOT / "manifests" + if not manifests_dir.exists(): + return 0 + + prefix = f"{repo_key(abs_root)}__" + families: dict[str, list[tuple[float, Path]]] = { + "content": [], + "ref": [], + "timeline": [], + } + for path in manifests_dir.glob(f"{prefix}*.json.gz"): + if protect is not None and path == protect: + continue # counts against nothing; it always stays + try: + mtime = path.stat().st_mtime + except OSError: + continue # vanished under us; nothing to prune + families[_entry_family(path.name[len(prefix) :])].append((mtime, path)) + + deleted = 0 + for family, entries in families.items(): + # The protected entry is out of `entries`, so leave room for it. + keep = _FAMILY_KEEP[family] + if protect is not None and _entry_family(protect.name[len(prefix) :]) == family: + keep -= 1 + if len(entries) <= keep: + continue + entries.sort(key=lambda e: e[0], reverse=True) # newest first + for _, path in entries[keep:]: + try: + path.unlink() + deleted += 1 + except OSError: + pass + return deleted + + def cache_load_manifest( abs_root: Path, content_signature: str, @@ -476,20 +552,23 @@ def cache_save_manifest( manifest: "Manifest", ) -> None: """Atomically write the manifest cache for this (root, content_signature).""" - _save_gz_manifest(_manifest_cache_path(abs_root, content_signature), manifest) + path = _manifest_cache_path(abs_root, content_signature) + _save_gz_manifest(path, manifest) + prune_manifest_cache(abs_root, protect=path) def cache_load_ref_manifest(abs_root: Path, ref_sha: str) -> "Manifest | None": """Load the cached manifest for this (root, ref_sha). A resolved commit sha's manifest is immutable (the commit's content never changes), so - unlike the content-signature cache this key never needs invalidating — - only `cache_clear_manifests`/`cache_clear_all` remove it.""" + unlike the content-signature cache this key never needs invalidating.""" return _load_gz_manifest(_ref_manifest_cache_path(abs_root, ref_sha)) def cache_save_ref_manifest(abs_root: Path, ref_sha: str, manifest: "Manifest") -> None: """Atomically write the ref-keyed manifest cache for (root, ref_sha).""" - _save_gz_manifest(_ref_manifest_cache_path(abs_root, ref_sha), manifest) + path = _ref_manifest_cache_path(abs_root, ref_sha) + _save_gz_manifest(path, manifest) + prune_manifest_cache(abs_root, protect=path) def _excludes_key(excludes: frozenset[str]) -> str: @@ -531,12 +610,14 @@ def cache_save_timeline( excludes: frozenset[str] = frozenset(), ) -> None: """Atomically write the timeline bundle cache for (root, head_sha, excludes).""" + path = _timeline_cache_path(abs_root, head_sha, excludes) _save_gz_envelope( - _timeline_cache_path(abs_root, head_sha, excludes), + path, envelope_key="bundle", version=_TIMELINE_CACHE_VERSION, payload=cast("dict[str, object]", bundle), ) + prune_manifest_cache(abs_root, protect=path) def cache_clear_timeline(abs_root: Path) -> int: @@ -556,50 +637,3 @@ def cache_clear_timeline(abs_root: Path) -> int: except OSError: pass return count - - -def cache_clear_manifests(abs_root: Path) -> int: - """Delete every cached manifest file for this root, across all - signatures, every ref-keyed manifest, AND every timeline bundle (the - `__*.json.gz` glob below matches `__.json.gz`, - `__ref-.json.gz`, and `__timeline-.json.gz`). - Returns the count deleted. - - Silently ignores I/O errors per the rest of this module's hygiene — - cache cleanup failures must never break the response.""" - manifests_dir = CACHE_ROOT / "manifests" - if not manifests_dir.exists(): - return 0 - pattern = f"{repo_key(abs_root)}__*.json.gz" - count = 0 - for path in manifests_dir.glob(pattern): - try: - path.unlink() - count += 1 - except OSError: - pass - return count - - -def cache_clear_all(abs_root: Path) -> int: - """Delete EVERY per-root cache for this root — manifest (all - signatures), file-stat, git-history, and blob-stats. Returns the count - deleted. - - Backs the "clear cache" flow's clean-slate guarantee for a source. - The git clone working tree lives outside CACHE_ROOT, so the caller - removes it separately (see clone.remove_clone). Same swallow-errors - hygiene as the rest of this module — cleanup failures must never - break the response.""" - count = cache_clear_manifests(abs_root) - for path in ( - _file_cache_path(abs_root), - _git_history_cache_path(abs_root), - _blob_cache_path(abs_root), - ): - try: - path.unlink() - count += 1 - except OSError: - pass # missing file or I/O error — best-effort cleanup - return count diff --git a/api/services/clone.py b/api/services/clone.py index e07db4afa..f050220d1 100644 --- a/api/services/clone.py +++ b/api/services/clone.py @@ -70,7 +70,6 @@ class CloneInterruptedError(CloneError): "fetch_lfs_history", "hydrate_blobs", "list_remote_branches", - "remove_clone", ] @@ -855,17 +854,6 @@ def _partial_clone_filter(target: Path) -> str | None: return None -def remove_clone(url: str, branch: str | None) -> bool: - """Delete the cached clone working tree for ``(url, branch)``. Returns - True if a directory existed and was removed, False if there was nothing - to remove. Best-effort: rmtree errors are swallowed (ignore_errors).""" - target = clone_dir_for(url, branch) - if not target.exists(): - return False - shutil.rmtree(target, ignore_errors=True) - return True - - # ls-remote timeout: bounded so a black-holed remote can't wedge a request. # 20s covers a slow-but-live remote; a real hang trips it and surfaces a clean # HostUnreachableError to the caller. diff --git a/api/services/manifest_types.py b/api/services/manifest_types.py index cb5c058a9..3638129f8 100644 --- a/api/services/manifest_types.py +++ b/api/services/manifest_types.py @@ -13,10 +13,11 @@ leaf both import from. It imports only the pure models layer (never services), so it stays cycle-free for both. -Mirrors app/types/manifest.ts. Keep both in sync — the web app consumes -the JSON exactly as these TypedDicts describe it. Drift here is shape -drift in the wire format and will be caught by pyright on the Python -side and tsc on the TS side, but only within each language. +The frontend does not mirror this module by hand: `api/models/` produces the +OpenAPI schema, `just gen-types` turns that into +app/src/types/manifest.generated.ts, and app/src/types/manifest.ts derives from +it. So the pairing to keep honest is this module against `api/models/` — pyright +checks each side internally, but nothing checks the two against each other. """ from __future__ import annotations diff --git a/api/services/stats.py b/api/services/stats.py index 46ee83e61..371622021 100644 --- a/api/services/stats.py +++ b/api/services/stats.py @@ -94,8 +94,8 @@ def _longest_streak(dates: list[str]) -> int: def _author_hue(name: str) -> int: - """FNV-1a over the name's UTF-8 bytes, mod 360. Mirrors the 32-bit unsigned - arithmetic of the JS original so a name keeps the hue it already had.""" + """FNV-1a over the name's UTF-8 bytes, mod 360. The & 0xFFFFFFFF keeps the + hash in 32-bit unsigned range; widening it would repaint every author.""" h = 0x811C9DC5 for byte in name.encode("utf-8"): h ^= byte diff --git a/api/services/timeline.py b/api/services/timeline.py index d81ce4a6e..9a33e58ae 100644 --- a/api/services/timeline.py +++ b/api/services/timeline.py @@ -300,8 +300,8 @@ def compute_commit_line_ranges( def _iso_ms(value: str | None) -> int | None: - """Epoch ms for a Z-suffixed UTC stamp, or None. Matches JS Date.parse, - which the client used before this moved server-side.""" + """Epoch ms for a Z-suffixed UTC stamp, or None. Semantics match JS + Date.parse, since these values are compared against client-side dates.""" if not value: return None try: @@ -321,10 +321,13 @@ def compute_commit_date_ranges( commit — what the weathering (color, lit windows, grime) normalizes against, so range[HEAD] equals the live manifest's dateRanges. - Mirrors the client replay it replaces: a file uses its own full-precision - date once it has reached its final change, and the date of its latest change - commit before that; creation is fixed at its own date, falling back to its - genesis commit.""" + A file uses its own full-precision date once it has reached its final + change, and the date of its latest change commit before that; creation is + fixed at its own date, falling back to its genesis commit. + + The client walks these same deltas in city/timeline/replay.ts, but to build + a per-path index for per-frame scrub queries. Different output, different + consumer — neither is a copy of the other.""" commit_ms = [_iso_ms(c["date"]) or 0 for c in commits] final_idx: dict[str, int] = {} diff --git a/api/tests/test_cache.py b/api/tests/test_cache.py index 013637a36..880d179b3 100644 --- a/api/tests/test_cache.py +++ b/api/tests/test_cache.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import unittest from pathlib import Path @@ -508,29 +509,6 @@ def test_manifest_rejects_when_git_history_version_changed(self): # Loader must reject. self.assertIsNone(cache_load_manifest(root, sig)) - def test_clear_manifests_deletes_every_signature(self) -> None: - root = Path("/x") - manifest = self._make_manifest() - cache_mod.cache_save_manifest(root, "a" * 32, manifest) - cache_mod.cache_save_manifest(root, "b" * 32, manifest) - # Unrelated root — must NOT be deleted. - cache_mod.cache_save_manifest(Path("/y"), "c" * 32, manifest) - - deleted = cache_mod.cache_clear_manifests(root) - self.assertEqual(deleted, 2) - self.assertIsNone(cache_mod.cache_load_manifest(root, "a" * 32)) - self.assertIsNone(cache_mod.cache_load_manifest(root, "b" * 32)) - # Unrelated root's cache survives. - self.assertIsNotNone(cache_mod.cache_load_manifest(Path("/y"), "c" * 32)) - - def test_clear_manifests_no_entries_returns_zero(self) -> None: - self.assertEqual(cache_mod.cache_clear_manifests(Path("/never/scanned")), 0) - - def test_clear_manifests_missing_dir_returns_zero(self) -> None: - # CACHE_ROOT/manifests doesn't exist yet (no saves have happened). - self.assertFalse((cache_mod.CACHE_ROOT / "manifests").exists()) - self.assertEqual(cache_mod.cache_clear_manifests(Path("/x")), 0) - def test_ref_manifest_roundtrip(self) -> None: root = Path("/some/repo") sha = "a" * 40 @@ -543,19 +521,6 @@ def test_ref_manifest_load_missing_returns_none(self) -> None: cache_mod.cache_load_ref_manifest(Path("/never/scanned"), "b" * 40) ) - def test_clear_manifests_also_sweeps_ref_manifests(self) -> None: - # cache_clear_manifests's `{repo_key}__*.json.gz` glob covers BOTH - # content-signature and `__ref-` keyed files. - root = Path("/x") - manifest = self._make_manifest() - cache_mod.cache_save_manifest(root, "a" * 32, manifest) - cache_mod.cache_save_ref_manifest(root, "b" * 40, manifest) - - deleted = cache_mod.cache_clear_manifests(root) - self.assertEqual(deleted, 2) - self.assertIsNone(cache_mod.cache_load_manifest(root, "a" * 32)) - self.assertIsNone(cache_mod.cache_load_ref_manifest(root, "b" * 40)) - def _make_bundle(self) -> dict: return { "commits": [], @@ -617,18 +582,6 @@ def test_timeline_excludes_key_separately(self) -> None: cache_mod.cache_load_timeline(root, sha, frozenset({"other"})) ) - def test_clear_manifests_also_sweeps_timeline(self) -> None: - # cache_clear_manifests's `{repo_key}__*.json.gz` glob covers - # content-signature, `__ref-`, AND `__timeline-` keyed files. - root = Path("/x") - cache_mod.cache_save_manifest(root, "a" * 32, self._make_manifest()) - cache_mod.cache_save_timeline(root, "b" * 40, self._make_bundle()) - - deleted = cache_mod.cache_clear_manifests(root) - self.assertEqual(deleted, 2) - self.assertIsNone(cache_mod.cache_load_manifest(root, "a" * 32)) - self.assertIsNone(cache_mod.cache_load_timeline(root, "b" * 40)) - def test_clear_timeline_evicts_all_heads_only(self) -> None: # A no_cache scan clears every timeline bundle for the root (all HEADs) # but leaves the manifest caches untouched. @@ -794,3 +747,93 @@ def test_blob_stats_cache_version_mismatch_is_miss(tmp_path, monkeypatch): if __name__ == "__main__": unittest.main() + + +class ManifestCachePruneTests(CacheTestBase): + """Retention on the manifests/ dir. + + Every entry there is keyed by repo CONTENT, so the directory grew for the + life of the install — 844 files / 281 MB on one dev machine before this. + """ + + def _manifest(self) -> dict: + return { + "root": "/some/repo", + "scanned_at": "2026-05-17T00:00:00Z", + "content_signature": "deadbeef" * 4, + "tree": {"name": "repo", "type": "dir", "path": "", "children": []}, + } + + def _names(self, root: Path) -> list[str]: + prefix = f"{cache_mod.repo_key(root)}__" + d = cache_mod.CACHE_ROOT / "manifests" + return sorted(p.name[len(prefix) :] for p in d.glob(f"{prefix}*.json.gz")) + + def test_content_signatures_are_capped(self) -> None: + root = Path("/x") + keep = cache_mod._KEEP_CONTENT_MANIFESTS + for i in range(keep + 4): + cache_mod.cache_save_manifest(root, f"{i:032x}", self._manifest()) + + self.assertEqual(len(self._names(root)), keep) + + def test_the_entry_just_written_always_survives(self) -> None: + # Pruning runs after the save, so the newest write is never the victim. + root = Path("/x") + for i in range(cache_mod._KEEP_CONTENT_MANIFESTS + 3): + sig = f"{i:032x}" + cache_mod.cache_save_manifest(root, sig, self._manifest()) + self.assertIsNotNone(cache_mod.cache_load_manifest(root, sig)) + + def test_families_are_capped_independently(self) -> None: + # A scrub session writing many ref manifests must not evict the live + # content-signature manifest out from under the running scan. + root = Path("/x") + cache_mod.cache_save_manifest(root, "a" * 32, self._manifest()) + for i in range(cache_mod._KEEP_REF_MANIFESTS + 5): + cache_mod.cache_save_ref_manifest(root, f"{i:040x}", self._manifest()) + + self.assertIsNotNone(cache_mod.cache_load_manifest(root, "a" * 32)) + refs = [n for n in self._names(root) if n.startswith("ref-")] + self.assertEqual(len(refs), cache_mod._KEEP_REF_MANIFESTS) + + def test_pruning_one_repo_leaves_another_alone(self) -> None: + other = Path("/y") + cache_mod.cache_save_manifest(other, "c" * 32, self._manifest()) + for i in range(cache_mod._KEEP_CONTENT_MANIFESTS + 3): + cache_mod.cache_save_manifest(Path("/x"), f"{i:032x}", self._manifest()) + + self.assertIsNotNone(cache_mod.cache_load_manifest(other, "c" * 32)) + + def test_prune_on_a_never_scanned_root_is_a_noop(self) -> None: + self.assertEqual(cache_mod.prune_manifest_cache(Path("/never/scanned")), 0) + + def test_prune_with_no_manifests_dir_is_a_noop(self) -> None: + self.assertFalse((cache_mod.CACHE_ROOT / "manifests").exists()) + self.assertEqual(cache_mod.prune_manifest_cache(Path("/x")), 0) + + def test_protect_survives_even_when_it_ranks_oldest(self) -> None: + # Why `protect` exists rather than trusting the mtime sort: some + # filesystems resolve mtime only to the second, so a burst of saves ties + # and the just-written entry can rank anywhere — including the evicted + # tail. Pin it to the oldest possible mtime, the worst case, and it must + # still survive, because the caller is about to read it back. + root = Path("/x") + d = cache_mod.CACHE_ROOT / "manifests" + d.mkdir(parents=True, exist_ok=True) + + # Write past the cap directly, so setup does not prune as it goes. + paths = [] + for i in range(cache_mod._KEEP_CONTENT_MANIFESTS + 3): + path = cache_mod._manifest_cache_path(root, f"{i:032x}") + cache_mod._save_gz_manifest(path, self._manifest()) + paths.append(path) + + victim = paths[0] + os.utime(victim, (1, 1)) # oldest by a wide margin -> first to go + + cache_mod.prune_manifest_cache(root, protect=victim) + + self.assertTrue(victim.exists(), "protected entry was evicted") + remaining = list(d.glob(f"{cache_mod.repo_key(root)}__*.json.gz")) + self.assertEqual(len(remaining), cache_mod._KEEP_CONTENT_MANIFESTS) diff --git a/api/tests/test_cli.py b/api/tests/test_cli.py index 46f5072ec..33d4d5411 100644 --- a/api/tests/test_cli.py +++ b/api/tests/test_cli.py @@ -42,3 +42,23 @@ def test_main_invokes_uvicorn() -> None: run.assert_called_once() assert run.call_args.kwargs["port"] == 9999 assert run.call_args.kwargs["workers"] == 1 + + +def test_binds_loopback_by_default() -> None: + """The API is unauthenticated and serves any registered scan root, so the + default bind must not reach the network. Containers opt in explicitly.""" + from unittest import mock + from api.__main__ import main + + with mock.patch("api.__main__.uvicorn.run") as run: + assert main([]) == 0 + assert run.call_args.kwargs["host"] == "127.0.0.1" + + +def test_host_flag_can_opt_into_exposure() -> None: + from unittest import mock + from api.__main__ import main + + with mock.patch("api.__main__.uvicorn.run") as run: + assert main(["--host", "0.0.0.0"]) == 0 + assert run.call_args.kwargs["host"] == "0.0.0.0" diff --git a/api/tests/test_models.py b/api/tests/test_models.py index bf0b9d352..1ccdea1fb 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -168,8 +168,8 @@ def test_health_and_config(self) -> None: self.assertEqual(HealthResponse(ok=True).model_dump(), {"ok": True}) self.assertEqual( - ConfigResponse(allowLocalRepos=False).model_dump(), - {"allowLocalRepos": False}, + ConfigResponse(allowLocalRepos=False, maxBatchPaths=64).model_dump(), + {"allowLocalRepos": False, "maxBatchPaths": 64}, ) def test_sse_event_serialization(self) -> None: diff --git a/api/tests/test_scan_dirty.py b/api/tests/test_scan_dirty.py index 2440c7e25..f6654792e 100644 --- a/api/tests/test_scan_dirty.py +++ b/api/tests/test_scan_dirty.py @@ -117,8 +117,8 @@ def test_dirty_file_count_matches_flags(tmp_path: Path): def test_hash_repo_info_has_no_repo_level_dirty_set_param(): - # Dirty rides per-file now (Task 4); _hash_repo_info must not accept a - # repo-wide dirty_paths set anymore. + # Dirtiness is per-file, so a repo-wide dirty_paths set would recompute the + # signature for every file whenever any one of them changed. assert list(inspect.signature(_hash_repo_info).parameters) == ["sig", "repo_info"] diff --git a/api/tests/test_server_cache.py b/api/tests/test_server_cache.py deleted file mode 100644 index abbc43379..000000000 --- a/api/tests/test_server_cache.py +++ /dev/null @@ -1,93 +0,0 @@ -"""TestClient coverage for DELETE /api/manifest/cache.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - -from api.app import create_app -from api.services.cache import cache_save_manifest -from api.services.scan import signature_tree - - -def _git(*a: str, cwd: Path) -> None: - subprocess.run(["git", *a], cwd=cwd, check=True, capture_output=True) - - -@pytest.fixture() -def repo(tmp_path: Path) -> Path: - p = tmp_path / "repo" - p.mkdir() - _git("init", "-q", cwd=p) - _git("config", "user.email", "a@b.c", cwd=p) - _git("config", "user.name", "T", cwd=p) - (p / "f.txt").write_text("x") - _git("add", ".", cwd=p) - _git("commit", "-qm", "c", cwd=p) - return p - - -@pytest.fixture() -def client(tmp_path: Path, redirect_cache_root) -> TestClient: - static = tmp_path / "static" - static.mkdir() - (static / "index.html").write_text("x") - return TestClient(create_app(static_dir=static)) - - -def test_cache_missing_src(client: TestClient) -> None: - assert client.delete("/api/manifest/cache").status_code in (400, 422) - - -def test_cache_invalid_src_400(client: TestClient) -> None: - r = client.delete("/api/manifest/cache", params={"src": "neither-path-nor-url"}) - assert r.status_code == 400 - - -def test_cache_clears_warmed_local_source(client: TestClient, repo: Path) -> None: - # Warm the cache directly via the service layer (no SSE stream yet). - sig = signature_tree(str(repo), use_cache=False)["content_signature"] - cache_save_manifest(repo.resolve(), sig, {"root": str(repo)}) # type: ignore[arg-type] - r = client.delete("/api/manifest/cache", params={"src": str(repo)}) - assert r.status_code == 200 - assert r.json()["deleted"] >= 1 - - -def test_cache_delete_not_gated_by_local_repos( - client: TestClient, repo: Path, monkeypatch -) -> None: - # Cache-delete must work even when local repos are disabled. - monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) - r = client.delete("/api/manifest/cache", params={"src": str(repo)}) - assert r.status_code == 200 - assert "deleted" in r.json() - - -def test_cache_clear_removes_remote_clone_dir(client: TestClient) -> None: - # For a REMOTE source, clearing the cache also deletes the clone working - # tree so a re-add re-clones from scratch (the corrupt-clone recovery path). - from api.services import clone as clone_mod - - url = "https://example.com/owner/repo.git" - clone_dir = clone_mod.clone_dir_for(url, None) - clone_dir.mkdir(parents=True) - (clone_dir / "marker.txt").write_text("x") - r = client.delete("/api/manifest/cache", params={"src": url}) - assert r.status_code == 200 - assert not clone_dir.exists(), "remote clone dir should be removed on cache clear" - - -def test_cache_clear_does_not_delete_local_project( - client: TestClient, repo: Path -) -> None: - # A LOCAL source's actual project directory must NEVER be deleted — only - # its per-root caches under CACHE_ROOT are dropped. - sig = signature_tree(str(repo), use_cache=False)["content_signature"] - cache_save_manifest(repo.resolve(), sig, {"root": str(repo)}) # type: ignore[arg-type] - r = client.delete("/api/manifest/cache", params={"src": str(repo)}) - assert r.status_code == 200 - assert repo.is_dir(), "local project directory must not be deleted" - assert (repo / "f.txt").is_file() diff --git a/api/tests/test_server_config.py b/api/tests/test_server_config.py index 9ad6b4b24..4f80ea4ea 100644 --- a/api/tests/test_server_config.py +++ b/api/tests/test_server_config.py @@ -8,6 +8,7 @@ from fastapi.testclient import TestClient from api.app import create_app +from api.config import MAX_BATCH_PATHS @pytest.fixture() @@ -20,9 +21,23 @@ def client(tmp_path: Path) -> TestClient: def test_config_enabled(client: TestClient, monkeypatch) -> None: monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "1") - assert client.get("/api/config").json() == {"allowLocalRepos": True} + assert client.get("/api/config").json() == { + "allowLocalRepos": True, + "maxBatchPaths": MAX_BATCH_PATHS, + } def test_config_disabled(client: TestClient, monkeypatch) -> None: monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) - assert client.get("/api/config").json() == {"allowLocalRepos": False} + assert client.get("/api/config").json() == { + "allowLocalRepos": False, + "maxBatchPaths": MAX_BATCH_PATHS, + } + + +def test_config_publishes_the_cap_the_batch_routes_enforce() -> None: + """The batch routes truncate at MAX_BATCH_PATHS; /api/config is how the + client learns that number instead of hardcoding its own.""" + from api.routers import file as file_router + + assert file_router.MAX_BATCH_PATHS == MAX_BATCH_PATHS diff --git a/api/tests/test_server_health.py b/api/tests/test_server_health.py index 09daf0506..3534cf6e5 100644 --- a/api/tests/test_server_health.py +++ b/api/tests/test_server_health.py @@ -8,6 +8,7 @@ from fastapi.testclient import TestClient from api.app import create_app +from api.config import MAX_BATCH_PATHS @pytest.fixture() @@ -40,7 +41,7 @@ def test_config_default_disabled( app = create_app(static_dir=static) r = TestClient(app).get("/api/config") assert r.status_code == 200 - assert r.json() == {"allowLocalRepos": False} + assert r.json() == {"allowLocalRepos": False, "maxBatchPaths": MAX_BATCH_PATHS} def test_root_serves_index(client: TestClient) -> None: diff --git a/app/.prettierignore b/app/.prettierignore index 2f17f3385..2517415ab 100644 --- a/app/.prettierignore +++ b/app/.prettierignore @@ -3,6 +3,10 @@ node_modules package-lock.json coverage dist +# Scanner fixtures: a nested git repo whose file contents, line counts and +# mtimes the scan tests assert on. Formatting them rewrites those and fails +# test_scan, while the outer `git status` stays clean because it is its own repo. +../api/tests/fixtures # Auto-generated from the OpenAPI schema by `just gen-types` (openapi-typescript). # Edit api/models/*.py and regenerate; never hand-format this file. src/types/manifest.generated.ts diff --git a/app/package-lock.json b/app/package-lock.json index 303bb7f49..c8652e5fe 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -613,10 +613,33 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -887,14 +910,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -906,9 +929,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1072,9 +1095,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.34.15", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", - "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", "dev": true, "license": "MIT", "dependencies": { @@ -1083,7 +1106,7 @@ "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", - "js-yaml": "4.1.1", + "js-yaml": "4.2.0", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" @@ -1101,9 +1124,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -1124,9 +1147,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1141,9 +1164,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1158,9 +1181,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1175,9 +1198,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1192,9 +1215,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1209,9 +1232,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -1226,9 +1249,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1243,9 +1266,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1260,9 +1283,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1277,9 +1300,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1294,9 +1317,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1311,9 +1334,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1328,9 +1351,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -1338,41 +1361,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -1387,9 +1387,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1404,9 +1404,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1455,9 +1455,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -2100,16 +2100,16 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -3148,10 +3148,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3725,9 +3735,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -3982,9 +3992,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -4052,9 +4062,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -4072,7 +4082,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4262,14 +4272,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4278,21 +4288,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/safe-buffer": { @@ -4557,9 +4567,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -4728,9 +4738,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -4800,17 +4810,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4826,7 +4836,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/app/src/api/config.ts b/app/src/api/config.ts index f3a8216bf..1510b65a3 100644 --- a/app/src/api/config.ts +++ b/app/src/api/config.ts @@ -7,23 +7,50 @@ // path input that the server will reject anyway. import { apiUrl } from '@/api/apiUrl'; +import type { components } from '@/types/manifest.generated'; -export interface ServerConfig { - allowLocalRepos: boolean; -} +// Derived from the OpenAPI schema rather than re-declared, so a field added to +// the backend's ConfigResponse cannot drift from what this layer exposes. +export type ServerConfig = components['schemas']['ConfigResponse']; -const DISABLED: ServerConfig = { allowLocalRepos: false }; +// Pre-boot defaults, replaced by the real /api/config response. maxBatchPaths +// starts low because guessing high fails silently: the batch routes truncate +// anything past their cap, so an over-large chunk loses its tail. Guessing low +// only costs an extra request. +export const DEFAULT_SERVER_CONFIG: ServerConfig = { + allowLocalRepos: false, + maxBatchPaths: 16, +}; let _cached: Promise | null = null; +// The resolved value, for callers that need it synchronously mid-request (the +// batch coalescers). Not a second source of truth: it is written only from the +// memoized fetch below, which is the same one the SERVER_CONFIG signal mirrors. +let _resolved: ServerConfig = DEFAULT_SERVER_CONFIG; + +/** The server config as last resolved, or the defaults before boot completes. */ +export function serverConfigNow(): ServerConfig { + return _resolved; +} export async function fetchServerConfig(): Promise { try { const resp = await fetch(apiUrl('config')); - if (!resp.ok) return DISABLED; + if (!resp.ok) return DEFAULT_SERVER_CONFIG; const body = (await resp.json()) as Partial; - return { allowLocalRepos: !!body.allowLocalRepos }; + // Spread over the defaults rather than re-projecting field by field: the + // old shape listed each key by hand, so a field added on the server was + // silently dropped here. Only override what the body actually carries, so + // a truncated response can't yield a zero batch size. + return { + ...DEFAULT_SERVER_CONFIG, + allowLocalRepos: !!body.allowLocalRepos, + ...(typeof body.maxBatchPaths === 'number' && body.maxBatchPaths > 0 + ? { maxBatchPaths: body.maxBatchPaths } + : {}), + }; } catch (_) { - return DISABLED; + return DEFAULT_SERVER_CONFIG; } } @@ -34,7 +61,12 @@ export async function fetchServerConfig(): Promise { * tests that want a fresh roundtrip. */ export function getServerConfig(): Promise { - if (_cached === null) _cached = fetchServerConfig(); + if (_cached === null) { + _cached = fetchServerConfig().then((cfg) => { + _resolved = cfg; + return cfg; + }); + } return _cached; } @@ -42,4 +74,5 @@ export function getServerConfig(): Promise { * return different responses without leaking state. */ export function _resetServerConfigForTests(): void { _cached = null; + _resolved = DEFAULT_SERVER_CONFIG; } diff --git a/app/src/api/fingerprint.ts b/app/src/api/fingerprint.ts index c9a981e58..53e12bd91 100644 --- a/app/src/api/fingerprint.ts +++ b/app/src/api/fingerprint.ts @@ -1,62 +1,22 @@ -// api/fingerprint.ts — coalesces binary-file fingerprint fetches into POST -// /api/fingerprints batches (mirrors mediaBatch). The server returns a small -// base64 byte-pattern PNG per path; callers feed it to an via a data URL. -// A path the endpoint omits resolves to null. Shared by the city's data-building -// facade loader and the preview pane's data card. +// api/fingerprint.ts — binary-file fingerprints via POST /api/fingerprints. +// The server returns a small base64 byte-pattern PNG per path; callers feed it +// to an via a data URL. A path the endpoint omits resolves to null. +// Shared by the city's data-building facade loader and the preview pane's data +// card. Coalescing lives in createPathBatcher, shared with mediaBatch. -/** Max paths per request — mirrors the server-side cap. */ -const BATCH_SIZE = 32; -/** Coalescing window: data buildings register in a burst at scene build. */ -const FLUSH_MS = 16; +import { createPathBatcher } from '@/api/pathBatcher'; -interface BatchEntry { +interface FingerprintEntry { b64: string; } -type Waiter = (b64: string | null) => void; - -const _queue = new Map(); -let _timer: ReturnType | null = null; +const batcher = createPathBatcher({ + endpoint: '/api/fingerprints', + decode: (entry) => entry.b64, +}); /** Request a binary file's fingerprint PNG (base64) via the batch endpoint. * Resolves with the base64 string, or null when the server omitted it. */ export function fetchFingerprintB64(path: string): Promise { - return new Promise((resolve) => { - const waiters = _queue.get(path); - if (waiters) { - waiters.push(resolve); - } else { - _queue.set(path, [resolve]); - } - if (_timer === null) _timer = setTimeout(_flush, FLUSH_MS); - }); -} - -function _flush(): void { - _timer = null; - // Snapshot + clear so requests arriving during the awaits start a fresh batch. - const pending = new Map(_queue); - _queue.clear(); - const paths = [...pending.keys()]; - for (let i = 0; i < paths.length; i += BATCH_SIZE) { - void _sendBatch(paths.slice(i, i + BATCH_SIZE), pending); - } -} - -async function _sendBatch(paths: string[], pending: Map): Promise { - let result: Record = {}; - try { - const res = await fetch('/api/fingerprints', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ paths }), - }); - if (res.ok) result = (await res.json()) as Record; - } catch { - // Network failure → every path resolves null; callers keep their placeholder. - } - for (const path of paths) { - const entry = result[path]; - for (const resolve of pending.get(path) ?? []) resolve(entry ? entry.b64 : null); - } + return batcher.request(path); } diff --git a/app/src/api/manifest.ts b/app/src/api/manifest.ts index f624d7b3b..e202fd76d 100644 --- a/app/src/api/manifest.ts +++ b/app/src/api/manifest.ts @@ -220,12 +220,3 @@ export function streamManifest( }, }; } - -/** - * Clear the server-side scan cache for one (src, branch) pair. Best-effort — - * failures are swallowed (cache-clear is a UX nicety, not a correctness path). - */ -export function clearManifestCache(src: string, branch?: string): void { - const url = apiUrl('manifest/cache', { [URL_PARAMS.SRC]: src, [URL_PARAMS.BRANCH]: branch }); - fetch(url, { method: 'DELETE' }).catch(() => {}); -} diff --git a/app/src/api/pathBatcher.ts b/app/src/api/pathBatcher.ts new file mode 100644 index 000000000..73d8c30be --- /dev/null +++ b/app/src/api/pathBatcher.ts @@ -0,0 +1,84 @@ +// api/pathBatcher.ts — coalesces per-path requests into POST batches. +// +// Both media images and binary fingerprints hit the same wall: the scene asks +// for one asset per building, and firing a separate GET each exhausts the +// browser's HTTP/1.1 connection pool on asset-heavy repos (Infisical: 2.6k +// images). Each collects the paths requested inside a short window and sends +// them as a handful of batched POSTs instead of thousands of singletons. +// +// The two differ only in endpoint, request body and how a response entry +// decodes, so that is all a caller supplies. + +import { serverConfigNow } from '@/api/config'; + +/** One request's resolver. `null` means the server omitted that path. */ +type Waiter = (value: T | null) => void; + +export interface PathBatcherOptions { + /** POST target, e.g. `/api/images`. */ + endpoint: string; + /** Turn one response entry into the caller's value; return null to omit. */ + decode: (entry: E) => T | null; + /** Extra fields merged into the POST body alongside `paths`. */ + bodyFor?: (paths: string[]) => Record; + /** Runs once per path after its batch settles, before waiters resolve. */ + onSettled?: (path: string) => void; + /** Coalescing window. One frame gathers essentially a whole scene build. */ + flushMs?: number; +} + +export interface PathBatcher { + request(path: string): Promise; +} + +export function createPathBatcher(opts: PathBatcherOptions): PathBatcher { + const { endpoint, decode, bodyFor, onSettled, flushMs = 16 } = opts; + const queue = new Map[]>(); + let timer: ReturnType | null = null; + + async function sendBatch(paths: string[], pending: Map[]>): Promise { + let result: Record = {}; + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paths, ...(bodyFor?.(paths) ?? {}) }), + }); + if (res.ok) result = (await res.json()) as Record; + } catch { + // Network failure: every path in this batch resolves null, and callers + // fall back to whatever single-path route they have. + } + for (const path of paths) { + const entry = result[path]; + const value = entry === undefined ? null : decode(entry); + onSettled?.(path); + for (const resolve of pending.get(path) ?? []) resolve(value); + } + } + + function flush(): void { + timer = null; + // Snapshot + clear so requests arriving during the awaits start a fresh batch. + const pending = new Map(queue); + queue.clear(); + const paths = [...pending.keys()]; + // The server truncates past its cap, so chunk to the number it published + // rather than a local guess that could silently drop the tail. + const batchSize = serverConfigNow().maxBatchPaths; + for (let i = 0; i < paths.length; i += batchSize) { + void sendBatch(paths.slice(i, i + batchSize), pending); + } + } + + return { + request(path: string): Promise { + return new Promise((resolve) => { + const waiters = queue.get(path); + if (waiters) waiters.push(resolve); + else queue.set(path, [resolve]); + if (timer === null) timer = setTimeout(flush, flushMs); + }); + }, + }; +} diff --git a/app/src/city/capture/shots.ts b/app/src/city/capture/shots.ts index 39c631f9d..dd3d1694d 100644 --- a/app/src/city/capture/shots.ts +++ b/app/src/city/capture/shots.ts @@ -12,7 +12,7 @@ import type { SceneHandle } from '@/state/stores/scene'; import { NodeKind, type Manifest, type DirNode } from '@/types'; import { CAMERA } from '@/state/stores/settings/camera'; -import { TIMELINE_MODE, SCRUB_POS, TIMELINE_BUNDLE } from '@/state/stores/timeline'; +import { TIMELINE_MODE, SCRUB_MAX, TIMELINE_BUNDLE, setScrubPos } from '@/state/stores/timeline'; import { loadTimelineScene } from '@/hooks/useTimelineMode'; /** Set the default-view angle (degrees); the rig re-frames the whole city to @@ -41,10 +41,10 @@ export type ShotPose = ( * treeAnchor(sha) is null for commits the layout didn't place a tree for, so a * specific stat sha (e.g. the busiest commit) often misses. Walk commits * most-authors-first so the tree we land on also has the most firefly orbs. */ -function placedTree(h: SceneHandle, m: Manifest) { - const byAuthors = [...m.commits].sort((a, b) => b.authors.length - a.authors.length); +function placedTree(handle: SceneHandle, manifest: Manifest) { + const byAuthors = [...manifest.commits].sort((x, y) => y.authors.length - x.authors.length); for (const c of byAuthors) { - const tree = h.rig.treeAnchor(c.sha); + const tree = handle.rig.treeAnchor(c.sha); if (tree) return tree; } return null; @@ -84,33 +84,33 @@ let _timelineKickedOff = false; export const SHOTS: Record = { // Low side-on skyline. Aim just above the gem (toward the floating repo // label) and pull in close so the label reads and stays framed. - banner: (h, _m, o) => { - const a = h.rig.captureAnchors(); - const base = a.gem ?? a.center; + banner: (handle, _m, o) => { + const anchors = handle.rig.captureAnchors(); + const base = anchors.gem ?? anchors.center; if (!base) { - h.rig.reset(); + handle.rig.reset(); return; } const target = base.clone(); - target.y += a.cityRadius * 0.12; // lift toward the label so it stays in frame - h.rig.captureView({ + target.y += anchors.cityRadius * 0.12; // lift toward the label so it stays in frame + handle.rig.captureView({ target, - distance: o.dist ?? a.cityRadius * 0.55, + distance: o.dist ?? anchors.cityRadius * 0.55, elevation: o.elev ?? 9, azimuth: o.az ?? 12, }); }, // Whole-city framing: the rig fits the entire city to the chosen angle. - overview: (h, _m, o) => { + overview: (handle, _m, o) => { angle(o.elev ?? 46, o.az ?? 34); - h.rig.reset(); + handle.rig.reset(); }, // The whole city part-built at an older commit: enter Timeline mode, scrub to // mid-history, and frame the union city. No settings overrides — the shot // reflects the defaults (deleted stubs on, future files off). loadTimelineScene // is async, so return false until the mode + bundle are live — the harness retries. - timeline: (h, _m, o) => { + timeline: (handle, _m, o) => { if (!TIMELINE_MODE.peek()) { if (!_timelineKickedOff) { _timelineKickedOff = true; @@ -120,59 +120,59 @@ export const SHOTS: Record = { } const bundle = TIMELINE_BUNDLE.peek(); if (!bundle || bundle.commits.length === 0) return false; - SCRUB_POS.value = Math.floor((bundle.commits.length - 1) * 0.5); + setScrubPos(Math.floor(SCRUB_MAX.peek() * 0.5)); angle(o.elev ?? 44, o.az ?? 32); - h.rig.reset(); + handle.rig.reset(); }, // Close-up on the street whose buildings span the most file types (hue = // extension), for the widest spread of colors. The gem may be in view. - buildings: (h, m, o) => { - const a = h.rig.captureAnchors(); - const path = mostColorfulDirPath(m.tree); - const street = path ? h.rig.streetAnchor(path) : null; - const target = street?.pos ?? a.tallestBuilding ?? a.center; + buildings: (handle, manifest, o) => { + const anchors = handle.rig.captureAnchors(); + const path = mostColorfulDirPath(manifest.tree); + const street = path ? handle.rig.streetAnchor(path) : null; + const target = street?.pos ?? anchors.tallestBuilding ?? anchors.center; if (!target) { - h.rig.reset(); + handle.rig.reset(); return; } - if (street) target.y = a.tallestHeight * 0.25; // look at building mid-height, not the road - h.rig.captureView({ + if (street) target.y = anchors.tallestHeight * 0.25; // look at building mid-height, not the road + handle.rig.captureView({ target, - distance: o.dist ?? a.tallestHeight * 1.6, + distance: o.dist ?? anchors.tallestHeight * 1.6, elevation: o.elev ?? 16, azimuth: o.az ?? 24, }); }, - streets: (h, m, o) => { - const a = h.rig.captureAnchors(); - const path = m.stats.maxChildrenDir?.path; - const street = path ? h.rig.streetAnchor(path) : null; - const target = street?.pos ?? a.center; + streets: (handle, manifest, o) => { + const anchors = handle.rig.captureAnchors(); + const path = manifest.stats.maxChildrenDir?.path; + const street = path ? handle.rig.streetAnchor(path) : null; + const target = street?.pos ?? anchors.center; if (!target) { - h.rig.reset(); + handle.rig.reset(); return; } // Steep look down over the densest directory's street so the labeled road // grid fills the frame, not the gem. - h.rig.captureView({ + handle.rig.captureView({ target, - distance: o.dist ?? a.cityRadius * 0.3, + distance: o.dist ?? anchors.cityRadius * 0.3, elevation: o.elev ?? 64, azimuth: o.az ?? 18, }); }, - gem: (h, _m, o) => { - const a = h.rig.captureAnchors(); - if (!a.gem) { - h.rig.reset(); + gem: (handle, _m, o) => { + const anchors = handle.rig.captureAnchors(); + if (!anchors.gem) { + handle.rig.reset(); return; } // Looking down at the floating gem, pulled back so it clears the frame (its // size scales with the root street width, so distance does too). - h.rig.captureView({ - target: a.gem.clone(), - distance: o.dist ?? Math.max(a.rootStreetWidth * 6, 60), + handle.rig.captureView({ + target: anchors.gem.clone(), + distance: o.dist ?? Math.max(anchors.rootStreetWidth * 6, 60), elevation: o.elev ?? 46, azimuth: o.az ?? 20, }); @@ -183,13 +183,13 @@ export const SHOTS: Record = { // knows which slice of its recording to keep. Time-based, so the duration // holds regardless of frame rate, and a full 360deg loops seamlessly. // Tuning: ?elev = view angle, ?dist = distance, ?az = seconds per turn. - orbit: (h, _m, o) => { - const a = h.rig.captureAnchors(); - const target = a.gem ?? a.center; + orbit: (handle, _m, o) => { + const anchors = handle.rig.captureAnchors(); + const target = anchors.gem ?? anchors.center; if (!target) return false; const anchor = target.clone(); const elevation = o.elev ?? 30; - const distance = o.dist ?? a.cityRadius * 0.95; + const distance = o.dist ?? anchors.cityRadius * 0.95; const durationMs = (o.az ?? 18) * 1000; let startMs: number | null = null; const step = (nowMs: number): void => { @@ -198,7 +198,12 @@ export const SHOTS: Record = { document.documentElement.dataset.ccOrbitStart = '1'; } const p = Math.min((nowMs - startMs) / durationMs, 1); - h.rig.captureView({ target: anchor.clone(), distance, elevation, azimuth: -180 + p * 360 }); + handle.rig.captureView({ + target: anchor.clone(), + distance, + elevation, + azimuth: -180 + p * 360, + }); if (p >= 1) { document.documentElement.dataset.ccOrbitDone = '1'; return; @@ -212,28 +217,28 @@ export const SHOTS: Record = { // app/scripts/screenshots.mjs); codecity itself is too sparse to show either. // trees: wide forest immersion (dense trees fill the foreground, city behind); // fireflies: tighter on a busy tree so the author orbs read. - trees: (h, m, o) => { - const a = h.rig.captureAnchors(); - const tree = placedTree(h, m); + trees: (handle, manifest, o) => { + const anchors = handle.rig.captureAnchors(); + const tree = placedTree(handle, manifest); if (!tree) return false; // trees not placed yet: retry // Wide, low pull-back over a forest tree (distance scales with the city, not // the tree's canopy) so the forest fills the foreground with the city behind. - h.rig.captureView({ + handle.rig.captureView({ target: tree.pos, - distance: o.dist ?? a.cityRadius * 0.16, + distance: o.dist ?? anchors.cityRadius * 0.16, elevation: o.elev ?? 9, azimuth: o.az ?? 30, }); }, - fireflies: (h, m, o) => { - const tree = placedTree(h, m); + fireflies: (handle, manifest, o) => { + const tree = placedTree(handle, manifest); if (!tree) return false; // trees not placed yet: retry // Fit the tree's bounding sphere to the view (same math as the rig's // focusTree) at a low angle, so the single tree fills the frame. const span = tree.radius * 2; const boundingRadius = 0.5 * Math.sqrt(span * span * 2 + tree.height * tree.height); tree.pos.y = tree.height * 0.45; - h.rig.captureView({ + handle.rig.captureView({ target: tree.pos, distance: o.dist, // omitted -> fit the bounding sphere below fitRadius: boundingRadius, diff --git a/app/src/city/components/buildings/buildingKind.ts b/app/src/city/components/buildings/buildingKind.ts index 5c93a6105..aeea4721c 100644 --- a/app/src/city/components/buildings/buildingKind.ts +++ b/app/src/city/components/buildings/buildingKind.ts @@ -1,5 +1,7 @@ -// Per-instance building render kind (the iKind attribute). Mirror these float -// values in building.vert/frag.glsl. +// Per-instance building render kind (the iKind attribute). building.frag.glsl +// redeclares these as `const int KIND_*`; building-shader.test.ts asserts the +// two agree, since drift would silently render a whole class of buildings in +// the wrong mode. export const BuildingKind = { Normal: 0, Ruin: 1, // Timeline: deleted → crumbled gray stub diff --git a/app/src/city/components/buildings/facadePanels.ts b/app/src/city/components/buildings/facadePanels.ts index 9c0455579..5b40ddc36 100644 --- a/app/src/city/components/buildings/facadePanels.ts +++ b/app/src/city/components/buildings/facadePanels.ts @@ -707,7 +707,7 @@ function _releaseSlot(): void { * Videos: never batched (we only need the first frame, and