Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b75543c
Break the axe singleton cascade in the a11y audit (#108)
thalida Jul 26, 2026
407c039
Serve syntax themes from the bundle, not jsDelivr
thalida Jul 26, 2026
eb9ab18
Bind the API to loopback by default
thalida Jul 26, 2026
f89ab3d
Remove the unreachable manifest cache-clear surface (#124)
thalida Jul 26, 2026
fb8c1c5
Delete orphaned CSS and correct comments that describe code that move…
thalida Jul 26, 2026
dc25606
Reflow _mirrorOrient's signature after dropping its export
thalida Jul 26, 2026
51c2024
Publish the batch-path cap instead of hardcoding it in three places
thalida Jul 26, 2026
8db7ef1
Update the two config-shape assertions for maxBatchPaths
thalida Jul 26, 2026
db781ce
Correct two comments that describe hand-syncing where none exists
thalida Jul 26, 2026
ba98bea
Clear the vite / undici / postcss dev advisories
thalida Jul 26, 2026
38f7da6
Run the golden guards by default and cover the size edge cases (#73)
thalida Jul 26, 2026
7d366e4
Explain why both delta walks exist instead of implying one is stale
thalida Jul 26, 2026
2e3e185
Cap the manifest cache instead of growing it forever
thalida Jul 26, 2026
9070dce
Break the hook import cycle and the api -> state inversion
thalida Jul 26, 2026
6812918
Close the two gaps that let the em-dash guard pass while under-enforcing
thalida Jul 26, 2026
7319d0f
Extract the coalescing batcher the two asset fetchers both hand-rolled
thalida Jul 26, 2026
37a4dee
Cut layout time by a third without moving a single building
thalida Jul 26, 2026
1268c17
Split scrubController.update into named phases (complexity 88 -> 21)
thalida Jul 26, 2026
090ea51
Enforce a frontend coverage floor, and cut my own comments back
thalida Jul 26, 2026
d0eac76
Share the scene-context test fixtures instead of copying them 15 times
thalida Jul 26, 2026
bcd7a06
Split the two worst remaining tangled functions
thalida Jul 26, 2026
ec600a3
Keep prettier off the scanner fixtures
thalida Jul 26, 2026
46d0170
Bound the scrubbed-manifest cache
thalida Jul 26, 2026
733275e
Name the opaque locals in shots.ts and picker.ts
thalida Jul 26, 2026
d853af8
Stop a previous city's buildings resolving into the current one
thalida Jul 26, 2026
f22fff9
Drop the scrub controller on rebuild, like the tween queue already does
thalida Jul 26, 2026
3189645
Cut that comment to two lines
thalida Jul 26, 2026
892b6f5
Make returning to live part of the scene contract, not four ad-hoc calls
thalida Jul 26, 2026
f78482a
Revert "Make returning to live part of the scene contract, not four a…
thalida Jul 26, 2026
c8d5206
Reapply "Make returning to live part of the scene contract, not four …
thalida Jul 26, 2026
b7fa019
Revert "Reapply "Make returning to live part of the scene contract, n…
thalida Jul 26, 2026
7fd5815
Leave Timeline by rebuilding the live city, and keep the switcher bac…
thalida Jul 26, 2026
a2cb0eb
Give the loading overlay ownership of its header label
thalida Jul 26, 2026
fa21ee8
Give the golden guards timeouts that survive coverage instrumentation
thalida Jul 26, 2026
6ff0ecf
Make SCRUB_POS valid by construction instead of by convention
thalida Jul 27, 2026
5434807
Give Timeline one entry path, and enter only after the scene is packed
thalida Jul 27, 2026
49e417d
Repaint the scrubber ticks when Timeline is re-entered
thalida Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions api/__main__.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions api/models/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ class HealthResponse(BaseModel):

class ConfigResponse(BaseModel):
allowLocalRepos: bool


class CacheClearResponse(BaseModel):
deleted: int
maxBatchPaths: int


class CommitDetailResponse(BaseModel):
Expand Down
12 changes: 6 additions & 6 deletions api/routers/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
37 changes: 2 additions & 35 deletions api/routers/manifest.py
Original file line number Diff line number Diff line change
@@ -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)."""

Expand Down Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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-
Expand Down
7 changes: 5 additions & 2 deletions api/routers/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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,
)
146 changes: 90 additions & 56 deletions api/services/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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}__<rest>` cache file belongs to."""
if name_rest.startswith("ref-"):
return "ref"
if name_rest.startswith("timeline-"):
return "timeline"
return "content"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

these should be a proper enum



_FAMILY_KEEP = {
"content": _KEEP_CONTENT_MANIFESTS,
"ref": _KEEP_REF_MANIFESTS,
"timeline": _KEEP_TIMELINE_BUNDLES,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

enum, that's also ideally used here.

}


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."""

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

make this more concise

manifests_dir = CACHE_ROOT / "manifests"
if not manifests_dir.exists():
return 0

prefix = f"{repo_key(abs_root)}__"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

if this is the cache prefix, this should be a general shared constant, so that it doesn't become out of date w/ the the cache naming.

families: dict[str, list[tuple[float, Path]]] = {
"content": [],
"ref": [],
"timeline": [],
}
for path in manifests_dir.glob(f"{prefix}*.json.gz"):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

same thing w/ the file ending, there could be a shared helper function with shared constnats.

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,
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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 `__<signature>.json.gz`,
`__ref-<sha>.json.gz`, and `__timeline-<sha>.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
Loading