diff --git a/README.md b/README.md index 2f20347a0e..87f95e517c 100644 --- a/README.md +++ b/README.md @@ -770,6 +770,10 @@ DeerFlow runs the agent runtime inside the Gateway API. Development mode enables | **Stop** | `./scripts/serve.sh --stop`
`make stop` | `./scripts/docker.sh stop`
`make docker-stop` | `./scripts/deploy.sh down`
`make down` | | **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | — | +Local and Docker development stop commands also stop running sandbox containers +whose names start with `deer-flow-sandbox-`. Containers with that text elsewhere +in their names are left alone. + `make start` and `make start-daemon` rebuild the frontend with `next build` on every run. To reuse the last build instead, pass `SKIP_FRONTEND_BUILD=1` (or add `--skip-frontend-build` when calling `./scripts/serve.sh --prod` directly). This diff --git a/backend/tests/test_cleanup_containers_script.py b/backend/tests/test_cleanup_containers_script.py new file mode 100644 index 0000000000..923cb75a89 --- /dev/null +++ b/backend/tests/test_cleanup_containers_script.py @@ -0,0 +1,62 @@ +"""Offline checks for the names selected by the sandbox cleanup helper.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "cleanup-containers.sh" + + +def _stub(path: Path, name: str, source: str) -> None: + executable = path / name + executable.write_text("#!/bin/sh\n" + source, encoding="utf-8", newline="\n") + executable.chmod(0o755) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX executable stubs") +def test_docker_cleanup_stops_only_sandbox_prefix(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stop_log = tmp_path / "stopped" + _stub( + bin_dir, + "docker", + 'if [ "$1" = ps ]; then\n' + ' printf "other-id not-deer-flow-sandbox Up 3 days\\nvalid-id deer-flow-sandbox-abc Up 1 hour\\nbackup-id deer-flow-sandboxbackup Up 2 days\\n"\n' + 'elif [ "$1" = stop ]; then\n' + ' shift; printf "%s\\n" "$@" >> "$STOP_LOG"\n' + "fi\n", + ) + env = {**os.environ, "PATH": f"{bin_dir}:/usr/bin:/bin", "STOP_LOG": str(stop_log)} + result = subprocess.run(["bash", str(SCRIPT), "deer-flow-sandbox"], env=env, capture_output=True, text=True, timeout=5) + + assert result.returncode == 0, result.stderr + assert stop_log.read_text(encoding="utf-8").splitlines() == ["valid-id"] + assert "not-deer-flow-sandbox" not in result.stdout + assert "deer-flow-sandbox-abc Up 1 hour" in result.stdout + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX executable stubs") +def test_apple_cleanup_ignores_unrelated_names_and_bad_entries(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stop_log = tmp_path / "stopped" + entries = [ + {"configuration": None}, + {"configuration": {"id": "not-deer-flow-sandbox"}}, + {"configuration": {"id": "deer-flow-sandbox-abc"}}, + ] + payload = tmp_path / "containers.json" + payload.write_text(json.dumps(entries), encoding="utf-8") + _stub(bin_dir, "container", 'if [ "$1" = list ]; then cat "$CONTAINERS_JSON"; elif [ "$1" = stop ]; then printf "%s\\n" "$2" >> "$STOP_LOG"; fi\n') + env = {**os.environ, "PATH": f"{bin_dir}:/usr/bin:/bin", "STOP_LOG": str(stop_log), "CONTAINERS_JSON": str(payload)} + result = subprocess.run(["bash", str(SCRIPT), "deer-flow-sandbox"], env=env, capture_output=True, text=True, timeout=5) + + assert result.returncode == 0, result.stderr + assert stop_log.read_text(encoding="utf-8").splitlines() == ["deer-flow-sandbox-abc"] + assert "not-deer-flow-sandbox" not in result.stdout diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 7c25b97913..89aa9a29ca 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -214,6 +214,11 @@ runs `check-data-not-in-use.sh` before `make stop` (which would stop a live stack's sandboxes) and refuses while that container runs; an absent or unreachable Docker passes. `make stop` must still run before the delete: it stops `deer-flow-sandbox*` containers, whose thread mounts live in that tree. +`cleanup-containers.sh` must verify an anchored `-` name after Docker's +substring `name=` filter, and use the same rule for Apple Container IDs. Parse +Apple Container JSON without interpolating the prefix into Python source; bad +entries must not hide later valid containers. Offline stub coverage lives in +`backend/tests/test_cleanup_containers_script.py`. `RUNTIME_DATA_CONTENTS` feeds both the help line and the deletion notice. Host-side pnpm calls must go through `scripts/pnpm.py`. With native Windows diff --git a/scripts/cleanup-containers.sh b/scripts/cleanup-containers.sh index 7e44b602a6..caa613d4ad 100755 --- a/scripts/cleanup-containers.sh +++ b/scripts/cleanup-containers.sh @@ -22,14 +22,23 @@ echo "Cleaning up sandbox containers with prefix: ${PREFIX}" cleanup_docker() { if command -v docker &> /dev/null; then echo -n "Checking Docker containers... " - DOCKER_CONTAINERS=$(docker ps -q --filter "name=${PREFIX}" 2>/dev/null || echo "") + # Docker's name filter matches substrings. Check the returned names as + # well so a container like not-deer-flow-sandbox is never stopped. + DOCKER_LIST=$(docker ps --filter "name=${PREFIX}-" --format '{{.ID}} {{.Names}} {{.Status}}' 2>/dev/null || true) + docker_ids=() + docker_rows=() + while read -r cid name status; do + case "$name" in + "$PREFIX"-*) docker_ids+=("$cid"); docker_rows+=("$cid $name $status") ;; + esac + done <<< "$DOCKER_LIST" - if [ -n "$DOCKER_CONTAINERS" ]; then + if [ "${#docker_ids[@]}" -gt 0 ]; then echo "" echo "Found Docker containers to clean up:" - docker ps --filter "name=${PREFIX}" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}" + printf ' %s\n' "${docker_rows[@]}" echo "Stopping Docker containers..." - echo "$DOCKER_CONTAINERS" | xargs docker stop 2>/dev/null || true + docker stop "${docker_ids[@]}" >/dev/null 2>&1 || true echo -e "${GREEN}✓ Docker containers stopped${NC}" else echo -e "${GREEN}none found${NC}" @@ -49,21 +58,23 @@ cleanup_apple_container() { if [ "$CONTAINER_LIST" != "[]" ] && [ -n "$CONTAINER_LIST" ]; then # Extract container IDs that match our prefix - CONTAINER_IDS=$(echo "$CONTAINER_LIST" | python3 -c " + CONTAINER_IDS=$(echo "$CONTAINER_LIST" | CLEANUP_CONTAINER_PREFIX="$PREFIX" python3 -c ' import json +import os import sys try: containers = json.load(sys.stdin) if isinstance(containers, list): + prefix = os.environ["CLEANUP_CONTAINER_PREFIX"] + "-" for c in containers: if isinstance(c, dict): - # Apple Container uses 'id' field which contains the container name - cid = c.get('configuration').get('id', '') - if '${PREFIX}' in cid: + configuration = c.get("configuration") + cid = configuration.get("id") if isinstance(configuration, dict) else None + if isinstance(cid, str) and cid.startswith(prefix): print(cid) except: pass -" 2>/dev/null || echo "") +' 2>/dev/null || echo "") if [ -n "$CONTAINER_IDS" ]; then echo ""