e9deb6c2f2
* perf(harness): push thread metadata filters into SQL Replace Python-side metadata filtering (5x overfetch + in-memory match) with database-side json_extract predicates so LIMIT/OFFSET pagination is exact regardless of match density. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(harness): add dialect-aware JsonMatch compiler for type-safe metadata SQL filters Replace SQLAlchemy JSON index/comparator APIs with a custom JsonMatch ColumnElement that compiles to json_type/json_extract on SQLite and jsonb_typeof/->>/-> on PostgreSQL. Tighten key validation regex to single-segment identifiers, handle None/bool/numeric value types with json_type-based discrimination, and strengthen test coverage for edge cases and discriminability. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(harness): address Copilot review comments on JSON metadata filters - Use json_typeof instead of jsonb_typeof in PostgreSQL compiler; the metadata_json column is JSON not JSONB so jsonb_typeof would error at runtime on any PostgreSQL backend - Align _is_safe_json_key with json_match's _KEY_CHARSET_RE so keys containing hyphens or leading digits are not silently skipped - Add thread_id as secondary ORDER BY in search() to make pagination deterministic when updated_at values collide; remove asyncio.sleep from the pagination regression test Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com> * fix(harness): address remaining review comments on metadata SQL filters - Remove _is_safe_json_key() and reuse json_match ValueError to avoid validator drift (Copilot #3217603895, #3217411616) - Raise ValueError when all metadata keys are rejected so callers never get silent unfiltered results (WillemJiang) - Fix integer precision: split int/float branches, bind int as Integer() with INTEGER/BIGINT CAST instead of float() coercion (Copilot #3217603972) - Fix jsonb_typeof -> json_typeof on JSON column (Copilot #3217411579) - Replace manual _cleanup() calls with async yield fixture so teardown always runs (Copilot #3217604019) - Remove asyncio.sleep(0.01) pagination ordering; use thread_id secondary sort instead (Copilot #3217411636) - Add type annotations to _bind/_build_clause/_compile_* and remove EOL comments from _Dialect fields (coding.mdc) - Expand test coverage: boolean/null/mixed-type/large-int precision, partial unsafe-key skip with caplog assertion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(harness): address third-round Copilot review comments on JsonMatch - Reject unsupported value types (list, dict, ...) in JsonMatch.__init__ with TypeError so inherit_cache=True never receives an unhashable value and callers get an explicit error instead of silent str() coercion (Copilot #3217933201) - Upgrade int bindparam from Integer() to BigInteger() to align with BIGINT CAST and avoid overflow on large integers (Copilot #3217933252) - Catch TypeError alongside ValueError in search() so non-string metadata keys are warned and skipped rather than raising unexpectedly (Copilot #3217933300) - Add three tests: json_match rejects unsupported value types, search() warns and raises on non-string key, search() warns and raises on unsupported value type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(harness): address fourth-round Copilot review comments on JsonMatch - Add CASE WHEN guard for PostgreSQL integer matching: json_typeof returns 'number' for both ints and floats; wrap CAST in CASE with regex guard '^-?[0-9]+$' so float rows never trigger CAST error (Copilot #3218413860) - Validate isinstance(key, str) before regex match in JsonMatch.__init__ so non-string keys raise ValueError consistently instead of TypeError from re.match (Copilot #3218413900) - Include exception message in metadata filter skip warning so callers can distinguish invalid key from unsupported value type (Copilot #3218413924) - Update tests: assert CASE WHEN guard in PG int compilation, cover non-string key ValueError in test_json_match_rejects_unsafe_key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(harness): align ThreadMetaStore.search() signature with sql.py implementation Use `dict[str, Any]` for `metadata` and `list[dict[str, Any]]` as return type in base class and MemoryThreadMetaStore to resolve an LSP signature mismatch; also correct a test docstring that cited the wrong exception type. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(harness): surface InvalidMetadataFilterError as HTTP 400 in search endpoint Replace bare ValueError with a domain-specific InvalidMetadataFilterError (subclass of ValueError) so the Gateway handler can catch it and return HTTP 400 instead of letting it bubble up as a 500. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(harness): sanitize metadata keys in log output to prevent log injection Use ascii() instead of %r to escape control characters in client-supplied metadata keys before logging, preventing multiline/forged log entries. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(harness): validate metadata filters at API boundary and dedupe key/value rules - Add Pydantic ``field_validator`` on ``ThreadSearchRequest.metadata`` so unsafe keys / unsupported value types are rejected with HTTP 422 from both SQL and memory backends (closes Copilot review 3218830849). - Export ``validate_metadata_filter_key`` / ``validate_metadata_filter_value`` (and ``ALLOWED_FILTER_VALUE_TYPES``) from ``json_compat`` and have ``JsonMatch.__init__`` reuse them — the Gateway-side validator and the SQL-side ``JsonMatch`` constructor now share one admission rule and cannot drift. - Format ``InvalidMetadataFilterError`` rejected-keys list as a comma-separated plain string instead of a Python list repr so the surfaced HTTP 400 detail is readable (closes Copilot review 3218830899). - Update router tests to cover both 422 boundary paths plus the 400 defense-in-depth path when a backend still raises the error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(harness): harden JsonMatch compile-time key validation against __init__ bypass Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com> * fix: address review feedback on metadata filter SQL push-down - Add signed 64-bit range check to validate_metadata_filter_value; give out-of-range ints a distinct TypeError message. - Replace assert guards in _compile_sqlite/_compile_pg with explicit if/raise so they survive python -O optimisation. Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
152 lines
5.8 KiB
Python
152 lines
5.8 KiB
Python
"""In-memory ThreadMetaStore backed by LangGraph BaseStore.
|
|
|
|
Used when database.backend=memory. Delegates to the LangGraph Store's
|
|
``("threads",)`` namespace — the same namespace used by the Gateway
|
|
router for thread records.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from langgraph.store.base import BaseStore
|
|
|
|
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
|
from deerflow.utils.time import coerce_iso, now_iso
|
|
|
|
THREADS_NS: tuple[str, ...] = ("threads",)
|
|
|
|
|
|
class MemoryThreadMetaStore(ThreadMetaStore):
|
|
def __init__(self, store: BaseStore) -> None:
|
|
self._store = store
|
|
|
|
async def _get_owned_record(
|
|
self,
|
|
thread_id: str,
|
|
user_id: str | None | _AutoSentinel,
|
|
method_name: str,
|
|
) -> dict | None:
|
|
"""Fetch a record and verify ownership. Returns a mutable copy, or None."""
|
|
resolved = resolve_user_id(user_id, method_name=method_name)
|
|
item = await self._store.aget(THREADS_NS, thread_id)
|
|
if item is None:
|
|
return None
|
|
record = dict(item.value)
|
|
if resolved is not None and record.get("user_id") != resolved:
|
|
return None
|
|
return record
|
|
|
|
async def create(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
assistant_id: str | None = None,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
display_name: str | None = None,
|
|
metadata: dict | None = None,
|
|
) -> dict:
|
|
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.create")
|
|
now = now_iso()
|
|
record: dict[str, Any] = {
|
|
"thread_id": thread_id,
|
|
"assistant_id": assistant_id,
|
|
"user_id": resolved_user_id,
|
|
"display_name": display_name,
|
|
"status": "idle",
|
|
"metadata": metadata or {},
|
|
"values": {},
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
await self._store.aput(THREADS_NS, thread_id, record)
|
|
return record
|
|
|
|
async def get(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> dict | None:
|
|
return await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.get")
|
|
|
|
async def search(
|
|
self,
|
|
*,
|
|
metadata: dict[str, Any] | None = None,
|
|
status: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
) -> list[dict[str, Any]]:
|
|
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
|
filter_dict: dict[str, Any] = {}
|
|
if metadata:
|
|
filter_dict.update(metadata)
|
|
if status:
|
|
filter_dict["status"] = status
|
|
if resolved_user_id is not None:
|
|
filter_dict["user_id"] = resolved_user_id
|
|
|
|
items = await self._store.asearch(
|
|
THREADS_NS,
|
|
filter=filter_dict or None,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
return [self._item_to_dict(item) for item in items]
|
|
|
|
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
|
item = await self._store.aget(THREADS_NS, thread_id)
|
|
if item is None:
|
|
return not require_existing
|
|
record_user_id = item.value.get("user_id")
|
|
if record_user_id is None:
|
|
return True
|
|
return record_user_id == user_id
|
|
|
|
async def update_display_name(self, thread_id: str, display_name: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
|
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_display_name")
|
|
if record is None:
|
|
return
|
|
record["display_name"] = display_name
|
|
record["updated_at"] = now_iso()
|
|
await self._store.aput(THREADS_NS, thread_id, record)
|
|
|
|
async def update_status(self, thread_id: str, status: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
|
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_status")
|
|
if record is None:
|
|
return
|
|
record["status"] = status
|
|
record["updated_at"] = now_iso()
|
|
await self._store.aput(THREADS_NS, thread_id, record)
|
|
|
|
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
|
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
|
|
if record is None:
|
|
return
|
|
merged = dict(record.get("metadata") or {})
|
|
merged.update(metadata)
|
|
record["metadata"] = merged
|
|
record["updated_at"] = now_iso()
|
|
await self._store.aput(THREADS_NS, thread_id, record)
|
|
|
|
async def delete(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
|
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.delete")
|
|
if record is None:
|
|
return
|
|
await self._store.adelete(THREADS_NS, thread_id)
|
|
|
|
@staticmethod
|
|
def _item_to_dict(item) -> dict[str, Any]:
|
|
"""Convert a Store SearchItem to the dict format expected by callers."""
|
|
val = item.value
|
|
return {
|
|
"thread_id": item.key,
|
|
"assistant_id": val.get("assistant_id"),
|
|
"user_id": val.get("user_id"),
|
|
"display_name": val.get("display_name"),
|
|
"status": val.get("status", "idle"),
|
|
"metadata": val.get("metadata", {}),
|
|
# ``coerce_iso`` heals legacy unix-second values written by
|
|
# earlier Gateway versions that called ``str(time.time())``.
|
|
"created_at": coerce_iso(val.get("created_at", "")),
|
|
"updated_at": coerce_iso(val.get("updated_at", "")),
|
|
}
|