361e653d37
`create()` now accepts `workspace_id: str | None | _AutoSentinel = AUTO` on both the SQL and in-memory implementations (and the abstract base). AUTO resolves via `resolve_workspace_id()` from the workspace contextvar that PR4 AuthMiddleware sets; explicit None bypasses for migration paths; explicit str overrides the contextvar. Test infrastructure: - conftest gains an autouse `_auto_workspace_context` fixture mirroring the existing user fixture. Opt-out via `@pytest.mark.no_auto_workspace`. - A SQLAlchemy `after_create` listener on `Base.metadata` seeds the matching `test-workspace-autouse` + `test-user-autouse` rows whenever `init_engine` runs `create_all()`, so the FK from threads_meta to workspaces resolves. Alembic migration tests bypass create_all and are unaffected, keeping real FK constraints under test. - `test_thread_meta_workspace_filter.py` covers the three AUTO / explicit / None paths. 3 new tests pass; 115 existing thread_meta/run/feedback/run_event/owner tests stay green.
132 lines
3.9 KiB
Python
132 lines
3.9 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 both a ``user_id`` parameter
|
|
(member-scoped owner check) and a ``workspace_id`` parameter (tenant
|
|
scope). Both follow three-state semantics:
|
|
|
|
- ``AUTO`` (default): resolve from the request-scoped contextvar.
|
|
- Explicit ``str``: use the provided value verbatim.
|
|
- Explicit ``None``: bypass that filter (migration / CLI only).
|
|
|
|
The workspace scope is the **outer** boundary: a row in workspace A is
|
|
unreachable from any user_id under workspace B. ``check_access`` returns
|
|
False on cross-workspace mismatch so the route layer can convert it into
|
|
a 404 instead of leaking thread existence across tenants.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
|
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
|
from deerflow.runtime.workspace_context import AUTO as WORKSPACE_AUTO
|
|
from deerflow.runtime.workspace_context import _AutoSentinel as _WorkspaceAutoSentinel
|
|
|
|
|
|
class ThreadMetaStore(abc.ABC):
|
|
@abc.abstractmethod
|
|
async def create(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
assistant_id: str | None = None,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
|
display_name: str | None = None,
|
|
metadata: dict | None = None,
|
|
) -> dict:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def get(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
|
) -> dict | None:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def search(
|
|
self,
|
|
*,
|
|
metadata: dict | None = None,
|
|
status: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
|
) -> list[dict]:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def update_display_name(
|
|
self,
|
|
thread_id: str,
|
|
display_name: str,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
|
) -> None:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def update_status(
|
|
self,
|
|
thread_id: str,
|
|
status: str,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
|
) -> None:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def update_metadata(
|
|
self,
|
|
thread_id: str,
|
|
metadata: dict,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_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 user/workspace check fails.
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def check_access(
|
|
self,
|
|
thread_id: str,
|
|
user_id: str,
|
|
workspace_id: str,
|
|
*,
|
|
require_existing: bool = False,
|
|
) -> bool:
|
|
"""Check whether ``user_id`` (in ``workspace_id``) can access ``thread_id``.
|
|
|
|
Cross-workspace access returns ``False`` unconditionally so the
|
|
decorator layer can convert it into a 404 — never leak the
|
|
existence of a thread that belongs to a different tenant.
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def delete(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
|
|
) -> None:
|
|
pass
|