RongJie G 06c827903a
feat(persistence): add expand-phase thread incarnation storage (#5216)
* feat(persistence): expand thread incarnation storage

Add nullable thread and MCP task incarnation columns while preserving mixed-version writes. New thread records receive stable incarnation IDs, and new task rows copy the matching owned or shared thread incarnation without changing any read, claim, session, or deletion behavior.

* test(persistence): pin incarnation rollback compatibility

* test(api): pin internal thread response boundary

* fix(persistence): rebase incarnation rollout after projects

---------

Co-authored-by: CorgiBoyG <CorgiBoyG@users.noreply.github.com>
2026-09-09 15:52:09 +08:00

165 lines
5.7 KiB
Python

"""Abstract interface for thread metadata storage.
Implementations:
- ThreadMetaRepository: SQL-backed (sqlite / postgres via SQLAlchemy)
- MemoryThreadMetaStore: wraps LangGraph BaseStore (memory mode)
All mutating and querying methods accept a ``user_id`` parameter with
three-state semantics (see :mod:`deerflow.runtime.user_context`):
- ``AUTO`` (default): resolve from the request-scoped contextvar.
- Explicit ``str``: use the provided value verbatim.
- Explicit ``None``: bypass owner filtering (migration/CLI only).
"""
from __future__ import annotations
import abc
from typing import Any, ClassVar, Final
from deerflow.runtime.user_context import AUTO, _AutoSentinel
# Cross-component metadata key. Keep in sync with
# ``frontend/src/core/threads/utils.ts`` and
# ``frontend/tests/e2e/utils/mock-api.ts``.
THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
THREAD_ARCHIVED_METADATA_KEY = "deerflow_archived"
# Cross-component metadata key. Keep in sync with
# ``frontend/src/core/threads/utils.ts`` and
# ``frontend/tests/e2e/utils/mock-api.ts``.
THREAD_PROJECT_METADATA_KEY = "deerflow_project_id"
class _ProjectFilterUnset:
"""Sentinel for ``search(project_id=...)``: absent filter vs explicit unassigned."""
_instance: ClassVar[_ProjectFilterUnset | None] = None
def __new__(cls) -> _ProjectFilterUnset:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __repr__(self) -> str:
return "<PROJECT_FILTER_UNSET>"
PROJECT_FILTER_UNSET: Final = _ProjectFilterUnset()
class InvalidMetadataFilterError(ValueError):
"""Raised when all client-supplied metadata filter keys are rejected."""
class ThreadOwnershipConflictError(Exception):
"""Raised when create would overwrite a thread owned by another user."""
class ThreadMetaStore(abc.ABC):
@abc.abstractmethod
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,
project_id: str | None = None,
) -> dict:
"""Create a thread row; when ``project_id`` is set, validate the
project inside the insert transaction and raise
``ProjectNotAssignableError`` on failure (no partial row)."""
@abc.abstractmethod
async def claim_unowned(self, thread_id: str, owner: str) -> bool:
"""Atomically claim a legacy row whose owner is ``None``.
Returns ``True`` only when this call changed ``user_id`` from ``None``
to ``owner``. Missing and already-owned rows return ``False``.
"""
@abc.abstractmethod
async def set_project(self, thread_id: str, project_id: str | None, *, user_id: str | None | _AutoSentinel = AUTO) -> bool:
"""Atomically move a thread into/out of a project (RFC v2 §5.2).
Returns False when the thread is missing/foreign, or the target
project is missing/foreign/archived. Must not touch ``updated_at``.
"""
@abc.abstractmethod
async def get(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> dict | None:
pass
@abc.abstractmethod
async def search(
self,
*,
metadata: dict[str, Any] | None = None,
status: str | None = None,
archived: bool | None = None,
project_id: str | None | _ProjectFilterUnset = PROJECT_FILTER_UNSET,
limit: int = 100,
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
) -> list[dict[str, Any]]:
"""Search threads.
``archived=None`` includes all threads; False includes legacy rows
without a true archive flag. Filtering precedes pagination.
Results are ordered with pinned threads first
(``metadata.deerflow_pinned is True``), then by ``updated_at`` and
``thread_id`` descending within each group.
"""
pass
@abc.abstractmethod
async def update_display_name(
self,
thread_id: str,
display_name: str,
*,
remove_metadata_keys: tuple[str, ...] = (),
user_id: str | None | _AutoSentinel = AUTO,
) -> None:
pass
@abc.abstractmethod
async def update_status(self, thread_id: str, status: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
pass
@abc.abstractmethod
async def update_metadata(self, thread_id: str, metadata: dict, *, touch: bool = True, user_id: str | None | _AutoSentinel = AUTO) -> None:
"""Merge ``metadata`` into the thread's metadata field.
Existing keys are overwritten by the new values; keys absent from
``metadata`` are preserved. No-op if the thread does not exist
or the owner check fails.
When ``touch`` is ``True`` (default) the row's ``updated_at`` is
refreshed so the change bumps recency ordering. Pass ``touch=False``
for metadata that is not conversation activity (e.g. pin/unpin) so the
thread keeps its place in ``updated_at``-sorted lists.
"""
pass
@abc.abstractmethod
async def update_owner(self, thread_id: str, owner_user_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
"""Move a thread metadata row to a new owner.
Intended for trusted internal repair/migration paths. No-op if the
row does not exist or the caller fails the owner check.
"""
pass
@abc.abstractmethod
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
"""Check if ``user_id`` has access to ``thread_id``."""
pass
@abc.abstractmethod
async def delete(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
pass