nexo-brain 7.20.11 → 7.20.13

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.
@@ -37,6 +37,153 @@ def check_db_exists() -> DoctorCheck:
37
37
  )
38
38
 
39
39
 
40
+ def check_db_integrity(fix: bool = False) -> DoctorCheck:
41
+ """Detect and optionally repair a wiped/corrupt local Brain database."""
42
+ import sqlite3
43
+ import paths
44
+ from db_guard import (
45
+ CRITICAL_TABLES,
46
+ EMPTY_DB_SIZE_BYTES,
47
+ MIN_REFERENCE_ROWS,
48
+ db_looks_wiped,
49
+ db_row_counts,
50
+ find_latest_hourly_backup,
51
+ )
52
+
53
+ db_path = paths.db_path()
54
+ if not db_path.is_file():
55
+ return DoctorCheck(
56
+ id="boot.db_integrity",
57
+ tier="boot",
58
+ status="critical",
59
+ severity="error",
60
+ summary="Database file not found",
61
+ evidence=[str(db_path)],
62
+ repair_plan=["Run nexo-brain to initialize the database"],
63
+ )
64
+
65
+ try:
66
+ size_bytes = db_path.stat().st_size
67
+ except OSError:
68
+ size_bytes = -1
69
+
70
+ quick_ok = False
71
+ quick_error = ""
72
+ try:
73
+ conn = sqlite3.connect(str(db_path), timeout=2)
74
+ try:
75
+ row = conn.execute("PRAGMA quick_check").fetchone()
76
+ quick_ok = bool(row and str(row[0]).lower() == "ok")
77
+ if not quick_ok:
78
+ quick_error = str(row[0] if row else "quick_check returned no row")
79
+ finally:
80
+ conn.close()
81
+ except Exception as exc:
82
+ quick_error = f"{type(exc).__name__}: {exc}"
83
+
84
+ looks_wiped = db_looks_wiped(db_path, CRITICAL_TABLES)
85
+ reference = find_latest_hourly_backup(
86
+ paths.backups_dir(),
87
+ min_critical_rows=MIN_REFERENCE_ROWS,
88
+ )
89
+ reference_counts = db_row_counts(reference, CRITICAL_TABLES) if reference else {}
90
+ reference_rows = sum(v for v in reference_counts.values() if isinstance(v, int))
91
+ lower_error = quick_error.lower()
92
+ corrupt_error = any(token in lower_error for token in (
93
+ "database disk image is malformed",
94
+ "file is not a database",
95
+ "malformed",
96
+ "not a database",
97
+ ))
98
+ recoverable_wipe = bool(
99
+ reference
100
+ and looks_wiped
101
+ and (
102
+ size_bytes <= EMPTY_DB_SIZE_BYTES
103
+ or corrupt_error
104
+ or not quick_ok
105
+ )
106
+ )
107
+
108
+ if quick_ok and not recoverable_wipe:
109
+ if looks_wiped and not reference:
110
+ return DoctorCheck(
111
+ id="boot.db_integrity",
112
+ tier="boot",
113
+ status="healthy",
114
+ severity="info",
115
+ summary="Database is readable and looks like a fresh install",
116
+ evidence=[f"Size: {size_bytes} bytes", "No usable backup with user data found"],
117
+ )
118
+ return DoctorCheck(
119
+ id="boot.db_integrity",
120
+ tier="boot",
121
+ status="healthy",
122
+ severity="info",
123
+ summary="Database integrity OK",
124
+ evidence=[f"Size: {size_bytes} bytes"],
125
+ )
126
+
127
+ evidence = [
128
+ f"DB: {db_path}",
129
+ f"Size: {size_bytes} bytes",
130
+ f"quick_check: {'ok' if quick_ok else quick_error or 'not ok'}",
131
+ f"looks_wiped: {looks_wiped}",
132
+ ]
133
+ if reference:
134
+ evidence.append(f"Reference backup: {reference} ({reference_rows} critical rows)")
135
+
136
+ if fix and recoverable_wipe:
137
+ from plugins.recover import recover
138
+
139
+ report = recover(source=str(reference), force=True)
140
+ if report.get("ok"):
141
+ final_counts = report.get("final_row_counts") or {}
142
+ restored_rows = sum(v for v in final_counts.values() if isinstance(v, int))
143
+ return DoctorCheck(
144
+ id="boot.db_integrity",
145
+ tier="boot",
146
+ status="healthy",
147
+ severity="info",
148
+ summary=f"Database restored from backup ({restored_rows} critical rows)",
149
+ evidence=evidence + [f"Pre-recover snapshot: {report.get('pre_recover_dir', '')}"],
150
+ fixed=True,
151
+ )
152
+ return DoctorCheck(
153
+ id="boot.db_integrity",
154
+ tier="boot",
155
+ status="critical",
156
+ severity="error",
157
+ summary="Database repair failed",
158
+ evidence=evidence + [f"Recover errors: {report.get('errors') or []}"],
159
+ repair_plan=["Run nexo recover --force --yes, then restart Desktop"],
160
+ )
161
+
162
+ if recoverable_wipe:
163
+ return DoctorCheck(
164
+ id="boot.db_integrity",
165
+ tier="boot",
166
+ status="critical",
167
+ severity="error",
168
+ summary="Database appears wiped or corrupt but a valid backup exists",
169
+ evidence=evidence,
170
+ repair_plan=["Run nexo doctor --tier boot --plane database_real --fix"],
171
+ escalation_prompt="NEXO database needs automatic recovery from backup.",
172
+ )
173
+
174
+ status = "critical" if corrupt_error else "degraded"
175
+ severity = "error" if status == "critical" else "warn"
176
+ return DoctorCheck(
177
+ id="boot.db_integrity",
178
+ tier="boot",
179
+ status=status,
180
+ severity=severity,
181
+ summary="Database is not fully readable" if quick_error else "Database integrity is uncertain",
182
+ evidence=evidence,
183
+ repair_plan=["Close NEXO Desktop and run nexo doctor --tier boot --plane database_real --fix"],
184
+ )
185
+
186
+
40
187
  def check_required_dirs() -> DoctorCheck:
41
188
  """Check that required NEXO_HOME directories exist (post-F0.6 layout
42
189
  or pre-F0.6 fallback)."""
@@ -411,6 +558,7 @@ def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
411
558
  """Run all boot-tier checks."""
412
559
  checks = [
413
560
  safe_check(check_db_exists),
561
+ safe_check(check_db_integrity, fix=fix),
414
562
  safe_check(check_required_dirs),
415
563
  safe_check(check_disk_space),
416
564
  safe_check(check_wrapper_scripts),
@@ -9,6 +9,7 @@ from .api import (
9
9
  add_exclusion,
10
10
  add_root,
11
11
  clear_index,
12
+ context_router,
12
13
  context_query,
13
14
  diagnostics_tail,
14
15
  ensure_default_roots,
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "add_exclusion",
34
35
  "add_root",
35
36
  "clear_index",
37
+ "context_router",
36
38
  "context_query",
37
39
  "diagnostics_tail",
38
40
  "ensure_default_roots",