fix(cli): stream gateway logs in WebUI launcher

This commit is contained in:
Xubin Ren
2026-08-29 23:03:23 +08:00
parent c02f013b17
commit d7df2726de
4 changed files with 93 additions and 9 deletions
+1 -1
View File
@@ -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 |
+3 -1
View File
@@ -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:
+30 -3
View File
@@ -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]")
+59 -4
View File
@@ -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)")