@pentatonic-ai/ai-agent-sdk 0.7.13 → 0.8.1
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/package.json +1 -1
- package/packages/memory/openclaw-plugin/index.js +7 -0
- package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
- package/packages/memory/openclaw-plugin/package.json +1 -1
- package/packages/memory/src/__tests__/engine.test.js +142 -0
- package/packages/memory/src/engine.js +65 -0
- package/packages/memory-engine/compat/server.py +90 -5
- package/packages/memory-engine/docker-compose.yml +18 -8
- package/packages/memory-engine/engine/services/_shared/__init__.py +1 -0
- package/packages/memory-engine/engine/services/_shared/embed_provider.py +431 -0
- package/packages/memory-engine/engine/services/l2/Dockerfile +4 -2
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +640 -81
- package/packages/memory-engine/engine/services/l4/Dockerfile +5 -1
- package/packages/memory-engine/engine/services/l4/server.py +19 -57
- package/packages/memory-engine/engine/services/l5/Dockerfile +3 -1
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +24 -32
- package/packages/memory-engine/engine/services/l6/Dockerfile +3 -1
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +24 -29
- package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
- package/packages/memory-engine/tests/e2e_arena.sh +28 -4
- package/packages/memory-engine/tests/test_aggregate.py +333 -0
- package/packages/memory-engine/tests/test_arena_safety.py +232 -0
- package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
- package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
- package/packages/memory-engine/tests/test_embed_provider.py +354 -0
- package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Tests for the /aggregate endpoint and its L2 proxy backend.
|
|
2
|
+
|
|
3
|
+
Two flavours:
|
|
4
|
+
|
|
5
|
+
- Pure-unit tests around the request validation + group_by
|
|
6
|
+
whitelisting logic. Hermetic, fast, no Neo4j needed. Run on
|
|
7
|
+
every PR.
|
|
8
|
+
|
|
9
|
+
- Neo4j-backed integration tests (run when ``NEO4J_TEST_URI`` and
|
|
10
|
+
``NEO4J_TEST_PASSWORD`` env vars are set). These prove the
|
|
11
|
+
aggregate Cypher actually returns the right buckets for typed-
|
|
12
|
+
Person + COMMUNICATED graphs and stays inside the caller's
|
|
13
|
+
arena.
|
|
14
|
+
|
|
15
|
+
Run:
|
|
16
|
+
|
|
17
|
+
cd packages/memory-engine
|
|
18
|
+
.venv/bin/python -m pytest tests/test_aggregate.py -v
|
|
19
|
+
|
|
20
|
+
Run with Neo4j:
|
|
21
|
+
|
|
22
|
+
NEO4J_TEST_URI=bolt://localhost:17687 \\
|
|
23
|
+
NEO4J_TEST_PASSWORD=testpassword \\
|
|
24
|
+
.venv/bin/python -m pytest tests/test_aggregate.py -v
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import pytest
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
ENGINE_ROOT = Path(__file__).resolve().parent.parent / "engine" / "services" / "l2"
|
|
37
|
+
sys.path.insert(0, str(ENGINE_ROOT))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Unit tests — validation surface around the public shape.
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_group_by_keys_whitelist_keeps_supported_keys_in_order() -> None:
|
|
46
|
+
"""The L2 proxy templates group_by keys directly into Cypher; the
|
|
47
|
+
whitelist is the safety rail. Pin its contents + ordering
|
|
48
|
+
behaviour so a future change can't accidentally accept arbitrary
|
|
49
|
+
property names."""
|
|
50
|
+
# Import lazily so the unit tests don't pull pymilvus etc.
|
|
51
|
+
import importlib.util
|
|
52
|
+
|
|
53
|
+
spec = importlib.util.spec_from_file_location(
|
|
54
|
+
"l2_proxy_module",
|
|
55
|
+
ENGINE_ROOT / "l2-hybridrag-proxy.py",
|
|
56
|
+
)
|
|
57
|
+
assert spec and spec.loader
|
|
58
|
+
# The module imports fastapi/neo4j/etc. at import time. Skip when
|
|
59
|
+
# those aren't available — the whitelist is also asserted via the
|
|
60
|
+
# integration tests.
|
|
61
|
+
try:
|
|
62
|
+
mod = importlib.util.module_from_spec(spec)
|
|
63
|
+
spec.loader.exec_module(mod)
|
|
64
|
+
except ImportError:
|
|
65
|
+
pytest.skip("l2 proxy deps unavailable in this venv (fine for unit-only runs)")
|
|
66
|
+
|
|
67
|
+
# Public contract: only channel + direction are supported today.
|
|
68
|
+
# Adding more is a deliberate decision; this assertion is a guard
|
|
69
|
+
# against adding without thinking.
|
|
70
|
+
assert mod._AGGREGATE_GROUP_BY_KEYS == {"channel", "direction"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Neo4j-backed integration tests.
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_NEO4J_URI = os.environ.get("NEO4J_TEST_URI")
|
|
79
|
+
_NEO4J_USER = os.environ.get("NEO4J_TEST_USER", "neo4j")
|
|
80
|
+
_NEO4J_PASSWORD = os.environ.get("NEO4J_TEST_PASSWORD")
|
|
81
|
+
|
|
82
|
+
_skip_no_neo4j = pytest.mark.skipif(
|
|
83
|
+
not (_NEO4J_URI and _NEO4J_PASSWORD),
|
|
84
|
+
reason="set NEO4J_TEST_URI + NEO4J_TEST_PASSWORD to run integration tests",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.fixture
|
|
89
|
+
def neo4j_driver():
|
|
90
|
+
"""Open a Neo4j driver and clean test data on teardown.
|
|
91
|
+
|
|
92
|
+
Uses a randomised arena pair so concurrent test runs don't trample
|
|
93
|
+
each other; tears down by deleting nodes scoped to those arenas
|
|
94
|
+
(never a global wipe — must stay safe against a populated dev
|
|
95
|
+
database).
|
|
96
|
+
"""
|
|
97
|
+
from neo4j import GraphDatabase
|
|
98
|
+
|
|
99
|
+
driver = GraphDatabase.driver(_NEO4J_URI, auth=(_NEO4J_USER, _NEO4J_PASSWORD))
|
|
100
|
+
arenas = [f"agg_a_{uuid.uuid4().hex[:8]}", f"agg_b_{uuid.uuid4().hex[:8]}"]
|
|
101
|
+
yield driver, arenas
|
|
102
|
+
with driver.session() as session:
|
|
103
|
+
for arena in arenas:
|
|
104
|
+
session.run(
|
|
105
|
+
"MATCH (n) WHERE n.arena = $arena DETACH DELETE n",
|
|
106
|
+
arena=arena,
|
|
107
|
+
)
|
|
108
|
+
driver.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _seed_person_communications(
|
|
112
|
+
session,
|
|
113
|
+
arena: str,
|
|
114
|
+
email: str,
|
|
115
|
+
edges: list[tuple[str, str, str]],
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Materialise (:Person {arena, email})-[:COMMUNICATED {channel,
|
|
118
|
+
direction, occurred_at}]->(:Chunk {arena}) edges from a list of
|
|
119
|
+
(channel, direction, occurred_at) tuples. Mirrors the exact shape
|
|
120
|
+
that engine/services/l2/l2-hybridrag-proxy.py:_index-internal-batch
|
|
121
|
+
writes on STORE_MEMORY ingest."""
|
|
122
|
+
for i, (channel, direction, occurred_at) in enumerate(edges):
|
|
123
|
+
session.run(
|
|
124
|
+
"""
|
|
125
|
+
MERGE (c:Chunk {id: $cid})
|
|
126
|
+
SET c.arena = $arena, c.text = 't', c.path = 'p',
|
|
127
|
+
c.created_at = $occurred_at
|
|
128
|
+
MERGE (p:Entity:Person {arena: $arena, email: $email})
|
|
129
|
+
ON CREATE SET p.created_at = $occurred_at
|
|
130
|
+
MERGE (p)-[r:COMMUNICATED]->(c)
|
|
131
|
+
ON CREATE SET r.channel = $channel,
|
|
132
|
+
r.direction = $direction,
|
|
133
|
+
r.occurred_at = $occurred_at,
|
|
134
|
+
r.weight = 1.0
|
|
135
|
+
""",
|
|
136
|
+
cid=f"chunk_{arena}_{i}", arena=arena, email=email,
|
|
137
|
+
channel=channel, direction=direction, occurred_at=occurred_at,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _aggregate_request(
|
|
142
|
+
session,
|
|
143
|
+
arena: str,
|
|
144
|
+
contact_email: str,
|
|
145
|
+
group_by: list[str] | None = None,
|
|
146
|
+
) -> dict:
|
|
147
|
+
"""Run the same Cypher that /aggregate-internal would. Built
|
|
148
|
+
inline so tests stay free of FastAPI plumbing."""
|
|
149
|
+
# Mirror the real endpoint: an explicit empty list means "no
|
|
150
|
+
# group_by" (one global bucket), None means default to channel.
|
|
151
|
+
requested = ["channel"] if group_by is None else group_by
|
|
152
|
+
safe_group_by = []
|
|
153
|
+
seen = set()
|
|
154
|
+
for k in requested:
|
|
155
|
+
if k in {"channel", "direction"} and k not in seen:
|
|
156
|
+
seen.add(k)
|
|
157
|
+
safe_group_by.append(k)
|
|
158
|
+
|
|
159
|
+
if safe_group_by:
|
|
160
|
+
with_keys = ", ".join(f"r.{k} AS {k}" for k in safe_group_by)
|
|
161
|
+
return_keys = ", ".join(safe_group_by)
|
|
162
|
+
cypher = (
|
|
163
|
+
"MATCH (p:Person {arena: $arena})-[r:COMMUNICATED]->(c:Chunk {arena: $arena})\n"
|
|
164
|
+
"WHERE p.email = $contact_email\n"
|
|
165
|
+
f"WITH {with_keys}, r.direction AS _direction, r.occurred_at AS _occurred_at\n"
|
|
166
|
+
f"RETURN {return_keys},\n"
|
|
167
|
+
"count(*) AS count,\n"
|
|
168
|
+
"sum(CASE WHEN _direction = 'inbound' THEN 1 ELSE 0 END) AS inbound,\n"
|
|
169
|
+
"sum(CASE WHEN _direction = 'outbound' THEN 1 ELSE 0 END) AS outbound,\n"
|
|
170
|
+
"max(_occurred_at) AS last_seen,\n"
|
|
171
|
+
"min(_occurred_at) AS first_seen\n"
|
|
172
|
+
"ORDER BY count DESC\n"
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
cypher = (
|
|
176
|
+
"MATCH (p:Person {arena: $arena})-[r:COMMUNICATED]->(c:Chunk {arena: $arena})\n"
|
|
177
|
+
"WHERE p.email = $contact_email\n"
|
|
178
|
+
"WITH r.direction AS _direction, r.occurred_at AS _occurred_at\n"
|
|
179
|
+
"RETURN count(*) AS count,\n"
|
|
180
|
+
"sum(CASE WHEN _direction = 'inbound' THEN 1 ELSE 0 END) AS inbound,\n"
|
|
181
|
+
"sum(CASE WHEN _direction = 'outbound' THEN 1 ELSE 0 END) AS outbound,\n"
|
|
182
|
+
"max(_occurred_at) AS last_seen,\n"
|
|
183
|
+
"min(_occurred_at) AS first_seen\n"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
buckets = []
|
|
187
|
+
total = 0
|
|
188
|
+
latest = None
|
|
189
|
+
for rec in session.run(cypher, arena=arena, contact_email=contact_email):
|
|
190
|
+
count = int(rec["count"] or 0)
|
|
191
|
+
total += count
|
|
192
|
+
last_seen = rec["last_seen"]
|
|
193
|
+
if last_seen and (latest is None or str(last_seen) > latest):
|
|
194
|
+
latest = str(last_seen)
|
|
195
|
+
keys = {k: rec[k] for k in safe_group_by} if safe_group_by else {}
|
|
196
|
+
buckets.append({
|
|
197
|
+
"keys": keys,
|
|
198
|
+
"count": count,
|
|
199
|
+
"inbound": int(rec["inbound"] or 0),
|
|
200
|
+
"outbound": int(rec["outbound"] or 0),
|
|
201
|
+
"last_seen": str(last_seen) if last_seen else None,
|
|
202
|
+
"first_seen": str(rec["first_seen"]) if rec["first_seen"] else None,
|
|
203
|
+
})
|
|
204
|
+
return {"arena": arena, "total": total, "last_seen": latest, "buckets": buckets}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@_skip_no_neo4j
|
|
208
|
+
def test_aggregate_groups_by_channel_with_direction_counters(neo4j_driver) -> None:
|
|
209
|
+
"""5 emails (3 inbound, 2 outbound) + 2 slack (1/1) yields two
|
|
210
|
+
buckets ordered by count, each with correct inbound/outbound
|
|
211
|
+
splits and time bounds."""
|
|
212
|
+
driver, (arena_a, _) = neo4j_driver
|
|
213
|
+
email = "alex.tong@pentatonic.com"
|
|
214
|
+
|
|
215
|
+
with driver.session() as session:
|
|
216
|
+
_seed_person_communications(
|
|
217
|
+
session, arena_a, email,
|
|
218
|
+
[
|
|
219
|
+
("email", "inbound", "2026-05-08T09:00:00Z"),
|
|
220
|
+
("email", "outbound", "2026-05-09T10:00:00Z"),
|
|
221
|
+
("email", "inbound", "2026-05-07T08:00:00Z"),
|
|
222
|
+
("email", "outbound", "2026-05-09T11:00:00Z"),
|
|
223
|
+
("email", "inbound", "2026-05-06T12:00:00Z"),
|
|
224
|
+
("slack", "inbound", "2026-05-09T15:00:00Z"),
|
|
225
|
+
("slack", "outbound", "2026-05-08T11:00:00Z"),
|
|
226
|
+
],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
out = _aggregate_request(session, arena_a, email, ["channel"])
|
|
230
|
+
assert out["total"] == 7
|
|
231
|
+
assert out["last_seen"] == "2026-05-09T15:00:00Z"
|
|
232
|
+
assert len(out["buckets"]) == 2
|
|
233
|
+
|
|
234
|
+
# Busiest first (5 > 2).
|
|
235
|
+
assert out["buckets"][0]["keys"] == {"channel": "email"}
|
|
236
|
+
assert out["buckets"][0]["count"] == 5
|
|
237
|
+
assert out["buckets"][0]["inbound"] == 3
|
|
238
|
+
assert out["buckets"][0]["outbound"] == 2
|
|
239
|
+
|
|
240
|
+
assert out["buckets"][1]["keys"] == {"channel": "slack"}
|
|
241
|
+
assert out["buckets"][1]["count"] == 2
|
|
242
|
+
assert out["buckets"][1]["inbound"] == 1
|
|
243
|
+
assert out["buckets"][1]["outbound"] == 1
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@_skip_no_neo4j
|
|
247
|
+
def test_aggregate_arena_isolation(neo4j_driver) -> None:
|
|
248
|
+
"""A's aggregate never sees B's edges, even when both arenas have
|
|
249
|
+
a Person with the same email — the shared name was the bug
|
|
250
|
+
pattern that motivated the typed-Person work in #28."""
|
|
251
|
+
driver, (arena_a, arena_b) = neo4j_driver
|
|
252
|
+
email = "shared@example.com"
|
|
253
|
+
|
|
254
|
+
with driver.session() as session:
|
|
255
|
+
_seed_person_communications(
|
|
256
|
+
session, arena_a, email,
|
|
257
|
+
[("email", "inbound", "2026-05-09T09:00:00Z")],
|
|
258
|
+
)
|
|
259
|
+
_seed_person_communications(
|
|
260
|
+
session, arena_b, email,
|
|
261
|
+
[
|
|
262
|
+
("email", "inbound", "2026-05-09T10:00:00Z"),
|
|
263
|
+
("slack", "outbound", "2026-05-09T11:00:00Z"),
|
|
264
|
+
],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
out_a = _aggregate_request(session, arena_a, email)
|
|
268
|
+
assert out_a["total"] == 1
|
|
269
|
+
assert len(out_a["buckets"]) == 1
|
|
270
|
+
|
|
271
|
+
out_b = _aggregate_request(session, arena_b, email)
|
|
272
|
+
assert out_b["total"] == 2
|
|
273
|
+
assert len(out_b["buckets"]) == 2
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@_skip_no_neo4j
|
|
277
|
+
def test_aggregate_with_no_group_by_returns_single_bucket(neo4j_driver) -> None:
|
|
278
|
+
"""No group_by → one global bucket with overall totals only."""
|
|
279
|
+
driver, (arena_a, _) = neo4j_driver
|
|
280
|
+
email = "alex@x.io"
|
|
281
|
+
|
|
282
|
+
with driver.session() as session:
|
|
283
|
+
_seed_person_communications(
|
|
284
|
+
session, arena_a, email,
|
|
285
|
+
[
|
|
286
|
+
("email", "inbound", "2026-05-09T09:00:00Z"),
|
|
287
|
+
("slack", "outbound", "2026-05-09T11:00:00Z"),
|
|
288
|
+
("email", "outbound", "2026-05-09T15:00:00Z"),
|
|
289
|
+
],
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
out = _aggregate_request(session, arena_a, email, group_by=[])
|
|
293
|
+
assert out["total"] == 3
|
|
294
|
+
assert len(out["buckets"]) == 1
|
|
295
|
+
assert out["buckets"][0]["keys"] == {}
|
|
296
|
+
assert out["buckets"][0]["inbound"] == 1
|
|
297
|
+
assert out["buckets"][0]["outbound"] == 2
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@_skip_no_neo4j
|
|
301
|
+
def test_aggregate_returns_empty_when_person_node_missing(neo4j_driver) -> None:
|
|
302
|
+
"""When the typed-Person nodes don't exist for this contact yet
|
|
303
|
+
(older memories, tenants pre-#28), the response is total: 0 with
|
|
304
|
+
no buckets — caller falls back to whatever it had before. This is
|
|
305
|
+
the deliberate non-fallback at this layer; the over-fetch v1
|
|
306
|
+
lives in TES."""
|
|
307
|
+
driver, (arena_a, _) = neo4j_driver
|
|
308
|
+
out = _aggregate_request(
|
|
309
|
+
driver.session().__enter__(), arena_a, "no-one@example.com",
|
|
310
|
+
)
|
|
311
|
+
assert out["total"] == 0
|
|
312
|
+
assert out["buckets"] == []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@_skip_no_neo4j
|
|
316
|
+
def test_aggregate_unknown_group_by_keys_are_silently_dropped(neo4j_driver) -> None:
|
|
317
|
+
"""Defence-in-depth: even if a future caller passes
|
|
318
|
+
group_by=['evil_property_name'], we never template that string
|
|
319
|
+
into Cypher. Aggregate falls back to no group_by (one global
|
|
320
|
+
bucket) rather than failing — the whitelist is already on the
|
|
321
|
+
real endpoint, but the test pins the behaviour at the helper
|
|
322
|
+
level too."""
|
|
323
|
+
driver, (arena_a, _) = neo4j_driver
|
|
324
|
+
email = "alex@x.io"
|
|
325
|
+
with driver.session() as session:
|
|
326
|
+
_seed_person_communications(
|
|
327
|
+
session, arena_a, email,
|
|
328
|
+
[("email", "inbound", "2026-05-09T09:00:00Z")],
|
|
329
|
+
)
|
|
330
|
+
out = _aggregate_request(session, arena_a, email, ["evil; DROP TABLE"])
|
|
331
|
+
# No supported keys remained → single global bucket.
|
|
332
|
+
assert len(out["buckets"]) == 1
|
|
333
|
+
assert out["buckets"][0]["keys"] == {}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Static safety check: every Cypher node pattern that targets a
|
|
2
|
+
tenant-data label must scope by `arena` — not just somewhere in the
|
|
3
|
+
surrounding block, but on the same variable.
|
|
4
|
+
|
|
5
|
+
Run with:
|
|
6
|
+
cd packages/memory-engine
|
|
7
|
+
python -m pytest tests/test_arena_safety.py -v
|
|
8
|
+
|
|
9
|
+
How the check works:
|
|
10
|
+
|
|
11
|
+
1. Walk the live engine source and pull out every Cypher block (both
|
|
12
|
+
triple-quoted strings and inline ``session.run("…")`` calls).
|
|
13
|
+
|
|
14
|
+
2. For each block, find every node pattern that names one of the
|
|
15
|
+
tenant labels — patterns like ``(p:Person {...})`` or
|
|
16
|
+
``(e:Entity:Concept {...})``.
|
|
17
|
+
|
|
18
|
+
3. For each such pattern, the variable bound by that pattern (e.g.
|
|
19
|
+
``p`` / ``e``) must be tied to ``arena`` somewhere in the block:
|
|
20
|
+
either inside the pattern's own property bag (``{arena: $arena,
|
|
21
|
+
…}``) or via a WHERE clause that references ``<var>.arena``.
|
|
22
|
+
|
|
23
|
+
The earlier weaker version of this lint checked "the block contains
|
|
24
|
+
the string `arena` *somewhere*", which let a Person MERGE without
|
|
25
|
+
arena slip through if any neighbouring chunk-join in the same block
|
|
26
|
+
referenced `arena`. The bug-day repro was injecting
|
|
27
|
+
``MERGE (p:Entity:Person {email: $email})`` while the rest of the
|
|
28
|
+
block kept ``MATCH (c:Chunk {arena: $arena, …})`` — block contained
|
|
29
|
+
"arena", lint was happy, the Person node was global.
|
|
30
|
+
|
|
31
|
+
If a future change introduces a Cypher pattern on these labels without
|
|
32
|
+
arena (e.g. a debug helper that genuinely needs to span all tenants),
|
|
33
|
+
allow-list it via ``_ALLOWED_NO_ARENA_REASONS`` with a justification.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import pytest
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
44
|
+
ENGINE_LIVE = REPO_ROOT / "engine" / "services" / "l2" / "l2-hybridrag-proxy.py"
|
|
45
|
+
COMPAT_SHIM = REPO_ROOT / "compat" / "server.py"
|
|
46
|
+
|
|
47
|
+
# Labels that carry tenant data. Any Cypher pattern naming these MUST
|
|
48
|
+
# bind the variable to `arena` — either as a property in the pattern
|
|
49
|
+
# itself or via a WHERE clause on the same variable.
|
|
50
|
+
TENANT_LABELS = ("Entity", "Person", "Concept", "Channel", "Chunk", "ChannelStat")
|
|
51
|
+
_LABEL_ALT = "|".join(TENANT_LABELS)
|
|
52
|
+
|
|
53
|
+
# Triple-quoted strings.
|
|
54
|
+
_TRIPLE_STRING = re.compile(
|
|
55
|
+
r'"""(?P<body>[^"]*?(?:"(?!"")[^"]*?)*?)"""',
|
|
56
|
+
re.DOTALL,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Inline `session.run("…")` calls that aren't already in a triple-quote.
|
|
60
|
+
_SINGLELINE_RUN = re.compile(
|
|
61
|
+
r'session\.run\(\s*"((?:[^"\\]|\\.)+)"',
|
|
62
|
+
re.MULTILINE,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Anything that smells like Cypher inside a string literal.
|
|
66
|
+
_OP_PATTERN = re.compile(r"\b(MERGE|MATCH|DETACH\s+DELETE)\b", re.IGNORECASE)
|
|
67
|
+
|
|
68
|
+
# Node pattern: (var:Label1:Label2 {props}) or (var:Label)
|
|
69
|
+
# The var is optional in Cypher, but anonymous patterns can't carry a
|
|
70
|
+
# WHERE clause anyway — flag them as unsafe unless the inline property
|
|
71
|
+
# bag scopes by arena.
|
|
72
|
+
_NODE_PATTERN = re.compile(
|
|
73
|
+
r"""
|
|
74
|
+
\(
|
|
75
|
+
\s*(?P<var>[A-Za-z_][A-Za-z0-9_]*)? # optional variable
|
|
76
|
+
\s*(?P<labels>(?::(?:""" + _LABEL_ALT + r"""))+)\b # one+ tenant labels
|
|
77
|
+
\s*(?P<props>\{[^{}]*\})? # optional property bag
|
|
78
|
+
""",
|
|
79
|
+
re.VERBOSE,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Allow-list: cross-tenant Cypher that we deliberately want to keep.
|
|
83
|
+
# Map a unique substring of the offending pattern to a justification.
|
|
84
|
+
_ALLOWED_NO_ARENA_REASONS: dict[str, str] = {
|
|
85
|
+
# /index-internal-stats — global ops counters that return ints.
|
|
86
|
+
"MATCH (c:Chunk) RETURN count(c) AS n":
|
|
87
|
+
"ops counter — returns a single int, no tenant data exposed",
|
|
88
|
+
"MATCH (e:Entity) RETURN count(e) AS n":
|
|
89
|
+
"ops counter — returns a single int, no tenant data exposed",
|
|
90
|
+
# /forget-internal global-wipe path — gated by confirm: GLOBAL_WIPE.
|
|
91
|
+
"MATCH (c:Chunk) DETACH DELETE c RETURN count(c) AS n":
|
|
92
|
+
"global-wipe, gated by explicit confirm: GLOBAL_WIPE",
|
|
93
|
+
"MATCH (e:Entity) DETACH DELETE e RETURN count(e) AS n":
|
|
94
|
+
"global-wipe, gated by explicit confirm: GLOBAL_WIPE",
|
|
95
|
+
# Migration target — pre-arena legacy entities have no arena.
|
|
96
|
+
"MATCH (e:Entity) WHERE e.arena IS NULL DETACH DELETE e":
|
|
97
|
+
"legacy-wipe migration target (entities pre-arena scoping)",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Cypher line comments. Strip these from extracted blocks before
|
|
102
|
+
# running the tenant-label scan so that prose mentions of pattern
|
|
103
|
+
# syntax (e.g. "Person-COMMUNICATED edges") inside `// …` comments
|
|
104
|
+
# don't get parsed as real node patterns.
|
|
105
|
+
_CYPHER_LINE_COMMENT = re.compile(r"//[^\n]*")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _strip_cypher_comments(block: str) -> str:
|
|
109
|
+
return _CYPHER_LINE_COMMENT.sub("", block)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_cypher_blocks(source: str) -> list[tuple[int, str]]:
|
|
113
|
+
"""Return [(approx_line_no, body)] for every Cypher block."""
|
|
114
|
+
blocks: list[tuple[int, str]] = []
|
|
115
|
+
for m in _TRIPLE_STRING.finditer(source):
|
|
116
|
+
body = m.group("body")
|
|
117
|
+
if _OP_PATTERN.search(body):
|
|
118
|
+
line_no = source[: m.start()].count("\n") + 1
|
|
119
|
+
# Strip Cypher // comments so prose patterns inside
|
|
120
|
+
# comments don't get scanned as actual queries.
|
|
121
|
+
blocks.append((line_no, _strip_cypher_comments(body)))
|
|
122
|
+
# session.run("…") matches are skipped if they fell inside a triple
|
|
123
|
+
# string already covered above. Cheap dedup: if the body of a
|
|
124
|
+
# singleline match is a substring of any triple body, skip it.
|
|
125
|
+
triple_bodies = [b for _, b in blocks]
|
|
126
|
+
for m in _SINGLELINE_RUN.finditer(source):
|
|
127
|
+
body = m.group(1)
|
|
128
|
+
if not _OP_PATTERN.search(body):
|
|
129
|
+
continue
|
|
130
|
+
if any(body in tb for tb in triple_bodies):
|
|
131
|
+
continue
|
|
132
|
+
line_no = source[: m.start()].count("\n") + 1
|
|
133
|
+
blocks.append((line_no, body))
|
|
134
|
+
return blocks
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_allowed(block: str) -> str | None:
|
|
138
|
+
for fragment, reason in _ALLOWED_NO_ARENA_REASONS.items():
|
|
139
|
+
if fragment in block:
|
|
140
|
+
return reason
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _pattern_scopes_arena(block: str, var: str | None, props: str | None) -> bool:
|
|
145
|
+
"""True if this specific pattern is arena-scoped.
|
|
146
|
+
|
|
147
|
+
A pattern is arena-scoped when EITHER:
|
|
148
|
+
- The inline property bag contains `arena:`, OR
|
|
149
|
+
- A `WHERE` clause in the surrounding block references
|
|
150
|
+
`<var>.arena`.
|
|
151
|
+
"""
|
|
152
|
+
if props and re.search(r"\barena\s*:", props):
|
|
153
|
+
return True
|
|
154
|
+
if var is None:
|
|
155
|
+
# Anonymous pattern with no property bag — there's no way to
|
|
156
|
+
# scope it via WHERE since there's no var to reference.
|
|
157
|
+
return False
|
|
158
|
+
# Look for `<var>.arena` anywhere in the block. Crude but the
|
|
159
|
+
# variable name is unambiguous within a single Cypher block.
|
|
160
|
+
if re.search(rf"\b{re.escape(var)}\.arena\b", block):
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize(
|
|
166
|
+
"source_path",
|
|
167
|
+
[
|
|
168
|
+
pytest.param(ENGINE_LIVE, id="l2-hybridrag-proxy"),
|
|
169
|
+
pytest.param(COMPAT_SHIM, id="compat-shim"),
|
|
170
|
+
],
|
|
171
|
+
)
|
|
172
|
+
def test_every_tenant_pattern_is_arena_scoped(source_path: Path) -> None:
|
|
173
|
+
"""Each tenant-label node pattern is scoped by arena."""
|
|
174
|
+
if not source_path.exists():
|
|
175
|
+
pytest.skip(f"{source_path} not present")
|
|
176
|
+
source = source_path.read_text()
|
|
177
|
+
offenders: list[str] = []
|
|
178
|
+
for line_no, block in _extract_cypher_blocks(source):
|
|
179
|
+
# Honour block-level allow-list before per-pattern checks; that
|
|
180
|
+
# way an entire global-wipe block can be allow-listed once.
|
|
181
|
+
if _is_allowed(block):
|
|
182
|
+
continue
|
|
183
|
+
for m in _NODE_PATTERN.finditer(block):
|
|
184
|
+
var = m.group("var")
|
|
185
|
+
props = m.group("props")
|
|
186
|
+
if _pattern_scopes_arena(block, var, props):
|
|
187
|
+
continue
|
|
188
|
+
offenders.append(
|
|
189
|
+
f"{source_path.name}:~{line_no} pattern `{m.group(0).strip()}` "
|
|
190
|
+
f"in block:\n{block.strip()[:240]}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert not offenders, (
|
|
194
|
+
f"{len(offenders)} tenant-labelled Cypher pattern(s) miss arena scoping:\n\n"
|
|
195
|
+
+ "\n\n---\n\n".join(offenders)
|
|
196
|
+
+ "\n\nAdd `arena` to the pattern (e.g. `{arena: $arena, …}`) or to a "
|
|
197
|
+
"WHERE clause on the same variable. If the pattern genuinely needs "
|
|
198
|
+
"to span tenants, add an entry to _ALLOWED_NO_ARENA_REASONS with a "
|
|
199
|
+
"justification."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# A self-test: the lint should fail when given a block that's clearly
|
|
204
|
+
# unscoped. This guards against future refactors of the lint silently
|
|
205
|
+
# turning into a no-op.
|
|
206
|
+
def test_lint_self_test_catches_obvious_bug() -> None:
|
|
207
|
+
"""Inject an unscoped pattern into a fake source and assert lint flags it."""
|
|
208
|
+
bad_source = '''
|
|
209
|
+
def writer():
|
|
210
|
+
session.run("""
|
|
211
|
+
MERGE (p:Entity:Person {email: $email})
|
|
212
|
+
ON CREATE SET p.created_at = $now
|
|
213
|
+
MATCH (c:Chunk {arena: $arena, id: $cid})
|
|
214
|
+
MERGE (p)-[:MENTIONS]->(c)
|
|
215
|
+
""", email="x", arena="acme", cid="1", now="t")
|
|
216
|
+
'''
|
|
217
|
+
blocks = _extract_cypher_blocks(bad_source)
|
|
218
|
+
assert blocks, "lint helper failed to extract the test block"
|
|
219
|
+
block = blocks[0][1]
|
|
220
|
+
assert not _is_allowed(block)
|
|
221
|
+
flagged: list[str] = []
|
|
222
|
+
for m in _NODE_PATTERN.finditer(block):
|
|
223
|
+
var = m.group("var")
|
|
224
|
+
props = m.group("props")
|
|
225
|
+
if not _pattern_scopes_arena(block, var, props):
|
|
226
|
+
flagged.append(m.group(0))
|
|
227
|
+
# The Person MERGE has no arena anywhere on `p` — must be flagged.
|
|
228
|
+
# The Chunk MATCH has arena in the property bag — must NOT be flagged.
|
|
229
|
+
assert any("Person" in f for f in flagged), \
|
|
230
|
+
"self-test: unscoped Person pattern should have been flagged"
|
|
231
|
+
assert not any("Chunk" in f for f in flagged), \
|
|
232
|
+
"self-test: arena-scoped Chunk pattern should not have been flagged"
|