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
9 changes: 8 additions & 1 deletion docs/rich-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<full-scope>/` 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.
Expand All @@ -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.
31 changes: 28 additions & 3 deletions src/fava_trails/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions tests/test_rich_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading