diff --git a/AGENTS.md b/AGENTS.md index a3e652c4e..bbfd92a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ fonts, images, audio, and video uncompressed at the proxy layer. Both compose files publish that entry as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` — **loopback by default**, matching the README's documented deployment model. A bare `"${PORT}:2026"` binds `0.0.0.0`, which does not. +The root `PORT` value is Docker ingress configuration only; local orchestration pins +Next.js to `3000` so loading `.env` cannot make `make dev` wait on the wrong port. Nginx itself listens `default_server` on IPv4+IPv6 and the Gateway binds `0.0.0.0:8001` inside the container on purpose — both are container- internal; the published nginx port is the entire external surface, and the Gateway's @@ -126,6 +128,12 @@ make up / down # Build/stop the production Docker stack (browser at localhost: make docker-start / docker-stop / docker-logs # Docker development environment ``` +Production startup uses the image's pre-built Python environment with `uv run +--no-sync`, gives the Gateway a real `/health` probe, and makes `make up` wait +for that probe before printing its success banner. A readiness failure must +surface Compose status and recent Gateway logs instead of claiming the stack is +running. + Docker log and restart commands resolve `DEER_FLOW_ROOT` from the current checkout before invoking Compose, matching the start and stop commands. diff --git a/README.md b/README.md index 69d4e2624..5601eec35 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,12 @@ make down # Stop and remove containers Access: http://localhost:2026 +`make up` waits for the Gateway `/health` endpoint before reporting success. +If the Gateway does not become healthy within the startup window, deployment +exits non-zero and prints the container status plus recent Gateway logs. The +production image starts from its already-built environment and never resolves +or installs Python dependencies at container startup. + For persistent deployments, configure `database.backend` as `sqlite` or `postgres`. The selected backend is shared by the LangGraph checkpointer, LangGraph Store, and DeerFlow application data. The deprecated `checkpointer` @@ -347,6 +353,10 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P 6. **Access**: http://localhost:2026 +Local services always use their internal ports (`8001`, `3000`, and `2026`). +The root `.env` variable `PORT` configures only the published Docker ingress; +it does not change the Next.js port used by `make dev`. + #### Startup Modes DeerFlow runs the agent runtime inside the Gateway API. Development mode enables hot-reload; production mode uses a pre-built frontend. diff --git a/backend/tests/test_gateway_startup.py b/backend/tests/test_gateway_startup.py new file mode 100644 index 000000000..b16e67e55 --- /dev/null +++ b/backend/tests/test_gateway_startup.py @@ -0,0 +1,112 @@ +"""Regression coverage for local and Docker Gateway startup contracts.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _read(path: str) -> str: + return (REPO_ROOT / path).read_text(encoding="utf-8") + + +def _deploy_fixture(tmp_path: Path, *, docker_script: str) -> tuple[Path, dict[str, str]]: + worktree = tmp_path / "repo" + shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts") + shutil.copytree(REPO_ROOT / "docker", worktree / "docker") + (worktree / "backend").mkdir() + (worktree / "config.yaml").write_text("sandbox:\n use: deerflow.sandbox:LocalSandboxProvider\n", encoding="utf-8") + (worktree / "extensions_config.json").write_text('{"mcpServers":{},"skills":{}}\n', encoding="utf-8") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + docker = bin_dir / "docker" + docker.write_text(docker_script, encoding="utf-8") + docker.chmod(0o755) + + env = os.environ.copy() + env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" + env["BETTER_AUTH_SECRET"] = "test-better-auth-secret" + env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-internal-auth-token" + return worktree, env + + +def test_gateway_runtime_commands_never_sync_dependencies() -> None: + """A built environment must not resolve or install packages at process start.""" + compose = _read("docker/docker-compose.yaml") + serve = _read("scripts/serve.sh") + + assert "PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app" in compose + assert "PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app" in serve + + +def test_local_frontend_ignores_public_docker_port() -> None: + """Root PORT config is public Docker ingress, not the local Next.js port.""" + serve = _read("scripts/serve.sh") + + assert "env PORT=3000" in serve + assert 'run_service "Frontend"' in serve + assert "3000 120" in serve + + +def test_production_gateway_has_a_real_readiness_probe() -> None: + """Compose readiness must exercise the Gateway HTTP endpoint.""" + compose = _read("docker/docker-compose.yaml") + + assert "healthcheck:" in compose + assert "http://127.0.0.1:8001/health" in compose + assert "gateway:\n condition: service_healthy" in compose + + +def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> None: + capture = tmp_path / "docker-args.txt" + worktree, env = _deploy_fixture( + tmp_path, + docker_script=('#!/usr/bin/env sh\nprintf "%s\\n" "$@" > "$CAPTURE_DOCKER_ARGS"\nexit 0\n'), + ) + env["CAPTURE_DOCKER_ARGS"] = str(capture) + + result = subprocess.run( + ["bash", str(worktree / "scripts" / "deploy.sh"), "start"], + cwd=worktree, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + args = capture.read_text(encoding="utf-8").splitlines() + assert "--wait" in args + assert "--wait-timeout" in args + assert "DeerFlow is running!" in result.stdout + + +def test_deploy_failure_prints_gateway_diagnostics_and_never_claims_success(tmp_path: Path) -> None: + capture = tmp_path / "docker-calls.txt" + worktree, env = _deploy_fixture( + tmp_path, + docker_script=('#!/usr/bin/env sh\nprintf "%s\\n" "$*" >> "$CAPTURE_DOCKER_CALLS"\ncase " $* " in\n *" up "*) exit 1 ;;\n *) exit 0 ;;\nesac\n'), + ) + env["CAPTURE_DOCKER_CALLS"] = str(capture) + + result = subprocess.run( + ["bash", str(worktree / "scripts" / "deploy.sh"), "start"], + cwd=worktree, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "DeerFlow is running!" not in result.stdout + assert "DeerFlow services failed to become ready" in result.stderr + assert "supports `docker compose up --wait`" in result.stderr + calls = capture.read_text(encoding="utf-8") + assert any(call.endswith(" ps") for call in calls.splitlines()) + assert " logs --no-color --tail 100 gateway" in calls diff --git a/backend/tests/test_pnpm_script.py b/backend/tests/test_pnpm_script.py index bafdb4a0a..a808899c4 100644 --- a/backend/tests/test_pnpm_script.py +++ b/backend/tests/test_pnpm_script.py @@ -120,7 +120,7 @@ def test_official_entrypoints_route_pnpm_through_shared_runner(): assert "PNPM = $(PYTHON) ../scripts/pnpm.py" in frontend_makefile assert '"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" install --silent' in serve_script assert 'DEERFLOW_PNPM_RUNNER="$REPO_ROOT/scripts/pnpm.py"' in serve_script - assert 'FRONTEND_CMD=\'"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev\'' in serve_script + assert 'FRONTEND_CMD=\'env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev\'' in serve_script assert '"\\$DEERFLOW_PNPM_RUNNER\\" run preview"' in serve_script assert 'Path(__file__).resolve().with_name("pnpm.py")' in doctor_script assert 'project_root / "scripts" / "pnpm.py"' in support_bundle_script diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index fedd0762e..233e1a06b 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -58,8 +58,10 @@ services: sh -c "cp /etc/nginx/nginx.conf.template /etc/nginx/nginx.conf && nginx -g 'daemon off;'" depends_on: - - frontend - - gateway + frontend: + condition: service_started + gateway: + condition: service_healthy networks: - deer-flow restart: unless-stopped @@ -144,6 +146,12 @@ services: depends_on: redis: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=3); raise SystemExit(0 if response.status == 200 else 1)"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s networks: - deer-flow restart: unless-stopped diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 5bfd5358a..a0dce62b4 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,11 @@ +## Service Startup Contracts + +The root `PORT` value configures Docker's published nginx ingress only; local +orchestration pins Next.js to `3000`. Runtime commands launch from the already +synchronized environment with `uv run --no-sync`. Production Compose probes +Gateway `/health`, and `deploy.sh` waits for all services before reporting +success; failures print Compose status and recent Gateway logs. + ## Backend Static Analysis Commands The root `detect-thread-boundaries` target statically inventories execution diff --git a/scripts/deploy.sh b/scripts/deploy.sh index a07db701c..4c3cbd317 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -386,17 +386,34 @@ echo "" # ── Start / Up ─────────────────────────────────────────────────────────────── +report_startup_failure() { + echo -e "${RED}✗ DeerFlow services failed to become ready.${NC}" >&2 + echo ' If Docker Compose reports "unknown flag: --wait", upgrade to a version that' >&2 + echo ' supports `docker compose up --wait`.' >&2 + echo " Container status:" >&2 + "${COMPOSE_CMD[@]}" ps >&2 || true + echo "" >&2 + echo " Recent Gateway logs:" >&2 + "${COMPOSE_CMD[@]}" logs --no-color --tail 100 gateway >&2 || true +} + if [ "$CMD" = "start" ]; then echo "Starting containers (no rebuild)..." echo "" # shellcheck disable=SC2086 - "${COMPOSE_CMD[@]}" up -d --remove-orphans $services + if ! "${COMPOSE_CMD[@]}" up -d --remove-orphans --wait --wait-timeout 180 $services; then + report_startup_failure + exit 1 + fi else # Default: build + start echo "Building images and starting containers..." echo "" # shellcheck disable=SC2086 - "${COMPOSE_CMD[@]}" up --build -d --remove-orphans $services + if ! "${COMPOSE_CMD[@]}" up --build -d --remove-orphans --wait --wait-timeout 180 $services; then + report_startup_failure + exit 1 + fi fi echo "" diff --git a/scripts/serve.sh b/scripts/serve.sh index 82b419d3f..81550e6ac 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -304,9 +304,9 @@ export DEERFLOW_PNPM_PYTHON DEERFLOW_PNPM_RUNNER # Frontend command if $DEV_MODE; then - FRONTEND_CMD='"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev' + FRONTEND_CMD='env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev' else - FRONTEND_CMD="env BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run preview" + FRONTEND_CMD="env PORT=3000 BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run preview" fi # Runtime path defaults. Local `make dev` launches Gateway from `backend/`,