Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

277 lines
8.0 KiB
Python

"""File watcher for live code graph reindexing.
Monitors the project directory for source file changes and triggers
incremental reindexing via codebase-memory-mcp. Runs as a background
thread in the proxy process.
Cross-platform: uses watchdog (FSEvents on macOS, inotify on Linux,
ReadDirectoryChangesW on Windows).
Usage:
watcher = CodeGraphWatcher(project_dir="/path/to/project")
watcher.start() # Non-blocking, runs in background thread
...
watcher.stop() # Clean shutdown
"""
from __future__ import annotations
import json
import logging
import subprocess
import threading
import time
from pathlib import Path
from headroom._subprocess import run
logger = logging.getLogger(__name__)
# Source file extensions worth reindexing for
_SOURCE_EXTENSIONS = frozenset(
{
".py",
".js",
".jsx",
".ts",
".tsx",
".go",
".rs",
".java",
".c",
".cpp",
".cc",
".h",
".hpp",
".cs",
".kt",
".scala",
".rb",
".php",
".swift",
".lua",
".zig",
".ex",
".exs",
".m",
".mm",
".jl",
".r",
".R",
".sql",
# Config/docs that affect graph
".md",
".rst",
".toml",
".yaml",
".yml",
".json",
}
)
# Directories to ignore
_IGNORE_DIRS = frozenset(
{
".git",
".hg",
".svn",
"node_modules",
"__pycache__",
".venv",
"venv",
".tox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"dist",
"build",
".eggs",
"*.egg-info",
".headroom",
"target", # Rust/Java
}
)
class CodeGraphWatcher:
"""Watches project files and triggers incremental graph reindex.
Uses a debounce strategy: after a file change, waits for a quiet
period (no more changes) before triggering reindex. This handles
rapid multi-file edits efficiently.
Args:
project_dir: Root directory to watch.
debounce_seconds: Wait this long after last change before reindexing.
cbm_binary: Path to codebase-memory-mcp binary. Auto-detected if None.
"""
def __init__(
self,
project_dir: str | Path,
debounce_seconds: float = 2.0,
cbm_binary: str | None = None,
) -> None:
self.project_dir = str(project_dir)
self.debounce_seconds = debounce_seconds
self.cbm_binary: str | None = None
if cbm_binary:
self.cbm_binary = cbm_binary
else:
from headroom.graph.installer import get_cbm_path
path = get_cbm_path()
self.cbm_binary = str(path) if path else None
self._observer: object | None = None
self._debounce_timer: threading.Timer | None = None
self._lock = threading.Lock()
self._running = False
self._reindex_count = 0
self._last_reindex: float = 0
def start(self) -> bool:
"""Start watching in a background thread. Returns True if started."""
if not self.cbm_binary:
logger.debug("Code graph watcher: codebase-memory-mcp not found, not starting")
return False
try:
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
except ImportError:
logger.debug("Code graph watcher: watchdog not installed, not starting")
return False
class _Handler(FileSystemEventHandler):
def __init__(self, watcher: CodeGraphWatcher):
self._watcher = watcher
def on_any_event(self, event: object) -> None:
# watchdog event has src_path attribute
src_path = getattr(event, "src_path", "")
if not src_path:
return
path = Path(src_path)
# Skip ignored directories
for part in path.parts:
if part in _IGNORE_DIRS:
return
# Only react to source file changes
if path.suffix.lower() not in _SOURCE_EXTENSIONS:
return
# Skip temporary/swap files
if path.name.startswith(".") or path.name.endswith("~"):
return
self._watcher._schedule_reindex()
observer = Observer()
observer.schedule(_Handler(self), self.project_dir, recursive=True)
observer.daemon = True
observer.start()
self._observer = observer
self._running = True
logger.info(
"Code graph watcher: monitoring %s (debounce=%.1fs)",
self.project_dir,
self.debounce_seconds,
)
return True
def stop(self) -> None:
"""Stop watching and clean up."""
self._running = False
with self._lock:
if self._debounce_timer:
self._debounce_timer.cancel()
self._debounce_timer = None
if self._observer:
observer = self._observer
self._observer = None
# watchdog Observer has stop() and join()
if hasattr(observer, "stop"):
observer.stop() # type: ignore[union-attr]
if hasattr(observer, "join"):
observer.join(timeout=3) # type: ignore[union-attr]
if self._reindex_count > 0:
logger.info("Code graph watcher: stopped after %d reindexes", self._reindex_count)
def _schedule_reindex(self) -> None:
"""Schedule a debounced reindex. Resets timer on each call."""
with self._lock:
if self._debounce_timer:
self._debounce_timer.cancel()
self._debounce_timer = threading.Timer(self.debounce_seconds, self._do_reindex)
self._debounce_timer.daemon = True
self._debounce_timer.start()
def _do_reindex(self) -> None:
"""Trigger incremental reindex via codebase-memory-mcp."""
if not self._running or not self.cbm_binary:
return
try:
start = time.monotonic()
result = run(
[
self.cbm_binary,
"cli",
"index_repository",
json.dumps({"repo_path": self.project_dir, "mode": "fast"}),
],
capture_output=True,
text=True,
timeout=30,
)
elapsed = time.monotonic() - start
self._reindex_count += 1
self._last_reindex = time.time()
if result.returncode == 0:
# Check if incremental (look for "changed=N" in output)
changed = "?"
for line in result.stderr.splitlines():
if "changed=" in line:
import re
m = re.search(r"changed=(\d+)", line)
if m:
changed = m.group(1)
break
logger.info(
"Code graph: reindexed (%s files changed, %.1fs)",
changed,
elapsed,
)
else:
logger.debug(
"Code graph: reindex failed (exit=%d, %.1fs)",
result.returncode,
elapsed,
)
except subprocess.TimeoutExpired:
logger.warning("Code graph: reindex timed out after 30s")
except Exception as e:
logger.debug("Code graph: reindex error: %s", e)
@property
def stats(self) -> dict:
"""Return watcher statistics."""
return {
"running": self._running,
"project_dir": self.project_dir,
"reindex_count": self._reindex_count,
"last_reindex": self._last_reindex,
"debounce_seconds": self.debounce_seconds,
}