nexo-brain 7.32.0 → 7.34.0

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.
@@ -221,17 +221,65 @@ def load_recent_dedupe_keys(target_date: str, days: int = 7) -> set[str]:
221
221
  return keys
222
222
 
223
223
 
224
- def backup_db(db_path: Path, run_id: str) -> Path | None:
225
- """Create a backup of a database before mutations."""
224
+ class BackupError(RuntimeError):
225
+ """Raised when a fail-closed backup cannot be produced."""
226
+
227
+
228
+ def backup_db(db_path: Path, run_id: str, *, fail_closed: bool = False) -> Path | None:
229
+ """Create a consistent snapshot of a database before mutations.
230
+
231
+ Uses the SQLite online backup API (``conn.backup``) so the snapshot is
232
+ transaction-consistent and folds in any pending ``-wal``/``-shm`` state.
233
+ The previous implementation used ``shutil.copy2`` of only the ``.db`` file,
234
+ which could capture a torn copy when a WAL sidecar was live and silently
235
+ swallowed failures while still mutating the DB afterwards.
236
+
237
+ fail_closed=True makes the caller's intent explicit: if a consistent
238
+ snapshot cannot be written, raise ``BackupError`` instead of returning
239
+ ``None``. Callers that mutate memory MUST pass fail_closed=True so the
240
+ mutation phase aborts when no recovery point exists. Anti-data-loss
241
+ invariant: never mutate without a verified backup.
242
+ """
226
243
  if not db_path.exists():
244
+ if fail_closed:
245
+ # No source DB means nothing to back up and nothing to mutate; that
246
+ # is a safe no-op, not a backup failure.
247
+ return None
227
248
  return None
228
- backup_path = BACKUP_DIR / f"{run_id}-backup-{db_path.name}"
249
+ backup_path = Path(BACKUP_DIR) / f"{run_id}-backup-{db_path.name}"
229
250
  try:
230
- import shutil
231
- shutil.copy2(str(db_path), str(backup_path))
251
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
252
+ # Online backup API: consistent snapshot even with a live WAL.
253
+ src = sqlite3.connect(str(db_path))
254
+ try:
255
+ dst = sqlite3.connect(str(backup_path))
256
+ try:
257
+ src.backup(dst)
258
+ finally:
259
+ dst.close()
260
+ finally:
261
+ src.close()
262
+ # Verify the snapshot is a readable SQLite DB before trusting it.
263
+ verify = sqlite3.connect(str(backup_path))
264
+ try:
265
+ verify.execute("PRAGMA schema_version").fetchone()
266
+ finally:
267
+ verify.close()
268
+ if not backup_path.exists() or backup_path.stat().st_size == 0:
269
+ raise BackupError(f"backup snapshot empty for {db_path.name}")
232
270
  return backup_path
233
271
  except Exception as e:
234
- print(f" [apply] Warning: backup failed for {db_path.name}: {e}", file=sys.stderr)
272
+ # Best-effort cleanup of a partial/torn snapshot.
273
+ try:
274
+ if backup_path.exists():
275
+ backup_path.unlink()
276
+ except Exception:
277
+ pass
278
+ msg = f"backup failed for {db_path.name}: {e}"
279
+ if fail_closed:
280
+ print(f" [apply] ABORT: {msg}", file=sys.stderr)
281
+ raise BackupError(msg) from e
282
+ print(f" [apply] Warning: {msg}", file=sys.stderr)
235
283
  return None
236
284
 
237
285
 
@@ -534,10 +582,35 @@ def _update_learning_row(learning_id: int, updates: dict[str, object]) -> None:
534
582
  if not updates:
535
583
  return
536
584
  conn = sqlite3.connect(str(NEXO_DB))
585
+ conn.row_factory = sqlite3.Row
586
+ # Capture before-state for the changed columns so the edit is auditable and
587
+ # reversible (item_history is append-only; nothing is overwritten).
588
+ before: dict[str, object] = {}
589
+ try:
590
+ cols = ", ".join(updates.keys())
591
+ prev = conn.execute(
592
+ f"SELECT {cols} FROM learnings WHERE id = ?", (learning_id,)
593
+ ).fetchone()
594
+ if prev is not None:
595
+ before = {k: prev[k] for k in updates.keys()}
596
+ except Exception:
597
+ before = {}
537
598
  set_clause = ", ".join(f"{column} = ?" for column in updates)
538
599
  conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", list(updates.values()) + [learning_id])
539
600
  conn.commit()
540
601
  conn.close()
602
+ # Append an immutable history event. Failure here must never break the edit.
603
+ try:
604
+ nexo_db.add_item_history(
605
+ "learning",
606
+ str(learning_id),
607
+ "deep_sleep_update",
608
+ note="Deep Sleep edited learning fields",
609
+ actor="deep_sleep",
610
+ metadata={"before": before, "after": updates, "primitive": "_update_learning_row"},
611
+ )
612
+ except Exception:
613
+ pass
541
614
 
542
615
 
543
616
  def _bump_weight(existing_value, amount: float) -> float:
@@ -2351,12 +2424,16 @@ def main():
2351
2424
  existing_keys = load_recent_dedupe_keys(target_date)
2352
2425
  print(f"[apply] Existing dedupe keys (7d): {len(existing_keys)}")
2353
2426
 
2354
- # Backup databases before mutations
2427
+ # Backup databases before mutations (fail-closed: abort if no recovery point)
2355
2428
  auto_apply_count = sum(1 for a in actions if a.get("action_class") == "auto_apply")
2356
2429
  if auto_apply_count > 0:
2357
2430
  print("[apply] Creating database backups...")
2358
- nexo_backup = backup_db(NEXO_DB, run_id)
2359
- cog_backup = backup_db(COGNITIVE_DB, run_id)
2431
+ try:
2432
+ nexo_backup = backup_db(NEXO_DB, run_id, fail_closed=True)
2433
+ cog_backup = backup_db(COGNITIVE_DB, run_id, fail_closed=True)
2434
+ except BackupError as e:
2435
+ print(f"[apply] ABORT: cannot mutate without a backup ({e}).", file=sys.stderr)
2436
+ sys.exit(1)
2360
2437
  if nexo_backup:
2361
2438
  print(f" Backup: {nexo_backup}")
2362
2439
  if cog_backup: