Yash030 commited on
Commit
bbea853
·
1 Parent(s): 9aca16b

feat: obs lookup index, startup backfill, CORS wildcards, sync state in SQLite

Browse files

- Add global obs_id->folderPath/agentId coordinate lookup index (KV.obs_lookup)
so folder_search hydrates candidates via O(1) point lookups instead of O(N) scans
- Add backfill_obs_lookup_if_needed() on startup to populate missing lookup entries
- Add verify_index_sync_on_boot() in workers.py to detect and fix stale search index
after crash/abrupt shutdown - triggers background rebuild if counts diverge
- Print security warning on boot when AGENTMEMORY_SECRET is unset
- Update CORS default origins to allow vscode-webview://* and chrome-extension://*
prefix matching so IDE extensions connect without CORS errors
- Persist sync_state in SQLite sync_state_metadata table instead of local .sync_state
JSON file, ensuring sync state survives Space restarts
- Add tests/test_obs_lookup.py covering lookup write, delete propagation, backfill, and
desync detection (all 231 tests pass)

Files changed (6) hide show
  1. src/app.py +11 -1
  2. src/db.py +6 -0
  3. src/functions.py +147 -58
  4. src/workers.py +9 -3
  5. sync.py +35 -11
  6. tests/test_obs_lookup.py +107 -0
src/app.py CHANGED
@@ -46,6 +46,10 @@ def create_app() -> Flask:
46
  """Create and return a fully configured Flask application."""
47
  global kv, embedding_provider, persistence
48
 
 
 
 
 
49
  import search as search_mod
50
  import functions
51
  from db import StateKV
@@ -95,6 +99,12 @@ def create_app() -> Flask:
95
  loaded = persistence.load()
96
  print(f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}")
97
 
 
 
 
 
 
 
98
  # 4. Flask app + blueprints
99
  flask_app = Flask(__name__)
100
  from werkzeug.middleware.proxy_fix import ProxyFix
@@ -157,7 +167,7 @@ def create_app() -> Flask:
157
  _default_cors = (
158
  "http://localhost,http://127.0.0.1,"
159
  "https://huggingface.co,https://*.hf.space,"
160
- "vscode-webview://,chrome-extension://"
161
  )
162
  _cors_origins_raw = os.getenv("AGENTMEMORY_CORS_ORIGINS", _default_cors)
163
 
 
46
  """Create and return a fully configured Flask application."""
47
  global kv, embedding_provider, persistence
48
 
49
+ # Check security credentials
50
+ if not os.getenv("AGENTMEMORY_SECRET"):
51
+ print("[security] WARNING: AGENTMEMORY_SECRET is not set! All API endpoints are publicly accessible without authentication.")
52
+
53
  import search as search_mod
54
  import functions
55
  from db import StateKV
 
99
  loaded = persistence.load()
100
  print(f"[persistence] Load results: BM25={loaded['bm25']}, Vector={loaded['vector']}")
101
 
102
+ # Backfill coordinate lookup index if missing/incomplete
103
+ try:
104
+ functions.backfill_obs_lookup_if_needed(kv)
105
+ except Exception as e:
106
+ print(f"[db] Warning backfilling obs_lookup: {e}")
107
+
108
  # 4. Flask app + blueprints
109
  flask_app = Flask(__name__)
110
  from werkzeug.middleware.proxy_fix import ProxyFix
 
167
  _default_cors = (
168
  "http://localhost,http://127.0.0.1,"
169
  "https://huggingface.co,https://*.hf.space,"
170
+ "vscode-webview://*,chrome-extension://*"
171
  )
172
  _cors_origins_raw = os.getenv("AGENTMEMORY_CORS_ORIGINS", _default_cors)
173
 
src/db.py CHANGED
@@ -127,6 +127,12 @@ class StateKV:
127
  message TEXT NOT NULL
128
  )
129
  """)
 
 
 
 
 
 
130
  conn.commit()
131
  finally:
132
  conn.close()
 
127
  message TEXT NOT NULL
128
  )
129
  """)
