mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(doctor): check the config file the Gateway actually loads (#5987)
* fix(doctor): check the config file the Gateway actually loads make doctor always inspected <checkout>/config.yaml and ignored DEER_FLOW_CONFIG_PATH and DEER_FLOW_PROJECT_ROOT. A missing override that stops the Gateway from starting still reported "config.yaml found" and "config.yaml loadable", and a valid override pointing elsewhere got the wrong file checked. Doctor now resolves the path through the harness's own resolver, AppConfig.resolve_config_path, after defaulting DEER_FLOW_PROJECT_ROOT to the checkout as scripts/serve.sh does for make dev. An override the Gateway would reject fails "config.yaml found" with the Gateway's error and the config checks skip; an unimportable harness is reported rather than raised. * docs(changelog): reference #5987 * fix(doctor): hand the config-location variables over like serve.sh Review follow-up. serve.sh sources the checkout .env over the shell and then replaces an unset or empty DEER_FLOW_PROJECT_ROOT with the checkout. Doctor loaded .env shell-first and only defaulted an unset root, so a shell DEER_FLOW_CONFIG_PATH / DEER_FLOW_PROJECT_ROOT that differed from .env, or an empty root, made it check a different file than the Gateway loads. For those two variables the .env value now overrides the shell (other keys keep doctor's shell-first loading), and an empty root becomes the checkout, before the harness resolver runs. * fix(doctor): report any harness import failure and expand ~ like source Review follow-up. - The harness-import guard caught only ImportError, so a harness that is present but broken differently (TypeError/SyntaxError while the module body runs) still crashed doctor. Catch Exception, as check_config_loadable does, and name the exception type in the report. - serve.sh's `source .env` expands an unquoted leading `~` in DEER_FLOW_CONFIG_PATH / DEER_FLOW_PROJECT_ROOT and keeps a quoted one literal; dotenv_values expands neither. Expand the unquoted form so a `~/...` path in .env no longer fails a setup that starts, and keep the quoted form literal so doctor does not pass one the Gateway rejects. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
f8b69bd84b
commit
1cd42cf42e
12
CHANGELOG.md
12
CHANGELOG.md
@ -327,6 +327,17 @@ This release closes that milestone with **181 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **doctor:** `make doctor` now checks the config file the Gateway actually
|
||||
loads. It always inspected `<checkout>/config.yaml` and ignored
|
||||
`DEER_FLOW_CONFIG_PATH` and `DEER_FLOW_PROJECT_ROOT`, so a missing override
|
||||
that stops the Gateway from starting still reported `✓ config.yaml found`
|
||||
and `✓ config.yaml loadable`, and a valid override pointing elsewhere got
|
||||
the wrong file checked. Doctor now resolves the path through the harness's
|
||||
own resolver and hands it the location variables the way `make dev` does:
|
||||
`.env` values override the shell (expanding an unquoted leading `~`), and
|
||||
an unset or empty `DEER_FLOW_PROJECT_ROOT` becomes the checkout. An override
|
||||
the Gateway would reject fails `config.yaml found` with the Gateway's
|
||||
error, and the config checks skip. ([#5987])
|
||||
- **database:** `DatabaseConfig` now validates `pool_size`, `pool_recycle`, and
|
||||
`command_timeout` strictly. Previously, YAML booleans (`true`/`false`) were
|
||||
coerced to `1`/`0` respectively, allowing `pool_size: true` (pool size 1) and
|
||||
@ -6214,4 +6225,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#5964]: https://github.com/bytedance/deer-flow/pull/5964
|
||||
[#5981]: https://github.com/bytedance/deer-flow/pull/5981
|
||||
[#5982]: https://github.com/bytedance/deer-flow/pull/5982
|
||||
[#5987]: https://github.com/bytedance/deer-flow/pull/5987
|
||||
|
||||
|
||||
@ -332,6 +332,15 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **doctor:** `make doctor` 现在检查 Gateway 实际加载的配置文件。此前它
|
||||
固定检查 `<checkout>/config.yaml`,忽略 `DEER_FLOW_CONFIG_PATH` 与
|
||||
`DEER_FLOW_PROJECT_ROOT`:指向不存在路径、会让 Gateway 无法启动的覆盖值仍会
|
||||
显示 `✓ config.yaml found` 与 `✓ config.yaml loadable`,而指向其他有效文件的
|
||||
覆盖值则会让它检查错误的文件。现在 doctor 通过 harness 自身的解析器确定路径,
|
||||
并像 `make dev` 一样处理这两个位置变量:`.env` 中的值覆盖 shell 中的值(未加
|
||||
引号的开头 `~` 会展开),`DEER_FLOW_PROJECT_ROOT` 未设置或为空时取仓库根目录。
|
||||
Gateway 会拒绝的覆盖值会让 `config.yaml found` 失败并给出 Gateway 的错误,
|
||||
其余配置检查随之跳过。([#5987])
|
||||
- **数据库:** `DatabaseConfig` 现在严格校验 `pool_size`、`pool_recycle` 与
|
||||
`command_timeout`。此前,YAML 布尔值(`true`/`false`)会被强制转换为 `1`/`0`,
|
||||
导致 `pool_size: true`(变成仅 1 个连接)和 `command_timeout: true`(变成 1 秒超时)
|
||||
@ -5272,3 +5281,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#5964]: https://github.com/bytedance/deer-flow/pull/5964
|
||||
[#5981]: https://github.com/bytedance/deer-flow/pull/5981
|
||||
[#5982]: https://github.com/bytedance/deer-flow/pull/5982
|
||||
[#5987]: https://github.com/bytedance/deer-flow/pull/5987
|
||||
|
||||
@ -53,9 +53,11 @@ application repositories continue to use `database`.
|
||||
|
||||
Configuration priority:
|
||||
1. Explicit `config_path` argument
|
||||
2. `DEER_FLOW_CONFIG_PATH` environment variable
|
||||
3. `config.yaml` in current directory (backend/)
|
||||
4. `config.yaml` in parent directory (project root - **recommended location**)
|
||||
2. `DEER_FLOW_CONFIG_PATH` environment variable (a missing file is an error, not a fallthrough)
|
||||
3. `config.yaml` under `DEER_FLOW_PROJECT_ROOT`, or the current directory when it is unset
|
||||
4. Legacy `backend/config.yaml`, then repository-root `config.yaml` (project root is the **recommended location**)
|
||||
|
||||
`scripts/doctor.py` calls `AppConfig.resolve_config_path` rather than copying this order.
|
||||
|
||||
Config values starting with `$` are resolved as environment variables (e.g., `$OPENAI_API_KEY`).
|
||||
`ModelConfig` also declares `use_responses_api` and `output_version` so OpenAI `/v1/responses` can be enabled explicitly while still using `langchain_openai:ChatOpenAI`.
|
||||
|
||||
@ -6,12 +6,14 @@ Run from repo root:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import doctor
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@ -112,6 +114,103 @@ class TestCheckConfigExists:
|
||||
assert result.status == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_config_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime_path_env(monkeypatch):
|
||||
"""Clear the runtime path variables and restore them after the test.
|
||||
|
||||
``main()`` defaults ``DEER_FLOW_PROJECT_ROOT`` in ``os.environ`` the way
|
||||
``make dev`` does. ``monkeypatch.delenv`` records nothing for an unset
|
||||
variable, so set each one first to make teardown remove what main() adds.
|
||||
"""
|
||||
for name in ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_PROJECT_ROOT"):
|
||||
monkeypatch.setenv(name, "")
|
||||
monkeypatch.delenv(name)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
class TestResolveConfigPath:
|
||||
def test_uses_config_path_env(self, tmp_path, runtime_path_env):
|
||||
cfg = tmp_path / "elsewhere.yaml"
|
||||
cfg.write_text("config_version: 5\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(cfg))
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path / "root"))
|
||||
|
||||
assert doctor.resolve_config_path() == (cfg, None)
|
||||
|
||||
def test_missing_config_path_env_fails(self, tmp_path, runtime_path_env):
|
||||
(tmp_path / "config.yaml").write_text("config_version: 5\n")
|
||||
missing = tmp_path / "missing.yaml"
|
||||
runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(missing))
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
|
||||
|
||||
path, failure = doctor.resolve_config_path()
|
||||
|
||||
assert path == missing
|
||||
assert failure is not None
|
||||
assert failure.status == "fail"
|
||||
assert "DEER_FLOW_CONFIG_PATH" in failure.detail
|
||||
assert "DEER_FLOW_CONFIG_PATH" in failure.fix
|
||||
|
||||
def test_uses_config_under_project_root_env(self, tmp_path, runtime_path_env):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("config_version: 5\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
|
||||
|
||||
assert doctor.resolve_config_path() == (cfg, None)
|
||||
|
||||
def test_missing_project_root_env_fails(self, tmp_path, runtime_path_env):
|
||||
missing_root = tmp_path / "missing-root"
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(missing_root))
|
||||
|
||||
path, failure = doctor.resolve_config_path()
|
||||
|
||||
assert not path.exists()
|
||||
assert failure is not None
|
||||
assert failure.status == "fail"
|
||||
assert "DEER_FLOW_PROJECT_ROOT" in failure.detail
|
||||
assert "DEER_FLOW_PROJECT_ROOT" in failure.fix
|
||||
|
||||
def test_no_config_anywhere_is_a_plain_missing_config(self, tmp_path, runtime_path_env):
|
||||
from deerflow.config import app_config
|
||||
|
||||
runtime_path_env.setattr(app_config, "_legacy_config_candidates", lambda: ())
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
|
||||
|
||||
path, failure = doctor.resolve_config_path()
|
||||
|
||||
assert path == tmp_path / "config.yaml"
|
||||
assert failure is None
|
||||
assert doctor.check_config_exists(path).fix == "Run 'make setup' to create it"
|
||||
|
||||
@pytest.mark.parametrize("error", [ImportError("no module"), TypeError("ABI mismatch"), SyntaxError("bad syntax")], ids=lambda e: type(e).__name__)
|
||||
def test_unimportable_harness_is_reported_not_raised(self, tmp_path, runtime_path_env, error):
|
||||
# A broken backend environment is exactly what doctor must diagnose,
|
||||
# whatever the harness raises while its module body executes.
|
||||
real_import = builtins.__import__
|
||||
|
||||
def broken_import(name, *args, **kwargs):
|
||||
if name == "deerflow.config.app_config":
|
||||
raise error
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
runtime_path_env.setattr(builtins, "__import__", broken_import)
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
|
||||
|
||||
path, failure = doctor.resolve_config_path()
|
||||
|
||||
assert path == tmp_path / "config.yaml"
|
||||
assert failure is not None
|
||||
assert failure.status == "fail"
|
||||
assert "harness" in failure.detail
|
||||
assert type(error).__name__ in failure.detail
|
||||
assert failure.fix == "Run 'make install'"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_config_version
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -869,17 +968,35 @@ class TestCheckSandbox:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMainExitCode:
|
||||
def test_returns_int(self, tmp_path, monkeypatch, capsys):
|
||||
"""main() should return 0 or 1 without raising."""
|
||||
repo_root = tmp_path / "repo"
|
||||
scripts_dir = repo_root / "scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
fake_doctor = scripts_dir / "doctor.py"
|
||||
fake_doctor.write_text("# test-only shim for __file__ resolution\n")
|
||||
def _fake_checkout(tmp_path: Path, monkeypatch) -> Path:
|
||||
"""Point doctor at an empty checkout under ``tmp_path``.
|
||||
|
||||
monkeypatch.chdir(repo_root)
|
||||
monkeypatch.setattr(doctor, "__file__", str(fake_doctor))
|
||||
The harness's legacy config fallback is pinned to the same checkout, so
|
||||
whether the real repository has a ``config.yaml`` cannot leak in.
|
||||
"""
|
||||
from deerflow.config import app_config
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
scripts_dir = repo_root / "scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
fake_doctor = scripts_dir / "doctor.py"
|
||||
fake_doctor.write_text("# test-only shim for __file__ resolution\n")
|
||||
|
||||
monkeypatch.chdir(repo_root)
|
||||
monkeypatch.setattr(doctor, "__file__", str(fake_doctor))
|
||||
monkeypatch.setattr(
|
||||
app_config,
|
||||
"_legacy_config_candidates",
|
||||
lambda: (repo_root / "backend" / "config.yaml", repo_root / "config.yaml"),
|
||||
)
|
||||
return repo_root
|
||||
|
||||
|
||||
class TestMainExitCode:
|
||||
def test_returns_int(self, tmp_path, runtime_path_env, capsys):
|
||||
"""main() should return 0 or 1 without raising."""
|
||||
monkeypatch = runtime_path_env
|
||||
_fake_checkout(tmp_path, monkeypatch)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
|
||||
@ -892,3 +1009,138 @@ class TestMainExitCode:
|
||||
assert output
|
||||
assert "config.yaml" in output
|
||||
assert ".env" in output
|
||||
|
||||
|
||||
class TestMainConfigResolution:
|
||||
def test_missing_config_path_env_fails_even_with_a_checkout_config(self, tmp_path, runtime_path_env, capsys):
|
||||
repo_root = _fake_checkout(tmp_path, runtime_path_env)
|
||||
(repo_root / "config.yaml").write_text("config_version: 5\nmodels: []\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "missing.yaml"))
|
||||
|
||||
exit_code = doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert exit_code == 1
|
||||
assert "✗ config.yaml found" in output
|
||||
assert "DEER_FLOW_CONFIG_PATH" in output
|
||||
# The checkout's config.yaml is not the one the Gateway would read, so
|
||||
# nothing is reported about it.
|
||||
assert "— config.yaml loadable" in output
|
||||
assert "— models configured" in output
|
||||
|
||||
def test_checks_the_config_named_by_config_path_env(self, tmp_path, runtime_path_env, capsys):
|
||||
_fake_checkout(tmp_path, runtime_path_env)
|
||||
cfg = tmp_path / "custom.yaml"
|
||||
cfg.write_text("config_version: 5\nmodels: []\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(cfg))
|
||||
|
||||
doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "✓ config.yaml found" in output
|
||||
assert "✗ models configured (no models found)" in output
|
||||
|
||||
@pytest.mark.parametrize("project_root_env", [None, ""], ids=["unset", "empty"])
|
||||
def test_defaults_project_root_to_the_checkout_like_make_dev(self, tmp_path, runtime_path_env, capsys, project_root_env):
|
||||
repo_root = _fake_checkout(tmp_path, runtime_path_env)
|
||||
# `make dev` runs from backend/, but serve.sh pins an unset or empty
|
||||
# runtime root to the checkout, so the Gateway prefers
|
||||
# <checkout>/config.yaml over the legacy backend/config.yaml.
|
||||
# Resolving from the cwd would pick the backend copy instead.
|
||||
if project_root_env is not None:
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", project_root_env)
|
||||
backend_dir = repo_root / "backend"
|
||||
backend_dir.mkdir()
|
||||
runtime_path_env.chdir(backend_dir)
|
||||
(backend_dir / "config.yaml").write_text("config_version: 5\nmodels: []\n")
|
||||
(repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n")
|
||||
|
||||
doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "✓ config.yaml found" in output
|
||||
assert "✓ models configured (1 model(s))" in output
|
||||
|
||||
def test_missing_project_root_env_fails(self, tmp_path, runtime_path_env, capsys):
|
||||
repo_root = _fake_checkout(tmp_path, runtime_path_env)
|
||||
(repo_root / "config.yaml").write_text("config_version: 5\nmodels: []\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path / "missing-root"))
|
||||
|
||||
exit_code = doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert exit_code == 1
|
||||
assert "✗ config.yaml found" in output
|
||||
assert "DEER_FLOW_PROJECT_ROOT" in output
|
||||
|
||||
def test_dotenv_config_path_overrides_the_shell_like_make_dev(self, tmp_path, runtime_path_env, capsys):
|
||||
# serve.sh sources .env over the shell, so the Gateway loads the .env
|
||||
# config even when the shell exports a different (missing) one.
|
||||
repo_root = _fake_checkout(tmp_path, runtime_path_env)
|
||||
cfg = tmp_path / "from-dotenv.yaml"
|
||||
cfg.write_text("config_version: 5\nmodels:\n - name: dotenv-model\n")
|
||||
(repo_root / ".env").write_text(f"DEER_FLOW_CONFIG_PATH={cfg}\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "missing.yaml"))
|
||||
|
||||
doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "✓ config.yaml found" in output
|
||||
assert "✓ models configured (1 model(s))" in output
|
||||
|
||||
def test_dotenv_project_root_overrides_the_shell_like_make_dev(self, tmp_path, runtime_path_env, capsys):
|
||||
repo_root = _fake_checkout(tmp_path, runtime_path_env)
|
||||
(repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n")
|
||||
(repo_root / ".env").write_text(f"DEER_FLOW_PROJECT_ROOT={repo_root}\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path / "missing-root"))
|
||||
|
||||
doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "✓ config.yaml found" in output
|
||||
assert "✓ models configured (1 model(s))" in output
|
||||
|
||||
def test_empty_dotenv_config_path_clears_the_shell_value_like_make_dev(self, tmp_path, runtime_path_env, capsys):
|
||||
# Sourcing `DEER_FLOW_CONFIG_PATH=` exports an empty value, which the
|
||||
# resolver skips, so the Gateway falls back to <checkout>/config.yaml.
|
||||
repo_root = _fake_checkout(tmp_path, runtime_path_env)
|
||||
(repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n")
|
||||
(repo_root / ".env").write_text("DEER_FLOW_CONFIG_PATH=\n")
|
||||
runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "missing.yaml"))
|
||||
|
||||
doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "✓ config.yaml found" in output
|
||||
assert "✓ models configured (1 model(s))" in output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "value", "found"),
|
||||
[
|
||||
("DEER_FLOW_CONFIG_PATH", "~/cfg.yaml", True),
|
||||
("DEER_FLOW_PROJECT_ROOT", "~/repo", True),
|
||||
# bash leaves a quoted tilde literal, so the Gateway fails too.
|
||||
("DEER_FLOW_CONFIG_PATH", '"~/cfg.yaml"', False),
|
||||
],
|
||||
ids=["config-path", "project-root", "quoted-config-path"],
|
||||
)
|
||||
def test_dotenv_location_tilde_expands_like_source(self, tmp_path, runtime_path_env, capsys, name, value, found):
|
||||
# serve.sh's `source .env` expands an unquoted leading `~` (bash tilde
|
||||
# expansion in an assignment) and keeps a quoted one literal.
|
||||
repo_root = _fake_checkout(tmp_path, runtime_path_env)
|
||||
for home_var in ("HOME", "USERPROFILE"):
|
||||
runtime_path_env.setenv(home_var, str(tmp_path))
|
||||
(tmp_path / "cfg.yaml").write_text("config_version: 5\nmodels:\n - name: home-model\n")
|
||||
(repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n")
|
||||
(repo_root / ".env").write_text(f"{name}={value}\n")
|
||||
|
||||
exit_code = doctor.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
if found:
|
||||
assert "✓ config.yaml found" in output
|
||||
assert "✓ models configured (1 model(s))" in output
|
||||
else:
|
||||
assert exit_code == 1
|
||||
assert "✗ config.yaml found" in output
|
||||
assert "~/cfg.yaml" in output
|
||||
|
||||
@ -40,6 +40,20 @@ where `make up` replaced the operator's secret with a generated one.
|
||||
its real-Compose cases run against the installed `docker` CLI and against any
|
||||
standalone binaries listed in `DEER_FLOW_TEST_COMPOSE_BINARIES`.
|
||||
|
||||
`doctor.py` checks the config file the Gateway would load, not a fixed
|
||||
`<checkout>/config.yaml`. It mirrors how `serve.sh` hands the two
|
||||
config-location variables to the Gateway: `.env` values for
|
||||
`DEER_FLOW_CONFIG_PATH` / `DEER_FLOW_PROJECT_ROOT` override the shell (other
|
||||
keys stay shell-first), an unquoted leading `~` in them expands as `source`
|
||||
does (a quoted one stays literal), and an unset or empty
|
||||
`DEER_FLOW_PROJECT_ROOT` becomes the checkout. It then asks the harness
|
||||
(`AppConfig.resolve_config_path`) instead of re-implementing its order. An
|
||||
override the Gateway would reject (`DEER_FLOW_CONFIG_PATH` missing,
|
||||
`DEER_FLOW_PROJECT_ROOT` not a directory) fails `config.yaml found` with the
|
||||
Gateway's error, and the config-dependent checks skip. Any failure to import
|
||||
the harness is reported, never raised: doctor diagnoses broken environments.
|
||||
Pinned by `backend/tests/test_doctor.py::TestMainConfigResolution`.
|
||||
|
||||
Root `make install` runs pre-commit through uv, so uv's tool bin directory
|
||||
need not be on `PATH`.
|
||||
|
||||
|
||||
@ -273,6 +273,64 @@ def check_nginx() -> CheckResult:
|
||||
)
|
||||
|
||||
|
||||
# Environment variables that choose which config file the Gateway loads.
|
||||
CONFIG_LOCATION_ENV_VARS = ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_PROJECT_ROOT")
|
||||
|
||||
|
||||
def _unquoted_dotenv_keys(env_path: Path) -> set[str]:
|
||||
"""Return the ``.env`` keys whose value is written without quotes."""
|
||||
from dotenv.parser import parse_stream
|
||||
|
||||
with open(env_path, encoding="utf-8") as stream:
|
||||
return {binding.key for binding in parse_stream(stream) if binding.key and binding.original.string.split("=", 1)[-1].lstrip()[:1] not in ("'", '"')}
|
||||
|
||||
|
||||
def resolve_config_path() -> tuple[Path, CheckResult | None]:
|
||||
"""Locate the config.yaml the Gateway would read.
|
||||
|
||||
Delegates to the harness resolver (``DEER_FLOW_CONFIG_PATH``, then
|
||||
``config.yaml`` under ``DEER_FLOW_PROJECT_ROOT``, then the legacy
|
||||
backend/repository-root locations) so doctor checks the same file the
|
||||
Gateway loads. When an environment override would stop the Gateway from
|
||||
starting, returns the path it names (which does not exist) and a failed
|
||||
``config.yaml found`` result carrying the Gateway's error.
|
||||
"""
|
||||
config_env = os.environ.get("DEER_FLOW_CONFIG_PATH")
|
||||
default_path = Path(os.environ.get("DEER_FLOW_PROJECT_ROOT") or ".") / "config.yaml"
|
||||
try:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
except Exception as exc:
|
||||
# Keep diagnosing a broken backend environment instead of crashing
|
||||
# (any import-time failure, as in check_config_loadable); the
|
||||
# YAML-only checks still run against the most likely path.
|
||||
return Path(config_env) if config_env else default_path, CheckResult(
|
||||
"config.yaml found",
|
||||
"fail",
|
||||
f"cannot import the DeerFlow harness to resolve it ({type(exc).__name__}: {exc})",
|
||||
fix="Run 'make install'",
|
||||
)
|
||||
|
||||
try:
|
||||
return AppConfig.resolve_config_path(), None
|
||||
except FileNotFoundError as exc:
|
||||
if not config_env:
|
||||
# Nothing at the default locations: a plain missing config.
|
||||
return default_path, None
|
||||
return Path(config_env), CheckResult(
|
||||
"config.yaml found",
|
||||
"fail",
|
||||
str(exc),
|
||||
fix="Point DEER_FLOW_CONFIG_PATH at an existing config.yaml, or unset it",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Path(os.environ["DEER_FLOW_PROJECT_ROOT"]) / "config.yaml", CheckResult(
|
||||
"config.yaml found",
|
||||
"fail",
|
||||
str(exc),
|
||||
fix="Point DEER_FLOW_PROJECT_ROOT at the DeerFlow checkout, or unset it",
|
||||
)
|
||||
|
||||
|
||||
def check_config_exists(config_path: Path) -> CheckResult:
|
||||
if config_path.exists():
|
||||
return CheckResult("config.yaml found", "ok")
|
||||
@ -760,18 +818,31 @@ def check_env_file(project_root: Path) -> CheckResult:
|
||||
|
||||
def main() -> int:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
config_path = project_root / "config.yaml"
|
||||
|
||||
# Load .env early so key checks work
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
from dotenv import dotenv_values, load_dotenv
|
||||
|
||||
env_path = project_root / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=False)
|
||||
# `make dev` (scripts/serve.sh) sources .env over the shell, so
|
||||
# for the variables that choose the config file, .env wins.
|
||||
unquoted = _unquoted_dotenv_keys(env_path)
|
||||
for name, value in dotenv_values(env_path).items():
|
||||
if name in CONFIG_LOCATION_ENV_VARS and value is not None:
|
||||
# `source` expands an unquoted leading `~`, not a quoted one.
|
||||
os.environ[name] = os.path.expanduser(value) if name in unquoted else value
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# serve.sh then replaces an unset or empty runtime root with the
|
||||
# checkout, so config resolution and the loadable check see what the
|
||||
# Gateway sees.
|
||||
if not os.environ.get("DEER_FLOW_PROJECT_ROOT"):
|
||||
os.environ["DEER_FLOW_PROJECT_ROOT"] = str(project_root)
|
||||
config_path, config_failure = resolve_config_path()
|
||||
|
||||
print()
|
||||
print(bold("DeerFlow Health Check"))
|
||||
print("═" * 40)
|
||||
@ -792,7 +863,7 @@ def main() -> int:
|
||||
cfg_checks: list[CheckResult] = [
|
||||
check_env_file(project_root),
|
||||
check_frontend_env(project_root),
|
||||
check_config_exists(config_path),
|
||||
config_failure or check_config_exists(config_path),
|
||||
check_config_version(config_path, project_root),
|
||||
check_config_loadable(config_path),
|
||||
check_models_configured(config_path),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user