mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md (issue #5160, tracker #5129) in the slice order of the spec's §16. Slices: - A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY admission pinning (both server-owned sets + worker hoist); latest-only request-scoped <project> block via DynamicContextMiddleware wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID prefix + marker + provenance, never persisted); journal audit fingerprints; Instructions tab. - B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with locked check-and-set; hash-qualified immutable shelf storage with Paths helpers; upload/list/content/delete-to-trash routes; project delete trashes the shelf in-transaction; request-scoped bounded <documents> index with honest count/shown + actionable overflow note; list_project_documents/read_project_document tools registered only on pinned runs; PAT allowlist + drift guards; blocking-IO anchors. - C: shared thread-upload ingestion service (uploads router refactored to parity); POST from-thread with provenance; attach-to-thread with lock-staged copy (archived source allowed); read-only thread-files view with per-group truncation reporting. - D: restore (restored/merged/not_found/no_target/content_missing; no file moves), purge (continuous row lock across unlink/delete/commit, retryable on FS errors), retention sweep (lazy + startup, 24h orphan guard, row-side reconciliation never deletes). - E: Documents tab (shelf + conversation-files browser, provenance, archived banner, content-missing rows), /workspace/trash route, sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks + specs. Review hardening folded in (10 rounds, all with tests): - force active shelf content (HTML/XML family) to download; nosniff on artifact + content responses; unified unsandboxed-iframe PDF preview (fixes the pre-existing Chromium sandbox blank in the artifact viewer) - scope document trash to the URL project under the document lock - atomic no-overwrite filename reservation for ALL ingestion (seeded claims + os.link commit with suffix retry; same-name re-upload now unique-names instead of replacing); hidden staging only, no visible placeholders; lease cleanup on setup failure - serialize conversion under the document lock with post-lock active revalidation; drain locked filesystem work on cancellation; preserve bytes when an insert's commit state is uncertain (including trashed rows) - original-integrity checks before serving text or cached conversions; content_missing surfaced in list responses (UI reads the flag, no 409-probe); downloads always serve original bytes - bounded streaming document reads with cached char counts; shelf limits declared in middleware release identity - thread-root confinement for from-thread sources; config fallback rejects fractional/infinite values; composer counts staged attachments; pending attachments persist until submission or removal; in-flight instruction/rename edits survive save refetches; shelf and trash pagination; conversation-file and thread-files pages stay subscribed to refetches Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md contracts, config.example.yaml projects block. Review follow-ups (head b4807477 → this revision): - The trash retention sweep is split so repeated lazy triggers stay bounded: the indexed expiry purge still runs on every trigger (GET /api/trash/documents, POST /api/trash/purge) while the O(all rows + all files) reconciliation is throttled to one run per user per 15 minutes (process-local, per-user window). The startup sweep now runs as a background task instead of blocking gateway readiness, and shutdown awaits it (bounded). - The export scrub (stripInternalMarkers) is fence- and indentation-aware like the render path, so a pasted, fenced <project>/<documents> snippet survives markdown export while real injected blocks (never fenced) are still removed. Fence regexes moved to a dependency-free leaf module to avoid the messages↔streamdown import cycle. - The artifact viewer's PDF iframe no longer carries an added title attribute (the upstream e2e contract locates it via :not([title])), and the upstream artifact-preview spec now pins the new contract: PDFs render unsandboxed, images keep sandbox="". * fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title - Shutdown cancelled only the shield around the background startup sweep, so an all-users reconciliation that outlived the 5s budget kept walking rows and files while the document repo and DB engine were disposed underneath it. The wait now lives in `_shutdown_startup_trash_sweep`, which cancels the task and drains it before worker exit: the shield keeps the wait bounded, the cancel makes it final (CancelledError lands at the sweep's next await, and `_run_startup_trash_sweep` only catches `Exception`, so nothing swallows it). - The browser-preview iframe lost `title={getFileName(filepath)}` in the previous fix round, leaving the PDF frame without an accessible name while its siblings keep theirs. Restore it (WCAG frame titles), assert it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]` instead of `iframe:not([title])`. * fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel `Task.cancel()` returns False when the sweep already finished inside the window between the deadline firing and the cancel, so the shutdown log claimed a cancellation that never happened. Branch on that outcome: the warning stays for a real cancel, a late finish is logged at info, and both paths still reap the task before worker exit. * fix(projects): round-4 review — make Empty trash delete what it confirms `POST /api/trash/purge` only ran the retention sweep, and the sweep's candidate selection is age-gated, so a freshly trashed document survived "Empty trash" even though the confirmation promises that every listed document is permanently deleted. With one trashed row the route answered `{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps expired rows before listing, so the visible rows were normally ineligible for the action by construction. Empty trash now drives `purge_all_trashed`: the caller's trashed rows (`list_all_trashed`, no age filter) each go through the same guarded, row-locked `purge` as the single-document delete — bytes first, then the row, in one transaction — so a row restored mid-flight is skipped instead of force-deleted, and an unlink failure rolls that row back and answers 500 with a retryable message. Retention expiry stays where it was: the sweep's `purge_candidates` is now the only age-gated selection, and the lazy retention sweep still runs on the listing and at startup. Tests: the router suite replaces the retention-gated expectation with the reviewer's repro (fresh row purged, bytes unlinked, shelf and other users' trash untouched, a failing unlink stays retryable and 500); a blocking-I/O anchor drives the new entry point through the offload; the mocked e2e covers the action end to end; a new real-backend spec performs it against the real gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE and the phase-2 design docs (en+zh) state the age-independent contract.
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""Trash tier service: restore orchestration, guarded purge, retention sweep.
|
|
|
|
Phase-2 spec §8.2/§8.3. Restore is a database re-point (``stored_relpath`` is
|
|
projects-root-relative, so no file ever moves, §10.6); the only filesystem
|
|
work on the restore path is the post-commit merge cleanup of the discarded
|
|
namespace, best-effort with the sweep as backstop. Purge unlinks the
|
|
original and ``derived/converted.md`` inside the repository's continuously
|
|
row-locked transaction — ``FileNotFoundError`` counts as already removed,
|
|
any other unlink error rolls the row deletion back and keeps the trashed
|
|
row retryable. The retention sweep runs lazily on the trash listing and
|
|
once at gateway startup (no daemon, §15.9): it invokes the same guarded
|
|
purge for expired rows, then reconciles storage (``.staging`` and
|
|
unreferenced files older than 24 hours only) and detects — never deletes —
|
|
rows whose content is missing or size-mismatched (§15.17).
|
|
|
|
Every filesystem touch is offloaded through ``run_file_io``; the blocking-IO
|
|
anchors in ``tests/blocking_io/test_project_trash.py`` pin that.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.projects.documents import _content_intact, check_document_content, converted_markdown_path, original_file_path
|
|
from deerflow.utils.file_io import run_file_io
|
|
from deerflow.utils.time import coerce_iso
|
|
|
|
if TYPE_CHECKING:
|
|
from deerflow.persistence.projects import ProjectDocumentRepository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Nothing younger than this is ever collected or flagged, so an in-flight
|
|
#: upload or a freshly written row can never be swept (§8.3).
|
|
_ORPHAN_GUARD = timedelta(hours=24)
|
|
|
|
|
|
def _rmdir_if_empty(directory: Path) -> None:
|
|
try:
|
|
directory.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _unlink_document_files(paths: Paths, *, user_id: str, row: dict) -> None:
|
|
"""Worker-thread: unlink a row's original + derived companion, then rmdir
|
|
empty parents (best-effort). ``FileNotFoundError`` counts as already
|
|
removed (§8.3); any other unlink error propagates so the purge
|
|
transaction rolls back and keeps the trashed row retryable.
|
|
"""
|
|
original = original_file_path(paths, user_id=user_id, row=row)
|
|
derived = converted_markdown_path(paths, user_id=user_id, row=row)
|
|
for path in (original, derived):
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
namespace = paths.project_document_path(user_id, row["stored_relpath"])
|
|
documents_dir = namespace.parents[2]
|
|
for directory in (original.parent, derived.parent, namespace, namespace.parent, namespace.parent.parent):
|
|
if directory != documents_dir:
|
|
_rmdir_if_empty(directory)
|
|
|
|
|
|
def make_purge_file_remover(paths: Paths, *, user_id: str | None) -> Callable[[dict], Awaitable[None]]:
|
|
"""Build the ``remove_files`` hook for ``ProjectDocumentRepository.purge``.
|
|
|
|
The hook runs inside the purge transaction, continuously under the
|
|
document-row lock (§6.3). ``user_id=None`` (the startup sweep) resolves
|
|
the owner per row.
|
|
"""
|
|
|
|
async def _remove(row: dict) -> None:
|
|
await run_file_io(_unlink_document_files, paths, user_id=user_id or row["user_id"], row=row)
|
|
|
|
return _remove
|
|
|
|
|
|
def _remove_namespace_tree(paths: Paths, *, user_id: str, relpath: str) -> None:
|
|
"""Worker-thread: remove one document's whole namespace + empty parents."""
|
|
namespace = paths.project_document_path(user_id, relpath)
|
|
shutil.rmtree(namespace, ignore_errors=True)
|
|
documents_dir = namespace.parents[2]
|
|
parent = namespace.parent
|
|
while parent != documents_dir:
|
|
_rmdir_if_empty(parent)
|
|
parent = parent.parent
|
|
|
|
|
|
async def restore_document(
|
|
repo: ProjectDocumentRepository,
|
|
paths: Paths,
|
|
*,
|
|
user_id: str,
|
|
document_id: str,
|
|
target_project_id: str,
|
|
) -> tuple[str, dict | None]:
|
|
"""Restore one trashed document into an active target project (§8.2).
|
|
|
|
Thin orchestration over the repository's locked restore: the post-commit
|
|
merge cleanup (unlinking the discarded source namespace, which no
|
|
surviving row can reference) is the only filesystem work here —
|
|
best-effort; a failure is logged and left for the sweep (§8.2/§10.6).
|
|
Returns the repository's ``(outcome, row)`` pair unchanged.
|
|
"""
|
|
# Read-only probe: captures the discarded namespace a merge cleanup must
|
|
# remove. Every correctness check happens inside the repository's locked
|
|
# transaction, so this probe decides nothing (§15.5).
|
|
source = await repo.get(document_id, include_trashed=True, user_id=user_id)
|
|
|
|
async def _check(row: dict) -> bool:
|
|
return await check_document_content(paths, user_id=user_id, row=row)
|
|
|
|
outcome, row = await repo.restore(document_id, target_project_id=target_project_id, check_content=_check, user_id=user_id)
|
|
if outcome == "merged" and source is not None:
|
|
try:
|
|
await run_file_io(_remove_namespace_tree, paths, user_id=user_id, relpath=source["stored_relpath"])
|
|
except Exception:
|
|
logger.warning(
|
|
"Merge cleanup of the discarded namespace for document %s failed; the sweep will collect it",
|
|
document_id,
|
|
exc_info=True,
|
|
)
|
|
return outcome, row
|
|
|
|
|
|
async def purge_all_trashed(
|
|
repo: ProjectDocumentRepository,
|
|
paths: Paths,
|
|
*,
|
|
user_id: str,
|
|
) -> int:
|
|
"""Empty the caller's trash (§8.3): purge every trashed row regardless of age.
|
|
|
|
Empty trash deletes exactly what the user confirmed, so the retention
|
|
cutoff plays no part here — ``run_trash_retention_sweep`` stays the only
|
|
age-gated purge. Each row goes through the same guarded row-locked
|
|
``purge`` as a single-document delete: bytes first, then the row, in one
|
|
transaction, so a restore that wins the race leaves the row alone
|
|
(``purge`` answers ``False`` for a no-longer-trashed row and it is
|
|
skipped). Rows are not deleted atomically: an unlink error rolls that row
|
|
back and propagates, leaving it — and every row not yet visited —
|
|
trashed and retryable. Returns the number of rows actually purged.
|
|
"""
|
|
remove_files = make_purge_file_remover(paths, user_id=user_id)
|
|
purged = 0
|
|
for row in await repo.list_all_trashed(user_id=user_id):
|
|
if await repo.purge(row["id"], remove_files=remove_files, user_id=user_id):
|
|
purged += 1
|
|
return purged
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SweepReport:
|
|
"""Observable outcome of one retention sweep run."""
|
|
|
|
purged: int = 0
|
|
purge_failures: int = 0
|
|
orphans_removed: int = 0
|
|
staging_removed: int = 0
|
|
content_missing: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _sweep_project_documents_dir(documents_dir: Path, *, protected: set[Path], cutoff_ts: float, report: SweepReport) -> None:
|
|
"""Worker-thread: storage reconciliation under one ``documents/`` directory.
|
|
|
|
Removes ``.staging/*`` entries and unreferenced files older than the
|
|
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.
|
|
"""
|
|
staging = documents_dir / ".staging"
|
|
if staging.is_dir():
|
|
for entry in staging.iterdir():
|
|
try:
|
|
if entry.stat().st_mtime >= cutoff_ts:
|
|
continue
|
|
if entry.is_dir():
|
|
shutil.rmtree(entry, ignore_errors=True)
|
|
else:
|
|
entry.unlink()
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError:
|
|
logger.warning("Staging sweep could not remove %s; skipping", entry, exc_info=True)
|
|
continue
|
|
report.staging_removed += 1
|
|
for dirpath, dirnames, filenames in os.walk(documents_dir):
|
|
current = Path(dirpath)
|
|
# Never descend into protected namespaces or .staging.
|
|
dirnames[:] = [name for name in dirnames if (current / name) not in protected and not (current == documents_dir and name == ".staging")]
|
|
for filename in filenames:
|
|
file = current / filename
|
|
try:
|
|
if file.stat().st_mtime >= cutoff_ts:
|
|
continue
|
|
file.unlink()
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError:
|
|
logger.warning("Orphan sweep could not unlink %s; skipping", file, exc_info=True)
|
|
continue
|
|
report.orphans_removed += 1
|
|
# Rmdir empty parents best-effort (bottom-up), keeping the documents dir
|
|
# itself and protected namespaces.
|
|
for dirpath, _dirnames, _filenames in os.walk(documents_dir, topdown=False):
|
|
current = Path(dirpath)
|
|
if current != documents_dir and current not in protected:
|
|
_rmdir_if_empty(current)
|
|
|
|
|
|
def _reconcile_storage(paths: Paths, *, user_id: str | None, rows: list[dict], guard_cutoff: datetime, report: SweepReport) -> None:
|
|
"""Worker-thread: storage reconciliation across the swept users' trees."""
|
|
cutoff_ts = guard_cutoff.timestamp()
|
|
protected_by_user: dict[str, set[Path]] = {}
|
|
for row in rows:
|
|
owner = row.get("user_id")
|
|
if not isinstance(owner, str) or not owner:
|
|
continue
|
|
try:
|
|
namespace = paths.project_document_path(owner, row["stored_relpath"])
|
|
except (KeyError, ValueError):
|
|
continue
|
|
protected_by_user.setdefault(owner, set()).add(namespace)
|
|
if user_id is not None:
|
|
user_ids = [user_id]
|
|
else:
|
|
users_root = paths.base_dir / "users"
|
|
user_ids = sorted(entry.name for entry in users_root.iterdir() if entry.is_dir()) if users_root.is_dir() else []
|
|
for uid in user_ids:
|
|
projects_root = paths.user_projects_dir(uid)
|
|
if not projects_root.is_dir():
|
|
continue
|
|
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)
|
|
|
|
|
|
def _parse_iso(value: object) -> datetime | None:
|
|
text = coerce_iso(value)
|
|
if not text:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
|
|
|
|
def _reconcile_rows(paths: Paths, *, rows: list[dict], guard_cutoff: datetime, report: SweepReport) -> None:
|
|
"""Worker-thread: row-side reconciliation — detect, log, NEVER delete.
|
|
|
|
A row older than the 24-hour guard whose original is missing or
|
|
size-mismatched is surfaced as ``content_missing`` (§8.3/§15.17): the
|
|
row is the user's only record of the document, so disposal always
|
|
starts with the user moving it to trash; only explicit purge or
|
|
eligible retention purge may remove the trashed row.
|
|
"""
|
|
for row in rows:
|
|
created = _parse_iso(row.get("created_at"))
|
|
if created is None or created > guard_cutoff:
|
|
continue
|
|
if not _content_intact(paths, user_id=row["user_id"], row=row):
|
|
report.content_missing.append(row["id"])
|
|
logger.warning(
|
|
"Shelf document %s (%s) content is missing or size-mismatched; row retained and surfaced as content_missing (§8.3)",
|
|
row["id"],
|
|
row.get("name"),
|
|
)
|
|
|
|
|
|
async def run_trash_retention_sweep(
|
|
repo: ProjectDocumentRepository,
|
|
paths: Paths,
|
|
*,
|
|
retention_days: int,
|
|
user_id: str | None,
|
|
now: datetime | None = None,
|
|
include_reconciliation: bool = True,
|
|
) -> SweepReport:
|
|
"""Run one trash retention sweep (§8.3): expiry purge + reconciliation.
|
|
|
|
Triggered lazily by ``GET /api/trash/documents`` (the caller's user) and
|
|
once at gateway startup (``user_id=None`` ⇒ every user). No daemon, no
|
|
scheduler (§15.9). Expired rows go through the same guarded purge as
|
|
manual purges — the candidate's trash timestamp and the cutoff are
|
|
revalidated under the purge lock, so a row restored and later re-trashed
|
|
is not purged on its former expiry. A per-row purge failure keeps that
|
|
row trashed and retryable without aborting the rest of the sweep.
|
|
|
|
``include_reconciliation=False`` skips the row/storage reconciliation and
|
|
leaves it to the reference-aware retention purge, so repeated lazy
|
|
triggers can stay cheap: the expiry purge is an indexed candidate scan,
|
|
while reconciliation is O(all rows + all files) and only bounds external
|
|
interference and orphaned staging behind the 24-hour guard. The retention
|
|
guarantee itself (expired rows become purgeable) is unaffected.
|
|
"""
|
|
now = now or datetime.now(UTC)
|
|
cutoff = now - timedelta(days=retention_days)
|
|
report = SweepReport()
|
|
remove_files = make_purge_file_remover(paths, user_id=user_id)
|
|
for candidate in await repo.purge_candidates(retention_days, now=now, user_id=user_id):
|
|
try:
|
|
purged = await repo.purge(
|
|
candidate["id"],
|
|
retention_cutoff=cutoff,
|
|
expected_trashed_at=candidate.get("trashed_at"),
|
|
remove_files=remove_files,
|
|
user_id=user_id,
|
|
)
|
|
except Exception:
|
|
report.purge_failures += 1
|
|
logger.warning(
|
|
"Retention purge of document %s failed; trashed row retained (retryable)",
|
|
candidate["id"],
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
if purged:
|
|
report.purged += 1
|
|
if include_reconciliation:
|
|
rows = await repo.list_all_for_sweep(user_id=user_id)
|
|
guard_cutoff = now - _ORPHAN_GUARD
|
|
await run_file_io(_reconcile_storage, paths, user_id=user_id, rows=rows, guard_cutoff=guard_cutoff, report=report)
|
|
await run_file_io(_reconcile_rows, paths, rows=rows, guard_cutoff=guard_cutoff, report=report)
|
|
return report
|