codebrain 0.3.5__py3-none-any.whl → 0.4.0__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/cli.py +43 -24
- codebrain/graph/store.py +12 -5
- codebrain/indexer.py +53 -3
- codebrain/mcp_lifecycle.py +49 -24
- codebrain/mcp_server.py +123 -0
- {codebrain-0.3.5.dist-info → codebrain-0.4.0.dist-info}/METADATA +1 -1
- {codebrain-0.3.5.dist-info → codebrain-0.4.0.dist-info}/RECORD +12 -12
- {codebrain-0.3.5.dist-info → codebrain-0.4.0.dist-info}/WHEEL +0 -0
- {codebrain-0.3.5.dist-info → codebrain-0.4.0.dist-info}/entry_points.txt +0 -0
- {codebrain-0.3.5.dist-info → codebrain-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {codebrain-0.3.5.dist-info → codebrain-0.4.0.dist-info}/top_level.txt +0 -0
codebrain/__init__.py
CHANGED
codebrain/cli.py
CHANGED
|
@@ -58,9 +58,17 @@ def _require_index(repo_root: Path) -> Path:
|
|
|
58
58
|
import gc
|
|
59
59
|
gc.collect() # Release any lingering connections
|
|
60
60
|
try:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
# Prefer rebuilding in place — deleting the file fails on
|
|
62
|
+
# Windows while any MCP server holds it open. Only fall back
|
|
63
|
+
# to file replacement when the DB is unreadable.
|
|
64
|
+
try:
|
|
65
|
+
from codebrain.graph.store import GraphStore
|
|
66
|
+
with GraphStore(db) as _store:
|
|
67
|
+
_store.clear_all()
|
|
68
|
+
except Exception:
|
|
69
|
+
db.unlink(missing_ok=True)
|
|
70
|
+
for wal in db.parent.glob(f"{db.name}-*"):
|
|
71
|
+
wal.unlink(missing_ok=True)
|
|
64
72
|
from codebrain.indexer import full_index
|
|
65
73
|
full_index(repo_root, db)
|
|
66
74
|
click.echo(click.style("Database rebuilt successfully.", fg="green"), err=True)
|
|
@@ -1618,33 +1626,37 @@ def reindex(ctx: click.Context, yes: bool, as_json: bool) -> None:
|
|
|
1618
1626
|
db = _db_path(repo_root)
|
|
1619
1627
|
|
|
1620
1628
|
if not yes and not as_json:
|
|
1621
|
-
if not click.confirm("This will
|
|
1629
|
+
if not click.confirm("This will rebuild the entire index. Continue?"):
|
|
1622
1630
|
click.echo("Aborted.")
|
|
1623
1631
|
return
|
|
1624
1632
|
|
|
1633
|
+
# Rebuild IN PLACE (clear tables, re-fill) rather than deleting the DB
|
|
1634
|
+
# file. Deleting fails on Windows whenever any MCP server holds the DB
|
|
1635
|
+
# open — and with session-long server lifetimes that is "always". WAL
|
|
1636
|
+
# mode makes the in-place rebuild safe with readers attached, so reindex
|
|
1637
|
+
# never needs to find, kill, or wait for other processes.
|
|
1625
1638
|
if db.exists():
|
|
1639
|
+
from codebrain.graph.store import GraphStore
|
|
1626
1640
|
try:
|
|
1627
|
-
db
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
if
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1641
|
+
with GraphStore(db) as _store:
|
|
1642
|
+
_store.clear_all()
|
|
1643
|
+
except Exception as exc:
|
|
1644
|
+
# Corrupted beyond clearing — replacing the file is the only
|
|
1645
|
+
# option left. This fails if another process holds it open.
|
|
1646
|
+
click.echo(click.style(
|
|
1647
|
+
f" Index unreadable ({exc}); replacing the database file.", fg="yellow",
|
|
1648
|
+
))
|
|
1649
|
+
try:
|
|
1650
|
+
db.unlink()
|
|
1651
|
+
for wal in db.parent.glob(f"{db.name}-*"):
|
|
1652
|
+
wal.unlink(missing_ok=True)
|
|
1653
|
+
except PermissionError:
|
|
1654
|
+
click.echo(click.style(
|
|
1655
|
+
" The corrupted database is held open by a running MCP server.\n"
|
|
1656
|
+
" Close Claude Code sessions for this project (or run "
|
|
1657
|
+
"`brain doctor --kill-stale-mcps`) and retry.", fg="red",
|
|
1658
|
+
))
|
|
1636
1659
|
raise
|
|
1637
|
-
should_kill = yes or as_json or click.confirm(
|
|
1638
|
-
f"Database locked by CodeBrain MCP server PID {holder}. Terminate it and continue?"
|
|
1639
|
-
)
|
|
1640
|
-
if not should_kill:
|
|
1641
|
-
click.echo("Aborted.")
|
|
1642
|
-
return
|
|
1643
|
-
status = kill_pid(holder)
|
|
1644
|
-
click.echo(f" Killed MCP PID {holder}: {status}")
|
|
1645
|
-
import time as _time
|
|
1646
|
-
_time.sleep(1)
|
|
1647
|
-
db.unlink()
|
|
1648
1660
|
|
|
1649
1661
|
files = discover_files(repo_root)
|
|
1650
1662
|
|
|
@@ -1847,6 +1859,13 @@ def repair(ctx: click.Context) -> None:
|
|
|
1847
1859
|
break
|
|
1848
1860
|
except PermissionError:
|
|
1849
1861
|
_time.sleep(0.2)
|
|
1862
|
+
else:
|
|
1863
|
+
click.echo(click.style(
|
|
1864
|
+
"Could not replace the corrupted database — a running MCP server "
|
|
1865
|
+
"holds it open.\nClose Claude Code sessions for this project (or "
|
|
1866
|
+
"run `brain doctor --kill-stale-mcps`) and retry.", fg="red",
|
|
1867
|
+
))
|
|
1868
|
+
sys.exit(1)
|
|
1850
1869
|
# Also clean up WAL/SHM files
|
|
1851
1870
|
for suffix in ("-wal", "-shm"):
|
|
1852
1871
|
wal = db.parent / (db.name + suffix)
|
codebrain/graph/store.py
CHANGED
|
@@ -22,11 +22,18 @@ class GraphStore:
|
|
|
22
22
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
23
|
_log.debug("Opening database %s", self.db_path)
|
|
24
24
|
self.conn = sqlite3.connect(str(self.db_path), timeout=30, check_same_thread=False)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
try:
|
|
26
|
+
self.conn.row_factory = sqlite3.Row
|
|
27
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
28
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
29
|
+
self.conn.execute("PRAGMA foreign_keys=OFF")
|
|
30
|
+
migrate_db(self.conn)
|
|
31
|
+
except Exception:
|
|
32
|
+
# A corrupted/garbage file makes the pragmas raise AFTER the OS
|
|
33
|
+
# handle is open. Without this close, the dangling handle keeps
|
|
34
|
+
# the file locked on Windows and recovery-by-replacement fails.
|
|
35
|
+
self.conn.close()
|
|
36
|
+
raise
|
|
30
37
|
|
|
31
38
|
def close(self) -> None:
|
|
32
39
|
_log.debug("Closing database %s", self.db_path)
|
codebrain/indexer.py
CHANGED
|
@@ -490,6 +490,43 @@ def full_index(
|
|
|
490
490
|
}
|
|
491
491
|
|
|
492
492
|
|
|
493
|
+
def scan_stale(
|
|
494
|
+
repo_root: Path,
|
|
495
|
+
store: GraphStore,
|
|
496
|
+
settings: "Settings | None" = None,
|
|
497
|
+
) -> tuple[list[Path], list[Path]]:
|
|
498
|
+
"""Cheap staleness scan: which files changed since they were indexed?
|
|
499
|
+
|
|
500
|
+
Returns ``(changed_candidates, deleted)``. Uses an mtime-vs-last_indexed
|
|
501
|
+
prefilter so no file contents are read here — false candidates are fine
|
|
502
|
+
because :func:`incremental_update` hash-checks before re-parsing. This is
|
|
503
|
+
the fast freshness layer (milliseconds on a warm FS cache); the exhaustive
|
|
504
|
+
hash-everything pass lives in the watcher's startup catch-up sync.
|
|
505
|
+
"""
|
|
506
|
+
rows = store.conn.execute("SELECT path, last_indexed FROM files").fetchall()
|
|
507
|
+
stored = {row["path"]: row["last_indexed"] for row in rows}
|
|
508
|
+
|
|
509
|
+
changed: list[Path] = []
|
|
510
|
+
seen: set[str] = set()
|
|
511
|
+
# Files modified within this window before indexing finished may have
|
|
512
|
+
# raced the indexer — treat them as candidates (hash check disambiguates).
|
|
513
|
+
slack = 2.0
|
|
514
|
+
for file_path in discover_files(repo_root, settings):
|
|
515
|
+
rel = normalize_path(file_path, repo_root)
|
|
516
|
+
seen.add(rel)
|
|
517
|
+
last_indexed = stored.get(rel)
|
|
518
|
+
if last_indexed is None:
|
|
519
|
+
changed.append(file_path) # new file
|
|
520
|
+
continue
|
|
521
|
+
try:
|
|
522
|
+
if file_path.stat().st_mtime > last_indexed - slack:
|
|
523
|
+
changed.append(file_path)
|
|
524
|
+
except OSError:
|
|
525
|
+
continue
|
|
526
|
+
deleted = [repo_root / rel for rel in stored if rel not in seen]
|
|
527
|
+
return changed, deleted
|
|
528
|
+
|
|
529
|
+
|
|
493
530
|
def incremental_update(
|
|
494
531
|
repo_root: Path,
|
|
495
532
|
changed_files: list[Path],
|
|
@@ -510,6 +547,12 @@ def incremental_update(
|
|
|
510
547
|
store.remove_file(rel)
|
|
511
548
|
removed += 1
|
|
512
549
|
|
|
550
|
+
# tree-sitter can hang holding the GIL on Windows — isolate those
|
|
551
|
+
# extensions in a subprocess, same as full_index. A hang here would
|
|
552
|
+
# otherwise freeze the watcher/catch-up thread while it holds the DB
|
|
553
|
+
# lock, silently stopping auto-indexing for the rest of the session.
|
|
554
|
+
_NEEDS_ISOLATION = frozenset({".ts", ".tsx", ".js", ".jsx"})
|
|
555
|
+
|
|
513
556
|
for file_path in changed_files:
|
|
514
557
|
rel = normalize_path(file_path, repo_root)
|
|
515
558
|
try:
|
|
@@ -519,9 +562,16 @@ def incremental_update(
|
|
|
519
562
|
if current_hash == stored_hash:
|
|
520
563
|
continue
|
|
521
564
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
565
|
+
if sys.platform == "win32" and file_path.suffix in _NEEDS_ISOLATION:
|
|
566
|
+
pf, err = _parse_with_timeout(file_path, repo_root, timeout=30)
|
|
567
|
+
if err:
|
|
568
|
+
errors.append(err)
|
|
569
|
+
continue
|
|
570
|
+
else:
|
|
571
|
+
pf = _parse_file(file_path, repo_root)
|
|
572
|
+
if pf is not None:
|
|
573
|
+
store.upsert_file(pf)
|
|
574
|
+
updated += 1
|
|
525
575
|
except Exception as exc:
|
|
526
576
|
errors.append(f"{rel}: {exc}")
|
|
527
577
|
|
codebrain/mcp_lifecycle.py
CHANGED
|
@@ -79,39 +79,43 @@ def _read_pid_file(pid_file: Path) -> int | None:
|
|
|
79
79
|
return None
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
def _predecessor_host_pid(pid: int) -> int | None:
|
|
83
|
+
"""Return the PID of the live IDE host (claude/cursor/...) in ``pid``'s
|
|
84
|
+
ancestor chain, or None if there is none.
|
|
85
|
+
|
|
86
|
+
A predecessor with a live host belongs to *some* Claude session — but
|
|
87
|
+
whether it must be spared depends on WHICH session: a different host PID
|
|
88
|
+
means a concurrent sibling window (killing it would silently break that
|
|
89
|
+
session's MCP); the SAME host PID as ours means it is our own session's
|
|
90
|
+
orphaned predecessor left behind by a transport disconnect/reconnect,
|
|
91
|
+
and it must die or two servers fight over the SQLite DB.
|
|
88
92
|
"""
|
|
89
93
|
try:
|
|
90
94
|
import psutil
|
|
91
95
|
except ImportError:
|
|
92
|
-
return
|
|
96
|
+
return None
|
|
93
97
|
try:
|
|
94
98
|
proc = psutil.Process(pid)
|
|
95
99
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
96
|
-
return
|
|
100
|
+
return None
|
|
97
101
|
for _ in range(ANCESTOR_WALK_DEPTH):
|
|
98
102
|
try:
|
|
99
103
|
parent = proc.parent()
|
|
100
104
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
101
|
-
return
|
|
105
|
+
return None
|
|
102
106
|
if parent is None:
|
|
103
|
-
return
|
|
107
|
+
return None
|
|
104
108
|
try:
|
|
105
109
|
name = (parent.name() or "").lower()
|
|
106
110
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
107
|
-
return
|
|
111
|
+
return None
|
|
108
112
|
if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
|
|
109
|
-
return
|
|
113
|
+
return parent.pid
|
|
110
114
|
proc = parent
|
|
111
|
-
return
|
|
115
|
+
return None
|
|
112
116
|
|
|
113
117
|
|
|
114
|
-
def _kill_stale_predecessor(pid_file: Path) -> None:
|
|
118
|
+
def _kill_stale_predecessor(pid_file: Path, own_host_pid: int | None = None) -> None:
|
|
115
119
|
if not pid_file.exists():
|
|
116
120
|
return
|
|
117
121
|
old_pid = _read_pid_file(pid_file)
|
|
@@ -128,12 +132,27 @@ def _kill_stale_predecessor(pid_file: Path) -> None:
|
|
|
128
132
|
# PID was reused by an unrelated process. Don't touch.
|
|
129
133
|
_log.debug("PID %d reused by unrelated process; leaving alone", old_pid)
|
|
130
134
|
return
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
135
|
+
pred_host = _predecessor_host_pid(old_pid)
|
|
136
|
+
if pred_host is not None and pred_host != own_host_pid:
|
|
137
|
+
# Live IDE host that is NOT ours → concurrent sibling Claude session
|
|
138
|
+
# is still using this MCP — leave it. Without this, two Claude
|
|
139
|
+
# windows on the same repo race each other and whichever started
|
|
140
|
+
# last kills the other's MCP.
|
|
141
|
+
_log.debug(
|
|
142
|
+
"PID %d has live IDE host %d (ours: %s); sibling MCP, leaving alone",
|
|
143
|
+
old_pid, pred_host, own_host_pid,
|
|
144
|
+
)
|
|
136
145
|
return
|
|
146
|
+
if pred_host is not None:
|
|
147
|
+
# Same host as ours → our own session reconnected and left the old
|
|
148
|
+
# server behind with a dead stdio transport. It will never exit on
|
|
149
|
+
# its own (parent still alive, idle timer disabled when anchored)
|
|
150
|
+
# and holds the SQLite DB — kill it.
|
|
151
|
+
_log.warning(
|
|
152
|
+
"Predecessor PID %d shares our IDE host %d — duplicate from a "
|
|
153
|
+
"transport reconnect, terminating it",
|
|
154
|
+
old_pid, pred_host,
|
|
155
|
+
)
|
|
137
156
|
try:
|
|
138
157
|
proc = psutil.Process(old_pid)
|
|
139
158
|
_log.warning("Killing stale CodeBrain MCP predecessor PID %d", old_pid)
|
|
@@ -300,17 +319,23 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
|
|
|
300
319
|
start = time.time()
|
|
301
320
|
mark_activity()
|
|
302
321
|
|
|
322
|
+
# Resolve our own IDE host FIRST — the predecessor check needs it to
|
|
323
|
+
# distinguish "our own session's orphan after a transport reconnect"
|
|
324
|
+
# (kill) from "a concurrent sibling window's server" (spare).
|
|
325
|
+
immediate_ppid = os.getppid()
|
|
326
|
+
initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
|
|
327
|
+
idle_timeout = _effective_idle_timeout(host_anchored)
|
|
328
|
+
|
|
303
329
|
if repo_root is not None:
|
|
304
330
|
from codebrain.config import CODEBRAIN_DIR
|
|
305
331
|
pid_file = repo_root / CODEBRAIN_DIR / PID_FILE_NAME
|
|
306
|
-
_kill_stale_predecessor(
|
|
332
|
+
_kill_stale_predecessor(
|
|
333
|
+
pid_file,
|
|
334
|
+
own_host_pid=initial_ppid if host_anchored else None,
|
|
335
|
+
)
|
|
307
336
|
_write_pid_file(pid_file)
|
|
308
337
|
atexit.register(_remove_pid_file, pid_file)
|
|
309
338
|
|
|
310
|
-
immediate_ppid = os.getppid()
|
|
311
|
-
initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
|
|
312
|
-
idle_timeout = _effective_idle_timeout(host_anchored)
|
|
313
|
-
|
|
314
339
|
_log.info(
|
|
315
340
|
"MCP watchdogs installed (ppid=%d via=%d, host_anchored=%s, "
|
|
316
341
|
"idle_timeout=%ds, max_lifetime=%ds)",
|
codebrain/mcp_server.py
CHANGED
|
@@ -110,6 +110,125 @@ def _make_store():
|
|
|
110
110
|
return GraphStore(_find_db())
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Freshness-on-read
|
|
115
|
+
#
|
|
116
|
+
# Correctness must not depend on which background processes happen to be
|
|
117
|
+
# alive. The watcher (instant) and the startup catch-up sync (thorough) are
|
|
118
|
+
# optimizations; THIS is the guarantee: before serving a tool call, cheaply
|
|
119
|
+
# verify the index matches the working tree and sync the difference. Even if
|
|
120
|
+
# every watcher thread is dead, the answer the agent gets reflects current
|
|
121
|
+
# code.
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
import threading as _threading
|
|
125
|
+
import time as _time
|
|
126
|
+
|
|
127
|
+
FRESHNESS_INTERVAL_SECONDS = float(os.environ.get("CODEBRAIN_FRESHNESS_INTERVAL", "5"))
|
|
128
|
+
# Above this many stale files, sync in the background instead of inline so a
|
|
129
|
+
# huge drift (e.g. branch switch on a big repo) can't eat the tool deadline.
|
|
130
|
+
FRESHNESS_INLINE_LIMIT = 200
|
|
131
|
+
|
|
132
|
+
_freshness_lock = _threading.Lock()
|
|
133
|
+
_last_freshness_check: dict[str, float] = {}
|
|
134
|
+
|
|
135
|
+
# Tools that must NOT trigger a freshness sync:
|
|
136
|
+
# - validators compare new/written content against the PRE-change graph;
|
|
137
|
+
# syncing first would erase the baseline and the structural gate would
|
|
138
|
+
# silently pass everything
|
|
139
|
+
# - reindex_codebase rebuilds anyway
|
|
140
|
+
# - project/memory tools don't read graph data
|
|
141
|
+
_FRESHNESS_EXEMPT_TOOLS = frozenset({
|
|
142
|
+
"validate_change",
|
|
143
|
+
"validate_changes",
|
|
144
|
+
"validate_after_write",
|
|
145
|
+
"propose_change",
|
|
146
|
+
"diff_impact",
|
|
147
|
+
"get_validation_status",
|
|
148
|
+
"reindex_codebase",
|
|
149
|
+
"set_project",
|
|
150
|
+
"get_project",
|
|
151
|
+
"save_memory",
|
|
152
|
+
"recall_memories",
|
|
153
|
+
"list_memories",
|
|
154
|
+
"update_memory",
|
|
155
|
+
"delete_memory",
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ensure_fresh() -> None:
|
|
160
|
+
"""Best-effort staleness sync before serving a tool call.
|
|
161
|
+
|
|
162
|
+
Throttled per database; never blocks on a concurrent sync (a slightly
|
|
163
|
+
stale answer beats a deadlocked one); never raises.
|
|
164
|
+
"""
|
|
165
|
+
if FRESHNESS_INTERVAL_SECONDS <= 0:
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
db_path = _find_db()
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
return
|
|
171
|
+
key = str(db_path)
|
|
172
|
+
now = _time.monotonic()
|
|
173
|
+
if now - _last_freshness_check.get(key, 0.0) < FRESHNESS_INTERVAL_SECONDS:
|
|
174
|
+
return
|
|
175
|
+
if not _freshness_lock.acquire(blocking=False):
|
|
176
|
+
return # another sync in flight — serve what we have
|
|
177
|
+
try:
|
|
178
|
+
_last_freshness_check[key] = now
|
|
179
|
+
from codebrain.graph.store import GraphStore
|
|
180
|
+
from codebrain.indexer import incremental_update, scan_stale
|
|
181
|
+
|
|
182
|
+
repo_root = db_path.parent.parent
|
|
183
|
+
with GraphStore(db_path) as store:
|
|
184
|
+
changed, deleted = scan_stale(repo_root, store)
|
|
185
|
+
if not changed and not deleted:
|
|
186
|
+
return
|
|
187
|
+
if len(changed) + len(deleted) > FRESHNESS_INLINE_LIMIT:
|
|
188
|
+
_log.info(
|
|
189
|
+
"Freshness: %d files drifted — syncing in background",
|
|
190
|
+
len(changed) + len(deleted),
|
|
191
|
+
)
|
|
192
|
+
_threading.Thread(
|
|
193
|
+
target=_background_freshness_sync,
|
|
194
|
+
args=(repo_root, db_path, changed, deleted),
|
|
195
|
+
name="cb-freshness-sync",
|
|
196
|
+
daemon=True,
|
|
197
|
+
).start()
|
|
198
|
+
return
|
|
199
|
+
result = incremental_update(repo_root, changed, deleted, store)
|
|
200
|
+
if result["files_updated"] or result["files_removed"]:
|
|
201
|
+
_log.info(
|
|
202
|
+
"Freshness: updated %d, removed %d (%.3fs)",
|
|
203
|
+
result["files_updated"],
|
|
204
|
+
result["files_removed"],
|
|
205
|
+
result["elapsed_seconds"],
|
|
206
|
+
)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
_log.debug("Freshness sync skipped: %s", exc)
|
|
209
|
+
finally:
|
|
210
|
+
_freshness_lock.release()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _background_freshness_sync(
|
|
214
|
+
repo_root: Path, db_path: Path, changed: list, deleted: list,
|
|
215
|
+
) -> None:
|
|
216
|
+
try:
|
|
217
|
+
from codebrain.graph.store import GraphStore
|
|
218
|
+
from codebrain.indexer import incremental_update
|
|
219
|
+
|
|
220
|
+
with GraphStore(db_path) as store:
|
|
221
|
+
result = incremental_update(repo_root, changed, deleted, store)
|
|
222
|
+
_log.info(
|
|
223
|
+
"Background freshness sync: updated %d, removed %d (%.3fs)",
|
|
224
|
+
result["files_updated"],
|
|
225
|
+
result["files_removed"],
|
|
226
|
+
result["elapsed_seconds"],
|
|
227
|
+
)
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
_log.warning("Background freshness sync failed: %s", exc)
|
|
230
|
+
|
|
231
|
+
|
|
113
232
|
def _safe_tool(fn): # noqa: ANN001, ANN201
|
|
114
233
|
"""Wrap an MCP tool so it cooperates with MCP cancellation.
|
|
115
234
|
|
|
@@ -214,6 +333,10 @@ def _run_sync_protected(fn, args, kwargs): # noqa: ANN001, ANN202
|
|
|
214
333
|
saved_stdout = sys.stdout
|
|
215
334
|
sys.stdout = sys.stderr
|
|
216
335
|
try:
|
|
336
|
+
# Freshness gate: sync index drift before answering. Skip for tools
|
|
337
|
+
# that rebuild or don't read the graph state being synced.
|
|
338
|
+
if fn.__name__ not in _FRESHNESS_EXEMPT_TOOLS:
|
|
339
|
+
_ensure_fresh()
|
|
217
340
|
return fn(*args, **kwargs)
|
|
218
341
|
finally:
|
|
219
342
|
sys.stdout = saved_stdout
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebrain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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,11 +1,11 @@
|
|
|
1
|
-
codebrain/__init__.py,sha256=
|
|
1
|
+
codebrain/__init__.py,sha256=HrQ5FQd5GTjIxch8KWjHo5Q2CjcKK-ffXNRmY8Cr8ug,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
|
|
5
5
|
codebrain/api.py,sha256=LmeFK3KyUKR1xz4tAoPteaRA06c09KA965Hk33VhV6I,31653
|
|
6
6
|
codebrain/api_models.py,sha256=mE5sIsf8WLEWjGm2w6-baeyVoJH_65FOglHwFe3aJhs,1989
|
|
7
7
|
codebrain/architecture.py,sha256=RCN_LUNTjd0fXYFIYDacwH17qEeFCxNCyF6irbs5yfE,29485
|
|
8
|
-
codebrain/cli.py,sha256=
|
|
8
|
+
codebrain/cli.py,sha256=RbXLvRDMXihedsPH-CxbQjWTvFyJbsyFCdtRhMbVk2s,170135
|
|
9
9
|
codebrain/comprehension.py,sha256=eAVf1abecxVo72_2p8aEgRmSeXR7VNMyJnt_DTst47E,83023
|
|
10
10
|
codebrain/config.py,sha256=UvStpfDNvulMHkN7xnGjEF3EKvdf9tF3Cv4A01FdEQw,1745
|
|
11
11
|
codebrain/context.py,sha256=BFZ_WWRf242cJli-TXoz5JFqcvsKkwbmuUMBX8WvM9o,10980
|
|
@@ -18,13 +18,13 @@ codebrain/export.py,sha256=A4zdzJ2MVmo-sxJQjwoZlMRkm-QXCxE14lYDm9RrQEY,11576
|
|
|
18
18
|
codebrain/frontend.py,sha256=4kFIcWoPKS_pLaWAirmkJmYSYTlicCsi_GZgLLjunng,17675
|
|
19
19
|
codebrain/hook_runner.py,sha256=1shd-lw8Rx8zNOuJfgcbUOzhsNUVIwhmuzDpTQsdnHo,5458
|
|
20
20
|
codebrain/hooks.py,sha256=tT2Dx_T0iZLEQarL7QYvvVObYfelMUKVNXNz74MU-tM,3179
|
|
21
|
-
codebrain/indexer.py,sha256=
|
|
21
|
+
codebrain/indexer.py,sha256=NaMj-1ycT4cINJfgykjZC9cAs7cxVLfaojY0AKxaEZw,21083
|
|
22
22
|
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=
|
|
27
|
-
codebrain/mcp_server.py,sha256=
|
|
26
|
+
codebrain/mcp_lifecycle.py,sha256=xo-T1-RNCzFRKhWcKUkEF6ZahPCISp6swTfwRrKazGg,16598
|
|
27
|
+
codebrain/mcp_server.py,sha256=tdSmB4w6KYcXi1lHxcxle-adR12Nm-v7mRSoxBDKiD4,115349
|
|
28
28
|
codebrain/migration.py,sha256=ZMcug6OsvvK-DVdfmqhWCUx-oVj0KQD0EGlBVx6Jxvk,29489
|
|
29
29
|
codebrain/modernize.py,sha256=4usRjIFONEThpYhemLIjoYXb9ohEbO7j2wzVO15Dz5A,38525
|
|
30
30
|
codebrain/onboard.py,sha256=m63A0A8pJdZUiGFgAlRD1ySe7Bk_Eo_fiTPzLfkL1ig,15270
|
|
@@ -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=x8xkOrSGWQexIjEKP09CwCo8v1E1W4w34QQAUC8gvyE,16272
|
|
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
|
|
@@ -74,9 +74,9 @@ codebrain/parser/typescript_treesitter.py,sha256=7RTdcA-HTgrlRhEMwSGa-63saNcjqtP
|
|
|
74
74
|
codebrain/parser/vue_parser.py,sha256=ZWtjUOyItn_SNfvD6T8j7alV4aIfcHA94WDCS5P59Ps,13054
|
|
75
75
|
codebrain/watcher/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
76
76
|
codebrain/watcher/file_watcher.py,sha256=6x-fWIP5YBuwq5BCX6HOV-Kc8EVgE4JUoeLxYJtxDf0,9454
|
|
77
|
-
codebrain-0.
|
|
78
|
-
codebrain-0.
|
|
79
|
-
codebrain-0.
|
|
80
|
-
codebrain-0.
|
|
81
|
-
codebrain-0.
|
|
82
|
-
codebrain-0.
|
|
77
|
+
codebrain-0.4.0.dist-info/licenses/LICENSE,sha256=Dxb0L3H90lGFFik90WQXfMkM_8utGA1BDqizqdCT3UE,1079
|
|
78
|
+
codebrain-0.4.0.dist-info/METADATA,sha256=W-Gb7JPxHu6eSD_bDmm42i9NU7TLxxls8Ylp5mTEbsw,10129
|
|
79
|
+
codebrain-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
80
|
+
codebrain-0.4.0.dist-info/entry_points.txt,sha256=FHKSyI7le7GK78HxAKQK54-yzt-yHNxX0df3VpGC5wg,44
|
|
81
|
+
codebrain-0.4.0.dist-info/top_level.txt,sha256=mUxCZc80EyNOMzd2vAm22uIhnOb1Aw7ZOAUT_u-ksx4,10
|
|
82
|
+
codebrain-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|