diff --git a/docs/rich-views.md b/docs/rich-views.md index 8f0d99c..1e49fba 100644 --- a/docs/rich-views.md +++ b/docs/rich-views.md @@ -19,6 +19,13 @@ Generation defaults to governed current records: approved records whose approved The local server binds to `127.0.0.1:4321` by default. Supply `--port` to choose another local port. To generate and serve in one step, use `rich-view serve` with `--trails-dir` and optional repeated `--scope` arguments. Omitting scope includes all discovered scopes. Manual regeneration replaces generated pages, including obsolete routes. The footer records the input scope and generation time; it never implies that the snapshot is live. A future file watcher or JJ change trigger can invoke the same generator, but automatic regeneration is not included. + +Astro can use its managed background server in an agent environment. The FAVA +serve command waits for HTTP readiness even when Astro's starter exits successfully, +then prints the supported `npm exec astro dev status` and `npm exec astro dev stop` +controls for the generated reader directory. A failed starter or an unavailable +server still fails; a foreground server retains Ctrl-C cleanup. + ## Read the dashboard The index and `/scopes//` dashboards include descendant records. Summary counts describe the included records: total thoughts, descendant scopes, active decisions, draft/proposed records, and superseded records. An active decision is an approved decision without an effective approved successor. A raw replacement link alone does not retire it. @@ -39,4 +46,4 @@ Astro's supported Unified processor handles Markdown. `rehype-sanitize` removes ## Acceptance still requiring an operator -Implementation and fixture/browser verification are separate from the five real context re-entry or review sessions in issue #54. The operator still needs to record whether the dashboard reduces raw-file opening or manual reconstruction, what remains hard to inspect, and a continue, revise, or stop decision. Synthetic QA sessions do not count toward that acceptance. Automated verification establishes functional behavior; it supplies no completed operator sessions or product-outcome decision. +Implementation and fixture/browser verification are separate from the five real context re-entry or review sessions in issue #54. The operator still needs to record whether the dashboard reduces raw-file opening or manual reconstruction, what remains hard to inspect, and a continue, revise, or stop decision. Synthetic QA sessions do not count toward that acceptance. Automated verification establishes functional behavior; it supplies no completed operator sessions or product-outcome decision. \ No newline at end of file diff --git a/src/fava_trails/cli.py b/src/fava_trails/cli.py index 53f3841..bc22b7f 100644 --- a/src/fava_trails/cli.py +++ b/src/fava_trails/cli.py @@ -1009,8 +1009,17 @@ def cmd_rich_view_serve(args: argparse.Namespace) -> int: print(f" URL: {url}") print(f" Reader: {output_dir}") print(f" Scopes: {scopes}") - print(" Press Ctrl-C to stop.") + if process.poll() == 0: + _print_reader_background_controls(output_dir) + return 0 + print(" Attached Astro servers stop with Ctrl-C.") _run_reader_process(process) + exit_code = process.poll() + if exit_code not in (None, 0): + raise subprocess.SubprocessError(f"Astro dev server exited with code {exit_code}") + # Agent-aware Astro versions may detach just after the readiness probe. + if exit_code == 0 and _reader_server_is_ready(url): + _print_reader_background_controls(output_dir) except KeyboardInterrupt: print("\nStopped local FAVA reader.") return 0 @@ -1064,8 +1073,9 @@ def _wait_for_reader_server(url: str, process: subprocess.Popen, *, timeout: flo deadline = time.monotonic() + timeout last_error: Exception | None = None while time.monotonic() < deadline: - if process.poll() is not None: - raise subprocess.SubprocessError("Astro dev server exited before becoming ready") + exit_code = process.poll() + if exit_code not in (None, 0): + raise subprocess.SubprocessError(f"Astro dev server exited before becoming ready (exit {exit_code})") try: with urllib.request.urlopen(url, timeout=0.5): return @@ -1075,6 +1085,21 @@ def _wait_for_reader_server(url: str, process: subprocess.Popen, *, timeout: flo raise TimeoutError(f"Timed out waiting for local FAVA reader at {url}: {last_error}") +def _reader_server_is_ready(url: str) -> bool: + try: + with urllib.request.urlopen(url, timeout=0.5): + return True + except (OSError, urllib.error.URLError): + return False + + +def _print_reader_background_controls(output_dir: Path) -> None: + print(" Astro is running its managed background server.") + print(f" In {output_dir}:") + print(" Status: npm exec astro dev status") + print(" Stop: npm exec astro dev stop") + + def _run_reader_process(process: subprocess.Popen) -> None: try: process.wait() diff --git a/tests/test_rich_views.py b/tests/test_rich_views.py index c68b2f0..8eb12f5 100644 --- a/tests/test_rich_views.py +++ b/tests/test_rich_views.py @@ -575,3 +575,83 @@ def _make_serve_args( "no_install": no_install, }, )() + + +def test_reader_readiness_waits_for_background_exit_zero_to_become_ready(): + import urllib.error + from contextlib import nullcontext + + process = Mock() + process.poll.return_value = 0 + with patch("fava_trails.cli.urllib.request.urlopen", side_effect=[urllib.error.URLError("starting"), nullcontext()]) as request: + with patch("fava_trails.cli.time.sleep"): + cli._wait_for_reader_server("http://127.0.0.1:4321/", process, timeout=1) + assert request.call_count == 2 + + +def test_reader_readiness_rejects_failed_startup_even_if_a_url_is_occupied(): + import subprocess + + process = Mock() + process.poll.return_value = 2 + with patch("fava_trails.cli.urllib.request.urlopen") as request: + with pytest.raises(subprocess.SubprocessError, match="exit 2"): + cli._wait_for_reader_server("http://127.0.0.1:4321/", process) + request.assert_not_called() + + +def test_reader_successful_exit_without_server_still_times_out(): + import urllib.error + + process = Mock() + process.poll.return_value = 0 + with patch("fava_trails.cli.urllib.request.urlopen", side_effect=urllib.error.URLError("unavailable")): + with patch("fava_trails.cli.time.monotonic", side_effect=[0, 0.1, 2]): + with patch("fava_trails.cli.time.sleep"): + with pytest.raises(TimeoutError, match="Timed out"): + cli._wait_for_reader_server("http://127.0.0.1:4321/", process, timeout=1) + + +def test_reader_background_start_reports_supported_status_and_stop(tmp_path, capsys): + trails_dir = tmp_path / "trails" + output_dir = tmp_path / "reader" + _write_thought(trails_dir, "mw/eng/alpha", "decisions", EXPLICIT_TITLE_ID, "# Alpha") + process = Mock() + process.poll.return_value = 0 + with patch("fava_trails.cli._ensure_reader_node_modules"), patch("fava_trails.cli._wait_for_reader_server"): + with patch("fava_trails.cli._run_reader_process") as run_process: + with patch("subprocess.Popen", return_value=process): + assert cmd_rich_view_serve(_make_serve_args(trails_dir=trails_dir, out=output_dir)) == 0 + run_process.assert_not_called() + output = capsys.readouterr().out + assert "managed background server" in output + assert "npm exec astro dev status" in output and "npm exec astro dev stop" in output + assert "Press Ctrl-C" not in output + + +def test_reader_detach_after_readiness_reports_background_controls(tmp_path, capsys): + trails_dir = tmp_path / "trails" + output_dir = tmp_path / "reader" + _write_thought(trails_dir, "mw/eng/alpha", "decisions", EXPLICIT_TITLE_ID, "# Alpha") + process = Mock() + process.poll.side_effect = [None, 0] + with patch("fava_trails.cli._ensure_reader_node_modules"), patch("fava_trails.cli._wait_for_reader_server"): + with patch("fava_trails.cli._run_reader_process") as run_process: + with patch("fava_trails.cli._reader_server_is_ready", return_value=True): + with patch("subprocess.Popen", return_value=process): + assert cmd_rich_view_serve(_make_serve_args(trails_dir=trails_dir, out=output_dir)) == 0 + run_process.assert_called_once_with(process) + assert "managed background server" in capsys.readouterr().out + + +def test_reader_failure_after_readiness_returns_failure(tmp_path, capsys): + trails_dir = tmp_path / "trails" + output_dir = tmp_path / "reader" + _write_thought(trails_dir, "mw/eng/alpha", "decisions", EXPLICIT_TITLE_ID, "# Alpha") + process = Mock() + process.poll.side_effect = [None, 2] + with patch("fava_trails.cli._ensure_reader_node_modules"), patch("fava_trails.cli._wait_for_reader_server"): + with patch("fava_trails.cli._run_reader_process"): + with patch("subprocess.Popen", return_value=process): + assert cmd_rich_view_serve(_make_serve_args(trails_dir=trails_dir, out=output_dir)) == 1 + assert "exited with code 2" in capsys.readouterr().err