Zeren Wang a58ab484a6
feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash (#5443)
* 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.
2026-09-16 18:46:18 +08:00

568 lines
25 KiB
Python

"""Project document shelf service: staging, atomic insert, conversion.
Implements the Phase-2 shelf-insert atomicity (spec §6.3): bytes are staged
under ``.staging/{uuid}``, hashed, then — inside the repository's one
transaction (active-project row lock → dedup select → insert) — atomically
renamed into the document's exclusive namespace **before** the row exists
(file-before-row, §10.3). A dedup hit removes staging and returns the existing
row (the first writer's name wins, §10.9); a failed insert cleans up only its
own namespace, best-effort. Every filesystem operation runs off the event
loop via :func:`deerflow.utils.file_io.run_file_io`.
Files are immutable and hash-qualified: ``stored_relpath`` (relative to
``users/{user_id}/projects/``) embeds the content hash and the row's own
document ID, so rows never share bytes and a re-upload after trash lands in a
fresh namespace (§6.2). Conversion is lazy: a convertible original is turned
into ``derived/converted.md`` on first read, written through a temporary file
and an atomic rename, and only when ``uploads.auto_convert_documents`` is on
(§6.4, §7.3).
"""
from __future__ import annotations
import asyncio
import codecs
import hashlib
import logging
import os
import shutil
import uuid
from collections import OrderedDict
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from deerflow.config.paths import Paths
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
from deerflow.utils.file_io import run_file_io
from deerflow.utils.text_detection import is_text_file_by_content
if TYPE_CHECKING:
from deerflow.persistence.projects import ProjectDocumentRepository
logger = logging.getLogger(__name__)
_MAX_FILENAME_BYTES = 255
class ShelfUploadTooLargeError(Exception):
"""Raised when staged bytes exceed ``uploads.max_file_size`` (mapped to 413)."""
def validate_shelf_filename(name: str) -> str:
"""Normalize and validate a shelf display filename; ``ValueError`` ⇒ 400.
The display filename occupies its own path component (no hash prefix or
suffix), so it must be a bare, non-empty filename within the 255 UTF-8
byte filesystem limit — path separators are rejected outright rather than
stripped (§6.5).
"""
candidate = name.strip() if name else ""
if not candidate:
raise ValueError("Filename is empty")
if "/" in candidate or "\\" in candidate:
raise ValueError(f"Filename contains a path separator: {name!r}")
if candidate in {".", ".."}:
raise ValueError(f"Filename is unsafe: {name!r}")
if len(candidate.encode("utf-8")) > _MAX_FILENAME_BYTES:
raise ValueError(f"Filename exceeds {_MAX_FILENAME_BYTES} UTF-8 bytes")
return candidate
def shelf_relpath(project_id: str, sha256: str, document_id: str) -> str:
"""Content-addressed namespace root for one row, relative to ``users/{user_id}/projects/``."""
return f"{project_id}/documents/{sha256[:2]}/{sha256}/{document_id}"
def original_file_path(paths: Paths, *, user_id: str, row: dict) -> Path:
"""Absolute host path of a row's immutable original bytes."""
return resolve_document_paths(paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])[0]
def converted_markdown_path(paths: Paths, *, user_id: str, row: dict) -> Path:
"""Absolute host path of a row's optional ``derived/converted.md`` companion."""
return resolve_document_paths(paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])[1]
def resolve_document_paths(paths: Paths, *, user_id: str, relpath: str, name: str) -> tuple[Path, Path]:
"""Resolve a row's ``(original, derived)`` host paths — worker-thread only on async paths.
``Paths.project_document_path`` resolves symlinks against the real
filesystem, so async callers MUST dispatch this through ``run_file_io``
(filesystem-offload convention); the helper itself stays sync because it
is also used from worker contexts already off the loop.
"""
namespace = paths.project_document_path(user_id, relpath)
return namespace / "original" / name, namespace / "derived" / "converted.md"
def _content_intact(paths: Paths, *, user_id: str, row: dict) -> bool:
"""Worker-thread: resolve the original and verify it exists and matches the row's recorded size."""
original = original_file_path(paths, user_id=user_id, row=row)
try:
return original.stat().st_size == int(row.get("size_bytes") or -1)
except OSError:
return False
async def check_document_content(paths: Paths, *, user_id: str, row: dict) -> bool:
"""True when the row's immutable original exists and matches ``size_bytes``.
The one content check shared by the text serving path, restore (under
the document lock), the content endpoint, and the retention sweep's row
reconciliation (§8.3/§11): missing or size-mismatched bytes mean external
interference — reported as ``content_missing``, never served, repaired,
or treated as an empty document.
"""
return await run_file_io(_content_intact, paths, user_id=user_id, row=row)
def _content_intact_batch(paths: Paths, *, user_id: str, rows: list[dict]) -> list[bool]:
"""Worker-thread: existence+size check for a whole page of rows in one offload pass."""
return [_content_intact(paths, user_id=user_id, row=row) for row in rows]
@dataclass(slots=True)
class StagedDocument:
"""Bytes staged under ``.staging/`` with their content address."""
staging_path: Path
sha256: str
size_bytes: int
def _open_staging(staging_dir: Path, staging_path: Path):
"""Worker-thread: create the staging directory and open the staging file."""
staging_dir.mkdir(parents=True, exist_ok=True)
return open(staging_path, "wb")
def _close_and_unlink(handle: Any, staging_path: Path) -> None:
try:
handle.close()
finally:
staging_path.unlink(missing_ok=True)
async def stage_document_bytes(paths: Paths, *, user_id: str, project_id: str, chunks: AsyncIterator[bytes] | list[bytes], max_bytes: int) -> StagedDocument:
"""Stage upload/promote bytes off-loop, enforcing the single-file size cap.
Chunks are streamed to ``.staging/{uuid}`` (writes offloaded one by one,
the uploads router's discipline) while the content hash accumulates.
``max_bytes`` is the thread-upload limit (``uploads.max_file_size``) —
the shelf deliberately reuses it instead of growing a second knob with
the same meaning (§6.4). Overflow raises :class:`ShelfUploadTooLargeError`
(the route maps it to 413, mirroring the uploads router) and leaves no
staging file behind.
"""
staging_dir = paths.project_documents_dir(user_id, project_id) / ".staging"
staging_path = staging_dir / uuid.uuid4().hex
handle = await run_file_io(_open_staging, staging_dir, staging_path)
digest = hashlib.sha256()
size = 0
async def _aiter() -> AsyncIterator[bytes]:
if isinstance(chunks, list):
for chunk in chunks:
yield chunk
else:
async for chunk in chunks:
yield chunk
try:
async for chunk in _aiter():
size += len(chunk)
if size > max_bytes:
raise ShelfUploadTooLargeError(f"File exceeds the {max_bytes}-byte limit")
digest.update(chunk)
await run_file_io(handle.write, chunk)
await run_file_io(handle.close)
except Exception:
await run_file_io(_close_and_unlink, handle, staging_path)
raise
return StagedDocument(staging_path=staging_path, sha256=digest.hexdigest(), size_bytes=size)
def _place_staging(staging_path: Path, final_path: Path) -> None:
"""Atomically move staged bytes into the document's exclusive namespace."""
final_path.parent.mkdir(parents=True, exist_ok=True)
try:
os.replace(staging_path, final_path)
except Exception:
staging_path.unlink(missing_ok=True)
raise
def _remove_staging(staging_path: Path) -> None:
staging_path.unlink(missing_ok=True)
def _remove_namespace(paths: Paths, *, user_id: str, relpath: str) -> None:
"""Best-effort removal of one document's exclusive namespace (never another row's)."""
shutil.rmtree(paths.project_document_path(user_id, relpath), ignore_errors=True)
async def add_staged_document(
repo: ProjectDocumentRepository,
paths: Paths,
*,
user_id: str,
project_id: str,
name: str,
staged: StagedDocument,
source_thread_id: str | None = None,
source_kind: str | None = None,
source_name: str | None = None,
) -> tuple[dict, bool] | None:
"""Publish staged bytes as a shelf row, or fold into the dedup hit.
Returns ``(row, created)`` — ``created`` is ``False`` for a dedup hit, in
which case the returned row is the pre-existing one (its name and
provenance win, §10.9) and the staged bytes are discarded. ``None`` means
the project is missing, foreign, or archived (the route maps it to 404).
A failure removes this document's namespace only when the row PROVABLY
does not exist: a post-commit failure (e.g. the insert's trailing
refresh) leaves the live row serving its placed bytes and is reported as
success, and an indeterminate liveness check leaves the namespace for
the reference-aware sweep (§8.3).
"""
document_id = uuid.uuid4().hex
relpath = shelf_relpath(project_id, staged.sha256, document_id)
# Symlink-resolving lookup — offloaded like every filesystem touch here.
final_path = (await run_file_io(resolve_document_paths, paths, user_id=user_id, relpath=relpath, name=name))[0]
async def _place() -> None:
await run_file_io(_place_staging, staged.staging_path, final_path)
try:
row = await repo.insert_active(
project_id,
document_id=document_id,
name=name,
relpath=relpath,
sha256=staged.sha256,
size_bytes=staged.size_bytes,
source_thread_id=source_thread_id,
source_kind=source_kind,
source_name=source_name,
place_file=_place,
user_id=user_id,
)
except Exception:
# ``insert_active`` owns its commit: a failure may surface AFTER the
# row committed (e.g. its trailing refresh), so the placed namespace
# is removed only when the row PROVABLY does not exist. When the row
# is live the insert effectively succeeded — the shelf already serves
# these bytes — so return it instead of raising; when liveness cannot
# be determined, leave the namespace for the reference-aware
# retention sweep (§8.3), which only collects unreferenced files and
# protects any row's namespace. The probe includes TRASHED rows: a
# row raced into trash (or a trashing project deletion) before the
# re-check still owns the placed bytes — trash is recoverable, so
# deleting the namespace would destroy a recoverable document.
await run_file_io(_remove_staging, staged.staging_path)
committed: dict | None
try:
committed = await repo.get(document_id, include_trashed=True, user_id=user_id)
except Exception:
committed = None
row_provably_absent = False
logger.warning(
"Shelf insert of %s failed and the liveness re-check failed too; leaving the namespace for the retention sweep",
document_id,
exc_info=True,
)
else:
row_provably_absent = committed is None
if committed is not None:
if committed.get("trashed_at") is None:
logger.warning("Shelf insert of %s raised after commit; the row is live — keeping its namespace and reporting success", document_id)
return committed, True
# Committed but raced into trash before the re-check: keep the
# bytes recoverable in trash and surface the failure rather than
# presenting a trashed row as created.
logger.warning("Shelf insert of %s raised after commit and the row was trashed meanwhile; keeping its namespace in trash", document_id)
elif row_provably_absent:
# Confirmed rollback: the bytes are unreferenced — clean up only
# this document's own namespace (§6.3).
await run_file_io(_remove_namespace, paths, user_id=user_id, relpath=relpath)
raise
if row is None:
# Missing/foreign/archived project: nothing was placed or inserted.
await run_file_io(_remove_staging, staged.staging_path)
return None
if row["id"] != document_id:
# Dedup hit: the existing row wins; discard the staged duplicate.
await run_file_io(_remove_staging, staged.staging_path)
return row, False
return row, True
def _convert_in_thread(original: Path, output_path: Path) -> Path | None:
"""Run the async conversion engine on a private loop inside the worker.
``convert_file_to_markdown`` offloads its own heavy work (>1 MiB) but
stats and writes on its caller's loop; running the whole coroutine here
keeps every byte of the conversion path off the serving event loop.
"""
return asyncio.run(convert_file_to_markdown(original, output_path=output_path))
def _convert_and_publish(original: Path, derived: Path) -> bool:
"""Worker-thread body: convert to a temp file, then atomically publish.
The temp file lives beside the final path (same filesystem) so the
rename is atomic: readers only ever see a complete ``converted.md`` or
none at all. A conversion failure publishes nothing and removes the temp.
"""
derived.parent.mkdir(parents=True, exist_ok=True)
temp_path = derived.parent / f".{derived.name}.{uuid.uuid4().hex}.tmp"
try:
produced = _convert_in_thread(original, temp_path)
if produced is None:
return False
os.replace(produced, derived)
return True
except Exception:
logger.warning("Failed to convert shelf document %s", original, exc_info=True)
temp_path.unlink(missing_ok=True)
return False
finally:
temp_path.unlink(missing_ok=True)
async def ensure_converted_markdown(repo: ProjectDocumentRepository, paths: Paths, *, user_id: str, row: dict, auto_convert: bool) -> tuple[Path | None, str | None]:
"""Return ``(converted_path, None)`` or ``(None, reason)``, converting on first read.
Conversion runs — and ``derived/converted.md`` is published — while the
repository holds the document row lock, with ownership, shelf membership
and active state revalidated AFTER locking (§6.3): a trash/purge that
committed first makes this decline as ``content_missing`` without
converting or publishing anything, and one arriving during conversion
blocks on the row lock and then proceeds. The temp-file + atomic
``os.replace`` publish stays inside the lock, so no partial converted
text is ever exposed. The published companion is valid for the lifetime
of the row's immutable original and never needs invalidation (§6.2,
§10.9). ``reason`` is one of ``"conversion_disabled"`` (auto-convert
off, §7.3), ``"content_missing"`` (row no longer live), or ``"binary"``
(not convertible, or the conversion itself failed).
"""
if not auto_convert:
return None, "conversion_disabled"
original, derived = await run_file_io(resolve_document_paths, paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])
if original.suffix.lower() not in CONVERTIBLE_EXTENSIONS:
return None, "binary"
if await run_file_io(derived.is_file):
return derived, None
async def _convert(_locked_row: dict) -> bool:
# Re-check under the lock: a conversion that blocked on this row's
# lock may already have been published by the lock's previous holder.
if await run_file_io(derived.is_file):
return True
return await run_file_io(_convert_and_publish, original, derived)
locked = await repo.convert_under_live_lock(row["id"], project_id=row["project_id"], convert=_convert, user_id=user_id)
if locked is None:
# Purged/trashed (or foreign) between the caller's read and the
# locked revalidation: decline without publishing anything (§6.3).
return None, "content_missing"
_locked_row, published = locked
return (derived, None) if published else (None, "binary")
async def read_text_serving_path(repo: ProjectDocumentRepository, paths: Paths, *, user_id: str, row: dict, auto_convert: bool) -> tuple[Path | None, str | None]:
"""Resolve which file serves a row's text reads, or why none can.
Returns ``(path, None)`` for a servable text source — the cached
``derived/converted.md``, the original when it samples as text, or a
freshly converted companion — and ``(None, reason)`` otherwise, where
``reason`` is one of ``"content_missing"``, ``"conversion_disabled"`` or
``"binary"`` so the caller can produce the §11 error without re-deriving
the classification. The immutable ORIGINAL is validated (existence AND
recorded size, the shared §8.3 check) before either serving source is
selected: a truncated or zero-byte original is ``content_missing``, never
served as complete, and the derived companion is only servable while its
owning original validates (§6.2). A convertible extension
(``CONVERTIBLE_EXTENSIONS``) takes the conversion path — or the
``conversion_disabled`` decline — BEFORE the text heuristic: a
null-free head (e.g. an ASCII85 PDF) must never be raw-served as text.
Genuine text extensions keep the sampled-head heuristic (no
``mime``/``is_text`` columns exist by design, §6.1).
"""
if not await check_document_content(paths, user_id=user_id, row=row):
return None, "content_missing"
original, derived = await run_file_io(resolve_document_paths, paths, user_id=user_id, relpath=row["stored_relpath"], name=row["name"])
if await run_file_io(derived.is_file):
return derived, None
if original.suffix.lower() in CONVERTIBLE_EXTENSIONS:
return await ensure_converted_markdown(repo, paths, user_id=user_id, row=row, auto_convert=auto_convert)
if await run_file_io(is_text_file_by_content, original):
return original, None
return None, "binary"
#: Bounded decode window: reads stop once the requested character page is full.
_TEXT_READ_CHUNK = 65536
#: Bounded process-local LRU of decoded character counts, keyed by content
#: identity ``(document_id, sha256)``. Documents are immutable within a row's
#: lifetime (§6.2/§10.9) and ids are never reused, so a purge simply orphans
#: an entry until LRU eviction — no invalidation hook needed.
_CHAR_COUNT_CACHE: OrderedDict[tuple[str, str], int] = OrderedDict()
_CHAR_COUNT_CACHE_MAX = 256
def _read_text_window(path: Path, *, offset: int, limit: int) -> str:
"""Worker-thread: decode only enough of *path* to fill ``[offset, offset+limit)``.
Incremental UTF-8 decode (replacement-tolerant, split-sequence safe) over
bounded chunks: the full decoded string is never materialized and reading
stops as soon as the window is filled, so an early page of a multi-MiB
document never reads the whole file.
"""
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
parts: list[str] = []
position = 0 # decoded characters seen so far
end = offset + limit
with open(path, "rb") as handle:
while position < end:
chunk = handle.read(_TEXT_READ_CHUNK)
final = not chunk
text = decoder.decode(chunk, final)
if not text:
if final:
break
continue
if position + len(text) > offset:
parts.append(text[max(offset - position, 0) :])
position += len(text)
if final:
break
return "".join(parts)[:limit]
def _count_text_chars(path: Path) -> int:
"""Worker-thread: full scan-decode COUNT without materializing the string."""
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
total = 0
with open(path, "rb") as handle:
while chunk := handle.read(_TEXT_READ_CHUNK):
total += len(decoder.decode(chunk))
return total + len(decoder.decode(b"", True))
def _cached_char_count(document_id: str, sha256: str, path: Path) -> int:
"""Worker-thread: character count of an immutable document, cached by content identity."""
key = (document_id, sha256)
cached = _CHAR_COUNT_CACHE.get(key)
if cached is not None:
_CHAR_COUNT_CACHE.move_to_end(key)
return cached
total = _count_text_chars(path)
_CHAR_COUNT_CACHE[key] = total
while len(_CHAR_COUNT_CACHE) > _CHAR_COUNT_CACHE_MAX:
_CHAR_COUNT_CACHE.popitem(last=False)
return total
async def read_document_text_window(path: Path, *, offset: int, limit: int) -> str:
"""Decode the ``[offset, offset+limit)`` character window of a serving file, off-loop."""
return await run_file_io(_read_text_window, path, offset=offset, limit=limit)
async def document_char_count(*, document_id: str, sha256: str, path: Path) -> int:
"""Total decoded characters of a serving file, off-loop and content-identity cached."""
return await run_file_io(_cached_char_count, document_id, sha256, path)
def auto_convert_documents_enabled(app_config: Any) -> bool:
"""Return whether host-side document conversion is enabled (secure default off).
Mirrors the uploads router's reader: a malformed value declines rather
than crashing, and stringly-typed YAML booleans are honored.
"""
try:
uploads_cfg = getattr(app_config, "uploads", None)
raw = uploads_cfg.get("auto_convert_documents", False) if isinstance(uploads_cfg, dict) else getattr(uploads_cfg, "auto_convert_documents", False)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return bool(raw)
except Exception:
return False
class ShelfContentMissingError(Exception):
"""A live row's original bytes are absent or size-mismatched (§11 ``content_missing``)."""
async def read_file_chunks(path: Path, *, chunk_size: int = 65536) -> AsyncIterator[bytes]:
"""Yield one file's bytes with every read off the event loop.
Shared by the from-thread promote path (thread file → shelf staging) and
the attach path (staged shelf copy → thread-upload ingestion).
"""
def _open() -> Any:
return open(path, "rb")
handle = await run_file_io(_open)
try:
while chunk := await run_file_io(handle.read, chunk_size):
yield chunk
finally:
await run_file_io(handle.close)
def _copy_original_under_lock(paths: Paths, staging_path: Path, *, user_id: str, row: dict) -> None:
"""Worker-thread: copy a live document's original to a stable staging file.
Runs inside the document row lock (``stage_live_copy``): the bytes are
immutable, so an absent file or a size disagreement means external
interference — reported as ``content_missing``, never copied (§8.3).
Resolving the original's path happens here too: ``project_document_path``
resolves symlinks against the real filesystem, so it stays off the
caller's event loop.
"""
source = original_file_path(paths, user_id=user_id, row=row)
expected_size = row.get("size_bytes")
if not source.is_file():
raise ShelfContentMissingError("original bytes are missing")
if expected_size is not None and source.stat().st_size != expected_size:
raise ShelfContentMissingError("original size disagrees with the row")
staging_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, staging_path)
async def stage_document_copy_for_attach(
repo: ProjectDocumentRepository,
paths: Paths,
*,
user_id: str,
project_id: str,
document_id: str,
) -> tuple[dict, Path] | None:
"""Stage a stable copy of a live document's original under its row lock.
§7.3 item 3: attach is serialized against purge/trash — the copy runs
while the repository holds the document row lock, then the lock is
released BEFORE the caller performs sandbox allocation/network sync.
The copy lands in the project shelf's ``.staging/`` (a crash leftover is
collected by the retention sweep's 24-hour orphan guard, §8.3) and is
the caller's responsibility to unlink. ``None`` means the document is
missing, foreign, trashed, or not on this project's shelf (the route
maps it to 404); :class:`ShelfContentMissingError` maps to 409.
"""
staging_path = paths.project_documents_dir(user_id, project_id) / ".staging" / f"attach-{uuid.uuid4().hex}"
async def _copy(row: dict) -> Path:
await run_file_io(_copy_original_under_lock, paths, staging_path, user_id=user_id, row=row)
return staging_path
result = await repo.stage_live_copy(document_id, project_id=project_id, stage=_copy, user_id=user_id)
if result is None:
return None
row, staged_path = result
return row, staged_path