122 lines
4.4 KiB
Python
122 lines
4.4 KiB
Python
"""Canonical owner resolution for a thread's filesystem paths.
|
|
|
|
The agent's generated files (md / artifacts) and uploads live under a
|
|
*per-user* directory::
|
|
|
|
{base_dir}/users/{user_id}/threads/{thread_id}/user-data/...
|
|
|
|
Using the request context alone is not reliable for filesystem lookup: an
|
|
internal call or a context propagation edge case resolves to ``users/default``
|
|
even though the authenticated route has already verified access to a persisted
|
|
thread. The thread creator is an immutable, stable filesystem bucket, so all
|
|
persisted threads resolve to ``threads_meta.user_id``. Task-deeplink and
|
|
roundtable threads additionally need this because they can intentionally be
|
|
reopened by another account.
|
|
|
|
For missing metadata rows (legacy/memory-mode threads), resolution safely falls
|
|
back to the current login.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# thread_id -> creator user_id. The pair is immutable for a persisted thread,
|
|
# so positive results are cached forever; the fallback (current login) is never
|
|
# cached because a row may appear after the first lookup.
|
|
_owner_cache: dict[str, str] = {}
|
|
_owner_cache_lock = threading.Lock()
|
|
_OWNER_CACHE_MAX = 4096
|
|
_LOCAL_ONLY_THREAD_PREFIXES = ("open-", "__skill-review__")
|
|
|
|
|
|
def _is_local_only_thread(thread_id: str | None) -> bool:
|
|
"""Return True for synthetic threads that are never persisted in threads_meta."""
|
|
return bool(thread_id) and str(thread_id).startswith(_LOCAL_ONLY_THREAD_PREFIXES)
|
|
|
|
|
|
def _cache_get(thread_id: str) -> str | None:
|
|
with _owner_cache_lock:
|
|
return _owner_cache.get(thread_id)
|
|
|
|
|
|
def _cache_put(thread_id: str, owner: str) -> None:
|
|
with _owner_cache_lock:
|
|
# Crude bound: clear wholesale rather than track LRU order. The mapping
|
|
# is tiny and immutable, so a rare full clear just re-warms lazily.
|
|
if len(_owner_cache) >= _OWNER_CACHE_MAX:
|
|
_owner_cache.clear()
|
|
_owner_cache[thread_id] = owner
|
|
|
|
|
|
async def _fetch_thread_owner(thread_id: str) -> str | None:
|
|
"""Return the persisted creator user_id for *thread_id*.
|
|
|
|
Returns ``None`` for untracked threads, the memory-mode store, or any DB
|
|
error — callers then fall back to the current login.
|
|
"""
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
|
|
|
session_factory = get_session_factory()
|
|
if session_factory is None:
|
|
return None
|
|
store = ThreadMetaRepository(session_factory)
|
|
# user_id=None bypasses the owner filter so we can read any thread's row.
|
|
row = await store.get(thread_id, user_id=None)
|
|
if not row:
|
|
return None
|
|
owner = row.get("user_id")
|
|
if owner:
|
|
return str(owner)
|
|
return None
|
|
|
|
|
|
def resolve_path_user_id(thread_id: str | None) -> str:
|
|
"""Canonical user bucket for *thread_id*'s files (sync).
|
|
|
|
A persisted thread always uses its creator's stable directory. This is the
|
|
same bucket as the current user for ordinary owned threads, and also keeps
|
|
file lookup correct when request context is unavailable. Legacy/untracked
|
|
threads use the current login (``get_effective_user_id``).
|
|
"""
|
|
if not thread_id or _is_local_only_thread(thread_id):
|
|
return get_effective_user_id()
|
|
cached = _cache_get(thread_id)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
from deerflow.persistence.engine import run_db_blocking
|
|
|
|
owner = run_db_blocking(_fetch_thread_owner(thread_id))
|
|
except Exception:
|
|
logger.debug("Failed to resolve creator for thread %s", thread_id, exc_info=True)
|
|
owner = None
|
|
if owner:
|
|
_cache_put(thread_id, owner)
|
|
return owner
|
|
return get_effective_user_id()
|
|
|
|
|
|
async def aresolve_path_user_id(thread_id: str | None) -> str:
|
|
"""Async variant of :func:`resolve_path_user_id` for FastAPI handlers."""
|
|
if not thread_id or _is_local_only_thread(thread_id):
|
|
return get_effective_user_id()
|
|
cached = _cache_get(thread_id)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
owner = await _fetch_thread_owner(thread_id)
|
|
except Exception:
|
|
logger.debug("Failed to resolve creator for thread %s", thread_id, exc_info=True)
|
|
owner = None
|
|
if owner:
|
|
_cache_put(thread_id, owner)
|
|
return owner
|
|
return get_effective_user_id()
|