- title: 标题模型用隔离 config(不继承父级 streaming callbacks),force_disable_thinking,<think> 块清洗,避免推理 token 进入实时流/运行日志 - uploads: 无文件的普通对话轮次直接跳过,不再解析线程归属(避免命中 DB) - thread_data: ephemeral 会话用当前登录用户解析数据目录 - thread_paths: open-/__skill-review__ 等合成线程视为 local-only,直接用当前用户 - frontend: 新增 build:test 构建脚本 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
6.4 KiB
Python
155 lines
6.4 KiB
Python
"""Canonical owner resolution for a thread's filesystem paths.
|
|
|
|
Task-deeplink conversations (those whose ``threads_meta.metadata.taskId`` is
|
|
set — rwfx / 3qfx / xdfx) are **shared across all users**: any logged-in
|
|
account may open the same conversation (enforced by the ``taskId`` escape
|
|
branches in :mod:`deerflow.persistence.thread_meta.sql`). But 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/...
|
|
|
|
If that ``{user_id}`` were the **current login**, account B opening a shared
|
|
task conversation would resolve to an empty ``users/B/...`` directory and 404
|
|
on every file account A generated — which is exactly the "切换账号后生成的 md
|
|
文件找不到" bug.
|
|
|
|
To keep the *file* layer consistent with the *conversation-sharing* layer, all
|
|
path resolution for a task-shared thread must use one stable user bucket: the
|
|
thread's **creator** (``threads_meta.user_id``). That value is fixed at thread
|
|
creation and never changes, so every account resolves to the same physical
|
|
directory.
|
|
|
|
Personal (non-task) threads are unaffected: their creator is always the only /
|
|
current user, so :func:`resolve_path_user_id` returns exactly what
|
|
``get_effective_user_id`` would have. On any failure (no DB, missing row,
|
|
memory-mode store) it falls back to the current login, so behaviour degrades
|
|
safely to the pre-fix semantics.
|
|
"""
|
|
|
|
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, populated only for task-shared threads. The
|
|
# (taskId, creator) pair is immutable for a 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_task_owner(thread_id: str) -> str | None:
|
|
"""Return the creator user_id iff *thread_id* is a task-shared thread.
|
|
|
|
Returns ``None`` for personal threads, 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
|
|
metadata = row.get("metadata") or {}
|
|
owner = row.get("user_id")
|
|
if owner and _is_creator_shared_thread(metadata):
|
|
return str(owner)
|
|
return None
|
|
|
|
|
|
def _is_creator_shared_thread(metadata: dict) -> bool:
|
|
"""True iff a thread's files should resolve to its **creator** (shared bucket).
|
|
|
|
Two kinds of threads are shared to one stable directory rather than the
|
|
current login:
|
|
|
|
- **Task-deeplink conversations** (``metadata.taskId``) — rwfx / 3qfx / xdfx,
|
|
explicitly shared across all users;
|
|
- **Roundtable 会商 system threads** (``metadata.thread_type == "roundtable"``)
|
|
— the Step 2 seats / coordinator and Step 3 report / summary / dashboard /
|
|
structure threads. Their sandbox artifacts (md / 流程图 / 报告) live under the
|
|
creator's directory, but a历史草稿 reopened later — or a taskId会商 draft opened
|
|
by **another account** — must still be able to view them. Resolving to the
|
|
creator keeps the *file* layer consistent with the *draft-sharing* layer.
|
|
|
|
Roundtable thread ids are random UUIDs surfaced only inside the owner's draft
|
|
(personal drafts are owner-scoped; taskId drafts are already shared), so this
|
|
does not let an unrelated account *discover* another user's 会商 files — it only
|
|
lets a caller that already holds the thread id read the creator's bucket.
|
|
"""
|
|
return bool(metadata.get("taskId")) or metadata.get("thread_type") == "roundtable"
|
|
|
|
|
|
def resolve_path_user_id(thread_id: str | None) -> str:
|
|
"""Canonical user bucket for *thread_id*'s files (sync).
|
|
|
|
For a task-shared thread this is the thread creator, so all accounts that
|
|
share the conversation read/write the same physical directory. For every
|
|
other case it is 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_task_owner(thread_id))
|
|
except Exception:
|
|
logger.debug("Failed to resolve task-shared owner 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_task_owner(thread_id)
|
|
except Exception:
|
|
logger.debug("Failed to resolve task-shared owner for thread %s", thread_id, exc_info=True)
|
|
owner = None
|
|
if owner:
|
|
_cache_put(thread_id, owner)
|
|
return owner
|
|
return get_effective_user_id()
|