fix(projects): keep referenced originals when the trash sweep walks a symlinked tree (#7732)

* fix(projects): keep referenced originals when the trash sweep walks a symlinked tree

The storage reconciliation protects each row's namespace by its resolved
path (project_document_path resolves symlinks) but walked the unresolved
documents directory. With a symlinked .deer-flow (no DEER_FLOW_HOME),
users/, users/{id}/ or projects/, no walked directory matched a protected
namespace, so the startup sweep and the lazy trash-listing sweep unlinked
live document originals older than 24 hours as orphans.

Walk each project's resolved documents directory so both sides compare
the same paths.

* docs(changelog): reference #7732 for the trash sweep symlink fix

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-31 20:16:50 +09:00 committed by GitHub
parent 12b2a9429c
commit eb5d1e1880
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 57 additions and 1 deletions

View File

@ -19,6 +19,13 @@ This section accumulates work toward the **2.3.0** milestone
### Fixed
- **projects:** The trash retention sweep no longer deletes live document
originals when the data directory, `users/`, a user's directory or a
user's `projects/` is a symlink. It protected each document by its resolved
path but walked the unresolved tree, so no document matched and every
original older than 24 hours was removed as an orphan, at Gateway startup
and from `GET /api/trash/documents`. ([#7732])
- **dev:** `scripts/dev_multi_instance.sh logs all` now prints the nginx logs
as well as both Gateway logs; it printed only the Gateway logs, the same as
`logs` with no argument. `logs nginx` and `logs all` also include `nginx.log`,
@ -15949,3 +15956,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727
[#7729]: https://github.com/bytedance/deer-flow/pull/7729
[#7732]: https://github.com/bytedance/deer-flow/pull/7732

View File

@ -20,6 +20,11 @@
### 修复
- **项目:** 当数据目录、`users/`、某个用户目录或用户的 `projects/` 是符号链接时,
回收站保留期清扫不再删除仍在使用的文档原件。此前它按解析后的路径保护每个文档,
却遍历未解析的目录树,导致没有任何文档能匹配上,所有超过 24 小时的原件都被当作
孤立文件删除,Gateway 启动时和 `GET /api/trash/documents` 都会触发。([#7732])
- **开发:** `scripts/dev_multi_instance.sh logs all` 现在除两个 Gateway 日志外也会输出
nginx 日志;此前它只输出 Gateway 日志,与不带参数的 `logs` 相同。`logs nginx` 与
`logs all` 还会包含 `nginx.log`(nginx 启动命令自身的输出),此前没有任何选项会输出它。
@ -12713,3 +12718,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727
[#7729]: https://github.com/bytedance/deer-flow/pull/7729
[#7732]: https://github.com/bytedance/deer-flow/pull/7732

View File

@ -200,6 +200,8 @@ def _sweep_project_documents_dir(documents_dir: Path, *, protected: set[Path], c
24-hour guard; a row's namespace (active or trashed, even after restore
re-pointed it to another project) is protected in full (§8.3). Empty
parents are rmdir'd best-effort. Nothing younger is ever collected.
``documents_dir`` must be resolved, like the protected namespaces it is
compared against.
"""
staging = documents_dir / ".staging"
if staging.is_dir():
@ -266,7 +268,11 @@ def _reconcile_storage(paths: Paths, *, user_id: str | None, rows: list[dict], g
for project_dir in sorted(projects_root.iterdir()):
documents_dir = project_dir / "documents"
if documents_dir.is_dir():
_sweep_project_documents_dir(documents_dir, protected=protected_by_user.get(uid, set()), cutoff_ts=cutoff_ts, report=report)
# Protected namespaces come from ``project_document_path``, which
# resolves symlinks. Walking an unresolved root through a
# symlinked ``.deer-flow``, ``users/`` or ``projects/`` would match
# none of them and collect live originals as orphans.
_sweep_project_documents_dir(documents_dir.resolve(), protected=protected_by_user.get(uid, set()), cutoff_ts=cutoff_ts, report=report)
def _parse_iso(value: object) -> datetime | None:

View File

@ -526,6 +526,42 @@ class TestRetentionSweep:
assert not ghost_ns.parent.exists()
assert documents_dir.is_dir()
@pytest.mark.parametrize("sweep_user", [_USER, None], ids=["lazy", "startup"])
@pytest.mark.parametrize("linked", [".deer-flow", "users", f"users/{_USER}", f"users/{_USER}/projects"])
async def test_symlinked_storage_ancestor_keeps_referenced_originals(self, env, monkeypatch, tmp_path, linked, sweep_user):
"""§8.3: a row protects its namespace even when the data root, the
users tree or a user's projects tree is a symlink. Protected
namespaces are resolved, so the walk must be too; an unreferenced
file past the guard is still collected through the link."""
target = tmp_path / "elsewhere"
target.mkdir()
if linked == ".deer-flow":
# Without DEER_FLOW_HOME the data root is the unresolved
# ``<project root>/.deer-flow``.
checkout = tmp_path / "checkout"
checkout.mkdir()
monkeypatch.delenv("DEER_FLOW_HOME")
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(checkout))
link = checkout / ".deer-flow"
else:
link = env.paths.base_dir / linked
link.parent.mkdir(parents=True, exist_ok=True)
symlink_or_skip(link, target, target_is_directory=True)
project = await env.projects.create(name="P", user_id=_USER)
row = await _add(env, project["id"], name="keep.txt", data=b"keep")
kept = original_file_path(env.paths, user_id=_USER, row=row)
_age(kept, hours=25)
orphan = env.paths.project_documents_dir(_USER, project["id"]) / "ab" / ("ab" * 32) / "ghost" / "original" / "old.bin"
orphan.parent.mkdir(parents=True)
orphan.write_bytes(b"x")
_age(orphan, hours=25)
report = await run_trash_retention_sweep(env.docs, env.paths, retention_days=30, user_id=sweep_user)
assert kept.read_bytes() == b"keep"
assert report.orphans_removed == 1
assert not orphan.exists()
async def test_row_reconciliation_detects_logs_and_never_deletes(self, env, caplog):
"""§8.3/§15.17: a row whose content vanished past the 24h guard is
surfaced as content_missing (log + report) but the row is retained —