mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 03:26: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.
315 lines
15 KiB
Python
315 lines
15 KiB
Python
"""Shared thread-upload ingestion service (Projects Phase 2 Slice C, §7.3 item 3).
|
|
|
|
Owns the whole pipeline for files landing in a thread's uploads directory:
|
|
staging, ``claim_unique_filename``, size checks, optional conversion under
|
|
``uploads.auto_convert_documents``, sandbox-readable permissions, and
|
|
non-mounted-provider synchronization of original + derived files through the
|
|
authorized sandbox request lease. A denied ``sandbox:execute`` retains the
|
|
host upload without allocating a sandbox (ordinary uploads behavior);
|
|
acquisition, sync and conversion failures follow the ordinary upload error
|
|
and cleanup behavior.
|
|
|
|
Two callers share this pipeline:
|
|
|
|
- the ordinary uploads endpoint (``routers/uploads.py``), a thin adapter
|
|
mapping ``UploadFile`` parts to chunk streams with zero behavior change;
|
|
- the project-shelf attach route (``routers/project_documents.py``), which
|
|
feeds a staged shelf copy through the same lifecycle (§7.3 item 3).
|
|
|
|
Behavior-preservation bridge: the pre-extraction upload tests pin this
|
|
pipeline by patching collaborators on the ``routers.uploads`` module
|
|
namespace (``ensure_uploads_dir``, ``get_sandbox_provider``,
|
|
``_get_upload_limits``, ``_auto_convert_documents_enabled``,
|
|
``convert_file_to_markdown``, the low-level file machinery). The service
|
|
therefore resolves every collaborator through that module object at call
|
|
time (:func:`_uploads`) instead of importing the names directly, so those
|
|
patches keep binding to the one pipeline both callers use.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.utils.file_io import run_file_io
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import Request
|
|
|
|
from app.gateway.authz import SandboxRequestLease
|
|
from app.gateway.routers.uploads import UploadLimits
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _uploads() -> Any:
|
|
"""Return the uploads router module (late binding — see module docstring)."""
|
|
from app.gateway.routers import uploads
|
|
|
|
return uploads
|
|
|
|
|
|
class UnsafeFilenameError(ValueError):
|
|
"""The display filename could not be normalized/claimed; ordinary behavior skips it."""
|
|
|
|
|
|
class UnsafeUploadDestinationError(Exception):
|
|
"""The claimed destination failed safety validation; carries the claimed name.
|
|
|
|
Ordinary uploads record the file as skipped (``skipped_files``) and keep
|
|
ingesting the rest of the request; attach maps it to an ingestion
|
|
failure. Wraps the manager's ``UnsafeUploadPathError`` so the claimed
|
|
(deduplicated) filename survives to the caller.
|
|
"""
|
|
|
|
def __init__(self, filename: str) -> None:
|
|
super().__init__(f"Unsafe upload destination: {filename}")
|
|
self.filename = filename
|
|
|
|
|
|
class ThreadUploadIngestionService:
|
|
"""One thread's ingestion session: staging → conversion → permissions → sync.
|
|
|
|
Lifecycle: :meth:`open` (uploads dir + existing-name seed + optional
|
|
sandbox lease), one or
|
|
more :meth:`ingest_chunks` calls, :meth:`finalize` (sandbox-readable
|
|
permissions + non-mounted provider sync), then :meth:`aclose` (lease
|
|
release) — use it as an async context manager. :meth:`cleanup_written`
|
|
removes every file written by this session; the ordinary endpoint invokes
|
|
it when a per-file failure aborts the request, and attach mirrors that.
|
|
"""
|
|
|
|
def __init__(self, *, request: Request | None, thread_id: str, user_id: str, app_config: AppConfig) -> None:
|
|
self._request = request
|
|
self._thread_id = thread_id
|
|
self._user_id = user_id
|
|
self._config = app_config
|
|
self._limits: UploadLimits | None = None
|
|
self._uploads_dir: Path | None = None
|
|
self._sync_to_sandbox = False
|
|
self._sandbox_lease: SandboxRequestLease | None = None
|
|
self._sandbox: Any = None
|
|
self._auto_convert = False
|
|
self._seen_filenames: set[str] = set()
|
|
self._written_paths: list[Path] = []
|
|
self._sync_targets: list[tuple[Path, str]] = []
|
|
self._total_size = 0
|
|
|
|
async def __aenter__(self) -> ThreadUploadIngestionService:
|
|
await self.open()
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
await self.aclose()
|
|
|
|
@property
|
|
def limits(self) -> UploadLimits:
|
|
assert self._limits is not None, "open() must run before limits is read"
|
|
return self._limits
|
|
|
|
async def open(self) -> None:
|
|
"""Resolve limits, ensure the uploads dir, seed existing names, acquire the lease.
|
|
|
|
Mirrors the ordinary endpoint's setup order: a role denied
|
|
``sandbox:execute`` skips acquisition entirely (the host upload still
|
|
succeeds); an allowed caller whose sandbox vanishes right after
|
|
acquiring is a 500. ``ValueError`` from the uploads-dir resolution
|
|
(unsafe thread id) propagates for the caller to map. The existing-name
|
|
seed makes every later claim unique against the thread's CURRENT
|
|
files, so no ingestion — ordinary upload or shelf attach — ever
|
|
silently replaces a conversation file (same-name re-uploads land as
|
|
``name_1.ext``); the directory scan is offloaded per the filesystem
|
|
convention.
|
|
"""
|
|
uploads = _uploads()
|
|
self._limits = uploads._get_upload_limits(self._config)
|
|
self._uploads_dir = await run_file_io(uploads.ensure_uploads_dir, self._thread_id, user_id=self._user_id)
|
|
listing = await run_file_io(uploads.list_files_in_dir, self._uploads_dir)
|
|
self._seen_filenames.update(entry["filename"] for entry in listing["files"])
|
|
sandbox_provider = uploads.get_sandbox_provider()
|
|
self._sync_to_sandbox = not uploads._uses_thread_data_mounts(sandbox_provider)
|
|
if self._sync_to_sandbox:
|
|
self._sandbox_lease = await uploads.try_acquire_sandbox_for_request(
|
|
self._request,
|
|
sandbox_provider,
|
|
self._thread_id,
|
|
user_id=self._user_id,
|
|
app_config=self._config,
|
|
owner_prefix="gateway:upload",
|
|
release_on_last=False,
|
|
)
|
|
self._sandbox = self._sandbox_lease.sandbox
|
|
if not self._sandbox_lease.denied and self._sandbox is None:
|
|
raise HTTPException(status_code=500, detail="Failed to acquire sandbox")
|
|
self._auto_convert = uploads._auto_convert_documents_enabled(self._config)
|
|
|
|
async def _link_commit_with_retry(self, uploads: Any, staged_path: Path, claimed_name: str) -> tuple[str, Path]:
|
|
"""Publish a staged file under *claimed_name*, atomically and with no overwrite.
|
|
|
|
``os.link`` (inside ``_commit_upload_temp_no_overwrite`` /
|
|
``_link_staged_no_overwrite``) fails with :class:`FileExistsError`
|
|
when a concurrent session won the name after this session's seed —
|
|
the next suffix is claimed against the seeded set and the link
|
|
retried, so two concurrent ingestions of one name always land as
|
|
``name.ext`` + ``name_1.ext`` with both byte streams intact. The
|
|
staged bytes stay hidden under the ``.upload-*.part`` pattern until
|
|
the link makes the final name visible, fully formed.
|
|
"""
|
|
assert self._uploads_dir is not None, "open() must run before committing destinations"
|
|
name = claimed_name
|
|
while True:
|
|
try:
|
|
return name, await run_file_io(uploads._link_staged_no_overwrite, staged_path, self._uploads_dir, name)
|
|
except FileExistsError:
|
|
name = uploads.claim_unique_filename(name, self._seen_filenames)
|
|
|
|
async def ingest_chunks(self, chunks: AsyncIterator[bytes], *, display_name: str) -> dict[str, Any]:
|
|
"""Ingest one file from a chunk stream; return its wire metadata dict.
|
|
|
|
Owns the per-file pipeline exactly as the ordinary endpoint defines
|
|
it: normalize + claim a unique name, staged write with single-file
|
|
and request-total size caps (413), an atomic no-overwrite link commit
|
|
with next-suffix retry, and conversion with its companion-name claim
|
|
when ``uploads.auto_convert_documents`` is on. Raises
|
|
:class:`UnsafeFilenameError` (caller logs and skips),
|
|
:class:`UnsafeUploadDestinationError` (caller records a skipped
|
|
file), ``HTTPException`` (size/over-limit — caller cleans up and
|
|
re-raises) or an unexpected error (caller cleans up and answers 500).
|
|
"""
|
|
assert self._uploads_dir is not None and self._limits is not None, "open() must run before ingest_chunks()"
|
|
uploads = _uploads()
|
|
try:
|
|
original_filename = uploads.normalize_filename(display_name)
|
|
except ValueError as exc:
|
|
raise UnsafeFilenameError(str(exc)) from exc
|
|
safe_filename = uploads.claim_unique_filename(original_filename, self._seen_filenames)
|
|
|
|
file_size = 0
|
|
upload_temp = None
|
|
try:
|
|
upload_temp = await run_file_io(uploads._prepare_upload_destination, self._uploads_dir, safe_filename)
|
|
async for chunk in chunks:
|
|
file_size += len(chunk)
|
|
self._total_size += len(chunk)
|
|
if file_size > self._limits.max_file_size:
|
|
raise HTTPException(status_code=413, detail=f"File too large: {safe_filename}")
|
|
if self._total_size > self._limits.max_total_size:
|
|
raise HTTPException(status_code=413, detail="Total upload size too large")
|
|
await run_file_io(uploads._write_upload_chunk, upload_temp, chunk)
|
|
# Link-commit with collision retry: the FileExistsError arm
|
|
# leaves the staged part in place for the retry under the next
|
|
# suffix (the handle's second close is idempotent).
|
|
while True:
|
|
try:
|
|
file_path = await run_file_io(uploads._commit_upload_temp_no_overwrite, upload_temp, self._uploads_dir, safe_filename)
|
|
break
|
|
except FileExistsError:
|
|
safe_filename = uploads.claim_unique_filename(safe_filename, self._seen_filenames)
|
|
upload_temp = None
|
|
except uploads.UnsafeUploadPathError as exc:
|
|
if upload_temp is not None:
|
|
await run_file_io(uploads._abort_upload_temp, upload_temp)
|
|
raise UnsafeUploadDestinationError(safe_filename) from exc
|
|
except Exception:
|
|
if upload_temp is not None:
|
|
await run_file_io(uploads._abort_upload_temp, upload_temp)
|
|
raise
|
|
|
|
self._written_paths.append(file_path)
|
|
virtual_path = uploads.upload_virtual_path(safe_filename)
|
|
if self._sync_to_sandbox:
|
|
self._sync_targets.append((file_path, virtual_path))
|
|
|
|
file_info: dict[str, Any] = {
|
|
"filename": safe_filename,
|
|
"size": file_size,
|
|
"path": str(self._uploads_dir / safe_filename),
|
|
"virtual_path": virtual_path,
|
|
"artifact_url": uploads.upload_artifact_url(self._thread_id, safe_filename),
|
|
}
|
|
if safe_filename != original_filename:
|
|
file_info["original_filename"] = original_filename
|
|
logger.info(f"Saved file: {safe_filename} ({file_size} bytes) to {file_info['path']}")
|
|
|
|
if self._auto_convert and file_path.suffix.lower() in uploads.CONVERTIBLE_EXTENSIONS:
|
|
# The companion gets the same atomic no-overwrite commit as the
|
|
# original: staged under the hidden .part pattern, link-committed
|
|
# with next-suffix retry — conversion can never silently truncate
|
|
# another uploaded or derived file, in this session or a
|
|
# concurrent one.
|
|
provisional_md_name = Path(safe_filename).with_suffix(".md").name
|
|
unique_md_name = uploads.claim_unique_filename(provisional_md_name, self._seen_filenames)
|
|
md_staging = self._uploads_dir / f"{uploads.UPLOAD_STAGING_PREFIX}{uuid.uuid4().hex}{uploads.UPLOAD_STAGING_SUFFIX}"
|
|
try:
|
|
md_staged = await uploads.convert_file_to_markdown(file_path, output_path=md_staging)
|
|
except Exception:
|
|
self._seen_filenames.discard(unique_md_name)
|
|
await run_file_io(md_staging.unlink, True)
|
|
raise
|
|
if not md_staged:
|
|
# Conversion failed and wrote nothing (or a partial staged
|
|
# file, removed here): release the claim; holding it would
|
|
# rename a later same-stem upload against a name nothing
|
|
# occupies.
|
|
self._seen_filenames.discard(unique_md_name)
|
|
await run_file_io(md_staging.unlink, True)
|
|
else:
|
|
unique_md_name, md_path = await self._link_commit_with_retry(uploads, Path(md_staged), unique_md_name)
|
|
self._written_paths.append(md_path)
|
|
md_virtual_path = uploads.upload_virtual_path(md_path.name)
|
|
if self._sync_to_sandbox:
|
|
self._sync_targets.append((md_path, md_virtual_path))
|
|
file_info["markdown_file"] = md_path.name
|
|
file_info["markdown_path"] = str(self._uploads_dir / md_path.name)
|
|
file_info["markdown_virtual_path"] = md_virtual_path
|
|
file_info["markdown_artifact_url"] = uploads.upload_artifact_url(self._thread_id, md_path.name)
|
|
return file_info
|
|
|
|
async def finalize(self) -> None:
|
|
"""Make written files sandbox-readable, then sync to a non-mounted sandbox.
|
|
|
|
Runs after every file of the session ingested successfully — exactly
|
|
the ordinary endpoint's tail. Failures here propagate (the host files
|
|
stay in place; the caller does not clean them up, matching ordinary
|
|
upload behavior for sync-phase errors).
|
|
"""
|
|
uploads = _uploads()
|
|
# Uploaded files are created with 0o600 permissions (owner read/write
|
|
# only). In Docker sandbox deployments the gateway writes as root but
|
|
# the sandbox process runs as a non-root user (typically UID 1000).
|
|
# Without group/other read bits the sandbox cannot access the files —
|
|
# whether the uploads directory is bind-mounted into the container or
|
|
# synced via sandbox.update_file. Always add group/other read bits so
|
|
# every sandbox configuration can read the uploaded content.
|
|
await run_file_io(uploads._make_uploaded_paths_sandbox_readable, self._written_paths)
|
|
if self._sync_to_sandbox and self._sandbox is not None:
|
|
for file_path, virtual_path in self._sync_targets:
|
|
await run_file_io(uploads._sync_upload_to_sandbox, self._sandbox, file_path, virtual_path)
|
|
|
|
async def cleanup_written(self) -> None:
|
|
"""Remove every file this session wrote (ordinary rejected-request cleanup)."""
|
|
if not self._written_paths:
|
|
return
|
|
uploads = _uploads()
|
|
await run_file_io(uploads._cleanup_uploaded_paths, self._written_paths)
|
|
self._written_paths = []
|
|
|
|
async def aclose(self) -> None:
|
|
"""Release the sandbox request lease (failures are logged, never raised)."""
|
|
if self._sandbox_lease is None:
|
|
return
|
|
try:
|
|
await self._sandbox_lease.release()
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to release sandbox request lease after upload sync: %s",
|
|
self._sandbox_lease.sandbox_id,
|
|
exc_info=True,
|
|
)
|