From 6fef2c51ec790bb0216808cc51588c30a3c6c65e Mon Sep 17 00:00:00 2001 From: knight22-21 Date: Mon, 26 Oct 2026 00:19:55 +0530 Subject: [PATCH] fix(codeprism): sync external file changes automatically --- devagent/agent/flows.py | 14 +++++ devagent/codeprism/client.py | 19 ++++++ devagent/codeprism/watcher.py | 101 ++++++++++++++++++++++++++++++++ tests/test_codeprism_watcher.py | 53 +++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 devagent/codeprism/watcher.py create mode 100644 tests/test_codeprism_watcher.py diff --git a/devagent/agent/flows.py b/devagent/agent/flows.py index ed06dd6..801b328 100644 --- a/devagent/agent/flows.py +++ b/devagent/agent/flows.py @@ -263,6 +263,18 @@ def __init__( except Exception: pass self._cp_client = cp_client + self._index_watcher = None + if cp_client is not None: + try: + from devagent.codeprism.watcher import ProjectIndexWatcher + + self._index_watcher = ProjectIndexWatcher( + self._project_root, + cp_client.update_file, + ) + self._index_watcher.start() + except Exception: + self._index_watcher = None # ── Security gate confirm callback ──────────────────────────── def _confirm(msg: str) -> bool: @@ -1439,6 +1451,8 @@ def print_header(self, title: str = "DevAgent") -> None: ) def _print_exit(self) -> None: + if self._index_watcher is not None: + self._index_watcher.stop() from devagent.agent import permissions as perm_registry perm_registry.unregister(self.session_id) if self._hook_runner and self._hook_runner.hooks: diff --git a/devagent/codeprism/client.py b/devagent/codeprism/client.py index 4e1fcb4..fffceb4 100644 --- a/devagent/codeprism/client.py +++ b/devagent/codeprism/client.py @@ -343,6 +343,25 @@ def get_stats(self) -> dict: # Session tracking # ------------------------------------------------------------------ + def update_file(self, file_path: str) -> dict: + """Synchronize one external file through CodePrism's incremental updater.""" + if not self._ensure_ready(): + return {"status": "NOT_INDEXED"} + try: + from codeprism.indexer.incremental_updater import IncrementalUpdater + + updater = IncrementalUpdater(self._prism._graph, self._prism._storage) + result = _run(updater.update_file(str(Path(file_path).resolve()))) + return { + "status": "PASS", + "nodes_added": result.nodes_added, + "nodes_removed": result.nodes_removed, + "edges_updated": result.edges_updated, + "skipped": result.skipped, + } + except Exception as exc: + return {"status": "ERROR", "error": str(exc)} + def record_read(self, file: str, symbol: str) -> None: if not self._ensure_ready() or self._session_obj is None: return diff --git a/devagent/codeprism/watcher.py b/devagent/codeprism/watcher.py new file mode 100644 index 0000000..5acd8d1 --- /dev/null +++ b/devagent/codeprism/watcher.py @@ -0,0 +1,101 @@ +"""Debounced project file watcher for automatic CodePrism synchronization.""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Callable +from pathlib import Path + +_WATCHED_SUFFIXES = { + ".c", ".cc", ".cpp", ".cs", ".go", ".h", ".hpp", ".java", ".js", ".jsx", + ".json", ".kt", ".kts", ".md", ".php", ".py", ".rb", ".rs", ".sh", ".sql", + ".swift", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml", +} +_IGNORED_DIRECTORIES = { + ".git", ".codeprism", ".devagent", ".mypy_cache", ".pytest_cache", + ".ruff_cache", ".venv", "__pycache__", "node_modules", "venv", +} + + +class ProjectIndexWatcher: + """Poll for stable file changes and update CodePrism incrementally.""" + + def __init__( + self, + project_root: Path, + update_file: Callable[[str], object], + *, + interval: float = 1.0, + debounce: float = 1.0, + ) -> None: + self.project_root = project_root.resolve() + self._update_file = update_file + self._interval = interval + self._debounce = debounce + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._snapshot = self._take_snapshot() + self._pending: dict[Path, tuple[tuple[int, int], float]] = {} + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._thread = threading.Thread( + target=self._run, + daemon=True, + name="devagent-codeprism-watcher", + ) + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=max(self._interval * 2, 1.0)) + + def scan_once(self) -> list[Path]: + """Process stable changes once; exposed for deterministic tests.""" + now = time.monotonic() + current = self._take_snapshot() + changed = { + path for path in set(self._snapshot) | set(current) + if self._snapshot.get(path) != current.get(path) + } + self._snapshot = current + + for path in changed: + signature = current.get(path, (0, 0)) + self._pending[path] = (signature, now) + + synced: list[Path] = [] + for path, (signature, changed_at) in list(self._pending.items()): + if current.get(path, (0, 0)) != signature: + continue + if now - changed_at < self._debounce: + continue + self._update_file(str(path)) + synced.append(path) + del self._pending[path] + return synced + + def _run(self) -> None: + while not self._stop_event.wait(self._interval): + try: + self.scan_once() + except OSError: + # A file can disappear between directory scan and stat. + continue + + def _take_snapshot(self) -> dict[Path, tuple[int, int]]: + snapshot: dict[Path, tuple[int, int]] = {} + for path in self.project_root.rglob("*"): + if not path.is_file() or path.suffix.lower() not in _WATCHED_SUFFIXES: + continue + if any(part in _IGNORED_DIRECTORIES for part in path.relative_to(self.project_root).parts): + continue + try: + stat = path.stat() + except OSError: + continue + snapshot[path] = (stat.st_mtime_ns, stat.st_size) + return snapshot diff --git a/tests/test_codeprism_watcher.py b/tests/test_codeprism_watcher.py new file mode 100644 index 0000000..1acafe0 --- /dev/null +++ b/tests/test_codeprism_watcher.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import time +from pathlib import Path + +from devagent.codeprism.watcher import ProjectIndexWatcher + + +def test_watcher_syncs_stable_changed_file(tmp_path: Path) -> None: + source = tmp_path / "module.py" + source.write_text("value = 1", encoding="utf-8") + updates: list[str] = [] + watcher = ProjectIndexWatcher(tmp_path, updates.append, debounce=0) + + source.write_text("value = 2", encoding="utf-8") + assert watcher.scan_once() == [source] + assert updates == [str(source)] + + +def test_watcher_syncs_deleted_file(tmp_path: Path) -> None: + source = tmp_path / "module.py" + source.write_text("value = 1", encoding="utf-8") + updates: list[str] = [] + watcher = ProjectIndexWatcher(tmp_path, updates.append, debounce=0) + + source.unlink() + assert watcher.scan_once() == [source] + assert updates == [str(source)] + + +def test_watcher_ignores_build_directories(tmp_path: Path) -> None: + ignored = tmp_path / "node_modules" + ignored.mkdir() + source = ignored / "module.js" + source.write_text("const value = 1", encoding="utf-8") + updates: list[str] = [] + watcher = ProjectIndexWatcher(tmp_path, updates.append, debounce=0) + + source.write_text("const value = 2", encoding="utf-8") + assert watcher.scan_once() == [] + assert updates == [] + + +def test_watcher_debounces_changes(tmp_path: Path) -> None: + source = tmp_path / "module.py" + source.write_text("value = 1", encoding="utf-8") + updates: list[str] = [] + watcher = ProjectIndexWatcher(tmp_path, updates.append, debounce=0.05) + + source.write_text("value = 2", encoding="utf-8") + assert watcher.scan_once() == [] + time.sleep(0.06) + assert watcher.scan_once() == [source]