diff --git a/README.md b/README.md index 13c83c0c..dd5af043 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,10 @@ action. Text and JSON output redact local cwd paths by default; use For the same redacted metadata in a local browser view, run `code-mower board serve --repo OWNER/REPO` and open the printed localhost URL. -Plain `board serve` is read-only. To build local history while the browser view -is open, run `code-mower board serve --repo OWNER/REPO --record-events`; it +Plain `board serve` is read-only. If the default Board port is busy, the CLI +uses a nearby free loopback port and prints the URL; an explicit `--port` stays +strict and reports a friendly conflict. To build local history while the browser +view is open, run `code-mower board serve --repo OWNER/REPO --record-events`; it records at most one snapshot every 60 seconds by default. To append one snapshot without serving the browser view, run `code-mower board record --repo OWNER/REPO` from the repository checkout; it diff --git a/docs/board-data-contract.md b/docs/board-data-contract.md index 9af2b0b5..dff0469e 100644 --- a/docs/board-data-contract.md +++ b/docs/board-data-contract.md @@ -107,6 +107,10 @@ The browser UI fetches `/api/status` from a loopback-only HTTP server. It does not mutate GitHub and does not upload payloads. Plain `board serve` does not mutate local repository state. `board serve --record-events` is the explicit local-only write mode for filling board history while the browser view is open. +When the default port is already in use, `board serve` falls forward to a nearby +free loopback port and prints the selected URL. An explicit `--port` stays +strict so scripts and bookmarks fail clearly instead of silently moving. The +printed URL is local to that machine or VM unless the operator creates a tunnel. ## Local Event Store diff --git a/docs/quickstart.md b/docs/quickstart.md index 4da6826f..13ad1bfa 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -321,7 +321,10 @@ code-mower board serve --repo OWNER/REPO ``` The board serves only on loopback by default. It is read-only, does not upload -data, and uses the same local-path redaction as `lanes status`. +data, and uses the same local-path redaction as `lanes status`. If the default +port is busy, it picks a nearby free loopback port and prints the URL to open; +an explicit `--port` fails with a friendly conflict instead. The printed URL is +local to that machine or VM unless you create your own tunnel. When you want the Recent Local History panel to fill while the board is open, start it explicitly with: diff --git a/src/code_mower/board.py b/src/code_mower/board.py index 0d1519bd..453e252a 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import errno import json import re import socket @@ -41,6 +42,7 @@ class BoardConfig: repo: str host: str = DEFAULT_HOST port: int = DEFAULT_PORT + port_was_default: bool = True pr_limit: int = 50 workflow_limit: int = 20 stale_minutes: int = 30 @@ -111,6 +113,46 @@ def _server_url(host: str, port: int) -> str: return f"http://{display_host}:{port}/" +def _candidate_ports(config: BoardConfig) -> list[int]: + if not config.port_was_default: + return [config.port] + last_port = min(65535, config.port + 9) + return list(range(config.port, last_port + 1)) + + +def _explicit_port_conflict_message(host: str, port: int) -> str: + suggestions = list(range(port + 1, min(65535, port + 3) + 1)) + suggestion_text = f" such as {', '.join(str(candidate) for candidate in suggestions)}" if suggestions else "" + return ( + f"error: Code Mower Board port {port} is already in use on {host}. " + f"Pass --port with a free loopback port{suggestion_text}." + ) + + +def _bind_board_server( + config: BoardConfig, + handler: type[BaseHTTPRequestHandler], +) -> ThreadingHTTPServer | None: + server_type = _server_class(config.host) + tried: list[int] = [] + for port in _candidate_ports(config): + tried.append(port) + try: + return server_type((config.host, port), handler) + except OSError as exc: + if exc.errno != errno.EADDRINUSE: + raise + if not config.port_was_default: + print(_explicit_port_conflict_message(config.host, port), file=sys.stderr) + return None + print( + "error: Code Mower Board could not find a free loopback port in " + f"{tried[0]}-{tried[-1]}; pass --port with a free port.", + file=sys.stderr, + ) + return None + + def status_payload( config: BoardConfig, *, @@ -781,6 +823,9 @@ def serve(config: BoardConfig, *, open_browser: bool = False) -> int: if not _is_loopback(config.host): print("error: board host must be loopback; use 127.0.0.1 or localhost", file=sys.stderr) return 2 + if not 0 <= config.port <= 65535: + print("error: --port must be between 0 and 65535", file=sys.stderr) + return 2 if config.record_interval_seconds < 0: print("error: --record-interval-seconds must be non-negative", file=sys.stderr) return 2 @@ -791,10 +836,14 @@ def serve(config: BoardConfig, *, open_browser: bool = False) -> int: print("error: --max-events must be at least 1", file=sys.stderr) return 2 handler = make_handler(config) - server_type = _server_class(config.host) - with server_type((config.host, config.port), handler) as server: + server = _bind_board_server(config, handler) + if server is None: + return 2 + with server: port = int(server.server_address[1]) url = _server_url(config.host, port) + if config.port_was_default and port != config.port: + print(f"Code Mower Board: default port {config.port} was busy; using {port}", file=sys.stderr) print(f"Code Mower Board: {url}", flush=True) if open_browser: webbrowser.open(url) @@ -1057,22 +1106,32 @@ def main(argv: list[str] | None = None) -> int: subparsers = parser.add_subparsers(dest="command", required=True) serve_parser = subparsers.add_parser("serve") serve_parser.add_argument("--repo", required=True) - serve_parser.add_argument("--host", default=DEFAULT_HOST) - serve_parser.add_argument("--port", type=int, default=DEFAULT_PORT) - serve_parser.add_argument("--pr-limit", type=int, default=50) - serve_parser.add_argument("--workflow-limit", type=int, default=20) - serve_parser.add_argument("--stale-minutes", type=int, default=30) - serve_parser.add_argument("--refresh-seconds", type=int, default=15) - serve_parser.add_argument("--show-local-paths", action="store_true") - serve_parser.add_argument("--repo-path", default=".") - serve_parser.add_argument("--store-path") - serve_parser.add_argument("--spend-path") - serve_parser.add_argument("--agent-adapters-path") - serve_parser.add_argument("--event-limit", type=int, default=20) - serve_parser.add_argument("--record-events", action="store_true") - serve_parser.add_argument("--record-interval-seconds", type=int, default=60) - serve_parser.add_argument("--retention-days", type=int, default=board_store.DEFAULT_RETENTION_DAYS) - serve_parser.add_argument("--max-events", type=int, default=board_store.DEFAULT_MAX_EVENTS) + serve_parser.add_argument("--host", default=DEFAULT_HOST, help="loopback host to bind; default: 127.0.0.1") + serve_parser.add_argument( + "--port", + type=int, + default=None, + help="loopback port; the default auto-falls forward when busy", + ) + serve_parser.add_argument("--pr-limit", type=int, default=50, help="open PRs to show") + serve_parser.add_argument("--workflow-limit", type=int, default=20, help="recent Code Mower workflow runs to show") + serve_parser.add_argument("--stale-minutes", type=int, default=30, help="minutes before gate evidence is stale") + serve_parser.add_argument("--refresh-seconds", type=int, default=15, help="browser refresh interval") + serve_parser.add_argument("--show-local-paths", action="store_true", help="show local cwd paths for debugging") + serve_parser.add_argument("--repo-path", default=".", help="repository checkout used for local Board files") + serve_parser.add_argument("--store-path", help="custom local Board event store path") + serve_parser.add_argument("--spend-path", help="custom reviewer spend ledger path") + serve_parser.add_argument("--agent-adapters-path", help="custom local agent card directory") + serve_parser.add_argument("--event-limit", type=int, default=20, help="local history events to show") + serve_parser.add_argument("--record-events", action="store_true", help="append local history while the Board is open") + serve_parser.add_argument("--record-interval-seconds", type=int, default=60, help="minimum seconds between records") + serve_parser.add_argument( + "--retention-days", + type=int, + default=board_store.DEFAULT_RETENTION_DAYS, + help="local history retention window", + ) + serve_parser.add_argument("--max-events", type=int, default=board_store.DEFAULT_MAX_EVENTS, help="maximum local events") serve_parser.add_argument("--open", action="store_true", help="open the local board in a browser") record_parser = subparsers.add_parser("record") record_parser.add_argument("--repo", required=True) @@ -1115,7 +1174,8 @@ def main(argv: list[str] | None = None) -> int: BoardConfig( repo=args.repo, host=args.host, - port=args.port, + port=DEFAULT_PORT if args.port is None else args.port, + port_was_default=args.port is None, pr_limit=args.pr_limit, workflow_limit=args.workflow_limit, stale_minutes=args.stale_minutes, diff --git a/tests/test_board.py b/tests/test_board.py index 0ea66fee..359d03fc 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -73,6 +73,18 @@ def _command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: return _completed("", returncode=1) +def _occupy_loopback_port(start: int = 5332, stop: int = 5400) -> socket.socket: + for port in range(start, stop): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.bind(("127.0.0.1", port)) + sock.listen() + return sock + except OSError: + sock.close() + raise RuntimeError("could not reserve a loopback port for Board test") + + class BoardTests(TestCase): def test_render_board_html_contains_local_app_shell(self) -> None: html = board.render_board_html(board.BoardConfig(repo="owner/repo")) @@ -95,6 +107,74 @@ def test_render_board_html_escapes_script_terminators(self) -> None: self.assertIn("owner/repo<\\/script>bad<\\/b>", html) self.assertNotIn("owner/repobad", html) + def test_candidate_ports_only_auto_fall_forward_for_default_port(self) -> None: + self.assertEqual( + board._candidate_ports(board.BoardConfig(repo="owner/repo", port=5332, port_was_default=True)), + list(range(5332, 5342)), + ) + self.assertEqual( + board._candidate_ports(board.BoardConfig(repo="owner/repo", port=6000, port_was_default=False)), + [6000], + ) + + def test_explicit_port_conflict_message_clamps_suggestions(self) -> None: + self.assertIn("65535", board._explicit_port_conflict_message("127.0.0.1", 65534)) + self.assertNotIn("65536", board._explicit_port_conflict_message("127.0.0.1", 65534)) + self.assertNotIn("such as", board._explicit_port_conflict_message("127.0.0.1", 65535)) + + def test_bind_board_server_falls_forward_when_default_port_is_busy(self) -> None: + with _occupy_loopback_port() as occupied: + busy_port = int(occupied.getsockname()[1]) + handler = board.make_handler(board.BoardConfig(repo="owner/repo", port=busy_port)) + + server = board._bind_board_server( + board.BoardConfig(repo="owner/repo", port=busy_port, port_was_default=True), + handler, + ) + + self.assertIsNotNone(server) + assert server is not None + try: + self.assertGreaterEqual(int(server.server_address[1]), busy_port + 1) + finally: + server.server_close() + + def test_bind_board_server_reports_explicit_port_conflict(self) -> None: + with _occupy_loopback_port() as occupied: + busy_port = int(occupied.getsockname()[1]) + handler = board.make_handler(board.BoardConfig(repo="owner/repo", port=busy_port)) + err = StringIO() + + with redirect_stderr(err): + server = board._bind_board_server( + board.BoardConfig(repo="owner/repo", port=busy_port, port_was_default=False), + handler, + ) + + self.assertIsNone(server) + self.assertIn(f"port {busy_port} is already in use", err.getvalue()) + self.assertIn("Pass --port", err.getvalue()) + + def test_serve_treats_abbreviated_port_flag_as_explicit(self) -> None: + with _occupy_loopback_port() as occupied: + busy_port = int(occupied.getsockname()[1]) + err = StringIO() + + with redirect_stderr(err): + code = board.main(["serve", "--repo", "owner/repo", "--po", str(busy_port)]) + + self.assertEqual(code, 2) + self.assertIn(f"port {busy_port} is already in use", err.getvalue()) + + def test_serve_rejects_invalid_port_before_binding(self) -> None: + err = StringIO() + + with redirect_stderr(err): + code = board.serve(board.BoardConfig(repo="owner/repo", port=70000)) + + self.assertEqual(code, 2) + self.assertIn("--port", err.getvalue()) + def test_status_payload_redacts_local_paths_by_default(self) -> None: payload = board.status_payload( board.BoardConfig(repo="owner/repo"),