"""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()