fix: gate hybrid graph reads on complete local indexes

This commit is contained in:
HumbleTurtle
2026-08-21 22:37:05 -03:00
parent 8921b131ef
commit a9a682dee0
3 changed files with 162 additions and 5 deletions
+96 -4
View File
@@ -6227,6 +6227,15 @@ def index_connect():
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS local_index_state (
project_id TEXT PRIMARY KEY,
graph_complete INTEGER NOT NULL DEFAULT 0,
graph_completed_at TEXT
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS local_index_edges_source "
"ON local_index_edges(project_id, source_node_id)"
@@ -6260,6 +6269,52 @@ def index_connect():
return conn
def local_graph_index_state(project_id, conn=None):
def read(connection):
row = connection.execute(
"SELECT graph_complete, graph_completed_at "
"FROM local_index_state WHERE project_id = ?",
(project_id,),
).fetchone()
return {
"graph_complete": bool(row and row["graph_complete"]),
"graph_completed_at": row["graph_completed_at"] if row else None,
}
if conn is not None:
return read(conn)
with index_connect() as connection:
return read(connection)
def set_local_graph_index_complete(project_id, complete, conn=None):
completed_at = iso_now() if complete else None
def write(connection):
connection.execute(
"""
INSERT INTO local_index_state (
project_id, graph_complete, graph_completed_at
)
VALUES (?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
graph_complete = excluded.graph_complete,
graph_completed_at = excluded.graph_completed_at
""",
(project_id, 1 if complete else 0, completed_at),
)
if conn is not None:
write(conn)
return
lock = index_file_lock()
try:
with index_connect() as connection:
write(connection)
finally:
release_index_file_lock(lock)
def flatten_index_values(value):
if value is None:
return []
@@ -6586,6 +6641,11 @@ def local_index_diagnostics(project_id, conn=None):
"sync_recommended": count == 0,
}
)
graph_state = local_graph_index_state(project_id, connection)
diagnostics.update(graph_state)
diagnostics["sync_recommended"] = bool(
diagnostics["sync_recommended"] or not graph_state["graph_complete"]
)
return diagnostics
if conn is not None:
@@ -6682,7 +6742,7 @@ def reindex_project(args):
nodes = []
cursor = None
while True:
query = {"limit": limit}
query = {"limit": limit, "view": "full"}
if cursor:
query["cursor"] = cursor
page = request_json("GET", f"/v1/projects/{project_id}/nodes?{rest_query_string(query)}")
@@ -6693,13 +6753,21 @@ def reindex_project(args):
lock = index_file_lock()
try:
with index_connect() as conn:
set_local_graph_index_complete(project_id, False, conn)
conn.execute("DELETE FROM local_index_fts WHERE project_id = ?", (project_id,))
conn.execute("DELETE FROM local_index_edges WHERE project_id = ?", (project_id,))
conn.execute("DELETE FROM local_index_nodes WHERE project_id = ?", (project_id,))
finally:
release_index_file_lock(lock)
for node in nodes:
index_node(node)
return {"project_id": project_id, "indexed": len(nodes), "index_path": str(INDEX_PATH)}
set_local_graph_index_complete(project_id, True)
return {
"project_id": project_id,
"indexed": len(nodes),
"index_path": str(INDEX_PATH),
"graph_complete": True,
}
def sync_local_index(args):
@@ -6709,6 +6777,15 @@ def sync_local_index(args):
include_archived = as_bool(args.get("include_archived"), True)
lookback_seconds = normalize_int(args.get("lookback_seconds"), 5, 0, 3600)
status_before = local_index_diagnostics(project_id)
if not status_before.get("graph_complete"):
result = reindex_project({**args, "project_id": project_id, "limit": limit})
return {
**result,
"status": "synced",
"mode": "full_reindex",
"reason": "local_graph_index_incomplete",
"index": local_index_diagnostics(project_id),
}
latest_node_updated_at = status_before.get("latest_node_updated_at")
updated_after = args.get("updated_after") or iso_minus_seconds(latest_node_updated_at, lookback_seconds)
nodes = []
@@ -6766,7 +6843,9 @@ def clear_local_index(args):
try:
with index_connect() as conn:
conn.execute("DELETE FROM local_index_fts WHERE project_id = ?", (project_id,))
conn.execute("DELETE FROM local_index_edges WHERE project_id = ?", (project_id,))
conn.execute("DELETE FROM local_index_nodes WHERE project_id = ?", (project_id,))
set_local_graph_index_complete(project_id, False, conn)
finally:
release_index_file_lock(lock)
return {"project_id": project_id, "cleared": True}
@@ -11000,6 +11079,7 @@ def local_memory_context(args, seed_ids):
if direction not in {"outgoing", "incoming", "both"}:
raise RuntimeError("direction must be outgoing, incoming, or both")
with index_connect() as conn:
graph_state = local_graph_index_state(project_id, conn)
def node_row(node_id=None, path=None):
if node_id:
return conn.execute(
@@ -11088,6 +11168,11 @@ def local_memory_context(args, seed_ids):
queue = unique_preserve_order(next_ids)
if not queue:
break
warnings = []
if not graph_state["graph_complete"]:
warnings.append(
"Local graph index coverage is incomplete; run nap index sync before relying on local-only neighborhoods."
)
return {
"ok": True,
"seed_node_ids": [row["node_id"] for row in seed_rows],
@@ -11099,8 +11184,8 @@ def local_memory_context(args, seed_ids):
"truncated": bool(truncation_reasons),
"truncation_reasons": truncation_reasons,
"source": "local_index",
"freshness": {"indexed_at": indexed_at},
"warnings": [],
"freshness": {"indexed_at": indexed_at, **graph_state},
"warnings": warnings,
"view": "titles",
}
@@ -11155,6 +11240,13 @@ def get_memory_context(args):
raise RuntimeError("mode must be hybrid, local, or server")
if mode in {"local", "hybrid"}:
try:
if mode == "hybrid":
project_id = resolve_project_id(args)
graph_state = local_graph_index_state(project_id)
if not graph_state["graph_complete"]:
raise RuntimeError(
"local graph index coverage is incomplete; run nap index sync"
)
return local_memory_context(args, seed_ids)
except Exception as exc:
if mode == "local":