圆桌会商系统线程(metadata.thread_type==roundtable)的 Step2 席位/总控 +
Step3 报告/总结/大屏/结构化 沙箱产物(md/流程图/报告)落在创建者目录下,
之前按当前登录解析路径 + owner_check 校验 → 历史草稿重开或 taskId 会商被
其他账号打开时 403/404 查看不了。沿用任务深链对话方案(23e6685)扩展到圆桌:
- thread_paths._is_creator_shared_thread:taskId 或 thread_type==roundtable
都解析到对话创建者(threads_meta.user_id),覆盖所有线程路径关口
- ThreadMetaRepository.check_access:圆桌系统线程同样跳过 owner 过滤
(artifacts 路由 owner_check 据此放行其他账号/历史查看)
线程 id 仅存在于草稿中,无关账号无法发现,与 taskId 共享模型一致。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.6 KiB
Python
169 lines
6.6 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 asyncio
|
|
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
|
|
|
|
|
|
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 _run_coro_blocking(coro):
|
|
"""Run an async coroutine to completion from sync code.
|
|
|
|
Mirrors ``lead_agent.prompt._run_coro_blocking``: when already inside a
|
|
running event loop, hand the coroutine to a fresh loop in a worker thread;
|
|
otherwise run it directly.
|
|
"""
|
|
try:
|
|
asyncio.get_running_loop()
|
|
in_loop = True
|
|
except RuntimeError:
|
|
in_loop = False
|
|
|
|
if in_loop:
|
|
import concurrent.futures
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
return pool.submit(asyncio.run, coro).result()
|
|
return asyncio.run(coro)
|
|
|
|
|
|
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:
|
|
return get_effective_user_id()
|
|
cached = _cache_get(thread_id)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
owner = _run_coro_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:
|
|
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()
|