Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/board-data-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
98 changes: 79 additions & 19 deletions src/code_mower/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import argparse
import errno
import json
import re
import socket
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions tests/test_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -95,6 +107,74 @@ def test_render_board_html_escapes_script_terminators(self) -> None:
self.assertIn("owner/repo<\\/script><b>bad<\\/b>", html)
self.assertNotIn("owner/repo</script><b>bad</b>", 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"),
Expand Down
Loading