deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/runtime/thread_paths.py
2026-09-07 18:24:55 +08:00

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