Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions devagent/agent/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions devagent/codeprism/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions devagent/codeprism/watcher.py
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions tests/test_codeprism_watcher.py
Original file line number Diff line number Diff line change
@@ -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]
Loading