superlocalmemory 4.0.5 → 4.0.7
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.
- package/CHANGELOG.md +108 -0
- package/README.md +8 -9
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/truth.py +80 -10
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +28 -3
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +85 -0
- package/src/superlocalmemory/cli/summary_cmd.py +195 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
- package/src/superlocalmemory/code_graph/config.py +65 -1
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/fact_consolidator.py +24 -1
- package/src/superlocalmemory/core/maintenance.py +93 -1
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +78 -7
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +21 -0
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/server/routes/brain.py +283 -15
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +61 -0
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/database.py +36 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +29 -12
- package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +257 -77
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -238,18 +238,39 @@ def _handle_update_memory(
|
|
|
238
238
|
content: str,
|
|
239
239
|
source_agent_id: str = "system",
|
|
240
240
|
) -> dict:
|
|
241
|
-
"""Update a fact after capability-derived authorization.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
241
|
+
"""Update a fact after capability-derived authorization.
|
|
242
|
+
|
|
243
|
+
CORRECTIONS CANNOT BE PERFORMED FROM THIS WORKER, AND THAT IS BY DESIGN.
|
|
244
|
+
|
|
245
|
+
Since corrections became a review-gated lifecycle (M042) rather than an
|
|
246
|
+
in-place edit, update_fact_authorized() requires a canonical correction
|
|
247
|
+
writer. That writer is the daemon's single-writer mutation boundary
|
|
248
|
+
(CanonicalRememberRuntime, owned by unified_daemon and handed to the HTTP
|
|
249
|
+
route). A worker subprocess cannot own it: CanonicalRememberRuntime.ready
|
|
250
|
+
requires a live worker of its own, so constructing one here would nest
|
|
251
|
+
worker processes and hand a second writer the ownership context that is
|
|
252
|
+
supposed to be exclusive.
|
|
253
|
+
|
|
254
|
+
Previously this called through anyway, and mutations.py answered
|
|
255
|
+
"canonical correction writer is temporarily unavailable" with
|
|
256
|
+
retryable=True. Nothing about it was temporary — with the daemon down that
|
|
257
|
+
path can NEVER succeed, so a caller could retry forever. This is the
|
|
258
|
+
honest, non-retryable answer with the actual remedy: the daemon-backed
|
|
259
|
+
route (used automatically whenever the daemon is running) does support
|
|
260
|
+
corrections.
|
|
261
|
+
"""
|
|
262
|
+
return {
|
|
263
|
+
"ok": False,
|
|
264
|
+
"retryable": False,
|
|
265
|
+
"error": (
|
|
266
|
+
"Corrections are review-gated and require the SuperLocalMemory "
|
|
267
|
+
"daemon, which owns the single correction writer. Start it with "
|
|
268
|
+
"`slm serve start` and retry — updates route through the daemon "
|
|
269
|
+
"automatically when it is running."
|
|
270
|
+
),
|
|
271
|
+
"remedy": "slm serve start",
|
|
272
|
+
"fact_id": fact_id,
|
|
273
|
+
}
|
|
253
274
|
|
|
254
275
|
|
|
255
276
|
def _handle_summarize(texts: list[str], mode: str) -> dict:
|
|
@@ -25,6 +25,10 @@ from datetime import datetime, timedelta, timezone
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from typing import Dict, Generator, List, Optional
|
|
27
27
|
|
|
28
|
+
from superlocalmemory.infra.backup_obligations import (
|
|
29
|
+
BackupObligationStore,
|
|
30
|
+
erase_profile_from_snapshot,
|
|
31
|
+
)
|
|
28
32
|
from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
|
|
29
33
|
|
|
30
34
|
logger = logging.getLogger("superlocalmemory.backup")
|
|
@@ -387,6 +391,21 @@ class BackupCoordinator:
|
|
|
387
391
|
# Phase C succeeded — remove pre-restore snapshots.
|
|
388
392
|
self._cleanup_pre_restore_snapshots(pre_restore_map, pre_restore_lance)
|
|
389
393
|
|
|
394
|
+
# Phase D — GDPR obligation replay (invariant: no restore may resurrect
|
|
395
|
+
# erased personal data). Must run AFTER live files are in place and
|
|
396
|
+
# pre-restore snapshots are removed so there is no window where the
|
|
397
|
+
# erased data could be read. A replay failure raises immediately; the
|
|
398
|
+
# caller receives BackupRestoreError and must treat the restore as
|
|
399
|
+
# failed until an operator manually remediates.
|
|
400
|
+
if backup_set_dir is not None:
|
|
401
|
+
_replay_obligations_after_restore(
|
|
402
|
+
data_root=self._base_dir,
|
|
403
|
+
snapshot_key=str(backup_set_dir),
|
|
404
|
+
restored_db_paths=[
|
|
405
|
+
self._base_dir / e.store_name for e in manifest.stores
|
|
406
|
+
],
|
|
407
|
+
)
|
|
408
|
+
|
|
390
409
|
# ------------------------------------------------------------------
|
|
391
410
|
# Internal helpers (factored out for subclass testability)
|
|
392
411
|
# ------------------------------------------------------------------
|
|
@@ -727,6 +746,24 @@ class BackupManager:
|
|
|
727
746
|
src.close()
|
|
728
747
|
|
|
729
748
|
logger.info("Restored: %s -> %s", filename, target.name)
|
|
749
|
+
|
|
750
|
+
# GDPR obligation replay — prevent a restore from resurrecting
|
|
751
|
+
# previously erased personal data. Failure is fatal: return False
|
|
752
|
+
# so the caller knows the restore is not clean.
|
|
753
|
+
try:
|
|
754
|
+
_replay_obligations_after_restore(
|
|
755
|
+
data_root=self.db_path.parent,
|
|
756
|
+
snapshot_key=str(backup_path),
|
|
757
|
+
restored_db_paths=[target],
|
|
758
|
+
)
|
|
759
|
+
except BackupRestoreError as exc:
|
|
760
|
+
logger.error(
|
|
761
|
+
"Restore obligation replay failed for %s: %s — "
|
|
762
|
+
"restore is NOT clean; erased data may be present",
|
|
763
|
+
filename, exc,
|
|
764
|
+
)
|
|
765
|
+
return False
|
|
766
|
+
|
|
730
767
|
return True
|
|
731
768
|
|
|
732
769
|
except Exception as exc:
|
|
@@ -798,3 +835,104 @@ class BackupManager:
|
|
|
798
835
|
"total_size_mb": round(sum(b["size_mb"] for b in backups), 2),
|
|
799
836
|
"backups": backups,
|
|
800
837
|
}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# ---------------------------------------------------------------------------
|
|
841
|
+
# GDPR obligation replay — module-level so both backup classes can call it
|
|
842
|
+
# ---------------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _replay_obligations_after_restore(
|
|
846
|
+
data_root: Path,
|
|
847
|
+
snapshot_key: str,
|
|
848
|
+
restored_db_paths: list[Path],
|
|
849
|
+
) -> None:
|
|
850
|
+
"""Re-apply pending erasure obligations to freshly restored database files.
|
|
851
|
+
|
|
852
|
+
This function is the enforcement point for the restore-replay invariant:
|
|
853
|
+
no restore may resurface data that was Art.17-erased from the live stores.
|
|
854
|
+
|
|
855
|
+
``snapshot_key`` is the string path that was recorded as the obligation's
|
|
856
|
+
``snapshot_path`` when the obligation was created (typically the backup-set
|
|
857
|
+
directory for new-style backups, or the per-file `.db` path for legacy
|
|
858
|
+
backups).
|
|
859
|
+
|
|
860
|
+
Raises:
|
|
861
|
+
BackupRestoreError: if any obligation cannot be replayed. The caller
|
|
862
|
+
must treat the restore as failed and surface this to the operator.
|
|
863
|
+
"""
|
|
864
|
+
store = BackupObligationStore(data_root)
|
|
865
|
+
obligations = store.list_pending_for_snapshot(snapshot_key)
|
|
866
|
+
|
|
867
|
+
# Belt-and-suspenders: even when path matching fails (e.g. backup moved),
|
|
868
|
+
# detect erased profiles by scanning the restored DB and checking whether
|
|
869
|
+
# any of the profiles present have pending obligations.
|
|
870
|
+
if not obligations:
|
|
871
|
+
for db_path in restored_db_paths:
|
|
872
|
+
if not db_path.exists():
|
|
873
|
+
continue
|
|
874
|
+
try:
|
|
875
|
+
import sqlite3 as _sq3
|
|
876
|
+
conn = _sq3.connect(str(db_path))
|
|
877
|
+
try:
|
|
878
|
+
tables = {
|
|
879
|
+
r[0] for r in
|
|
880
|
+
conn.execute(
|
|
881
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
882
|
+
).fetchall()
|
|
883
|
+
}
|
|
884
|
+
for tbl in ("profiles", "atomic_facts"):
|
|
885
|
+
if tbl not in tables:
|
|
886
|
+
continue
|
|
887
|
+
cols = {
|
|
888
|
+
r[1] for r in
|
|
889
|
+
conn.execute(f"PRAGMA table_info({tbl})").fetchall()
|
|
890
|
+
}
|
|
891
|
+
if "profile_id" not in cols:
|
|
892
|
+
continue
|
|
893
|
+
for (pid,) in conn.execute(
|
|
894
|
+
f"SELECT DISTINCT profile_id FROM {tbl}"
|
|
895
|
+
).fetchall():
|
|
896
|
+
if pid:
|
|
897
|
+
obligations.extend(
|
|
898
|
+
store.list_pending_for_profile(pid)
|
|
899
|
+
)
|
|
900
|
+
finally:
|
|
901
|
+
conn.close()
|
|
902
|
+
except Exception as exc: # noqa: BLE001
|
|
903
|
+
logger.warning(
|
|
904
|
+
"_replay_obligations_after_restore: scan failed for %s: %s",
|
|
905
|
+
db_path.name, exc,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
if not obligations:
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
profile_ids = {o["profile_id"] for o in obligations}
|
|
912
|
+
|
|
913
|
+
for profile_id in profile_ids:
|
|
914
|
+
for db_path in restored_db_paths:
|
|
915
|
+
if not db_path.exists():
|
|
916
|
+
continue
|
|
917
|
+
try:
|
|
918
|
+
deleted = erase_profile_from_snapshot(db_path, profile_id)
|
|
919
|
+
if deleted:
|
|
920
|
+
logger.info(
|
|
921
|
+
"Restore replay: erased profile %r from %s: %s",
|
|
922
|
+
profile_id, db_path.name, deleted,
|
|
923
|
+
)
|
|
924
|
+
except Exception as exc: # noqa: BLE001
|
|
925
|
+
raise BackupRestoreError(
|
|
926
|
+
f"Erasure obligation replay failed for profile {profile_id!r} "
|
|
927
|
+
f"in {db_path.name}: {exc}. "
|
|
928
|
+
"The restored database may contain previously erased personal "
|
|
929
|
+
"data. Manual remediation required."
|
|
930
|
+
) from exc
|
|
931
|
+
|
|
932
|
+
# Discharge obligations for this specific snapshot so they do not block
|
|
933
|
+
# the completeness flag for future erasures of the same profile.
|
|
934
|
+
discharged = store.discharge_for_snapshot(snapshot_key, "replayed_on_restore")
|
|
935
|
+
logger.info(
|
|
936
|
+
"Obligation replay: discharged %d obligations for snapshot %s",
|
|
937
|
+
discharged, snapshot_key,
|
|
938
|
+
)
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""Backup erasure obligation ledger — GDPR Art.17 backup residue tracking.
|
|
6
|
+
|
|
7
|
+
After Art.17 erasure the live stores are clean, but rotating backup snapshots
|
|
8
|
+
still hold the erased personal data. This module tracks ``outstanding
|
|
9
|
+
obligations`` — one per (profile_id, snapshot_path) pair — so that:
|
|
10
|
+
|
|
11
|
+
1. The erasure receipt accurately declares completeness as FALSE while any
|
|
12
|
+
snapshot still contains the data (C1 gap closure).
|
|
13
|
+
|
|
14
|
+
2. A restore that would resurrect erased data is intercepted and the profile
|
|
15
|
+
data re-erased from the restored store before any caller can read it
|
|
16
|
+
(restore-replay invariant).
|
|
17
|
+
|
|
18
|
+
3. Obligations age out automatically when a snapshot exceeds the configured
|
|
19
|
+
retention window, keeping the ledger finite.
|
|
20
|
+
|
|
21
|
+
IMPORTANT: backup_obligations.db is intentionally NOT in MANAGED_DATABASES
|
|
22
|
+
and is therefore never backed up itself. This prevents a restore from loading
|
|
23
|
+
stale obligation state.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import sqlite3
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("superlocalmemory.infra.backup_obligations")
|
|
35
|
+
|
|
36
|
+
_OBLIGATION_DB_NAME = "backup_obligations.db"
|
|
37
|
+
|
|
38
|
+
_DDL = """
|
|
39
|
+
CREATE TABLE IF NOT EXISTS backup_obligations (
|
|
40
|
+
obligation_id TEXT PRIMARY KEY,
|
|
41
|
+
profile_id TEXT NOT NULL,
|
|
42
|
+
erasure_id TEXT NOT NULL,
|
|
43
|
+
snapshot_path TEXT NOT NULL,
|
|
44
|
+
snapshot_epoch INTEGER NOT NULL,
|
|
45
|
+
retention_days INTEGER NOT NULL DEFAULT 90,
|
|
46
|
+
recorded_at REAL NOT NULL,
|
|
47
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
48
|
+
discharged_at REAL,
|
|
49
|
+
discharge_reason TEXT
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_bko_profile
|
|
52
|
+
ON backup_obligations(profile_id, status);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_bko_snapshot
|
|
54
|
+
ON backup_obligations(snapshot_path, status);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_bko_erasure
|
|
56
|
+
ON backup_obligations(erasure_id);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BackupObligationStore:
|
|
61
|
+
"""Persistent ledger of outstanding erasure obligations against snapshots.
|
|
62
|
+
|
|
63
|
+
An obligation is created for each backup snapshot that was found to contain
|
|
64
|
+
data for a profile that has since been Art.17-erased from the live stores.
|
|
65
|
+
The obligation is discharged when:
|
|
66
|
+
* The snapshot ages past the configured retention window (auto-discharge).
|
|
67
|
+
* The snapshot is restored and the profile data is re-erased during
|
|
68
|
+
restore-replay (explicit discharge).
|
|
69
|
+
|
|
70
|
+
The store lives at ``<data_root>/backup_obligations.db``. It is a plain
|
|
71
|
+
SQLite file that is NOT in MANAGED_DATABASES and therefore never appears
|
|
72
|
+
inside a backup set.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, data_root: Path) -> None:
|
|
76
|
+
self._db_path = Path(data_root) / _OBLIGATION_DB_NAME
|
|
77
|
+
self._ensure_schema()
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Schema
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _connect(self) -> sqlite3.Connection:
|
|
84
|
+
conn = sqlite3.connect(str(self._db_path))
|
|
85
|
+
conn.row_factory = sqlite3.Row
|
|
86
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
87
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
88
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
89
|
+
return conn
|
|
90
|
+
|
|
91
|
+
def _ensure_schema(self) -> None:
|
|
92
|
+
try:
|
|
93
|
+
with self._connect() as conn:
|
|
94
|
+
for stmt in _DDL.strip().split(";"):
|
|
95
|
+
stmt = stmt.strip()
|
|
96
|
+
if stmt:
|
|
97
|
+
conn.execute(stmt)
|
|
98
|
+
conn.commit()
|
|
99
|
+
except Exception as exc: # noqa: BLE001
|
|
100
|
+
logger.warning("BackupObligationStore: schema init failed: %s", exc)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Write path
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def record(
|
|
107
|
+
self,
|
|
108
|
+
profile_id: str,
|
|
109
|
+
erasure_id: str,
|
|
110
|
+
snapshot_path: str,
|
|
111
|
+
snapshot_epoch: int,
|
|
112
|
+
retention_days: int = 90,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Record one outstanding obligation. Returns the obligation_id."""
|
|
115
|
+
oid = uuid.uuid4().hex
|
|
116
|
+
try:
|
|
117
|
+
with self._connect() as conn:
|
|
118
|
+
conn.execute(
|
|
119
|
+
"INSERT OR IGNORE INTO backup_obligations "
|
|
120
|
+
"(obligation_id, profile_id, erasure_id, snapshot_path, "
|
|
121
|
+
" snapshot_epoch, retention_days, recorded_at, status) "
|
|
122
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')",
|
|
123
|
+
(
|
|
124
|
+
oid, profile_id, erasure_id, str(snapshot_path),
|
|
125
|
+
int(snapshot_epoch), int(retention_days), time.time(),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
conn.commit()
|
|
129
|
+
except Exception as exc: # noqa: BLE001
|
|
130
|
+
logger.warning("BackupObligationStore.record failed: %s", exc)
|
|
131
|
+
return oid
|
|
132
|
+
|
|
133
|
+
def discharge(self, obligation_id: str, reason: str) -> None:
|
|
134
|
+
"""Mark a single obligation as discharged."""
|
|
135
|
+
try:
|
|
136
|
+
with self._connect() as conn:
|
|
137
|
+
conn.execute(
|
|
138
|
+
"UPDATE backup_obligations "
|
|
139
|
+
"SET status='discharged', discharged_at=?, discharge_reason=? "
|
|
140
|
+
"WHERE obligation_id=?",
|
|
141
|
+
(time.time(), reason, obligation_id),
|
|
142
|
+
)
|
|
143
|
+
conn.commit()
|
|
144
|
+
except Exception as exc: # noqa: BLE001
|
|
145
|
+
logger.warning("BackupObligationStore.discharge failed: %s", exc)
|
|
146
|
+
|
|
147
|
+
def discharge_for_snapshot(self, snapshot_path: str, reason: str) -> int:
|
|
148
|
+
"""Discharge ALL pending obligations for a snapshot path. Returns count."""
|
|
149
|
+
try:
|
|
150
|
+
with self._connect() as conn:
|
|
151
|
+
cur = conn.execute(
|
|
152
|
+
"UPDATE backup_obligations "
|
|
153
|
+
"SET status='discharged', discharged_at=?, discharge_reason=? "
|
|
154
|
+
"WHERE snapshot_path=? AND status='pending'",
|
|
155
|
+
(time.time(), reason, str(snapshot_path)),
|
|
156
|
+
)
|
|
157
|
+
conn.commit()
|
|
158
|
+
return cur.rowcount
|
|
159
|
+
except Exception as exc: # noqa: BLE001
|
|
160
|
+
logger.warning("BackupObligationStore.discharge_for_snapshot: %s", exc)
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# Read path
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def _discharge_aged_out(self) -> int:
|
|
168
|
+
"""Auto-discharge obligations whose snapshot has aged past retention."""
|
|
169
|
+
try:
|
|
170
|
+
with self._connect() as conn:
|
|
171
|
+
now = time.time()
|
|
172
|
+
cur = conn.execute(
|
|
173
|
+
"UPDATE backup_obligations "
|
|
174
|
+
"SET status='discharged', discharged_at=?, "
|
|
175
|
+
" discharge_reason='snapshot_aged_out' "
|
|
176
|
+
"WHERE status='pending' "
|
|
177
|
+
" AND (snapshot_epoch + retention_days * 86400) < ?",
|
|
178
|
+
(now, now),
|
|
179
|
+
)
|
|
180
|
+
conn.commit()
|
|
181
|
+
return cur.rowcount
|
|
182
|
+
except Exception as exc: # noqa: BLE001
|
|
183
|
+
logger.warning("BackupObligationStore._discharge_aged_out: %s", exc)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
def count_pending(self, profile_id: str) -> int:
|
|
187
|
+
"""Count pending obligations for *profile_id* (auto-discharges aged-out first).
|
|
188
|
+
|
|
189
|
+
Fail-closed: if the store is unreadable, returns 1 to block completeness.
|
|
190
|
+
"""
|
|
191
|
+
self._discharge_aged_out()
|
|
192
|
+
try:
|
|
193
|
+
with self._connect() as conn:
|
|
194
|
+
row = conn.execute(
|
|
195
|
+
"SELECT COUNT(*) FROM backup_obligations "
|
|
196
|
+
"WHERE profile_id=? AND status='pending'",
|
|
197
|
+
(profile_id,),
|
|
198
|
+
).fetchone()
|
|
199
|
+
return int(row[0]) if row else 0
|
|
200
|
+
except Exception as exc: # noqa: BLE001
|
|
201
|
+
logger.warning(
|
|
202
|
+
"BackupObligationStore.count_pending failed: %s — "
|
|
203
|
+
"returning 1 to block completeness claim",
|
|
204
|
+
exc,
|
|
205
|
+
)
|
|
206
|
+
return 1 # fail-closed
|
|
207
|
+
|
|
208
|
+
def list_pending_for_snapshot(self, snapshot_path: str) -> list[dict]:
|
|
209
|
+
"""Return all pending obligations for *snapshot_path*."""
|
|
210
|
+
try:
|
|
211
|
+
with self._connect() as conn:
|
|
212
|
+
rows = conn.execute(
|
|
213
|
+
"SELECT * FROM backup_obligations "
|
|
214
|
+
"WHERE snapshot_path=? AND status='pending'",
|
|
215
|
+
(str(snapshot_path),),
|
|
216
|
+
).fetchall()
|
|
217
|
+
return [dict(r) for r in rows]
|
|
218
|
+
except Exception as exc: # noqa: BLE001
|
|
219
|
+
logger.warning("BackupObligationStore.list_pending_for_snapshot: %s", exc)
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
def list_pending_for_profile(self, profile_id: str) -> list[dict]:
|
|
223
|
+
"""Return all pending obligations for *profile_id* (auto-discharges first)."""
|
|
224
|
+
self._discharge_aged_out()
|
|
225
|
+
try:
|
|
226
|
+
with self._connect() as conn:
|
|
227
|
+
rows = conn.execute(
|
|
228
|
+
"SELECT * FROM backup_obligations "
|
|
229
|
+
"WHERE profile_id=? AND status='pending'",
|
|
230
|
+
(profile_id,),
|
|
231
|
+
).fetchall()
|
|
232
|
+
return [dict(r) for r in rows]
|
|
233
|
+
except Exception as exc: # noqa: BLE001
|
|
234
|
+
logger.warning("BackupObligationStore.list_pending_for_profile: %s", exc)
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Snapshot helpers (used by gdpr.py and backup.py)
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def scan_backup_snapshots_for_profile(
|
|
244
|
+
backup_dir: Path,
|
|
245
|
+
profile_id: str,
|
|
246
|
+
) -> list[tuple[str, int]]:
|
|
247
|
+
"""Scan *backup_dir* for snapshots containing *profile_id*'s data.
|
|
248
|
+
|
|
249
|
+
Returns a list of (snapshot_path_str, epoch) tuples. Handles both legacy
|
|
250
|
+
per-file backups (``*.db`` files) and new-style BackupCoordinator sets
|
|
251
|
+
(``backup_XXXX/`` directories containing a ``manifest.json``).
|
|
252
|
+
"""
|
|
253
|
+
backup_dir = Path(backup_dir)
|
|
254
|
+
results: list[tuple[str, int]] = []
|
|
255
|
+
|
|
256
|
+
if not backup_dir.exists():
|
|
257
|
+
return results
|
|
258
|
+
|
|
259
|
+
# New-style backup sets: backup_XXXX/ directories with manifest.json.
|
|
260
|
+
# Record obligation at the set-directory level (one obligation per set).
|
|
261
|
+
for candidate in sorted(backup_dir.iterdir()):
|
|
262
|
+
if not candidate.is_dir() or not (candidate / "manifest.json").exists():
|
|
263
|
+
continue
|
|
264
|
+
hit = False
|
|
265
|
+
for db_file in candidate.glob("*.db"):
|
|
266
|
+
if _snapshot_db_contains_profile(db_file, profile_id):
|
|
267
|
+
hit = True
|
|
268
|
+
break
|
|
269
|
+
if hit:
|
|
270
|
+
epoch = int(candidate.stat().st_mtime)
|
|
271
|
+
results.append((str(candidate), epoch))
|
|
272
|
+
|
|
273
|
+
# Legacy per-file backups: ``memory-YYYYMMDD-HHMMSS.db``, etc.
|
|
274
|
+
for db_file in sorted(backup_dir.glob("*.db")):
|
|
275
|
+
if db_file.name.startswith("."):
|
|
276
|
+
continue
|
|
277
|
+
if _snapshot_db_contains_profile(db_file, profile_id):
|
|
278
|
+
epoch = int(db_file.stat().st_mtime)
|
|
279
|
+
results.append((str(db_file), epoch))
|
|
280
|
+
|
|
281
|
+
return results
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _snapshot_db_contains_profile(db_path: Path, profile_id: str) -> bool:
|
|
285
|
+
"""Return True if the SQLite file at *db_path* contains rows for *profile_id*.
|
|
286
|
+
|
|
287
|
+
Opens read-only (immutable) to avoid side-effects. Returns False on any
|
|
288
|
+
error so an unreadable snapshot does not block erasure; caller logs the
|
|
289
|
+
warning separately.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
uri = f"file:{db_path}?mode=ro"
|
|
293
|
+
conn = sqlite3.connect(uri, uri=True, timeout=5)
|
|
294
|
+
try:
|
|
295
|
+
tables = {
|
|
296
|
+
row[0]
|
|
297
|
+
for row in conn.execute(
|
|
298
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
299
|
+
).fetchall()
|
|
300
|
+
}
|
|
301
|
+
for table in ("profiles", "atomic_facts", "memories", "graph_nodes"):
|
|
302
|
+
if table not in tables:
|
|
303
|
+
continue
|
|
304
|
+
cols = {
|
|
305
|
+
row[1]
|
|
306
|
+
for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
307
|
+
}
|
|
308
|
+
if "profile_id" not in cols:
|
|
309
|
+
# graph_nodes has no profile_id — flag unconditionally as personal
|
|
310
|
+
if table == "graph_nodes":
|
|
311
|
+
row = conn.execute(
|
|
312
|
+
"SELECT 1 FROM graph_nodes LIMIT 1"
|
|
313
|
+
).fetchone()
|
|
314
|
+
if row is not None:
|
|
315
|
+
return True
|
|
316
|
+
continue
|
|
317
|
+
row = conn.execute(
|
|
318
|
+
f"SELECT 1 FROM {table} WHERE profile_id=? LIMIT 1",
|
|
319
|
+
(profile_id,),
|
|
320
|
+
).fetchone()
|
|
321
|
+
if row is not None:
|
|
322
|
+
return True
|
|
323
|
+
finally:
|
|
324
|
+
conn.close()
|
|
325
|
+
except Exception as exc: # noqa: BLE001
|
|
326
|
+
logger.warning(
|
|
327
|
+
"_snapshot_db_contains_profile: error reading %s: %s — treating as clean",
|
|
328
|
+
db_path.name, exc,
|
|
329
|
+
)
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def erase_profile_from_snapshot(db_path: Path, profile_id: str) -> dict[str, int]:
|
|
334
|
+
"""Delete all *profile_id* rows from a snapshot SQLite file.
|
|
335
|
+
|
|
336
|
+
Used during restore-replay. Raises on any fatal error so the caller can
|
|
337
|
+
refuse to mark the restore complete when personal data cannot be purged.
|
|
338
|
+
"""
|
|
339
|
+
deleted: dict[str, int] = {}
|
|
340
|
+
# isolation_level=None → autocommit so VACUUM can run outside any transaction.
|
|
341
|
+
conn = sqlite3.connect(str(db_path))
|
|
342
|
+
conn.isolation_level = None
|
|
343
|
+
try:
|
|
344
|
+
conn.execute("PRAGMA foreign_keys=OFF")
|
|
345
|
+
tables = [
|
|
346
|
+
row[0]
|
|
347
|
+
for row in conn.execute(
|
|
348
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
349
|
+
).fetchall()
|
|
350
|
+
if not row[0].startswith("sqlite_")
|
|
351
|
+
]
|
|
352
|
+
conn.execute("BEGIN")
|
|
353
|
+
for table in tables:
|
|
354
|
+
cols = {
|
|
355
|
+
row[1]
|
|
356
|
+
for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
357
|
+
}
|
|
358
|
+
if "profile_id" not in cols:
|
|
359
|
+
continue
|
|
360
|
+
cur = conn.execute(
|
|
361
|
+
f"DELETE FROM {table} WHERE profile_id=?", (profile_id,)
|
|
362
|
+
)
|
|
363
|
+
if cur.rowcount:
|
|
364
|
+
deleted[table] = cur.rowcount
|
|
365
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
366
|
+
conn.execute("COMMIT")
|
|
367
|
+
# VACUUM must run in autocommit (no active transaction).
|
|
368
|
+
conn.execute("VACUUM")
|
|
369
|
+
except Exception:
|
|
370
|
+
try:
|
|
371
|
+
conn.execute("ROLLBACK")
|
|
372
|
+
except Exception: # noqa: BLE001
|
|
373
|
+
pass
|
|
374
|
+
conn.close()
|
|
375
|
+
raise
|
|
376
|
+
else:
|
|
377
|
+
conn.close()
|
|
378
|
+
return deleted
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def erase_code_graph_from_snapshot(db_path: Path) -> dict[str, int]:
|
|
382
|
+
"""Wipe all tables from a code_graph.db snapshot (no profile_id column).
|
|
383
|
+
|
|
384
|
+
The code graph is installation-level personal data (repo paths, symbol
|
|
385
|
+
names). On erasure or restore-replay the entire graph is cleared.
|
|
386
|
+
"""
|
|
387
|
+
deleted: dict[str, int] = {}
|
|
388
|
+
if not db_path.exists():
|
|
389
|
+
return deleted
|
|
390
|
+
conn = sqlite3.connect(str(db_path))
|
|
391
|
+
conn.isolation_level = None # autocommit so VACUUM runs outside any transaction
|
|
392
|
+
try:
|
|
393
|
+
conn.execute("PRAGMA foreign_keys=OFF")
|
|
394
|
+
tables = [
|
|
395
|
+
row[0]
|
|
396
|
+
for row in conn.execute(
|
|
397
|
+
"SELECT name FROM sqlite_master WHERE type='table' "
|
|
398
|
+
"AND name NOT LIKE 'sqlite_%'"
|
|
399
|
+
).fetchall()
|
|
400
|
+
]
|
|
401
|
+
conn.execute("BEGIN")
|
|
402
|
+
for table in tables:
|
|
403
|
+
# Skip FTS virtual tables — deleting the base table handles them
|
|
404
|
+
if table.endswith(("_fts", "_fts_data", "_fts_idx", "_fts_content",
|
|
405
|
+
"_fts_docsize", "_fts_config")):
|
|
406
|
+
continue
|
|
407
|
+
cur = conn.execute(f"DELETE FROM {table}")
|
|
408
|
+
if cur.rowcount:
|
|
409
|
+
deleted[table] = cur.rowcount
|
|
410
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
411
|
+
conn.execute("COMMIT")
|
|
412
|
+
# VACUUM must run in autocommit (no active transaction).
|
|
413
|
+
conn.execute("VACUUM")
|
|
414
|
+
except Exception:
|
|
415
|
+
try:
|
|
416
|
+
conn.execute("ROLLBACK")
|
|
417
|
+
except Exception: # noqa: BLE001
|
|
418
|
+
pass
|
|
419
|
+
conn.close()
|
|
420
|
+
raise
|
|
421
|
+
else:
|
|
422
|
+
conn.close()
|
|
423
|
+
return deleted
|