Spaces:
Running
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)
- src/app.py +11 -1
- src/db.py +6 -0
- src/functions.py +147 -58
- src/workers.py +9 -3
- sync.py +35 -11
- tests/test_obs_lookup.py +107 -0
|
@@ -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 |
|
|
@@ -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()
|
|
@@ -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 |
-
# ---
|
| 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 |
-
|
| 1186 |
-
if
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
continue
|
| 1195 |
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
|
@@ -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 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,),
|
|
@@ -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 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|