codebrain 0.3.4__py3-none-any.whl → 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codebrain/__init__.py +1 -1
- codebrain/graph/store.py +5 -0
- codebrain/mcp_lifecycle.py +41 -14
- codebrain/watcher/file_watcher.py +76 -3
- {codebrain-0.3.4.dist-info → codebrain-0.3.5.dist-info}/METADATA +1 -1
- {codebrain-0.3.4.dist-info → codebrain-0.3.5.dist-info}/RECORD +10 -10
- {codebrain-0.3.4.dist-info → codebrain-0.3.5.dist-info}/WHEEL +0 -0
- {codebrain-0.3.4.dist-info → codebrain-0.3.5.dist-info}/entry_points.txt +0 -0
- {codebrain-0.3.4.dist-info → codebrain-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {codebrain-0.3.4.dist-info → codebrain-0.3.5.dist-info}/top_level.txt +0 -0
codebrain/__init__.py
CHANGED
codebrain/graph/store.py
CHANGED
|
@@ -113,6 +113,11 @@ class GraphStore:
|
|
|
113
113
|
).fetchone()
|
|
114
114
|
return row["content_hash"] if row else None
|
|
115
115
|
|
|
116
|
+
def all_file_paths(self) -> list[str]:
|
|
117
|
+
"""Return every file path currently in the index."""
|
|
118
|
+
rows = self.conn.execute("SELECT path FROM files").fetchall()
|
|
119
|
+
return [row["path"] for row in rows]
|
|
120
|
+
|
|
116
121
|
# ------------------------------------------------------------------
|
|
117
122
|
# Node operations
|
|
118
123
|
# ------------------------------------------------------------------
|
codebrain/mcp_lifecycle.py
CHANGED
|
@@ -167,7 +167,7 @@ def _remove_pid_file(pid_file: Path) -> None:
|
|
|
167
167
|
pass
|
|
168
168
|
|
|
169
169
|
|
|
170
|
-
def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
170
|
+
def _find_watch_target(start_pid: int) -> tuple[int, float | None, bool]:
|
|
171
171
|
"""Pick the PID whose death should kill the MCP.
|
|
172
172
|
|
|
173
173
|
Walks up the ancestor chain (up to ANCESTOR_WALK_DEPTH levels) looking
|
|
@@ -175,6 +175,10 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
175
175
|
real IDE host. Falls back to ``start_pid`` if no hint matches, psutil
|
|
176
176
|
is unavailable, or ``CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK=1`` is set.
|
|
177
177
|
|
|
178
|
+
Returns ``(pid, create_time, host_anchored)``. ``host_anchored`` is True
|
|
179
|
+
only when an actual IDE host process was found — callers use it to decide
|
|
180
|
+
whether the idle-timeout backstop is needed at all.
|
|
181
|
+
|
|
178
182
|
Why: on Windows, Claude Code spawns the MCP via a transient launcher
|
|
179
183
|
(cmd.exe wrapper or Electron worker shell). The launcher exits soon
|
|
180
184
|
after the python child starts, so watching ``os.getppid()`` directly
|
|
@@ -183,16 +187,16 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
183
187
|
try:
|
|
184
188
|
import psutil
|
|
185
189
|
except ImportError:
|
|
186
|
-
return start_pid, None
|
|
190
|
+
return start_pid, None, False
|
|
187
191
|
|
|
188
192
|
fallback_create_time: float | None = None
|
|
189
193
|
try:
|
|
190
194
|
fallback_create_time = psutil.Process(start_pid).create_time()
|
|
191
195
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
192
|
-
return start_pid, None
|
|
196
|
+
return start_pid, None, False
|
|
193
197
|
|
|
194
198
|
if os.environ.get("CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK") == "1":
|
|
195
|
-
return start_pid, fallback_create_time
|
|
199
|
+
return start_pid, fallback_create_time, False
|
|
196
200
|
|
|
197
201
|
try:
|
|
198
202
|
proc = psutil.Process(start_pid)
|
|
@@ -202,7 +206,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
202
206
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
203
207
|
break
|
|
204
208
|
if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
|
|
205
|
-
return proc.pid, proc.create_time()
|
|
209
|
+
return proc.pid, proc.create_time(), True
|
|
206
210
|
try:
|
|
207
211
|
parent = proc.parent()
|
|
208
212
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
@@ -212,7 +216,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
|
|
|
212
216
|
proc = parent
|
|
213
217
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
214
218
|
pass
|
|
215
|
-
return start_pid, fallback_create_time
|
|
219
|
+
return start_pid, fallback_create_time, False
|
|
216
220
|
|
|
217
221
|
|
|
218
222
|
def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> None:
|
|
@@ -241,16 +245,35 @@ def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> No
|
|
|
241
245
|
_log.debug("parent watchdog tick error: %s", exc)
|
|
242
246
|
|
|
243
247
|
|
|
244
|
-
def
|
|
245
|
-
|
|
248
|
+
def _effective_idle_timeout(host_anchored: bool) -> int:
|
|
249
|
+
"""Resolve the idle timeout in seconds (0 disables the idle watchdog).
|
|
250
|
+
|
|
251
|
+
The idle watchdog is a *backstop* for when the parent watchdog has no
|
|
252
|
+
reliable IDE host to watch. When we are anchored to a real host
|
|
253
|
+
(claude/cursor/vscode), the parent watchdog deterministically tears the
|
|
254
|
+
server down at session end — an idle timeout on top of that only kills
|
|
255
|
+
the file watcher mid-session and leaves the index stale. So:
|
|
256
|
+
|
|
257
|
+
- explicit ``CODEBRAIN_MCP_IDLE_TIMEOUT`` env var always wins
|
|
258
|
+
- otherwise: disabled when host-anchored, 30-min backstop when not
|
|
259
|
+
"""
|
|
260
|
+
if "CODEBRAIN_MCP_IDLE_TIMEOUT" in os.environ:
|
|
261
|
+
return IDLE_TIMEOUT_SECONDS
|
|
262
|
+
if host_anchored:
|
|
263
|
+
return 0
|
|
264
|
+
return IDLE_TIMEOUT_SECONDS
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _idle_watchdog(timeout: int) -> None:
|
|
268
|
+
if timeout <= 0:
|
|
246
269
|
return
|
|
247
|
-
poll = max(5, min(60,
|
|
270
|
+
poll = max(5, min(60, timeout // 4))
|
|
248
271
|
while True:
|
|
249
272
|
time.sleep(poll)
|
|
250
273
|
with _last_activity_lock:
|
|
251
274
|
idle = time.time() - _last_activity
|
|
252
|
-
if idle >
|
|
253
|
-
_exit(f"idle for {idle:.0f}s (limit {
|
|
275
|
+
if idle > timeout:
|
|
276
|
+
_exit(f"idle for {idle:.0f}s (limit {timeout}s)")
|
|
254
277
|
|
|
255
278
|
|
|
256
279
|
def _lifetime_watchdog(start: float) -> None:
|
|
@@ -285,13 +308,16 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
|
|
|
285
308
|
atexit.register(_remove_pid_file, pid_file)
|
|
286
309
|
|
|
287
310
|
immediate_ppid = os.getppid()
|
|
288
|
-
initial_ppid, initial_create_time = _find_watch_target(immediate_ppid)
|
|
311
|
+
initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
|
|
312
|
+
idle_timeout = _effective_idle_timeout(host_anchored)
|
|
289
313
|
|
|
290
314
|
_log.info(
|
|
291
|
-
"MCP watchdogs installed (ppid=%d via=%d,
|
|
315
|
+
"MCP watchdogs installed (ppid=%d via=%d, host_anchored=%s, "
|
|
316
|
+
"idle_timeout=%ds, max_lifetime=%ds)",
|
|
292
317
|
initial_ppid,
|
|
293
318
|
immediate_ppid,
|
|
294
|
-
|
|
319
|
+
host_anchored,
|
|
320
|
+
idle_timeout,
|
|
295
321
|
MAX_LIFETIME_SECONDS,
|
|
296
322
|
)
|
|
297
323
|
|
|
@@ -303,6 +329,7 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
|
|
|
303
329
|
).start()
|
|
304
330
|
threading.Thread(
|
|
305
331
|
target=_idle_watchdog,
|
|
332
|
+
args=(idle_timeout,),
|
|
306
333
|
name="cb-idle-watchdog",
|
|
307
334
|
daemon=True,
|
|
308
335
|
).start()
|
|
@@ -11,9 +11,10 @@ from watchdog.observers import Observer
|
|
|
11
11
|
|
|
12
12
|
from codebrain.config import INDEXABLE_EXTENSIONS, WATCHER_DEBOUNCE_SECONDS
|
|
13
13
|
from codebrain.graph.store import GraphStore
|
|
14
|
-
from codebrain.indexer import incremental_update
|
|
14
|
+
from codebrain.indexer import discover_files, incremental_update
|
|
15
|
+
from codebrain.utils import normalize_path
|
|
15
16
|
from codebrain.logging import get_logger
|
|
16
|
-
from codebrain.settings import load_settings
|
|
17
|
+
from codebrain.settings import Settings, load_settings
|
|
17
18
|
|
|
18
19
|
_log = get_logger("watcher")
|
|
19
20
|
|
|
@@ -37,6 +38,8 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
37
38
|
self._changed: set[Path] = set()
|
|
38
39
|
self._deleted: set[Path] = set()
|
|
39
40
|
self._lock = threading.Lock()
|
|
41
|
+
# Serializes DB writes between flush timer threads and the catch-up thread.
|
|
42
|
+
self.db_lock = threading.Lock()
|
|
40
43
|
self._timer: threading.Timer | None = None
|
|
41
44
|
self._last_validation: dict[str, object] = {} # rel_path -> ValidationReport
|
|
42
45
|
|
|
@@ -57,6 +60,14 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
57
60
|
if not changed and not deleted:
|
|
58
61
|
return
|
|
59
62
|
|
|
63
|
+
# Live file edits count as activity — without this the idle watchdog
|
|
64
|
+
# kills the MCP (and this watcher with it) mid-editing-session.
|
|
65
|
+
try:
|
|
66
|
+
from codebrain.mcp_lifecycle import mark_activity
|
|
67
|
+
mark_activity()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
60
71
|
# Redirect stdout to stderr to prevent MCP stdio protocol corruption
|
|
61
72
|
# when running inside the MCP server process.
|
|
62
73
|
import sys
|
|
@@ -67,7 +78,8 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
67
78
|
if changed:
|
|
68
79
|
self._validate_changed(changed)
|
|
69
80
|
|
|
70
|
-
|
|
81
|
+
with self.db_lock:
|
|
82
|
+
result = incremental_update(self.repo_root, changed, deleted, self.store)
|
|
71
83
|
total = result["files_updated"] + result["files_removed"]
|
|
72
84
|
if total:
|
|
73
85
|
_log.info(
|
|
@@ -147,6 +159,59 @@ class _DebouncedHandler(FileSystemEventHandler):
|
|
|
147
159
|
self._schedule_flush()
|
|
148
160
|
|
|
149
161
|
|
|
162
|
+
def catch_up_sync(
|
|
163
|
+
repo_root: Path,
|
|
164
|
+
store: GraphStore,
|
|
165
|
+
settings: Settings | None = None,
|
|
166
|
+
db_lock: threading.Lock | None = None,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""Bring the index up to date with changes made while no watcher was alive.
|
|
169
|
+
|
|
170
|
+
The file watcher only sees events that happen while its process is
|
|
171
|
+
running. Edits made between sessions (or after a lifecycle watchdog
|
|
172
|
+
killed the MCP server) are otherwise missed forever, leaving the index
|
|
173
|
+
stale until a manual `brain reindex`. This diffs disk vs. index:
|
|
174
|
+
|
|
175
|
+
- changed/new files: detected by content hash inside incremental_update
|
|
176
|
+
- deleted files: indexed paths that no longer exist on disk
|
|
177
|
+
|
|
178
|
+
Returns the incremental_update summary dict.
|
|
179
|
+
"""
|
|
180
|
+
on_disk = discover_files(repo_root, settings)
|
|
181
|
+
disk_rels = {normalize_path(p, repo_root) for p in on_disk}
|
|
182
|
+
deleted = [
|
|
183
|
+
repo_root / rel
|
|
184
|
+
for rel in store.all_file_paths()
|
|
185
|
+
if rel not in disk_rels
|
|
186
|
+
]
|
|
187
|
+
lock = db_lock if db_lock is not None else threading.Lock()
|
|
188
|
+
with lock:
|
|
189
|
+
result = incremental_update(repo_root, on_disk, deleted, store)
|
|
190
|
+
if result["files_updated"] or result["files_removed"]:
|
|
191
|
+
_log.info(
|
|
192
|
+
"Catch-up sync: updated %d, removed %d (%.3fs)",
|
|
193
|
+
result["files_updated"],
|
|
194
|
+
result["files_removed"],
|
|
195
|
+
result["elapsed_seconds"],
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
_log.info("Catch-up sync: index already current")
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _catch_up_in_background(
|
|
203
|
+
repo_root: Path, store: GraphStore, settings: Settings, handler: _DebouncedHandler,
|
|
204
|
+
) -> None:
|
|
205
|
+
# NOTE: deliberately no sys.stdout redirect here — this thread runs
|
|
206
|
+
# concurrently with the MCP initialize handshake on the real stdout;
|
|
207
|
+
# swapping the global stdout would corrupt the JSON-RPC stream.
|
|
208
|
+
# Parsers do not print, so there is nothing to redirect anyway.
|
|
209
|
+
try:
|
|
210
|
+
catch_up_sync(repo_root, store, settings, db_lock=handler.db_lock)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
_log.warning("Catch-up sync failed: %s", exc)
|
|
213
|
+
|
|
214
|
+
|
|
150
215
|
def start_watching_background(
|
|
151
216
|
repo_root: Path, db_path: Path,
|
|
152
217
|
) -> tuple[Observer, GraphStore, _DebouncedHandler]:
|
|
@@ -166,7 +231,15 @@ def start_watching_background(
|
|
|
166
231
|
observer = Observer()
|
|
167
232
|
observer.daemon = True
|
|
168
233
|
observer.schedule(handler, str(repo_root), recursive=True)
|
|
234
|
+
# Observer starts BEFORE the catch-up scan so no event falls in the gap;
|
|
235
|
+
# the hash check in incremental_update makes any overlap harmless.
|
|
169
236
|
observer.start()
|
|
237
|
+
threading.Thread(
|
|
238
|
+
target=_catch_up_in_background,
|
|
239
|
+
args=(repo_root, store, settings, handler),
|
|
240
|
+
name="cb-catchup-sync",
|
|
241
|
+
daemon=True,
|
|
242
|
+
).start()
|
|
170
243
|
_log.info("Background watcher started for %s", repo_root)
|
|
171
244
|
return observer, store, handler
|
|
172
245
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebrain
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Know what breaks before you break it. Structural knowledge graph for codebases — impact analysis, dead code detection, health scores. No LLM required.
|
|
5
5
|
Author: CodeBrain Contributors
|
|
6
6
|
License: MIT License
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
codebrain/__init__.py,sha256
|
|
1
|
+
codebrain/__init__.py,sha256=x27U2sCVt0bfIOA_zO8t7cwyzq-sFjkbLTNm-MpjHME,392
|
|
2
2
|
codebrain/__main__.py,sha256=dgd9lRZovV1k1tScEW_wvPPjPEACXc-EcLtJ7AN3M48,115
|
|
3
3
|
codebrain/agent_bridge.py,sha256=JDny3232R6rfFsQTWOoYygGgykx6rb8ektLRENPUcDQ,6262
|
|
4
4
|
codebrain/analyzer.py,sha256=RxyajZp0E8XuB_1SkK1UEXD00gOo3e2vtBzS-US6ms8,40701
|
|
@@ -23,7 +23,7 @@ codebrain/kt.py,sha256=iXeHArPbSrrr2ObH4nuyXPobdBXSOR3b7JbIqXB0kj4,17199
|
|
|
23
23
|
codebrain/kt_video.py,sha256=quaUyPUJh8xXsj52aBCPp_L8fekNC4vHmNYrUTfCqcE,29211
|
|
24
24
|
codebrain/llm.py,sha256=0D5c5BJMVkLz2OoybfMxlUdJEbz16cZoNgVmm-LzFH0,25606
|
|
25
25
|
codebrain/logging.py,sha256=ORR5L8REVlh4aJz9vqErmi76aqjLN2xVKoA2gSv_jas,1110
|
|
26
|
-
codebrain/mcp_lifecycle.py,sha256=
|
|
26
|
+
codebrain/mcp_lifecycle.py,sha256=fpwajl4-5YdZe0cEMmE2_o_FFkyrh3bl3Jqwrp-XGAM,15332
|
|
27
27
|
codebrain/mcp_server.py,sha256=Lb5ob3NI-S0Ln7Yp8G8HxWsKvAuzVzl123N08ZkxPyw,110648
|
|
28
28
|
codebrain/migration.py,sha256=ZMcug6OsvvK-DVdfmqhWCUx-oVj0KQD0EGlBVx6Jxvk,29489
|
|
29
29
|
codebrain/modernize.py,sha256=4usRjIFONEThpYhemLIjoYXb9ohEbO7j2wzVO15Dz5A,38525
|
|
@@ -48,7 +48,7 @@ codebrain/actions/test_gen.py,sha256=PAnHfYuLp-AIF4YrQYQaBH5ADWhCqUVEI7bFDquxPjs
|
|
|
48
48
|
codebrain/graph/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
49
49
|
codebrain/graph/query.py,sha256=FdkWTIKteCdAGnal01LJDuoSXzSpNp3AT9e0AH-m1eg,44131
|
|
50
50
|
codebrain/graph/schema.py,sha256=5PItrNUMQt37kTsL3SUVLPB7rP2JtQXx-Y4uxX0A0H0,6521
|
|
51
|
-
codebrain/graph/store.py,sha256=
|
|
51
|
+
codebrain/graph/store.py,sha256=pqJu7c6krXgBYDQ5e-KZ8nHWVvNU4OeeXVtDVSNY7pI,15937
|
|
52
52
|
codebrain/memory/__init__.py,sha256=PuFR35m1GPIbRAuUxKu4JYBDlPXJWMPbyrWiggFT2sU,158
|
|
53
53
|
codebrain/memory/store.py,sha256=W31fe4aNTkdi-Woe8bZ8SZ9aUHpWrfBkb-9dQs0geyI,9394
|
|
54
54
|
codebrain/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -73,10 +73,10 @@ codebrain/parser/typescript_parser.py,sha256=fi0DoXLCi_L0aQzpadumDpFHaSyekfp3wmV
|
|
|
73
73
|
codebrain/parser/typescript_treesitter.py,sha256=7RTdcA-HTgrlRhEMwSGa-63saNcjqtPiKZYrEOBmlD4,26391
|
|
74
74
|
codebrain/parser/vue_parser.py,sha256=ZWtjUOyItn_SNfvD6T8j7alV4aIfcHA94WDCS5P59Ps,13054
|
|
75
75
|
codebrain/watcher/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
76
|
-
codebrain/watcher/file_watcher.py,sha256=
|
|
77
|
-
codebrain-0.3.
|
|
78
|
-
codebrain-0.3.
|
|
79
|
-
codebrain-0.3.
|
|
80
|
-
codebrain-0.3.
|
|
81
|
-
codebrain-0.3.
|
|
82
|
-
codebrain-0.3.
|
|
76
|
+
codebrain/watcher/file_watcher.py,sha256=6x-fWIP5YBuwq5BCX6HOV-Kc8EVgE4JUoeLxYJtxDf0,9454
|
|
77
|
+
codebrain-0.3.5.dist-info/licenses/LICENSE,sha256=Dxb0L3H90lGFFik90WQXfMkM_8utGA1BDqizqdCT3UE,1079
|
|
78
|
+
codebrain-0.3.5.dist-info/METADATA,sha256=As_yyzhW3AO24DXeNzezMPpyvNb8HYb8G6LhrtVUIGs,10129
|
|
79
|
+
codebrain-0.3.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
80
|
+
codebrain-0.3.5.dist-info/entry_points.txt,sha256=FHKSyI7le7GK78HxAKQK54-yzt-yHNxX0df3VpGC5wg,44
|
|
81
|
+
codebrain-0.3.5.dist-info/top_level.txt,sha256=mUxCZc80EyNOMzd2vAm22uIhnOb1Aw7ZOAUT_u-ksx4,10
|
|
82
|
+
codebrain-0.3.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|