From e29826950f7a16ab7228140de1b88fe42fdc092f Mon Sep 17 00:00:00 2001 From: Lewis Tunstall Date: Sun, 7 Jun 2026 22:30:47 +0200 Subject: [PATCH 1/3] Add managed local dev server helper Co-authored-by: OpenAI Codex --- AGENTS.md | 7 +- README.md | 22 ++ backend/main.py | 24 +- frontend/vite.config.ts | 55 ++-- scripts/dev_server.py | 460 ++++++++++++++++++++++++++++++++++ tests/unit/test_dev_server.py | 43 ++++ 6 files changed, 582 insertions(+), 29 deletions(-) create mode 100644 scripts/dev_server.py create mode 100644 tests/unit/test_dev_server.py diff --git a/AGENTS.md b/AGENTS.md index 1bc123aa..dabf7594 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,14 @@ ## Local Dev Servers +- Managed stack: from the repo root, run `uv run --frozen python scripts/dev_server.py up`. +- Stop the managed stack with `uv run --frozen python scripts/dev_server.py down`. +- Restart it with `uv run --frozen python scripts/dev_server.py restart`. +- The helper picks free frontend/backend ports, writes logs and state under ignored `scratch/dev-server/`, and prints the URLs it selected. - Frontend: from `frontend/`, run `npm ci` if dependencies are missing, then `npm run dev`. - Backend: from `backend/`, run `uv run uvicorn main:app --host ::1 --port 7860`. -- Frontend URL: http://localhost:5173/ +- Default managed frontend URL: http://127.0.0.1:5173/ +- Manual frontend URL: http://localhost:5173/ - Backend health check: `curl -g http://[::1]:7860/api` - Frontend proxy health check: `curl http://localhost:5173/api` diff --git a/README.md b/README.md index 75c2cea2..e0a553de 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,28 @@ GITHUB_TOKEN= All API-based model calls go through Hugging Face [Inference Providers](https://huggingface.co/docs/inference-providers/en/index), so your `HF_TOKEN` must be allowed to make Inference Provider calls. If no `HF_TOKEN` is set, the CLI will prompt you to paste one on first launch unless you start on a local model. To get a `GITHUB_TOKEN` follow the tutorial [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token). See the [local models section below](#local-models) for instructions on using agents that run on your hardware. +### Local Web Dev + +Use the managed local stack when working across concurrent branches or +worktrees: + +```bash +uv run --frozen python scripts/dev_server.py up +``` + +The helper starts the backend and frontend together, picks free ports when the +defaults are busy, points the Vite proxy at the selected backend, and writes +state/logs to ignored `scratch/dev-server/`. + +```bash +uv run --frozen python scripts/dev_server.py status +uv run --frozen python scripts/dev_server.py restart +uv run --frozen python scripts/dev_server.py down +``` + +If frontend dependencies are missing, run `cd frontend && npm ci` first, or +pass `--install` to let the helper run it. + ### Usage #### Interactive mode (start a chat session): diff --git a/backend/main.py b/backend/main.py index 73571084..fc861be0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -26,6 +26,23 @@ logger = logging.getLogger(__name__) +def _split_env_list(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def _cors_origins() -> list[str]: + default_origins = [ + "http://localhost:5173", # Vite dev server + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + ] + configured_origins = _split_env_list(os.environ.get("ML_INTERN_CORS_ORIGINS")) + return [*default_origins, *configured_origins] + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan handler.""" @@ -83,12 +100,7 @@ async def lifespan(app: FastAPI): # CORS middleware for development app.add_middleware( CORSMiddleware, - allow_origins=[ - "http://localhost:5173", # Vite dev server - "http://localhost:3000", - "http://127.0.0.1:5173", - "http://127.0.0.1:3000", - ], + allow_origins=_cors_origins(), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b8585dc3..f8b9500a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,29 +2,40 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, - }, - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:7860', - changeOrigin: true, - ws: true, // Proxy WebSocket connections (/api/ws/...) +const parsePort = (value: string | undefined, fallback: number) => { + const parsed = Number.parseInt(value ?? '', 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +export default defineConfig(() => { + const backendProxyTarget = + process.env.VITE_BACKEND_PROXY_TARGET ?? 'http://localhost:7860' + const devServerPort = parsePort(process.env.VITE_DEV_SERVER_PORT, 5173) + + return { + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), }, - '/auth': { - target: 'http://localhost:7860', - changeOrigin: true, + }, + server: { + port: devServerPort, + proxy: { + '/api': { + target: backendProxyTarget, + changeOrigin: true, + ws: true, // Proxy WebSocket connections (/api/ws/...) + }, + '/auth': { + target: backendProxyTarget, + changeOrigin: true, + }, }, }, - }, - build: { - outDir: 'dist', - sourcemap: false, - }, + build: { + outDir: 'dist', + sourcemap: false, + }, + } }) diff --git a/scripts/dev_server.py b/scripts/dev_server.py new file mode 100644 index 00000000..be8a5622 --- /dev/null +++ b/scripts/dev_server.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +"""Run the local web stack with conflict-free ports. + +Usage: + uv run --frozen python scripts/dev_server.py up + uv run --frozen python scripts/dev_server.py down + uv run --frozen python scripts/dev_server.py restart + uv run --frozen python scripts/dev_server.py status +""" + +import argparse +import json +import os +import signal +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +BACKEND_DIR = PROJECT_ROOT / "backend" +FRONTEND_DIR = PROJECT_ROOT / "frontend" +STATE_DIR = PROJECT_ROOT / "scratch" / "dev-server" +STATE_PATH = STATE_DIR / "state.json" +DEFAULT_BACKEND_HOST = "::1" +DEFAULT_FRONTEND_HOST = "127.0.0.1" +DEFAULT_BACKEND_PORT = 7860 +DEFAULT_FRONTEND_PORT = 5173 +DEFAULT_PORT_WINDOW = 100 +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def host_url(host: str, port: int) -> str: + if ":" in host and not host.startswith("["): + return f"http://[{host}]:{port}" + return f"http://{host}:{port}" + + +def state_exists() -> bool: + return STATE_PATH.exists() + + +def load_state() -> dict[str, Any] | None: + if not state_exists(): + return None + try: + return json.loads(STATE_PATH.read_text()) + except json.JSONDecodeError: + return None + + +def save_state(state: dict[str, Any]) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + STATE_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n") + + +def clear_state() -> None: + try: + STATE_PATH.unlink() + except FileNotFoundError: + pass + + +def port_is_free(host: str, port: int) -> bool: + try: + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except socket.gaierror: + return False + + for family, socktype, proto, _, sockaddr in infos: + with socket.socket(family, socktype, proto) as sock: + sock.settimeout(0.2) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(sockaddr) + except OSError: + return False + return True + + +def find_free_port(host: str, preferred_port: int, port_window: int) -> int: + final_port = min(65535, preferred_port + port_window) + for port in range(preferred_port, final_port + 1): + if port_is_free(host, port): + return port + raise RuntimeError( + f"No free port found on {host} from {preferred_port} to {final_port}." + ) + + +def process_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def process_command(pid: int) -> str: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() + + +def process_matches(process: dict[str, Any]) -> bool: + pid = int(process["pid"]) + if not process_alive(pid): + return False + + command = process_command(pid) + markers = process.get("match_markers", []) + return bool(command) and all(marker in command for marker in markers) + + +def active_processes(state: dict[str, Any] | None) -> list[dict[str, Any]]: + if not state: + return [] + return [ + process + for process in state.get("processes", []) + if isinstance(process, dict) and process_matches(process) + ] + + +def stop_process(process: dict[str, Any], timeout: float) -> None: + pid = int(process["pid"]) + pgid = int(process.get("pgid", pid)) + name = process.get("name", str(pid)) + + if not process_matches(process): + print(f"{name}: not running") + return + + print(f"{name}: stopping PID {pid}") + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return + except PermissionError: + os.kill(pid, signal.SIGTERM) + + deadline = time.time() + timeout + while time.time() < deadline: + if not process_alive(pid): + return + time.sleep(0.1) + + print(f"{name}: forcing PID {pid}") + try: + os.killpg(pgid, signal.SIGKILL) + except ProcessLookupError: + return + except PermissionError: + os.kill(pid, signal.SIGKILL) + + +def stop_state(state: dict[str, Any] | None, timeout: float) -> None: + if not state: + print("No managed dev server state found.") + clear_state() + return + + processes = state.get("processes", []) + for process in reversed(processes): + if isinstance(process, dict): + stop_process(process, timeout) + clear_state() + + +def wait_for_http(name: str, url: str, timeout: float, log_path: Path) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1.0) as response: + if response.status < 500: + return True + except (OSError, urllib.error.URLError): + time.sleep(0.25) + + print(f"{name} did not become ready at {url}. See {log_path}.") + return False + + +def start_process( + name: str, + command: list[str], + cwd: Path, + env: dict[str, str], + log_path: Path, + match_markers: list[str], +) -> dict[str, Any]: + log_path.parent.mkdir(parents=True, exist_ok=True) + log_file = log_path.open("ab") + try: + proc = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + finally: + log_file.close() + + return { + "name": name, + "pid": proc.pid, + "pgid": os.getpgid(proc.pid), + "command": command, + "cwd": str(cwd), + "log": str(log_path), + "match_markers": match_markers, + } + + +def ensure_frontend_dependencies(args: argparse.Namespace) -> int: + node_modules = FRONTEND_DIR / "node_modules" + if node_modules.exists(): + return 0 + if not args.install: + print( + "frontend/node_modules is missing. Run `cd frontend && npm ci`, " + "or rerun this command with `--install`." + ) + return 1 + + result = subprocess.run(["npm", "ci"], cwd=FRONTEND_DIR, check=False) + return result.returncode + + +def build_backend_env(frontend_url: str) -> dict[str, str]: + env = os.environ.copy() + existing = env.get("ML_INTERN_CORS_ORIGINS", "") + origins = [origin for origin in existing.split(",") if origin] + origins.extend( + [ + frontend_url, + frontend_url.replace("127.0.0.1", "localhost"), + ] + ) + env["ML_INTERN_CORS_ORIGINS"] = ",".join(dict.fromkeys(origins)) + return env + + +def build_frontend_env(backend_url: str, frontend_port: int) -> dict[str, str]: + env = os.environ.copy() + env["VITE_BACKEND_PROXY_TARGET"] = backend_url + env["VITE_DEV_SERVER_PORT"] = str(frontend_port) + return env + + +def command_up(args: argparse.Namespace) -> int: + existing_state = load_state() + active = active_processes(existing_state) + if active and not args.replace: + print("Managed dev server is already running.") + print_status(existing_state) + return 0 + if active: + stop_state(existing_state, args.stop_timeout) + elif state_exists(): + clear_state() + + if ensure_frontend_dependencies(args) != 0: + return 1 + + try: + backend_port = find_free_port( + args.backend_host, args.backend_port, args.port_window + ) + frontend_port = find_free_port( + args.frontend_host, args.frontend_port, args.port_window + ) + except RuntimeError as error: + print(error) + return 1 + + STATE_DIR.mkdir(parents=True, exist_ok=True) + backend_url = host_url(args.backend_host, backend_port) + frontend_url = host_url(args.frontend_host, frontend_port) + backend_log = STATE_DIR / "backend.log" + frontend_log = STATE_DIR / "frontend.log" + + backend_command = [ + "uv", + "run", + "--frozen", + "uvicorn", + "main:app", + "--host", + args.backend_host, + "--port", + str(backend_port), + ] + frontend_command = [ + "npm", + "run", + "dev", + "--", + "--host", + args.frontend_host, + "--port", + str(frontend_port), + "--strictPort", + "--clearScreen", + "false", + ] + + processes: list[dict[str, Any]] = [] + state = { + "started_at": utc_now(), + "backend_url": backend_url, + "frontend_url": frontend_url, + "frontend_proxy_health_url": f"{frontend_url}/api", + "state_path": str(STATE_PATH), + "processes": processes, + } + + try: + processes.append( + start_process( + "backend", + backend_command, + BACKEND_DIR, + build_backend_env(frontend_url), + backend_log, + ["uvicorn", "main:app"], + ) + ) + if not wait_for_http( + "backend", f"{backend_url}/api", args.timeout, backend_log + ): + raise RuntimeError("backend failed to start") + + processes.append( + start_process( + "frontend", + frontend_command, + FRONTEND_DIR, + build_frontend_env(backend_url, frontend_port), + frontend_log, + ["npm", "run", "dev"], + ) + ) + if not wait_for_http( + "frontend proxy", f"{frontend_url}/api", args.timeout, frontend_log + ): + raise RuntimeError("frontend failed to start") + except RuntimeError: + stop_state(state, args.stop_timeout) + return 1 + + save_state(state) + print("Started managed dev server.") + print_status(state) + return 0 + + +def command_down(args: argparse.Namespace) -> int: + stop_state(load_state(), args.stop_timeout) + return 0 + + +def command_restart(args: argparse.Namespace) -> int: + stop_state(load_state(), args.stop_timeout) + args.replace = False + return command_up(args) + + +def print_status(state: dict[str, Any] | None) -> None: + if not state: + print("No managed dev server state found.") + return + + print(f"Frontend: {state.get('frontend_url')}/") + print(f"Backend: {state.get('backend_url')}/api") + print(f"Proxy: {state.get('frontend_proxy_health_url')}") + print(f"State: {state.get('state_path', STATE_PATH)}") + + for process in state.get("processes", []): + if not isinstance(process, dict): + continue + status = "running" if process_matches(process) else "stopped" + print( + f"{process.get('name')}: {status}, " + f"pid={process.get('pid')}, log={process.get('log')}" + ) + + +def command_status(args: argparse.Namespace) -> int: + print_status(load_state()) + return 0 + + +def add_common_up_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--backend-host", default=DEFAULT_BACKEND_HOST) + parser.add_argument("--frontend-host", default=DEFAULT_FRONTEND_HOST) + parser.add_argument("--backend-port", type=int, default=DEFAULT_BACKEND_PORT) + parser.add_argument("--frontend-port", type=int, default=DEFAULT_FRONTEND_PORT) + parser.add_argument("--port-window", type=int, default=DEFAULT_PORT_WINDOW) + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS) + parser.add_argument("--stop-timeout", type=float, default=5.0) + parser.add_argument( + "--install", + action="store_true", + help="Run npm ci if frontend/node_modules is missing.", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + up = subparsers.add_parser("up", help="Start backend and frontend dev servers.") + add_common_up_options(up) + up.add_argument( + "--replace", + action="store_true", + help="Stop the managed server first if one is already running.", + ) + up.set_defaults(func=command_up) + + down = subparsers.add_parser("down", help="Stop the managed dev servers.") + down.add_argument("--stop-timeout", type=float, default=5.0) + down.set_defaults(func=command_down) + + restart = subparsers.add_parser("restart", help="Stop and start dev servers.") + add_common_up_options(restart) + restart.set_defaults(func=command_restart) + + status = subparsers.add_parser("status", help="Show managed dev server status.") + status.set_defaults(func=command_status) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_dev_server.py b/tests/unit/test_dev_server.py new file mode 100644 index 00000000..2b229684 --- /dev/null +++ b/tests/unit/test_dev_server.py @@ -0,0 +1,43 @@ +import importlib.util +import socket +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "dev_server.py" + + +def bind_first_available_port(host: str) -> socket.socket: + for port in range(45000, 45100): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.bind((host, port)) + return sock + except OSError: + sock.close() + raise AssertionError("No available test port found") + + +def load_dev_server_module(): + spec = importlib.util.spec_from_file_location("dev_server", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_host_url_wraps_ipv6_hosts(): + dev_server = load_dev_server_module() + + assert dev_server.host_url("::1", 7860) == "http://[::1]:7860" + + +def test_find_free_port_skips_occupied_port(): + dev_server = load_dev_server_module() + + with bind_first_available_port("127.0.0.1") as sock: + sock.listen() + occupied_port = sock.getsockname()[1] + + free_port = dev_server.find_free_port("127.0.0.1", occupied_port, 5) + + assert free_port > occupied_port From b4688ec2f65006a1777bd85b5980407805a8e8e5 Mon Sep 17 00:00:00 2001 From: Lewis Tunstall Date: Sun, 7 Jun 2026 22:42:12 +0200 Subject: [PATCH 2/3] Clean stale dev server processes before startup Co-authored-by: OpenAI Codex --- AGENTS.md | 3 +- README.md | 4 +- scripts/dev_server.py | 245 +++++++++++++++++++++++++++++++--- tests/unit/test_dev_server.py | 52 ++++++++ 4 files changed, 286 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dabf7594..25244adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,10 @@ ## Local Dev Servers - Managed stack: from the repo root, run `uv run --frozen python scripts/dev_server.py up`. +- Cleanup stale local server processes with `uv run --frozen python scripts/dev_server.py cleanup`. - Stop the managed stack with `uv run --frozen python scripts/dev_server.py down`. - Restart it with `uv run --frozen python scripts/dev_server.py restart`. -- The helper picks free frontend/backend ports, writes logs and state under ignored `scratch/dev-server/`, and prints the URLs it selected. +- The helper picks free frontend/backend ports, cleans stale dev servers from the same worktree before start, writes logs and state under ignored `scratch/dev-server/`, and prints the URLs it selected. - Frontend: from `frontend/`, run `npm ci` if dependencies are missing, then `npm run dev`. - Backend: from `backend/`, run `uv run uvicorn main:app --host ::1 --port 7860`. - Default managed frontend URL: http://127.0.0.1:5173/ diff --git a/README.md b/README.md index e0a553de..29fa9ffd 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,12 @@ uv run --frozen python scripts/dev_server.py up The helper starts the backend and frontend together, picks free ports when the defaults are busy, points the Vite proxy at the selected backend, and writes -state/logs to ignored `scratch/dev-server/`. +state/logs to ignored `scratch/dev-server/`. Before each start it also cleans +up stale backend/frontend dev server processes from the same worktree. ```bash uv run --frozen python scripts/dev_server.py status +uv run --frozen python scripts/dev_server.py cleanup uv run --frozen python scripts/dev_server.py restart uv run --frozen python scripts/dev_server.py down ``` diff --git a/scripts/dev_server.py b/scripts/dev_server.py index be8a5622..625191eb 100644 --- a/scripts/dev_server.py +++ b/scripts/dev_server.py @@ -3,12 +3,14 @@ Usage: uv run --frozen python scripts/dev_server.py up + uv run --frozen python scripts/dev_server.py cleanup uv run --frozen python scripts/dev_server.py down uv run --frozen python scripts/dev_server.py restart uv run --frozen python scripts/dev_server.py status """ import argparse +from dataclasses import dataclass import json import os import signal @@ -33,6 +35,19 @@ DEFAULT_FRONTEND_PORT = 5173 DEFAULT_PORT_WINDOW = 100 DEFAULT_TIMEOUT_SECONDS = 30.0 +BACKEND_COMMAND_MARKERS = ("uvicorn", "main:app") +FRONTEND_COMMAND_MARKER_GROUPS = ( + ("npm", "run", "dev"), + ("vite", "--host"), +) + + +@dataclass(frozen=True) +class ProcessInfo: + pid: int + pgid: int + command: str + cwd: Path | None def utc_now() -> str: @@ -70,6 +85,13 @@ def clear_state() -> None: pass +def same_path(left: Path, right: Path) -> bool: + try: + return left.resolve() == right.resolve() + except OSError: + return left == right + + def port_is_free(host: str, port: int) -> bool: try: infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) @@ -117,6 +139,25 @@ def process_command(pid: int) -> str: return result.stdout.strip() +def process_cwd(pid: int) -> Path | None: + try: + result = subprocess.run( + ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return None + + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + if line.startswith("n"): + return Path(line[1:]) + return None + + def process_matches(process: dict[str, Any]) -> bool: pid = int(process["pid"]) if not process_alive(pid): @@ -124,28 +165,46 @@ def process_matches(process: dict[str, Any]) -> bool: command = process_command(pid) markers = process.get("match_markers", []) - return bool(command) and all(marker in command for marker in markers) + if not (bool(command) and all(marker in command for marker in markers)): + return False + expected_cwd = process.get("cwd") + cwd = process_cwd(pid) if expected_cwd else None + if expected_cwd and cwd is not None and not same_path(cwd, Path(expected_cwd)): + return False -def active_processes(state: dict[str, Any] | None) -> list[dict[str, Any]]: + return True + + +def managed_processes(state: dict[str, Any] | None) -> list[dict[str, Any]]: if not state: return [] return [ - process - for process in state.get("processes", []) - if isinstance(process, dict) and process_matches(process) + process for process in state.get("processes", []) if isinstance(process, dict) ] -def stop_process(process: dict[str, Any], timeout: float) -> None: - pid = int(process["pid"]) - pgid = int(process.get("pgid", pid)) - name = process.get("name", str(pid)) +def active_processes(state: dict[str, Any] | None) -> list[dict[str, Any]]: + return [process for process in managed_processes(state) if process_matches(process)] - if not process_matches(process): + +def process_pgids(processes: list[dict[str, Any]]) -> set[int]: + return {int(process.get("pgid", process["pid"])) for process in processes} + + +def managed_stack_active( + state: dict[str, Any] | None, + active: list[dict[str, Any]] | None = None, +) -> bool: + processes = managed_processes(state) + active = active_processes(state) if active is None else active + return bool(processes) and len(active) == len(processes) + + +def terminate_process_group(name: str, pid: int, pgid: int, timeout: float) -> None: + if not process_alive(pid): print(f"{name}: not running") return - print(f"{name}: stopping PID {pid}") try: os.killpg(pgid, signal.SIGTERM) @@ -169,6 +228,18 @@ def stop_process(process: dict[str, Any], timeout: float) -> None: os.kill(pid, signal.SIGKILL) +def stop_process(process: dict[str, Any], timeout: float) -> None: + pid = int(process["pid"]) + pgid = int(process.get("pgid", pid)) + name = process.get("name", str(pid)) + + if not process_matches(process): + print(f"{name}: not running") + return + + terminate_process_group(name, pid, pgid, timeout) + + def stop_state(state: dict[str, Any] | None, timeout: float) -> None: if not state: print("No managed dev server state found.") @@ -182,6 +253,106 @@ def stop_state(state: dict[str, Any] | None, timeout: float) -> None: clear_state() +def command_matches_backend(command: str) -> bool: + return all(marker in command for marker in BACKEND_COMMAND_MARKERS) + + +def command_matches_frontend(command: str) -> bool: + return any( + all(marker in command for marker in markers) + for markers in FRONTEND_COMMAND_MARKER_GROUPS + ) + + +def command_looks_like_dev_server(command: str) -> bool: + return command_matches_backend(command) or command_matches_frontend(command) + + +def classify_dev_server_process(process: ProcessInfo) -> str | None: + if process.cwd is None: + return None + if same_path(process.cwd, BACKEND_DIR) and command_matches_backend(process.command): + return "backend" + if same_path(process.cwd, FRONTEND_DIR) and command_matches_frontend( + process.command + ): + return "frontend" + return None + + +def iter_candidate_processes() -> list[ProcessInfo]: + result = subprocess.run( + ["ps", "-axo", "pid=,pgid=,command="], + capture_output=True, + text=True, + check=False, + ) + candidates = [] + for line in result.stdout.splitlines(): + parts = line.strip().split(None, 2) + if len(parts) != 3: + continue + pid_text, pgid_text, command = parts + if not command_looks_like_dev_server(command): + continue + try: + pid = int(pid_text) + pgid = int(pgid_text) + except ValueError: + continue + candidates.append( + ProcessInfo( + pid=pid, + pgid=pgid, + command=command, + cwd=process_cwd(pid), + ) + ) + return candidates + + +def discover_stale_dev_servers(exclude_pgids: set[int]) -> list[dict[str, Any]]: + stale_processes = [] + seen_pgids = set(exclude_pgids) + for process in iter_candidate_processes(): + kind = classify_dev_server_process(process) + if kind is None or process.pgid in seen_pgids: + continue + seen_pgids.add(process.pgid) + stale_processes.append( + { + "name": f"stale {kind}", + "pid": process.pid, + "pgid": process.pgid, + "command": process.command, + "cwd": str(process.cwd) if process.cwd else None, + } + ) + return stale_processes + + +def cleanup_stale_servers( + timeout: float, + exclude_pgids: set[int] | None = None, + verbose: bool = True, +) -> int: + stale_processes = discover_stale_dev_servers(exclude_pgids or set()) + if not stale_processes: + if verbose: + print("No stale dev server processes found.") + return 0 + + print(f"Cleaning up {len(stale_processes)} stale dev server process group(s).") + for process in stale_processes: + terminate_process_group( + process["name"], + int(process["pid"]), + int(process["pgid"]), + timeout, + ) + return len(stale_processes) + + def wait_for_http(name: str, url: str, timeout: float, log_path: Path) -> bool: deadline = time.time() + timeout while time.time() < deadline: @@ -268,14 +439,29 @@ def build_frontend_env(backend_url: str, frontend_port: int) -> dict[str, str]: def command_up(args: argparse.Namespace) -> int: existing_state = load_state() active = active_processes(existing_state) - if active and not args.replace: + stack_active = managed_stack_active(existing_state, active) + + if stack_active and args.replace: + stop_state(existing_state, args.stop_timeout) + active = [] + stack_active = False + elif existing_state and not stack_active: + if active: + stop_state(existing_state, args.stop_timeout) + else: + clear_state() + active = [] + + cleanup_stale_servers( + args.stop_timeout, + exclude_pgids=process_pgids(active), + verbose=False, + ) + + if stack_active: print("Managed dev server is already running.") print_status(existing_state) return 0 - if active: - stop_state(existing_state, args.stop_timeout) - elif state_exists(): - clear_state() if ensure_frontend_dependencies(args) != 0: return 1 @@ -377,6 +563,26 @@ def command_down(args: argparse.Namespace) -> int: return 0 +def command_cleanup(args: argparse.Namespace) -> int: + existing_state = load_state() + active = active_processes(existing_state) + stack_active = managed_stack_active(existing_state, active) + + if existing_state and not stack_active: + if active: + stop_state(existing_state, args.stop_timeout) + else: + clear_state() + active = [] + + cleanup_stale_servers( + args.stop_timeout, + exclude_pgids=process_pgids(active), + verbose=True, + ) + return 0 + + def command_restart(args: argparse.Namespace) -> int: stop_state(load_state(), args.stop_timeout) args.replace = False @@ -440,6 +646,13 @@ def build_parser() -> argparse.ArgumentParser: down.add_argument("--stop-timeout", type=float, default=5.0) down.set_defaults(func=command_down) + cleanup = subparsers.add_parser( + "cleanup", + help="Stop stale unmanaged dev server processes for this worktree.", + ) + cleanup.add_argument("--stop-timeout", type=float, default=5.0) + cleanup.set_defaults(func=command_cleanup) + restart = subparsers.add_parser("restart", help="Stop and start dev servers.") add_common_up_options(restart) restart.set_defaults(func=command_restart) diff --git a/tests/unit/test_dev_server.py b/tests/unit/test_dev_server.py index 2b229684..20b5b468 100644 --- a/tests/unit/test_dev_server.py +++ b/tests/unit/test_dev_server.py @@ -41,3 +41,55 @@ def test_find_free_port_skips_occupied_port(): free_port = dev_server.find_free_port("127.0.0.1", occupied_port, 5) assert free_port > occupied_port + + +def test_classifies_repo_backend_process(): + dev_server = load_dev_server_module() + process = dev_server.ProcessInfo( + pid=123, + pgid=123, + command="uv run --frozen uvicorn main:app --host ::1 --port 7860", + cwd=dev_server.BACKEND_DIR, + ) + + assert dev_server.classify_dev_server_process(process) == "backend" + + +def test_classifies_repo_frontend_vite_process(): + dev_server = load_dev_server_module() + process = dev_server.ProcessInfo( + pid=123, + pgid=123, + command="node ./node_modules/.bin/vite --host 127.0.0.1 --port 5173", + cwd=dev_server.FRONTEND_DIR, + ) + + assert dev_server.classify_dev_server_process(process) == "frontend" + + +def test_ignores_matching_command_from_other_directory(tmp_path): + dev_server = load_dev_server_module() + process = dev_server.ProcessInfo( + pid=123, + pgid=123, + command="uv run --frozen uvicorn main:app --host ::1 --port 7860", + cwd=tmp_path, + ) + + assert dev_server.classify_dev_server_process(process) is None + + +def test_managed_stack_active_requires_all_processes(monkeypatch): + dev_server = load_dev_server_module() + state = { + "processes": [ + {"pid": 1, "pgid": 1, "match_markers": ["uvicorn"]}, + {"pid": 2, "pgid": 2, "match_markers": ["npm"]}, + ] + } + + monkeypatch.setattr( + dev_server, "process_matches", lambda process: process["pid"] == 1 + ) + + assert not dev_server.managed_stack_active(state) From dfc82e96e7108a1ce2f4f4ba55a2642873d8afd1 Mon Sep 17 00:00:00 2001 From: Lewis Tunstall Date: Sun, 7 Jun 2026 23:06:46 +0200 Subject: [PATCH 3/3] Document dev server teardown after merge Co-authored-by: OpenAI Codex --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 25244adc..29f70fd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ Notes: - Vite proxies `/api` and `/auth` to `http://localhost:7860`. - If `127.0.0.1:7860` is already owned by another local process, binding the backend to `::1` lets the Vite proxy resolve `localhost` cleanly. - Prefer `npm ci` over `npm install` for setup, since `npm install` may rewrite `frontend/package-lock.json` metadata depending on npm version. +- After a PR is merged and the local branch/worktree is being retired, run `uv run --frozen python scripts/dev_server.py down`, then `uv run --frozen python scripts/dev_server.py cleanup` before removing the worktree. - Non-local LLM calls use `https://router.huggingface.co/v1` with the active Hugging Face user's token. Web sessions default to Kimi K2.6 for free users and Claude Opus 4.8 for Pro users; the CLI default is Claude Opus 4.8. For local development, set `HF_TOKEN` and optionally `ML_INTERN_DEFAULT_MODEL_ID`. ## Development Checks