From 536a37de9471c9464ae2278d415dc0823ceef5d9 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 17:02:21 -0700 Subject: [PATCH 1/2] feat(board): align inventory versions and service guidance --- docs/board-data-contract.md | 26 +++++- docs/board-service-lifecycle.md | 34 ++++--- docs/launch-command-surface.md | 2 +- docs/quickstart.md | 13 +-- docs/troubleshooting.md | 27 ++++-- src/code_mower/board.py | 133 +++++++++++++++++++++++--- src/code_mower/lane_status.py | 44 ++++++++- tests/test_board.py | 160 +++++++++++++++++++++++++++++++- tests/test_lane_status.py | 52 ++++++++++- 9 files changed, 439 insertions(+), 52 deletions(-) diff --git a/docs/board-data-contract.md b/docs/board-data-contract.md index 5d56fe1c..b3703c26 100644 --- a/docs/board-data-contract.md +++ b/docs/board-data-contract.md @@ -915,11 +915,27 @@ touched. Pruning needs `--yes` and never signals any process. `code-mower board list` emits `code_mower.boardInventory.v1`, a local inventory of visible Code Mower Board listeners. It reports loopback URL, PID, process -name, parsed repo hint, serving version, installed package version, -`restart_recommended`, health, and next action. Local cwd paths are redacted by -default; `--show-local-paths` is for local debugging only. If the host blocks -listener inspection, the command reports an unavailable inventory instead of -calling GitHub or reading repository content. +name, identity-verified repository, invoking version, serving version, +installed package version, `restart_recommended`, managed-service identity, +health, and next action. An identity-verified transient row carries a +stop-and-install `promotion_command`; a stale managed row carries +`restart_command`. Both commands use the redacted +working-directory placeholder `--repo-path .`. + +`code-mower board list --repo OWNER/REPO` includes a row only when the Board's +`/api/identity` response establishes that repository. Command-line repo hints +never satisfy this filter, so legacy, malformed, and unresponsive listeners +fail closed. The `filter` block counts matched, identity-unverified, and +other-repository rows without exposing local paths. Local cwd paths are +redacted by default; `--show-local-paths` is for local debugging only. If the +host blocks listener inspection, the command reports an unavailable inventory +instead of calling GitHub or reading repository content. + +The invoking CLI sets `restart_recommended` when `serving_version` differs +from `invoking_version`, even if the process reports that its own serving and +installed versions agree. The `local_boards` block in `lanes status` is this +same enriched inventory, so its text and JSON expose the same versions, +managed-service fields, and guidance. `code-mower board stop --port PORT --yes` and `code-mower board stop --pid PID --yes` emit `code_mower.boardStop.v1`. Stop only sends a local termination diff --git a/docs/board-service-lifecycle.md b/docs/board-service-lifecycle.md index d95bed87..e4232c1f 100644 --- a/docs/board-service-lifecycle.md +++ b/docs/board-service-lifecycle.md @@ -346,24 +346,28 @@ Selectors are not exclusive: every selector supplied must agree on one binding. not be proven to release the port rather than be undone. `board list` marks each Board `managed` with its service label, or transient, -and says when that service's supervision is unconfirmed. +and says when that service's supervision is unconfirmed. Use `code-mower board +list --repo OWNER/REPO --json` to filter on the repository established by each +listener's `/api/identity` response. A legacy, malformed, or unresponsive +listener cannot satisfy the filter even when its process command line contains +that repository. Omit `--repo` to inspect those unverified rows globally. -In v1.5.2 the inventory is global: use `code-mower board list --json`, then -filter the returned rows by their identity-verified `repo` value. The command -does not accept `--repo`. Responsive rows carry `serving_version`, +Responsive rows carry `invoking_version`, `serving_version`, `installed_version`, `restart_recommended`, `managed`, and `service_label`. +Staleness is computed by the invoking CLI as well as read from the process, so +an old Board cannot declare itself current merely because its own serving and +installed versions agree. For one known listener, `GET /api/identity` is the compact identity/version -probe and `GET /api/status` carries the full `board.version` block. Static HTML -and `lanes status` are not version-verification surfaces in this release. - -When a transient Board needs to survive logout, stop it with the exact selector -reported by `board list`, then review and install a managed definition with -`board service render` and `board service install`. When a managed Board is -stale, use `board service restart` with the same repository path and port. -Repository-filtered inventory, version parity in `lanes status`, and copyable -promotion/restart guidance are tracked for v1.6.0 in -[#1063](https://github.com/codemower-ai/code-mower/issues/1063); do not use -those planned command shapes with v1.5.2. +probe and `GET /api/status` carries the full `board.version` block. `lanes +status` presents the same inventory fields and service identity as `board +list`. + +Every identity-verified transient row includes a copyable `promotion_command` +that stops the exact repo/port listener and then installs the service. Every +stale managed row includes its exact `restart_command`. The commands keep local +paths redacted with `--repo-path .`; run them from the intended checkout, where +the service lifecycle's origin guard verifies the repository before applying a +definition. A `launchctl` that cannot be probed at all is one of those unconfirmed cases, not an empty inventory. On macOS the installed definitions are enumerated even diff --git a/docs/launch-command-surface.md b/docs/launch-command-surface.md index 3a944077..e898102c 100644 --- a/docs/launch-command-surface.md +++ b/docs/launch-command-surface.md @@ -48,7 +48,7 @@ Select any additional builder or reviewer explicitly. | `code-mower board serve --repo OWNER/REPO --record-events` | Serve the board and append throttled metadata-only local history snapshots while it is open. | yes, local only | GitHub optional | | `code-mower board serve --repo OWNER/REPO --agent-adapters-path PATH` | Read opt-in local agent cards from a custom metadata-only adapter directory. | no | no | | `code-mower board serve --repo OWNER/REPO --observations-path PATH` | Render local `code_mower.boardObservation.v1` records from a custom read-only directory. The Board consumes that contract and never writes one. | no | no | -| `code-mower board list --json` | List the global local Board inventory with repo/version, restart hints, ports, and redacted cwd paths by default; v1.5.2 has no `--repo` filter. | no | no | +| `code-mower board list --repo OWNER/REPO --json` | List only identity-verified local Boards for a repository, with invoking/serving/installed versions, service identity, restart posture, copyable service guidance, ports, and redacted cwd paths by default. Omit `--repo` for the global inventory. | no | no | | `code-mower board stop --port PORT --yes` | Stop a local Board listener that the inventory identified as Code Mower. | local process signal | no | | `code-mower board record --repo OWNER/REPO` | Append one redacted status snapshot to `.code-mower/board/events.jsonl` for local board history. | yes, local only | GitHub optional | | `code-mower board events` | Print recent local board-history events without calling GitHub. | no | no | diff --git a/docs/quickstart.md b/docs/quickstart.md index dfefee5a..7e4296f7 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -455,12 +455,13 @@ 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. `lanes status` discovers local Board listeners best-effort across common macOS and Linux tools; if listener inventory is restricted, GitHub PR/check status still reports. -Use `code-mower board list --json` to see the global local inventory with -repo/version, restart hints, and whether each listener is managed or transient. -In v1.5.2 `board list` does not accept `--repo`; filter its identity-verified -rows after retrieval. For one known port, verify the version through -`/api/identity` or the `board.version` block in `/api/status`. Static Board HTML -and `lanes status` are not version-verification surfaces. Use +Use `code-mower board list --repo OWNER/REPO --json` to see only Boards whose +`/api/identity` response verifies that repository. Omit `--repo` for the global +inventory, including legacy and unresponsive listeners. `board list` and +`lanes status` report the invoking, serving, and installed versions, compute +restart posture from invoking/serving parity, and identify managed services. +Identity-verified transient rows include a copyable `promotion_command`; stale +managed rows include their exact `restart_command`. Use `code-mower board stop --repo OWNER/REPO --yes`, `code-mower board stop --port PORT --yes`, or `code-mower board stop --pid PID --yes` only when you want to stop a listener that the inventory identified as a high-confidence Code Mower diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 819e664d..9b2b7ce0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -142,13 +142,17 @@ version, the installed version, and whether a restart is recommended. For a scriptable inventory across all local listeners, use: ```bash -code-mower board list --json +code-mower board list --repo OWNER/REPO --json ``` -`board list` is a global local inventory in v1.5.2; it does not accept -`--repo`. Filter the returned rows by their identity-verified `repo` field. -Each responsive row reports `serving_version`, `installed_version`, -`restart_recommended`, `managed`, and its service label when managed. +The repository filter fails closed: a listener is included only when its +`/api/identity` response establishes that repository. Legacy and unresponsive +listeners remain visible in the unfiltered inventory, but are never selected +from a process command-line hint. Each row reports the invoking, serving, and +installed versions, `restart_recommended`, `managed`, and its service label. +The invoking CLI marks a Board stale whenever its serving version differs from +the invoking version, even when the old process reports its own serving and +installed versions as equal. For one known Board, query the local status endpoint on its printed port: @@ -158,10 +162,15 @@ curl -fsS http://127.0.0.1:PORT/api/status | python3 -m json.tool In `/api/status`, inspect `board.version.serving_version`, `board.version.installed_version`, and `board.version.restart_recommended`. -`GET /api/identity` is the smaller identity and version probe. Do not infer the -version from the static HTML or from `lanes status`; neither is the v1.5.2 -version-verification contract. -When restart is recommended, stop the old Board process and start it again: +`GET /api/identity` is the smaller identity and version probe. `lanes status` +uses the same local inventory fields as `board list`. When a managed Board is +stale, copy its `restart_command`; for an identity-verified transient Board, +copy its `promotion_command` to install the persistent service. Both commands +use `--repo-path .`, so run them from the intended repository checkout, where the +service lifecycle validates the repository origin. + +When restart is recommended for a transient Board, stop the old process and +start it again: ```bash code-mower board serve --repo OWNER/REPO diff --git a/src/code_mower/board.py b/src/code_mower/board.py index b4dd3d7e..2083608a 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -10,6 +10,7 @@ import json import os import re +import shlex import signal import socket import stat @@ -5093,19 +5094,39 @@ def _probe_board_status(board: Mapping[str, Any], *, timeout: float = 0.75) -> d ) -def _inventory_next_action(boards: list[dict[str, Any]], available: bool) -> tuple[str, str]: +def _inventory_next_action( + boards: list[dict[str, Any]], available: bool, *, repo: str = "" +) -> tuple[str, str]: if not available: return "fix local process inspection", "install lsof or ss, or grant this shell permission to inspect local listeners" if not boards: - return "start Board", "run code-mower board serve --repo OWNER/REPO" + target = shlex.quote(repo) if repo else "OWNER/REPO" + return "start Board", f"code-mower board serve --repo {target}" stale = [board for board in boards if board.get("restart_recommended")] if stale: + managed_commands = [ + str(board.get("restart_command") or "") + for board in stale + if board.get("restart_command") + ] + if len(managed_commands) == 1 and len(stale) == 1: + return "restart stale managed Board", managed_commands[0] ports = ", ".join(str(board.get("port")) for board in stale) - return "restart stale Board", f"stop stale Board port(s) {ports}, then restart with code-mower board serve --repo OWNER/REPO" + return ( + "restart stale Board", + f"use the restart or promotion command on stale Board port(s) {ports}", + ) unresponsive = [board for board in boards if board.get("health") == "unresponsive"] if unresponsive: ports = ", ".join(str(board.get("port")) for board in unresponsive) return "inspect unresponsive Board", f"Board listener port(s) {ports} did not answer /api/identity" + promotion_commands = [ + str(board.get("promotion_command") or "") + for board in boards + if board.get("promotion_command") + ] + if len(promotion_commands) == 1 and len(boards) == 1: + return "promote transient Board", promotion_commands[0] return "use listed localhost URL", "open the Board URL for the repo you want" @@ -5237,8 +5258,37 @@ def _default_pid_alive(pid: int) -> bool: return True +def _board_service_command( + action: str, item: Mapping[str, Any], *, managed_service: Any = None +) -> str: + """Return one path-redacted command for a verified Board identity.""" + + repo = str(item.get("repo") or "") + port = item.get("port") + if not board_service.REPO_SLUG_RE.match(repo) or not isinstance(port, int): + return "" + host = str(getattr(managed_service, "host", "") or item.get("host") or DEFAULT_HOST) + service_command = ( + f"code-mower board service {action} --repo {shlex.quote(repo)} " + f"--repo-path . --host {shlex.quote(host)} --port {port}" + ) + if action == "restart": + service_command += " --replace" + arguments = getattr(managed_service, "arguments", None) + recording_enabled = item.get("recording_enabled") + if (arguments is not None and "--record-events" not in arguments) or recording_enabled is False: + service_command += " --no-record-events" + if action == "install": + stop_command = ( + f"code-mower board stop --repo {shlex.quote(repo)} --port {port} --yes" + ) + return f"{stop_command} && {service_command}" + return service_command + + def board_inventory_payload( *, + repo: str = "", show_local_paths: bool = False, command_runner: lane_status.CommandRunner = lane_status.run_command, status_probe: Any = _probe_board_status, @@ -5257,15 +5307,30 @@ def board_inventory_payload( item["service_supervision"] = ( ("confirmed" if supervision_confirmed else "unknown") if managed is not None else "" ) + item["identity_verified"] = False + item["invoking_version"] = CODE_MOWER_VERSION + item["serving_version"] = "" + item["installed_version"] = "" + item["restart_recommended"] = False probed = status_probe(item) if status_probe else {} if isinstance(probed, Mapping) and probed.get("schema") in {BOARD_IDENTITY_SCHEMA, lane_status.LANE_STATUS_SCHEMA}: board_meta = probed.get("board") if isinstance(probed.get("board"), Mapping) else {} version = board_meta.get("version") if isinstance(board_meta.get("version"), Mapping) else {} + identity_repo = str(probed.get("repo") or "") + serving_version = str(version.get("serving_version") or "") item["health"] = "ok" - item["repo"] = str(probed.get("repo") or item.get("repo") or "") - item["serving_version"] = str(version.get("serving_version") or "") + item["repo"] = identity_repo or str(item.get("repo") or "") + item["identity_verified"] = bool(board_service.REPO_SLUG_RE.match(identity_repo)) + item["serving_version"] = serving_version item["installed_version"] = str(version.get("installed_version") or "") - item["restart_recommended"] = bool(version.get("restart_recommended")) + recording = board_meta.get("recording") + if isinstance(recording, Mapping) and isinstance(recording.get("enabled"), bool): + item["recording_enabled"] = recording["enabled"] + item["restart_recommended"] = bool(version.get("restart_recommended")) or bool( + serving_version and serving_version != CODE_MOWER_VERSION + ) + if serving_version and serving_version != CODE_MOWER_VERSION: + item["stale_reason"] = "serving_version_differs_from_invoking_version" elif isinstance(probed, Mapping) and not probed.get("available", True): reason = str(probed.get("reason") or "") if reason.startswith("legacy_"): @@ -5278,12 +5343,42 @@ def board_inventory_payload( else: item["health"] = "unresponsive" item["status_message"] = str(probed.get("message") or "Board status unavailable") - item.setdefault("restart_recommended", False) + item["restart_recommended"] = False else: item["health"] = "unknown" - item.setdefault("restart_recommended", False) + if item["identity_verified"]: + if item["managed"] and item["restart_recommended"]: + item["restart_command"] = _board_service_command( + "restart", item, managed_service=managed + ) + elif not item["managed"]: + item["promotion_command"] = _board_service_command("install", item) boards.append(item) - next_action, next_detail = _inventory_next_action(boards, bool(local.get("available"))) + filter_payload: dict[str, Any] | None = None + if repo: + requested = repo.casefold() + verified = [ + item + for item in boards + if item.get("identity_verified") and str(item.get("repo") or "").casefold() == requested + ] + filter_payload = { + "repo": repo, + "matched": len(verified), + "excluded_identity_unverified": sum( + 1 for item in boards if not item.get("identity_verified") + ), + "excluded_other_repository": sum( + 1 + for item in boards + if item.get("identity_verified") + and str(item.get("repo") or "").casefold() != requested + ), + } + boards = verified + next_action, next_detail = _inventory_next_action( + boards, bool(local.get("available")), repo=repo + ) payload = { "schema": BOARD_INVENTORY_SCHEMA, "available": bool(local.get("available")), @@ -5292,6 +5387,10 @@ def board_inventory_payload( "next_action": next_action, "next_detail": next_detail, } + if filter_payload is not None: + payload["filter"] = filter_payload + if not boards and local.get("available"): + payload["message"] = f"no identity-verified Boards for {repo}" if not show_local_paths: _redact_inventory_paths(payload) return payload @@ -5303,7 +5402,8 @@ def render_inventory_text(payload: Mapping[str, Any]) -> str: lines.append(f"Inventory: unavailable ({payload.get('message') or 'local process inspection failed'})") boards = payload.get("boards") if isinstance(payload.get("boards"), list) else [] if not boards and payload.get("available"): - lines.append("Boards: none visible") + message = str(payload.get("message") or "").strip() + lines.append(f"Boards: none visible ({message})" if message else "Boards: none visible") for board_item in boards: repo = board_item.get("repo") or "unknown repo" version = board_item.get("serving_version") or "unknown version" @@ -5327,8 +5427,14 @@ def render_inventory_text(payload: Mapping[str, Any]) -> str: managed = "" lines.append( f"- {board_item.get('url') or 'localhost'} pid={board_item.get('pid')} " - f"repo={repo} version={version} health={health}{restart}{managed}{cwd}" + f"repo={repo} invoking={board_item.get('invoking_version') or 'unknown'} " + f"serving={version} installed={board_item.get('installed_version') or 'unknown'} " + f"health={health}{restart}{managed}{cwd}" ) + if board_item.get("restart_command"): + lines.append(f" restart: {board_item['restart_command']}") + if board_item.get("promotion_command"): + lines.append(f" promote: {board_item['promotion_command']}") lines.extend(["", f"Next: {payload.get('next_action') or 'inspect'}"]) if payload.get("next_detail"): lines.append(f"Detail: {payload['next_detail']}") @@ -6277,6 +6383,7 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="code-mower board") subparsers = parser.add_subparsers(dest="command", required=True) list_parser = subparsers.add_parser("list") + list_parser.add_argument("--repo", default="", help="only show identity-verified Boards for OWNER/REPO") list_parser.add_argument("--show-local-paths", action="store_true", help="show local cwd paths for debugging") list_parser.add_argument("--json", action="store_true") stop_parser = subparsers.add_parser("stop") @@ -6367,7 +6474,9 @@ def main(argv: list[str] | None = None) -> int: reset_parser.add_argument("--json", action="store_true") args = parser.parse_args(list(argv or ())) if args.command == "list": - payload = board_inventory_payload(show_local_paths=args.show_local_paths) + if args.repo and not board_service.REPO_SLUG_RE.match(args.repo): + parser.error("board list --repo must be OWNER/REPO") + payload = board_inventory_payload(repo=args.repo, show_local_paths=args.show_local_paths) output = json.dumps(payload, indent=2, sort_keys=True) + "\n" if args.json else render_inventory_text(payload) print(output, end="") return 0 if payload.get("available") else 1 diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index 403db5da..bccd0cc6 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -56,6 +56,7 @@ class LaneStatusUnavailable(RuntimeError): GitHubJsonRunner = Callable[[Sequence[str]], Any] CommandRunner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]] +BoardInventoryCollector = Callable[..., dict[str, Any]] def _text(value: Any) -> str: @@ -1014,6 +1015,21 @@ def collect_local_boards(command_runner: CommandRunner = _run_command) -> dict[s return {"available": True, "boards": boards, "message": ""} +def collect_board_inventory( + *, + command_runner: CommandRunner = _run_command, + show_local_paths: bool = False, +) -> dict[str, Any]: + """Collect the operator-grade Board inventory without a module import cycle.""" + + from . import board + + return board.board_inventory_payload( + command_runner=command_runner, + show_local_paths=show_local_paths, + ) + + # --- Board startup grace --------------------------------------------------- # # A snapshot taken moments after `code-mower board serve` starts can observe the @@ -1328,14 +1344,19 @@ def collect_status( jira_reader: tracker_queue.JiraQueueReader | None = None, tracker_links: Mapping[tuple[str, str, str], int] | None = None, checkout: str | Path | None = None, + board_inventory_collector: BoardInventoryCollector | None = None, ) -> dict[str, Any]: observed_at = now or _now() + inventory_collector = board_inventory_collector or collect_board_inventory report = { "schema": LANE_STATUS_SCHEMA, "repo": repo, "generated_at": observed_at.isoformat().replace("+00:00", "Z"), "remote": _remote(repo, gh_json_runner, observed_at, pr_limit, workflow_limit, stale_minutes, lineage_config), - "local_boards": collect_local_boards(command_runner), + "local_boards": inventory_collector( + command_runner=command_runner, + show_local_paths=show_local_paths, + ), "local_processes": collect_lane_processes(command_runner), "orchestrator_lease": session_lease.observe_lease(start=checkout, now=observed_at, repo=repo), } @@ -1437,7 +1458,26 @@ def render_text(report: Mapping[str, Any]) -> str: lines.append("Local boards:" if boards else f"Local boards: none ({message})") for board in boards: cwd = f" cwd={board['cwd']}" if board.get("cwd") else "" - lines.append(f"- localhost:{board['port']} pid={board['pid']} process={board['process']} confidence={board['confidence']}{cwd}") + url = board.get("url") or f"localhost:{board['port']}" + managed = ( + f" service={board.get('service_label')}" + if board.get("managed") + else " service=none (transient)" + ) + lines.append( + f"- {url} pid={board['pid']} " + f"process={board['process']} confidence={board['confidence']} " + f"repo={board.get('repo') or 'unknown'} " + f"invoking={board.get('invoking_version') or 'unknown'} " + f"serving={board.get('serving_version') or 'unknown'} " + f"installed={board.get('installed_version') or 'unknown'} " + f"restart_recommended={str(bool(board.get('restart_recommended'))).lower()}" + f"{managed}{cwd}" + ) + if board.get("restart_command"): + lines.append(f" restart: {board['restart_command']}") + if board.get("promotion_command"): + lines.append(f" promote: {board['promotion_command']}") lines.append("") processes = report["local_processes"].get("processes") or [] message = report["local_processes"].get("message") or "none visible" diff --git a/tests/test_board.py b/tests/test_board.py index 44497561..8d915428 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -21,6 +21,7 @@ import urllib.request from datetime import UTC, datetime, timedelta from pathlib import Path +from types import SimpleNamespace from unittest import SkipTest, TestCase, skipUnless from unittest.mock import patch from io import StringIO @@ -692,7 +693,11 @@ def status_probe(_board_item: dict[str, object]) -> dict[str, object]: }, } - payload = board.board_inventory_payload(command_runner=_command_runner, status_probe=status_probe) + payload = board.board_inventory_payload( + command_runner=_command_runner, + status_probe=status_probe, + service_probe=lambda: [], + ) self.assertEqual(payload["schema"], board.BOARD_INVENTORY_SCHEMA) self.assertTrue(payload["available"]) @@ -700,11 +705,161 @@ def status_probe(_board_item: dict[str, object]) -> dict[str, object]: self.assertEqual(payload["boards"][0]["url"], "http://127.0.0.1:5332/") self.assertEqual(payload["boards"][0]["serving_version"], "0.9.3b1") self.assertEqual(payload["boards"][0]["installed_version"], "0.9.4b1") + self.assertEqual(payload["boards"][0]["invoking_version"], board.CODE_MOWER_VERSION) self.assertTrue(payload["boards"][0]["restart_recommended"]) self.assertEqual(payload["boards"][0]["cwd"], lane_status.LOCAL_PATH_REDACTION) self.assertEqual(payload["next_action"], "restart stale Board") self.assertIn("port(s) 5332", payload["next_detail"]) + def test_board_inventory_repo_filter_fails_closed_without_verified_identity(self) -> None: + def command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: + if args[:4] == ["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"]: + return _completed( + "p101\ncPython\nn127.0.0.1:5332\n" + "p102\ncPython\nn127.0.0.1:5333\n" + "p103\ncPython\nn127.0.0.1:5334\n" + ) + commands = { + 101: "code-mower board serve --repo owner/repo", + 102: "code-mower board serve --repo owner/repo", + 103: "code-mower board serve --repo other/repo", + } + for pid, command in commands.items(): + if args == ["ps", "-p", str(pid), "-o", "command="]: + return _completed(command) + if args == ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"]: + return _completed(f"p{pid}\nn/private/{pid}\n") + return _completed("", returncode=1) + + def status_probe(item: dict[str, object]) -> dict[str, object]: + if item["port"] == 5332: + return { + "schema": board.BOARD_IDENTITY_SCHEMA, + "repo": "owner/repo", + "board": {"version": {"serving_version": board.CODE_MOWER_VERSION}}, + } + if item["port"] == 5333: + return {"available": False, "message": "connection refused"} + return { + "schema": board.BOARD_IDENTITY_SCHEMA, + "repo": "other/repo", + "board": {"version": {"serving_version": board.CODE_MOWER_VERSION}}, + } + + payload = board.board_inventory_payload( + repo="owner/repo", + command_runner=command_runner, + status_probe=status_probe, + service_probe=lambda: [], + ) + + self.assertEqual([item["port"] for item in payload["boards"]], [5332]) + self.assertTrue(payload["boards"][0]["identity_verified"]) + self.assertEqual(payload["filter"]["excluded_identity_unverified"], 1) + self.assertEqual(payload["filter"]["excluded_other_repository"], 1) + self.assertNotIn("/private/", json.dumps(payload)) + + missing = board.board_inventory_payload( + repo="missing/repo", + command_runner=command_runner, + status_probe=status_probe, + service_probe=lambda: [], + ) + self.assertEqual(missing["boards"], []) + self.assertIn("no identity-verified Boards for missing/repo", board.render_inventory_text(missing)) + + def test_invoking_cli_marks_old_managed_board_stale_and_gives_exact_restart(self) -> None: + service = SimpleNamespace( + label="ai.codemower.board.5332", + port=5332, + pid=123, + loaded=True, + host="127.0.0.1", + arguments=("code-mower", "board", "serve", "--record-events"), + ) + with patch("code_mower.board.CODE_MOWER_VERSION", "1.6.0"): + payload = board.board_inventory_payload( + command_runner=_command_runner, + status_probe=lambda _item: { + "schema": board.BOARD_IDENTITY_SCHEMA, + "repo": "owner/repo", + "board": { + "version": { + "serving_version": "1.5.2", + "installed_version": "1.5.2", + "restart_recommended": False, + } + }, + }, + service_probe=lambda: [service], + ) + + row = payload["boards"][0] + self.assertEqual(row["invoking_version"], "1.6.0") + self.assertTrue(row["restart_recommended"]) + self.assertEqual(row["stale_reason"], "serving_version_differs_from_invoking_version") + self.assertEqual( + row["restart_command"], + "code-mower board service restart --repo owner/repo --repo-path . " + "--host 127.0.0.1 --port 5332 --replace", + ) + self.assertEqual(payload["next_action"], "restart stale managed Board") + self.assertEqual(payload["next_detail"], row["restart_command"]) + + def test_transient_board_gives_copyable_promotion_command(self) -> None: + payload = board.board_inventory_payload( + command_runner=_command_runner, + status_probe=lambda _item: { + "schema": board.BOARD_IDENTITY_SCHEMA, + "repo": "owner/repo", + "board": { + "recording": {"enabled": False}, + "version": { + "serving_version": board.CODE_MOWER_VERSION, + "installed_version": board.CODE_MOWER_VERSION, + "restart_recommended": False, + } + }, + }, + service_probe=lambda: [], + ) + + row = payload["boards"][0] + self.assertFalse(row["managed"]) + self.assertEqual( + row["promotion_command"], + "code-mower board stop --repo owner/repo --port 5332 --yes && " + "code-mower board service install --repo owner/repo --repo-path . " + "--host 127.0.0.1 --port 5332 --no-record-events", + ) + self.assertIn(f"promote: {row['promotion_command']}", board.render_inventory_text(payload)) + + def test_board_list_passes_repository_filter_to_inventory(self) -> None: + payload = { + "schema": board.BOARD_INVENTORY_SCHEMA, + "available": True, + "message": "", + "boards": [], + "next_action": "start Board", + "next_detail": "code-mower board serve --repo owner/repo", + } + output = StringIO() + with ( + patch("code_mower.board.board_inventory_payload", return_value=payload) as inventory, + redirect_stdout(output), + ): + code = board.main(["list", "--repo", "owner/repo", "--json"]) + + self.assertEqual(code, 0) + inventory.assert_called_once_with(repo="owner/repo", show_local_paths=False) + self.assertEqual(json.loads(output.getvalue())["schema"], board.BOARD_INVENTORY_SCHEMA) + + def test_board_list_rejects_malformed_repository_filter(self) -> None: + with redirect_stderr(StringIO()), self.assertRaises(SystemExit) as caught: + board.main(["list", "--repo", "not-a-slug"]) + + self.assertEqual(caught.exception.code, 2) + def test_board_inventory_payload_handles_missing_process_permissions(self) -> None: # Neither probe can run: `lsof` is refused outright and the `ss` # fallback exits nonzero. `lsof` exiting 1 would not belong here -- that @@ -731,6 +886,8 @@ def test_board_inventory_payload_marks_unresponsive_listener_without_restart(sel self.assertEqual(payload["boards"][0]["health"], "unresponsive") self.assertFalse(payload["boards"][0]["restart_recommended"]) + self.assertEqual(payload["boards"][0]["serving_version"], "") + self.assertEqual(payload["boards"][0]["installed_version"], "") self.assertEqual(payload["next_action"], "inspect unresponsive Board") self.assertIn("did not answer", payload["next_detail"]) @@ -746,6 +903,7 @@ def test_board_inventory_payload_marks_legacy_listener_restart_recommended(self) self.assertEqual(payload["boards"][0]["health"], "legacy") self.assertTrue(payload["boards"][0]["restart_recommended"]) + self.assertEqual(payload["boards"][0]["invoking_version"], board.CODE_MOWER_VERSION) self.assertIn("legacy / restart recommended", payload["boards"][0]["status_message"]) self.assertEqual(payload["next_action"], "restart stale Board") rendered = board.render_inventory_text(payload) diff --git a/tests/test_lane_status.py b/tests/test_lane_status.py index 6ceb6f24..bffb1c33 100644 --- a/tests/test_lane_status.py +++ b/tests/test_lane_status.py @@ -10,7 +10,7 @@ from pathlib import Path from unittest import TestCase -from code_mower import lane_status +from code_mower import board, lane_status NOW = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) @@ -415,6 +415,9 @@ def command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: gh_json_runner=gh_json, command_runner=command_runner, now=NOW, + board_inventory_collector=lambda **kwargs: lane_status.collect_local_boards( + kwargs["command_runner"] + ), ) self.assertFalse(report["remote"]["available"]) @@ -460,6 +463,9 @@ def command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: gh_json_runner=gh_json, command_runner=command_runner, now=NOW, + board_inventory_collector=lambda **kwargs: lane_status.collect_local_boards( + kwargs["command_runner"] + ), ) self.assertFalse(report["remote"]["available"]) @@ -497,6 +503,9 @@ def command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: gh_json_runner=gh_json, command_runner=command_runner, now=NOW, + board_inventory_collector=lambda **kwargs: lane_status.collect_local_boards( + kwargs["command_runner"] + ), ) boards = report["local_boards"]["boards"] @@ -504,6 +513,47 @@ def command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: self.assertEqual([board["url"] for board in boards], ["http://127.0.0.1:5332/", "http://127.0.0.1:5333/"]) self.assertNotIn("/tmp/one", json.dumps(report)) + def test_lanes_status_uses_inventory_versions_service_identity_and_guidance(self) -> None: + inventory = { + "schema": board.BOARD_INVENTORY_SCHEMA, + "available": True, + "message": "", + "boards": [ + { + "port": 5332, + "pid": 123, + "process": "code-mower", + "confidence": "high", + "url": "http://127.0.0.1:5332/", + "repo": "owner/repo", + "invoking_version": "1.6.0", + "serving_version": "1.5.2", + "installed_version": "1.5.2", + "restart_recommended": True, + "managed": True, + "service_label": "ai.codemower.board.5332", + "service_supervision": "confirmed", + "restart_command": "code-mower board service restart --repo owner/repo --repo-path . --host 127.0.0.1 --port 5332 --replace", + } + ], + "next_action": "restart stale managed Board", + "next_detail": "restart it", + } + report = lane_status.collect_status( + lineage_config=policy({}), + repo="owner/repo", + gh_json_runner=lambda args: [], + command_runner=lambda args: _completed(""), + now=NOW, + board_inventory_collector=lambda **_kwargs: inventory, + ) + + self.assertEqual(report["local_boards"], inventory) + rendered = lane_status.render_text(report) + self.assertIn("invoking=1.6.0 serving=1.5.2 installed=1.5.2", rendered) + self.assertIn("service=ai.codemower.board.5332", rendered) + self.assertIn(f"restart: {inventory['boards'][0]['restart_command']}", rendered) + def test_collect_status_never_reports_no_active_lanes_when_github_unavailable( self, ) -> None: From b83c5fc0b85f24d18a92e87f8be8508831a60c31 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Mon, 21 Sep 2026 18:05:51 -0700 Subject: [PATCH 2/2] fix(board): preserve stale legacy recovery guidance --- src/code_mower/board.py | 10 ++++++++++ tests/test_board.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/src/code_mower/board.py b/src/code_mower/board.py index 2083608a..00a16dba 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -5112,6 +5112,16 @@ def _inventory_next_action( if len(managed_commands) == 1 and len(stale) == 1: return "restart stale managed Board", managed_commands[0] ports = ", ".join(str(board.get("port")) for board in stale) + if any( + not (board.get("restart_command") or board.get("promotion_command")) + for board in stale + ): + target = shlex.quote(repo) if repo else "OWNER/REPO" + return ( + "restart stale Board", + f"stop stale Board port(s) {ports}, then restart with " + f"code-mower board serve --repo {target}", + ) return ( "restart stale Board", f"use the restart or promotion command on stale Board port(s) {ports}", diff --git a/tests/test_board.py b/tests/test_board.py index 8d915428..cb1bd504 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -906,6 +906,11 @@ def test_board_inventory_payload_marks_legacy_listener_restart_recommended(self) self.assertEqual(payload["boards"][0]["invoking_version"], board.CODE_MOWER_VERSION) self.assertIn("legacy / restart recommended", payload["boards"][0]["status_message"]) self.assertEqual(payload["next_action"], "restart stale Board") + self.assertEqual( + payload["next_detail"], + "stop stale Board port(s) 5332, then restart with " + "code-mower board serve --repo OWNER/REPO", + ) rendered = board.render_inventory_text(payload) self.assertIn("health=legacy / restart recommended", rendered)