mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-25 22:29:07 +00:00
* fix(config): close out #4124 review follow-ups Extracts the (mtime, size, sha256) content-signature helper that was duplicated between config/app_config.py and mcp/cache.py into a new config/file_signature.py, and fixes ExtensionsConfig.resolve_config_path() to return None instead of raising FileNotFoundError when an explicit config_path argument or DEER_FLOW_EXTENSIONS_CONFIG_PATH points at a file that has since been deleted -- the exact resolution mode Docker dev/prod uses per AGENTS.md, so the MCP tools-cache staleness check could raise instead of degrading to "not stale". Both were flagged by willem-bd in review on #4124 and explicitly deferred there ("flagging for visibility", "leaving the extraction as the follow-up you suggested"). * fix(config): keep explicit extensions-config paths fail-loud resolve_config_path() previously turned every missing-file case into a clean None, including an explicit config_path argument or DEER_FLOW_EXTENSIONS_CONFIG_PATH (the exact mode Docker dev/prod uses). That silently downgrades a bad Docker mount, typo, or deleted production config to "no extensions" instead of surfacing the misconfiguration, per fancyboi999's review [P1] and willem-bd's follow-up notes on this PR. Restores FileNotFoundError for the two explicit modes (config_path argument, DEER_FLOW_EXTENSIONS_CONFIG_PATH); only the fallback search mode (no explicit path/env var, nothing found in the usual locations) still returns None, since that is the legitimate "extensions were never configured" case. The one caller that needs the old fail-soft behavior -- the MCP tools-cache staleness check, which re-resolves the path on every get_cached_mcp_tools() call -- gets a narrow, local catch instead (deerflow.mcp.cache._resolve_config_path) so a config file going missing mid-run still degrades the cache to "not stale" rather than crashing a hot per-request path. Also hoists the double os.getenv() read in the env-var branch into a local, per willem-bd's nit. Adds resolver-level tests for both explicit-path and env-var raises, a dedicated search-mode None regression test, and updates the existing MCP-cache docstrings to describe the corrected split.
56 lines
2.4 KiB
Python
56 lines
2.4 KiB
Python
"""Shared content-signature helper for runtime-editable config files.
|
|
|
|
Both ``config/app_config.py`` (``config.yaml``) and ``mcp/cache.py``
|
|
(``extensions_config.json``) need to detect when a runtime-editable config
|
|
file has actually changed, even under conditions a bare mtime comparison
|
|
misses: same-second edits, mtime that stays put or moves backward
|
|
(``git checkout``, ``cp -p`` / backup restore, ``tar`` / ``rsync`` that
|
|
preserve timestamps, object-store / network mounts), or a switch to a
|
|
different file whose mtime is <= the previously recorded one.
|
|
|
|
This module is the single implementation of that ``(mtime, size, sha256)``
|
|
signature so the two call sites share one behavior instead of maintaining
|
|
verbatim-duplicate copies that can silently drift apart over time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
# (mtime, size, sha256-hexdigest) recorded for a config file, or the current
|
|
# values recomputed for comparison against a previously recorded one. A
|
|
# ``None`` digest (third element) means the stat succeeded but the content
|
|
# could not be read; the whole tuple is ``None`` when the file could not be
|
|
# stat-ed at all (e.g. it does not exist).
|
|
ConfigSignature = tuple[float | None, int | None, str | None]
|
|
|
|
|
|
def get_config_signature(config_path: Path) -> ConfigSignature | None:
|
|
"""Get cache metadata for *config_path*, including a content digest.
|
|
|
|
Returns ``None`` when the file cannot be stat-ed (e.g. it does not
|
|
exist), so callers can treat "no file" as a distinct case from "file
|
|
with unreadable content" (which still yields a partial signature below).
|
|
"""
|
|
try:
|
|
stat_result = config_path.stat()
|
|
except OSError:
|
|
return None
|
|
|
|
# Always hash the full file here rather than short-circuiting when
|
|
# mtime/size already match a previously recorded signature: swapping in
|
|
# different content of identical byte length within the same second
|
|
# leaves mtime *and* size unchanged, so only the sha256 catches that
|
|
# swap. Skipping the hash on an mtime/size match would reopen the narrow
|
|
# gap this signature was built to close.
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with config_path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
except OSError:
|
|
return (stat_result.st_mtime, stat_result.st_size, None)
|
|
|
|
return (stat_result.st_mtime, stat_result.st_size, digest.hexdigest())
|