diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f42836397..f600807e9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -139,7 +139,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. | Command | Description | |---|---| -| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` | +| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, open `http://127.0.0.1:8765`, and follow new gateway logs | | `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits | | `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates | | `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser | diff --git a/docs/webui.md b/docs/webui.md index e855dd53c..369791de6 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -23,7 +23,9 @@ one is missing, starts or joins the same on-demand gateway used by the native TUI, and opens the browser. With a fresh config, it can open before a model is configured so you can finish setup in **Settings → Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so -it is not available from other devices on your LAN. +it is not available from other devices on your LAN. While the launcher remains +attached, it mirrors new log output from that exact gateway instance in the +terminal without replaying older logs. After model setup, explicitly promote the shared gateway when you do not want to keep a client open: diff --git a/nanobot/cli/webui_support.py b/nanobot/cli/webui_support.py index 34c256615..1c0b88f70 100644 --- a/nanobot/cli/webui_support.py +++ b/nanobot/cli/webui_support.py @@ -457,27 +457,54 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None: console.print("[green]WebUI is attached to the shared gateway.[/green]") console.print("[dim]Closing the browser does not stop channels or automations.[/dim]") console.print( - "[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]" + "[dim]Following live gateway logs. Press Ctrl+C to detach; the gateway stops " + "only when the last local client exits.[/dim]" ) +def _read_new_gateway_logs(log_path: Path, offset: int) -> tuple[list[str], int]: + """Read gateway log lines appended after *offset*.""" + try: + if log_path.stat().st_size < offset: + offset = 0 + with log_path.open("r", encoding="utf-8", errors="replace") as handle: + handle.seek(offset) + lines = [line.rstrip("\r\n") for line in handle] + return lines, handle.tell() + except OSError: + return [], offset + + def _attach_to_background_gateway( runtime: "GatewayRuntime", *, poll_hook: Callable[[], None] | None = None, sleep: Callable[[float], None] = time.sleep, ) -> None: - """Keep a WebUI launcher attached without taking ownership of the gateway.""" + """Keep the launcher attached and mirror this gateway's new log output.""" _print_webui_foreground_lifecycle(attached=True) + status = runtime.status() + log_path = status.log_path try: - while runtime.status().running: + log_offset = log_path.stat().st_size + except OSError: + log_offset = 0 + try: + while status.running: + lines, log_offset = _read_new_gateway_logs(log_path, log_offset) + for line in lines: + console.print(line, markup=False, highlight=False) if poll_hook is not None: poll_hook() sleep(0.5) + status = runtime.status() except KeyboardInterrupt: console.print("\n[yellow]WebUI launcher detached.[/yellow]") return + lines, _ = _read_new_gateway_logs(log_path, log_offset) + for line in lines: + console.print(line, markup=False, highlight=False) console.print("[yellow]Gateway stopped.[/yellow]") diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 69f83badd..9eca6844d 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -2654,12 +2654,14 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_ assert seen["lease_release_wait_for_stop"] is False -def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None: +def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys, tmp_path: Path) -> None: stopped = False + log_path = tmp_path / "gateway.log" + log_path.touch() class _FakeRuntime: def status(self): - return SimpleNamespace(running=True) + return SimpleNamespace(running=True, log_path=log_path) def stop(self): nonlocal stopped @@ -2679,10 +2681,63 @@ def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None: assert "WebUI launcher detached" in rendered -def test_attach_to_background_gateway_checks_owned_sidecar() -> None: +def test_attach_to_background_gateway_follows_only_new_logs(capsys, tmp_path: Path) -> None: + log_path = tmp_path / "gateway.log" + log_path.write_text("historical log\n", encoding="utf-8") + polls = 0 + class _FakeRuntime: def status(self): - return SimpleNamespace(running=True) + return SimpleNamespace(running=True, log_path=log_path) + + def _append_then_interrupt(_seconds: float) -> None: + nonlocal polls + if polls == 0: + with log_path.open("a", encoding="utf-8") as handle: + handle.write("[websocket] live log\n") + polls += 1 + return + raise KeyboardInterrupt + + cli_webui_support._attach_to_background_gateway( + _FakeRuntime(), + sleep=_append_then_interrupt, + ) + + output = capsys.readouterr().out + assert "[websocket] live log" in output + assert "historical log" not in output + + +def test_read_new_gateway_logs_recovers_after_truncation(tmp_path: Path) -> None: + log_path = tmp_path / "gateway.log" + log_path.write_text("a much longer historical log line\n", encoding="utf-8") + offset = log_path.stat().st_size + log_path.write_text("fresh log\n", encoding="utf-8") + + lines, next_offset = cli_webui_support._read_new_gateway_logs(log_path, offset) + + assert lines == ["fresh log"] + assert next_offset == log_path.stat().st_size + + +def test_read_new_gateway_logs_tolerates_missing_file(tmp_path: Path) -> None: + lines, offset = cli_webui_support._read_new_gateway_logs( + tmp_path / "missing.log", + 0, + ) + + assert lines == [] + assert offset == 0 + + +def test_attach_to_background_gateway_checks_owned_sidecar(tmp_path: Path) -> None: + log_path = tmp_path / "gateway.log" + log_path.touch() + + class _FakeRuntime: + def status(self): + return SimpleNamespace(running=True, log_path=log_path) def sidecar_exited() -> None: raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")