130
+ conn.execute("""
131
+ CREATE TABLE IF NOT EXISTS sync_state_metadata (
132
+ key TEXT PRIMARY KEY,
133
+ value TEXT NOT NULL
134
+ )
135
+ """)
136
  conn.commit()
137
  finally:
138
  conn.close()
src/functions.py CHANGED
@@ -35,6 +35,10 @@ class KV:
35
  # Key = "{safe_folder_path}:{agent_id}", value = FolderIndexEntry dict.
36
  folders = "mem:folders"
37
 
 
 
 
 
38
  @staticmethod
39
  def folder_obs(folder_path: str, agent_id: str) -> str:
40
  """Per-(folder, agent) observations scope.
@@ -1056,6 +1060,12 @@ def folder_observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
1056
  obs_scope = KV.folder_obs(folder_path, agent_id)
1057
  kv.set(obs_scope, obs_id, obs)
1058
 
 
 
 
 
 
 
1059
  # 7. Upsert folder metadata (REQ-005)
1060
  meta_scope = KV.folder_meta(folder_path, agent_id)
1061
  meta = kv.get(meta_scope, "meta") or {
@@ -1136,41 +1146,7 @@ def folder_search(
1136
 
1137
  candidates = _hybrid_search.search(query, limit * 2)
1138
 
1139
- # --- Build a comprehensive obs_id observation mapping from all folder pairs ---
1140
- # First, collect all known (folder_path, agent_id) pairs from the folders index.
1141
- index_entries = kv.list(KV.folders)
1142
-
1143
- obs_map: Dict[str, Dict[str, Any]] = {} # obs_id → observation dict
1144
-
1145
- for entry in index_entries:
1146
- fp = entry.get("folderPath", "")
1147
- aid = entry.get("agentId", "")
1148
- if not fp or not aid:
1149
- continue
1150
-
1151
- # Apply early filter: skip pairs that don't match the requested filters
1152
- if folder_path is not None and fp != folder_path:
1153
- continue
1154
- if agent_id is not None and aid != agent_id:
1155
- continue
1156
-
1157
- obs_scope = KV.folder_obs(fp, aid)
1158
- pair_obs_list = kv.list(obs_scope)
1159
- for obs in pair_obs_list:
1160
- oid = obs.get("id")
1161
- if oid:
1162
- obs_map[oid] = obs
1163
-
1164
- # --- Also build a memory map from KV.memories for cross-inclusion (REQ-018) ---
1165
- # We include memories regardless of folder/agent filters since they are global.
1166
- all_memories = kv.list(KV.memories)
1167
- memory_map: Dict[str, Dict[str, Any]] = {}
1168
- for mem in all_memories:
1169
- mid = mem.get("id")
1170
- if mid:
1171
- memory_map[mid] = mem
1172
-
1173
- # --- Hydrate candidates from the search results ---
1174
  results: List[Dict[str, Any]] = []
1175
  seen_ids: set = set()
1176
 
@@ -1178,32 +1154,68 @@ def folder_search(
1178
  obs_id = candidate.get("obsId") or candidate.get("id", "")
1179
  score = candidate.get("combinedScore") or candidate.get("score", 0.0)
1180
 
1181
- if obs_id in seen_ids:
1182
  continue
1183
 
1184
- # Try folder observation first
1185
- obs = obs_map.get(obs_id)
1186
- if obs is not None:
1187
- result = dict(obs)
1188
- result["score"] = score
1189
- # Ensure folderPath and agentId are present (REQ-019)
1190
- result.setdefault("folderPath", obs.get("folderPath", ""))
1191
- result.setdefault("agentId", obs.get("agentId", ""))
1192
- results.append(result)
1193
- seen_ids.add(obs_id)
1194
- continue
1195
 
1196
- # Try global memory (REQ-018)
1197
- mem = memory_map.get(obs_id)
1198
- if mem is not None:
1199
- result = dict(mem)
1200
- result["score"] = score
1201
- # Global memories have no folder scope; use empty strings as sentinels
1202
- result.setdefault("folderPath", "")
1203
- result.setdefault("agentId", mem.get("agentId") or "")
1204
- results.append(result)
1205
- seen_ids.add(obs_id)
1206
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1207
 
1208
  # obs_id not found in either map — skip (stale index entry)
1209
 
@@ -1447,6 +1459,7 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1447
  for oid in obs_ids:
1448
  existed = kv.delete(obs_scope, oid)
1449
  if existed:
 
1450
  _bm25_index.remove(oid)
1451
  if _vector_index:
1452
  _vector_index.remove(oid)
@@ -1475,6 +1488,7 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1475
  obs_id = obs.get("id")
1476
  if obs_id:
1477
  kv.delete(obs_scope, obs_id)
 
1478
  _bm25_index.remove(obs_id)
1479
  if _vector_index:
1480
  _vector_index.remove(obs_id)
@@ -1498,6 +1512,7 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1498
 
1499
  kv.delete(KV.observations(session_id), base_oid)
1500
  kv.delete(KV.observations(session_id), f"{base_oid}:raw")
 
1501
 
1502
  for o in (obs, raw_obs):
1503
  if o:
@@ -1519,6 +1534,7 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1519
  obs_list = kv.list(KV.observations(session_id))
1520
  for obs in obs_list:
1521
  kv.delete(KV.observations(session_id), obs["id"])
 
1522
  img = obs.get("imageData") or obs.get("imageRef")
1523
  if img:
1524
  refs = kv.get(KV.imageRefs, img) or 0
@@ -2286,6 +2302,9 @@ def rebuild_index(kv: StateKV) -> int:
2286
  for obs in obs_list:
2287
  if not obs.get("id"):
2288
  continue
 
 
 
2289
  _bm25_index.add(obs)
2290
  comb_text = (obs.get("title") or "") + " " + (obs.get("text") or "")
2291
  vector_index_add_guarded(obs["id"], fp, comb_text.strip(), {"kind": "folder_observation", "logId": obs["id"]})
@@ -2591,6 +2610,10 @@ def migrate_sessions_to_folders(kv: StateKV, dry_run: bool = False) -> Dict[str,
2591
 
2592
  if not dry_run:
2593
  kv.set(KV.folder_obs(fp, aid), obs_id, folder_obs)
 
 
 
 
2594
  session_obs_count += 1
2595
  migrated_observations += 1
2596
 
@@ -3620,3 +3643,69 @@ def broadcast_stream(payload: Dict[str, Any]) -> None:
3620
  _stream_broadcaster(payload)
3621
  except Exception as e:
3622
  print(f"[broadcaster] Failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # Key = "{safe_folder_path}:{agent_id}", value = FolderIndexEntry dict.
36
  folders = "mem:folders"
37
 
38
+ # Lookup index for O(1) observation hydration.
39
+ # Scope = "mem:obs_lookup", Key = obs_id, Value = {"folderPath": folder_path, "agentId": agent_id}
40
+ obs_lookup = "mem:obs_lookup"
41
+
42
  @staticmethod
43
  def folder_obs(folder_path: str, agent_id: str) -> str:
44
  """Per-(folder, agent) observations scope.
 
1060
  obs_scope = KV.folder_obs(folder_path, agent_id)
1061
  kv.set(obs_scope, obs_id, obs)
1062
 
1063
+ # Write coordinate lookup mapping
1064
+ kv.set(KV.obs_lookup, obs_id, {
1065
+ "folderPath": folder_path,
1066
+ "agentId": agent_id,
1067
+ })
1068
+
1069
  # 7. Upsert folder metadata (REQ-005)
1070
  meta_scope = KV.folder_meta(folder_path, agent_id)
1071
  meta = kv.get(meta_scope, "meta") or {
 
1146
 
1147
  candidates = _hybrid_search.search(query, limit * 2)
1148
 
1149
+ # --- Hydrate candidates from the search results (REQ-018, REQ-019) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
  results: List[Dict[str, Any]] = []
1151
  seen_ids: set = set()
1152
 
 
1154
  obs_id = candidate.get("obsId") or candidate.get("id", "")
1155
  score = candidate.get("combinedScore") or candidate.get("score", 0.0)
1156
 
1157
+ if not obs_id or obs_id in seen_ids:
1158
  continue
1159
 
1160
+ # 1. Try folder observation first via O(1) coordinate lookup index
1161
+ lookup = kv.get(KV.obs_lookup, obs_id)
1162
+ if lookup and isinstance(lookup, dict):
1163
+ fp = lookup.get("folderPath")
1164
+ aid = lookup.get("agentId")
1165
+ if fp and aid:
1166
+ if folder_path is not None and fp != folder_path:
1167
+ continue
1168
+ if agent_id is not None and aid != agent_id:
1169
+ continue
 
1170
 
1171
+ obs = kv.get(KV.folder_obs(fp, aid), obs_id)
1172
+ if obs and isinstance(obs, dict):
1173
+ result = dict(obs)
1174
+ result["score"] = score
1175
+ result.setdefault("folderPath", fp)
1176
+ result.setdefault("agentId", aid)
1177
+ results.append(result)
1178
+ seen_ids.add(obs_id)
1179
+ continue
1180
+
1181
+ # 2. Fallback scan for unindexed folder observations (e.g. from prior versions)
1182
+ if obs_id.startswith("fobs_"):
1183
+ found = False
1184
+ for entry in kv.list(KV.folders):
1185
+ fp = entry.get("folderPath", "")
1186
+ aid = entry.get("agentId", "")
1187
+ if not fp or not aid:
1188
+ continue
1189
+ if folder_path is not None and fp != folder_path:
1190
+ continue
1191
+ if agent_id is not None and aid != agent_id:
1192
+ continue
1193
+ obs = kv.get(KV.folder_obs(fp, aid), obs_id)
1194
+ if obs and isinstance(obs, dict):
1195
+ result = dict(obs)
1196
+ result["score"] = score
1197
+ result.setdefault("folderPath", fp)
1198
+ result.setdefault("agentId", aid)
1199
+ results.append(result)
1200
+ seen_ids.add(obs_id)
1201
+ # Lazy backfill the lookup index
1202
+ kv.set(KV.obs_lookup, obs_id, {"folderPath": fp, "agentId": aid})
1203
+ found = True
1204
+ break
1205
+ if found:
1206
+ continue
1207
+
1208
+ # 3. Try global memory (REQ-018)
1209
+ mem = kv.get(KV.memories, obs_id)
1210
+ if mem and isinstance(mem, dict):
1211
+ if mem.get("isLatest") is not False:
1212
+ result = dict(mem)
1213
+ result["score"] = score
1214
+ result.setdefault("folderPath", "")
1215
+ result.setdefault("agentId", mem.get("agentId") or "")
1216
+ results.append(result)
1217
+ seen_ids.add(obs_id)
1218
+ continue
1219
 
1220
  # obs_id not found in either map — skip (stale index entry)
1221
 
 
1459
  for oid in obs_ids:
1460
  existed = kv.delete(obs_scope, oid)
1461
  if existed:
1462
+ kv.delete(KV.obs_lookup, oid)
1463
  _bm25_index.remove(oid)
1464
  if _vector_index:
1465
  _vector_index.remove(oid)
 
1488
  obs_id = obs.get("id")
1489
  if obs_id:
1490
  kv.delete(obs_scope, obs_id)
1491
+ kv.delete(KV.obs_lookup, obs_id)
1492
  _bm25_index.remove(obs_id)
1493
  if _vector_index:
1494
  _vector_index.remove(obs_id)
 
1512
 
1513
  kv.delete(KV.observations(session_id), base_oid)
1514
  kv.delete(KV.observations(session_id), f"{base_oid}:raw")
1515
+ kv.delete(KV.obs_lookup, base_oid)
1516
 
1517
  for o in (obs, raw_obs):
1518
  if o:
 
1534
  obs_list = kv.list(KV.observations(session_id))
1535
  for obs in obs_list:
1536
  kv.delete(KV.observations(session_id), obs["id"])
1537
+ kv.delete(KV.obs_lookup, obs["id"])
1538
  img = obs.get("imageData") or obs.get("imageRef")
1539
  if img:
1540
  refs = kv.get(KV.imageRefs, img) or 0
 
2302
  for obs in obs_list:
2303
  if not obs.get("id"):
2304
  continue
2305
+ # Populate coordinate lookup index
2306
+ kv.set(KV.obs_lookup, obs["id"], {"folderPath": fp, "agentId": aid})
2307
+
2308
  _bm25_index.add(obs)
2309
  comb_text = (obs.get("title") or "") + " " + (obs.get("text") or "")
2310
  vector_index_add_guarded(obs["id"], fp, comb_text.strip(), {"kind": "folder_observation", "logId": obs["id"]})
 
2610
 
2611
  if not dry_run:
2612
  kv.set(KV.folder_obs(fp, aid), obs_id, folder_obs)
2613
+ kv.set(KV.obs_lookup, obs_id, {
2614
+ 'folderPath': fp,
2615
+ 'agentId': aid,
2616
+ })
2617
  session_obs_count += 1
2618
  migrated_observations += 1
2619
 
 
3643
  _stream_broadcaster(payload)
3644
  except Exception as e:
3645
  print(f"[broadcaster] Failed: {e}")
3646
+
3647
+
3648
+ def backfill_obs_lookup_if_needed(kv: StateKV) -> None:
3649
+ """Ensure every folder observation has an entry in KV.obs_lookup."""
3650
+ folders = kv.list(KV.folders)
3651
+ if not folders:
3652
+ return
3653
+
3654
+ # Check if lookup index needs populating
3655
+ lookups = kv.list(KV.obs_lookup)
3656
+ if len(lookups) >= sum(int(f.get("obsCount", 0)) for f in folders):
3657
+ return # already populated
3658
+
3659
+ print("[db] Backfilling obs_lookup index...")
3660
+ for entry in folders:
3661
+ fp = entry.get("folderPath")
3662
+ aid = entry.get("agentId")
3663
+ if not fp or not aid:
3664
+ continue
3665
+ obs_list = kv.list(KV.folder_obs(fp, aid))
3666
+ for obs in obs_list:
3667
+ oid = obs.get("id")
3668
+ if oid:
3669
+ kv.set(KV.obs_lookup, oid, {"folderPath": fp, "agentId": aid})
3670
+ print("[db] obs_lookup backfill complete.")
3671
+
3672
+
3673
+ def verify_index_sync_on_boot(kv: StateKV) -> bool:
3674
+ """Check if the search index size matches the database counts.
3675
+ Returns True if in sync, False if a rebuild is needed.
3676
+ """
3677
+ try:
3678
+ # 1. Total folder obs count
3679
+ folders = kv.list(KV.folders)
3680
+ folder_obs_count = sum(int(f.get("obsCount", 0)) for f in folders)
3681
+
3682
+ # 2. Total memories count
3683
+ memories = kv.list(KV.memories)
3684
+ latest_memories_count = len([m for m in memories if m.get("isLatest") is not False])
3685
+
3686
+ # 3. Total legacy observations count
3687
+ legacy_obs_count = 0
3688
+ try:
3689
+ sessions = kv.list(KV.sessions)
3690
+ for s in sessions:
3691
+ sid = s.get("id")
3692
+ if sid:
3693
+ obs_list = kv.list(KV.observations(sid))
3694
+ # Only legacy observations that were indexed (having title and narrative)
3695
+ legacy_obs_count += len([o for o in obs_list if o.get("title") and o.get("narrative")])
3696
+ except Exception:
3697
+ pass
3698
+
3699
+ total_db_count = folder_obs_count + latest_memories_count + legacy_obs_count
3700
+ index_size = _bm25_index.size
3701
+
3702
+ if total_db_count != index_size:
3703
+ print(f"[persistence] Index out of sync with DB (DB={total_db_count}, Index={index_size}). Rebuild required.")
3704
+ return False
3705
+
3706
+ print(f"[persistence] Index is in sync with DB (size={index_size}).")
3707
+ return True
3708
+ except Exception as e:
3709
+ print(f"[persistence] verify_index_sync_on_boot failed: {e}")
3710
+ return False
3711
+
src/workers.py CHANGED
@@ -125,9 +125,15 @@ def start_background_workers(kv) -> None:
125
  # Register graceful shutdown signal handlers (C5.1)
126
  _register_signal_handlers()
127
 
128
- # Rebuild search index if empty
129
- if functions._bm25_index.size == 0:
130
- print("[persistence] Search index is empty. Rebuilding in background thread...")
 
 
 
 
 
 
131
  t_rebuild = threading.Thread(
132
  target=_rebuild_index,
133
  args=(kv,),
 
125
  # Register graceful shutdown signal handlers (C5.1)
126
  _register_signal_handlers()
127
 
128
+ # Rebuild search index if empty or out of sync (Step 5)
129
+ index_empty = functions._bm25_index.size == 0
130
+ index_in_sync = True
131
+ if not index_empty:
132
+ index_in_sync = functions.verify_index_sync_on_boot(kv)
133
+
134
+ if index_empty or not index_in_sync:
135
+ reason = "empty" if index_empty else "out of sync"
136
+ print(f"[persistence] Search index is {reason}. Rebuilding in background thread...")
137
  t_rebuild = threading.Thread(
138
  target=_rebuild_index,
139
  args=(kv,),
sync.py CHANGED
@@ -79,22 +79,46 @@ def _get_audit_high_water_mark() -> int:
79
 
80
 
81
  def _load_sync_state() -> dict:
82
- """C4.1: Load the persisted sync state dict."""
83
- if os.path.exists(STATE_FILE):
84
- try:
85
- with open(STATE_FILE, "r", encoding="utf-8") as f:
86
- return json.load(f)
87
- except Exception:
88
- pass
 
 
 
 
 
 
 
 
 
 
 
89
  return {"last_synced_audit_id": 0, "last_sync_at": None, "sync_status": "never"}
90
 
91
 
92
  def _save_sync_state(state: dict) -> None:
93
- """C4.1/C4.2: Persist the sync state dict."""
94
  try:
95
- os.makedirs(DATA_DIR, exist_ok=True)
96
- with open(STATE_FILE, "w", encoding="utf-8") as f:
97
- json.dump(state, f)
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  except Exception as e:
99
  print(f"[sync] failed to save sync state: {e}")
100
 
 
79
 
80
 
81
  def _load_sync_state() -> dict:
82
+ """C4.1: Load the persisted sync state dict from SQLite sync_state_metadata table."""
83
+ try:
84
+ if os.path.exists(DB_PATH):
85
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=5)
86
+ try:
87
+ conn.execute("""
88
+ CREATE TABLE IF NOT EXISTS sync_state_metadata (
89
+ key TEXT PRIMARY KEY,
90
+ value TEXT NOT NULL
91
+ )
92
+ """)
93
+ row = conn.execute("SELECT value FROM sync_state_metadata WHERE key = ?", ("sync_state",)).fetchone()
94
+ if row:
95
+ return json.loads(row[0])
96
+ finally:
97
+ conn.close()
98
+ except Exception as e:
99
+ print(f"[sync] load state error: {e}")
100
  return {"last_synced_audit_id": 0, "last_sync_at": None, "sync_status": "never"}
101
 
102
 
103
  def _save_sync_state(state: dict) -> None:
104
+ """C4.1/C4.2: Persist the sync state dict to SQLite sync_state_metadata table."""
105
  try:
106
+ if os.path.exists(DB_PATH):
107
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=5)
108
+ try:
109
+ conn.execute("""
110
+ CREATE TABLE IF NOT EXISTS sync_state_metadata (
111
+ key TEXT PRIMARY KEY,
112
+ value TEXT NOT NULL
113
+ )
114
+ """)
115
+ conn.execute(
116
+ "INSERT OR REPLACE INTO sync_state_metadata (key, value) VALUES (?, ?)",
117
+ ("sync_state", json.dumps(state))
118
+ )
119
+ conn.commit()
120
+ finally:
121
+ conn.close()
122
  except Exception as e:
123
  print(f"[sync] failed to save sync state: {e}")
124
 
tests/test_obs_lookup.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for observation lookup index, backfill, and index sync validation."""
2
+ import sys
3
+ import os
4
+ import pytest
5
+ import datetime
6
+
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
8
+ from db import StateKV
9
+ import functions
10
+ from functions import folder_observe, folder_search, forget, backfill_obs_lookup_if_needed, verify_index_sync_on_boot, KV
11
+
12
+
13
+ def make_kv(tmp_path):
14
+ db_path = os.path.join(str(tmp_path), 'test_obs_lookup.db')
15
+ return StateKV(db_path=db_path)
16
+
17
+
18
+ def base_payload(folder="/home/user/proj", agent="kiro", text="Test observation"):
19
+ return {
20
+ 'folderPath': folder,
21
+ 'agentId': agent,
22
+ 'text': text,
23
+ 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z',
24
+ }
25
+
26
+
27
+ class TestObsLookupFlows:
28
+ def test_lookup_added_on_observe(self, tmp_path):
29
+ kv = make_kv(tmp_path)
30
+ res = folder_observe(kv, base_payload())
31
+ obs_id = res['observationId']
32
+
33
+ # Verify entry in KV.obs_lookup
34
+ lookup = kv.get(KV.obs_lookup, obs_id)
35
+ assert lookup is not None
36
+ assert lookup['folderPath'] == 'home/user/proj'
37
+ assert lookup['agentId'] == 'kiro'
38
+
39
+ def test_lookup_deleted_on_forget(self, tmp_path):
40
+ kv = make_kv(tmp_path)
41
+ res = folder_observe(kv, base_payload())
42
+ obs_id = res['observationId']
43
+
44
+ # Confirm it exists
45
+ assert kv.get(KV.obs_lookup, obs_id) is not None
46
+
47
+ # Delete it using forget
48
+ forget(kv, {
49
+ "folderPath": "/home/user/proj",
50
+ "agentId": "kiro",
51
+ "observationIds": [obs_id],
52
+ })
53
+
54
+ # Confirm it is gone from both stores
55
+ assert kv.get(KV.obs_lookup, obs_id) is None
56
+ assert kv.get(KV.folder_obs('home/user/proj', 'kiro'), obs_id) is None
57
+
58
+ def test_backfill_populates_missing_lookups(self, tmp_path):
59
+ kv = make_kv(tmp_path)
60
+
61
+ # Ingest observations
62
+ res1 = folder_observe(kv, base_payload(text="First"))
63
+ res2 = folder_observe(kv, base_payload(text="Second"))
64
+ obs1 = res1['observationId']
65
+ obs2 = res2['observationId']
66
+
67
+ # Manually clear the lookup index (simulating legacy data)
68
+ kv.delete(KV.obs_lookup, obs1)
69
+ kv.delete(KV.obs_lookup, obs2)
70
+ assert kv.get(KV.obs_lookup, obs1) is None
71
+ assert kv.get(KV.obs_lookup, obs2) is None
72
+
73
+ # Run backfill
74
+ backfill_obs_lookup_if_needed(kv)
75
+
76
+ # Verify populated
77
+ lookup1 = kv.get(KV.obs_lookup, obs1)
78
+ lookup2 = kv.get(KV.obs_lookup, obs2)
79
+ assert lookup1 is not None
80
+ assert lookup2 is not None
81
+ assert lookup1['folderPath'] == 'home/user/proj'
82
+ assert lookup2['folderPath'] == 'home/user/proj'
83
+
84
+ def test_verify_index_sync_detects_mismatch(self, tmp_path):
85
+ kv = make_kv(tmp_path)
86
+
87
+ # Clear indexes
88
+ functions._bm25_index.clear()
89
+ if functions._vector_index:
90
+ functions._vector_index.clear()
91
+
92
+ # Ingest one observation
93
+ res = folder_observe(kv, base_payload())
94
+ obs_id = res['observationId']
95
+
96
+ # BM25 size should be 1
97
+ assert functions._bm25_index.size == 1
98
+
99
+ # verify_index_sync_on_boot should return True (in sync)
100
+ assert verify_index_sync_on_boot(kv) is True
101
+
102
+ # Manually remove from BM25 (simulate dirty restart)
103
+ functions._bm25_index.remove(obs_id)
104
+ assert functions._bm25_index.size == 0
105
+
106
+ # verify_index_sync_on_boot should detect mismatch and return False
107
+ assert verify_index_sync_on_boot(kv) is False