Files
fengxiangdebaoziandClaude Opus 4.8 60626c384a fix(roundtable): 会商 Step2/Step3 沙箱产物按对话创建者解析,修复历史/跨用户查看不了
圆桌会商系统线程(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>
2026-06-19 20:51:05 +08:00

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