diff --git a/docs-site/docs/quick-start.md b/docs-site/docs/quick-start.md index b50e2134b..2347affc1 100644 --- a/docs-site/docs/quick-start.md +++ b/docs-site/docs/quick-start.md @@ -51,6 +51,16 @@ the running WebUI and finishes by starting a new one in the foreground: press Ctrl-C to stop it, then run `raven web` to keep Raven running in the background. Your settings and conversations in `~/.raven` are kept. +### Update from the page + +When the bottom of the sidebar says a new version is available, click it and +confirm. Raven shows the download's progress, installs the new version, +restarts, and the page reloads by itself. Your settings and conversations are +kept. While a turn, a sub-agent or a question waiting for an answer is still +running, including in an IM channel, the page asks you to let it finish first. +A Raven started by hand with `raven gateway` or `raven web --foreground` has +nothing to restart it, so update that one from the command line below. + ### Update a one-line install Run the same installer again. It installs the newest release over the current diff --git a/docs-site/docs/quick-start.zh.md b/docs-site/docs/quick-start.zh.md index 47add31d7..3fa0540b3 100644 --- a/docs-site/docs/quick-start.zh.md +++ b/docs-site/docs/quick-start.zh.md @@ -48,6 +48,14 @@ cd Raven 最后在前台启动新的 WebUI:按 Ctrl-C 停止后,运行 `raven web` 即可让 Raven 在后台持续运行。`~/.raven` 中的设置和会话都会保留。 +### 在页面上更新 { #update-from-the-page } + +左栏底部出现新版本提示时,点它并确认即可。Raven 会显示下载进度,装好新版本后自动 +重启,页面也会自己刷新,设置和会话都会保留。如果还有回合、子智能体或待回答的问题 +在进行(包括 IM 渠道里的),页面会提示你等它们结束再升级。用 `raven gateway` 或 +`raven web --foreground` 手动启动的 Raven 没有守护进程来重启它,请用下面的命令行 +方式更新。 + ### 更新一行命令安装的 Raven { #update-a-one-line-install } 再运行一次同一个安装命令即可。它会在当前版本之上安装最新发布版,完成后重新启动 diff --git a/i18n/messages.json b/i18n/messages.json index 7750fed3e..c4a8f20a8 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -1344,8 +1344,8 @@ "zh": "Hi,我是 Raven Agent" }, "gui.composer_ph": { - "en": "Describe your task, or have Raven build an agent for it", - "zh": "描述你的任务,或让 Raven 搭一个智能体来做" + "en": "Describe your task", + "zh": "描述你的任务" }, "gui.compress.done": { "en": "Archived {n} messages - {before} to {after} tokens", @@ -4375,6 +4375,18 @@ "en": "new version available", "zh": "有新版本" }, + "gui.upg.phase.download": { + "en": "Downloading the new version: {progress}", + "zh": "正在下载新版本:{progress}" + }, + "gui.upg.phase.install": { + "en": "Installing the new version...", + "zh": "正在安装新版本…" + }, + "gui.upg.phase.restart": { + "en": "Restarting Raven...", + "zh": "正在重启 Raven…" + }, "gui.upg.reauth": { "en": "Raven restarted but this page is no longer signed in. Reopen Raven.", "zh": "Raven 已重启,但这个页面的登录失效了,请重新打开 Raven。" @@ -4383,6 +4395,18 @@ "en": "Upgrade Raven?", "zh": "升级 Raven?" }, + "gui.upg.why.busy": { + "en": "Raven is still working on a turn, a sub-agent or a question waiting for an answer, possibly in an IM channel. Upgrading restarts Raven and would cut that off, so let it finish first.", + "zh": "Raven 还在处理回合、子智能体或待回答的问题(可能在 IM 渠道里)。升级会重启 Raven 并打断它们,请等它们结束再升级。" + }, + "gui.upg.why.not_serving": { + "en": "This page is not being served by a running Raven, so it has nothing here to upgrade.", + "zh": "这个页面当前没有正在运行的 Raven 在托管,这里没有可以升级的对象。" + }, + "gui.upg.why.unsupervised": { + "en": "This Raven was started by hand in a terminal, so nothing would bring it back after an upgrade. Run raven upgrade there, then start Raven again.", + "zh": "这个 Raven 是在终端里手动启动的,升级后没有守护进程能把它重新拉起来。请在那个终端里运行 raven upgrade,然后重新启动 Raven。" + }, "gui.upg.working": { "en": "Upgrading Raven...", "zh": "正在升级 Raven…" @@ -8360,8 +8384,8 @@ "zh": "继续对话…" }, "gui.composer.ph_busy": { - "en": "Continue the conversation; it is sent once this turn ends", - "zh": "继续对话,输入会排队到本轮结束再发" + "en": "Continue the conversation; Raven will receive your new instruction directly", + "zh": "继续对话,Raven 将直接收到你的新指令" }, "gui.plus.add": { "en": "Add", diff --git a/raven/cli/commands.py b/raven/cli/commands.py index 0c7f83767..2acdfa918 100644 --- a/raven/cli/commands.py +++ b/raven/cli/commands.py @@ -73,6 +73,23 @@ def _saved_language() -> str: return "en" +def _can_open_a_browser() -> bool: + """Whether this machine has a browser to hand the page to. + + ``webbrowser.get()`` raises when nothing is registered, which is exactly the + question worth asking before anything is started: on a headless box the page + would come up on a URL the reader has no way to open, while the TUI is the + surface that still works there. + """ + import webbrowser + + try: + webbrowser.get() + except webbrowser.Error: + return False + return True + + @app.callback() def main( ctx: typer.Context, @@ -80,14 +97,26 @@ def main( ): """Raven - Agent Framework. - Bare ``raven`` (no subcommand) is equivalent to ``raven tui``: it runs the - same startup gate (onboard when provider+model are missing, else launch the - session) and then enters the native TUI. Both paths share the identical - pre-launch check by routing through the ``tui`` callback. + Bare ``raven`` (no subcommand) opens the page, the same as ``raven web``: + that is where the installer ends, so the command a reader types the next day + should not land them somewhere else. + + Where no browser can be opened the page would be a URL nobody can reach, so + the bare command runs the native TUI instead -- through the ``tui`` callback, + so the startup gate (onboard when provider+model are missing, else launch the + session) is identical either way. ``raven web`` and ``raven tui`` still name + a surface outright and are unaffected. """ i18n.set_language(_saved_language()) if ctx.invoked_subcommand is not None: return + if _can_open_a_browser(): + from raven.cli.serve_commands import _web + from raven.rpc.transports.ws import DEFAULT_PORT + + _web(DEFAULT_PORT) + return + console.print("No browser to open the page with; starting the terminal UI. `raven web` prints the URL instead.") from raven.cli.tui_commands import tui as _tui_entry # Delegate to the exact `raven tui` callback so the onboarding gate and diff --git a/raven/cli/gateway_commands.py b/raven/cli/gateway_commands.py index 43503623b..47e0007f7 100644 --- a/raven/cli/gateway_commands.py +++ b/raven/cli/gateway_commands.py @@ -309,6 +309,41 @@ def _retire_generation_watchers(swaps: "SwapCoordinator", agent) -> None: logger.exception("skill watcher stop failed during shutdown; continuing") +def _work_in_flight(agent, brokers, scheduler) -> dict | None: + """What a config swap or an upgrade restart would cut off, or None when idle. + + Both refuse on this one answer rather than each keeping a copy: turns in + flight, sub-agents still running, and questions waiting on any surface -- + the IM round-trip's broker and the page's. + """ + questions = sum(broker.pending_count() for broker in brokers if broker is not None) + subagents = agent.subagents.get_running_count() + in_flight = agent.is_processing or (scheduler is not None and scheduler.has_running()) + if in_flight or questions or subagents: + return {"subagents": subagents, "questions": questions} + return None + + +def _hand_page_the_gateway(stop, busy) -> None: + """Give the mounted page this gateway's own stop, busy check and supervisor. + + The page's upgrade restarts the whole process, so it needs the stop the + control plane uses and the busy check a swap refuses on. The supervisor is + trusted only as this process's own parent: web.json outlives the run that + wrote it, and a supervisor that is not ours would never bring this gateway + back. + """ + import os + + from raven.cli.serve_commands import _read_web_state + from raven.rpc.serve_control import SERVE + + supervisor = _read_web_state() + if supervisor is not None and supervisor != os.getppid(): + supervisor = None + SERVE.hand_over(stop, busy, supervisor) + + def register(app: typer.Typer) -> None: # noqa: C901 (cc 87: pre-existing, above the ceiling) """Attach the ``gateway`` group to ``app``: the daemon as the bare command, the control-plane verbs (reload / status / stop) as sub-commands.""" @@ -360,6 +395,17 @@ def gateway( # noqa: C901 (cc 86: pre-existing, above the ceiling) """Start the Raven gateway.""" if ctx.invoked_subcommand is not None: return + # Before the instance lock, not after: the wait inside can last the + # whole install, and holding the lock through it is what would stop the + # gateway that the finished install is supposed to bring back. + # + # `raven serve` has guarded this since the marker existed; the gateway + # is the surface `raven web` actually supervises, so a restart landing + # inside an upgrade window came up on a half-written environment and + # served the placeholder page for the rest of its life. + from raven.cli.serve_commands import _refuse_incomplete_install + + _refuse_incomplete_install("raven gateway") from raven.agent.loop.bundles import HostWiring, TurnPolicy from raven.agent.loop.recovery import limits_from_defaults from raven.agent.workdir import WorkdirPolicy, WorkdirResolver, validate_override @@ -783,6 +829,11 @@ async def _question_to_channel(frame: dict) -> None: except OSError as exc: logger.warning("page mount failed ({}); gateway continues without the page", exc) if page_mount is not None: + # On every mount, not once: a swap's teardown disarms the + # page and the next generation mounts it afresh. Both names + # are bound later in `run`, before any bind runs. Pinned by + # source in tests/test_cli_gateway_commands.py. + _hand_page_the_gateway(_request_stop, _busy) # pragma: no cover # The page is what shows the deck template gallery, so its # covers are drawn now, in the background, rather than on # the click that opens it; a no-op without the engine or @@ -1064,25 +1115,27 @@ def _status() -> dict: "page": {"mounted": page_mount is not None, "url": getattr(page_mount, "url", None)}, } - async def _shutdown() -> None: + def _request_stop() -> None: # pragma: no cover - closure state; pinned by source nonlocal shutdown_requested shutdown_requested = True if main_task is not None: - # One tick later, so the {ok: true} reply leaves before - # the teardown closes the control socket. + # One tick later, so a reply already queued -- the control + # plane's {ok: true}, the page's system.upgrade result -- + # leaves before the teardown closes its socket. asyncio.get_running_loop().call_soon(main_task.cancel) + async def _shutdown() -> None: # pragma: no cover - closure over run(); pinned by source + _request_stop() + + def _busy() -> dict | None: # pragma: no cover - closure over run(); logic in _work_in_flight + page_questions = page_mount.question_broker if page_mount is not None else None + return _work_in_flight(agent, [question_broker, page_questions], gw_scheduler) + async def _reload(force: bool) -> dict: - # A swap cancels every in-flight turn, sub-agent and pending - # question; refuse while there is work unless told to force. if not force: - questions = question_broker.pending_count() if question_broker is not None else 0 - if page_mount is not None: - questions += page_mount.question_broker.pending_count() - subagents = agent.subagents.get_running_count() - in_flight = agent.is_processing or (gw_scheduler is not None and gw_scheduler.has_running()) - if in_flight or questions or subagents: - return {"ok": False, "reason": "busy", "subagents": subagents, "questions": questions} + busy = _busy() # pragma: no cover + if busy is not None: # pragma: no cover + return {"ok": False, "reason": "busy", **busy} return await _request_swap() control_dispatcher = Dispatcher() diff --git a/raven/cli/serve_commands.py b/raven/cli/serve_commands.py index b57d3ec88..b02ef2abe 100644 --- a/raven/cli/serve_commands.py +++ b/raven/cli/serve_commands.py @@ -522,9 +522,13 @@ async def _serve_main(port: int, open_browser: bool) -> None: _UPGRADE_POLL_S = 1.0 -def _refuse_incomplete_install() -> None: +def _refuse_incomplete_install(label: str = "raven serve") -> None: """Stop before binding a port if this environment is not whole. + ``label`` names the caller in the refusal, because both surfaces that serve + the page reach this: `raven serve` standalone, and `raven gateway`, which is + the one `raven web` actually supervises. + ``build_app`` chooses the page route once, so a serve that starts while uv is still writing the environment does not merely start slowly -- it answers ``/`` with the placeholder until it is restarted, on an installation that @@ -542,7 +546,7 @@ def _refuse_incomplete_install() -> None: return if fault.reason == "upgrading": - typer.echo(f"raven serve: {fault.detail}; waiting for it to finish", err=True) + typer.echo(f"{label}: {fault.detail}; waiting for it to finish", err=True) deadline = time.monotonic() + _UPGRADE_WAIT_S while time.monotonic() < deadline: time.sleep(_UPGRADE_POLL_S) @@ -555,14 +559,14 @@ def _refuse_incomplete_install() -> None: # sound -- but not for this process to run. It already holds half of the # build that was replaced, and every import still to come would load off # disk from the other one. - typer.echo("raven serve: the upgrade finished; start Raven again to run it.", err=True) + typer.echo(f"{label}: the upgrade finished; start Raven again to run it.", err=True) elif fault.reason == "upgrading": typer.echo( - "raven serve: the upgrade has not finished; not starting on a half-written installation.", + f"{label}: the upgrade has not finished; not starting on a half-written installation.", err=True, ) else: - typer.echo(f"raven serve: {fault.detail}; not starting.", err=True) + typer.echo(f"{label}: {fault.detail}; not starting.", err=True) typer.echo( "Repair it by rerunning the installer: curl -fsSL https://raven.evermind.ai/install.sh | sh", err=True, diff --git a/raven/rpc/methods/system.py b/raven/rpc/methods/system.py index 711eedd1e..0d2d991d4 100644 --- a/raven/rpc/methods/system.py +++ b/raven/rpc/methods/system.py @@ -201,12 +201,16 @@ async def system_version(params: dict, *, send_frame: Any = None) -> dict: async def system_upgrade(params: dict) -> dict: - """`system.upgrade` — install the latest release and restart the gateway. - - Only meaningful inside `raven serve`: the helper waits for *this* process to - exit, so the caller must be the process being replaced. Returns as soon as - the helper owns the install; the shutdown is scheduled a beat later so this - reply reaches the client first. + """`system.upgrade` — install the latest release and restart what serves the page. + + Two hosts, two restarts. Under standalone `raven serve` the helper waits for + this process and brings back another `raven serve`. Under `raven gateway` -- + what `raven web` runs -- this process exits cleanly, its supervisor reads the + zero exit as "stand down" and exits too, and the helper waits for *that* + before it installs and brings back a fresh `raven web --supervise`: the same + gateway, IM channels included, on the same port. Returns as soon as the + helper owns the install; the shutdown is scheduled a beat later so this reply + reaches the client first. """ import asyncio @@ -220,14 +224,24 @@ def _refuse(reason: str, detail: str) -> ConfigValidationError: return ConfigValidationError(detail, data={"reason": reason, "detail": detail}) if SERVE.hosted_by_gateway: - # The page is mounted inside `raven gateway`, and the relaunch below - # would replace that whole process with a bare `raven serve` -- the IM - # channels would silently vanish. Restarting the gateway in place is a - # later phase; until then this refusal is the honest answer. - raise _refuse( - "gateway_hosted", - "This page is hosted by `raven gateway`. Run `raven upgrade`, then restart the gateway.", - ) + if SERVE.supervisor_pid is None: + # Nothing would bring this gateway back: started by hand, or under + # `raven web --foreground`. Starting a resident supervisor on the + # reader's behalf would change how their Raven runs, so say so. + raise _refuse( + "unsupervised", + "This Raven was started by hand, so nothing would bring it back after an upgrade. " + "Run `raven upgrade` in a terminal, then start Raven again.", + ) + busy = SERVE.busy() + if busy is not None: + # The restart cancels every turn, sub-agent and pending question on + # this engine -- IM channels included, which the page cannot see. + raise _refuse( + "busy", + "Raven is still working on a turn, a sub-agent or a pending question. " + "Upgrading restarts it and would cut that off; let it finish first.", + ) if not SERVE.running: raise _refuse("not_serving", "system.upgrade is only available while `raven serve` is running") @@ -248,9 +262,23 @@ def _refuse(reason: str, detail: str) -> ConfigValidationError: relaunch: list[str] | None = None extra_env: dict[str, str] = {} + parent_pid = os.getpid() raven_bin = plan.target.bin_dir / ("raven.exe" if os.name == "nt" else "raven") + if SERVE.hosted_by_gateway and (SERVE.port is None or not raven_bin.parent.is_dir()): + # Under the gateway there is no degraded path: without a relaunch the + # helper would install and leave nothing running at all. + raise _refuse("not_upgradable", "Could not find the raven executable to restart the gateway from.") if SERVE.port is not None and raven_bin.parent.is_dir(): - relaunch = [str(raven_bin), "serve", "--port", str(SERVE.port)] + if SERVE.hosted_by_gateway: + # Waited for as the parent, not this process: the supervisor's own + # cleanup runs after the gateway exits and removes web.json, and a + # new supervisor started before that would have its file removed + # out from under it -- inviting the next `raven web` to start a + # second one beside it. + relaunch = [str(raven_bin), "web", "--supervise", "--port", str(SERVE.port)] + parent_pid = SERVE.supervisor_pid or parent_pid + else: + relaunch = [str(raven_bin), "serve", "--port", str(SERVE.port)] # Same port, same credentials: the browser reconnects to the origin it # already has and presents the cookie it already holds, so it stays # signed in across the restart instead of hitting an auth wall. Both @@ -265,9 +293,10 @@ def _refuse(reason: str, detail: str) -> ConfigValidationError: try: spawn_detached_upgrade( plan, - parent_pid=os.getpid(), + parent_pid=parent_pid, relaunch=relaunch, extra_env=extra_env or None, + status_port=SERVE.port if relaunch is not None else None, ) except UpgradeError as exc: raise _refuse("handoff_failed", str(exc)) from exc diff --git a/raven/rpc/serve_control.py b/raven/rpc/serve_control.py index bfd95a538..8fe8f77a2 100644 --- a/raven/rpc/serve_control.py +++ b/raven/rpc/serve_control.py @@ -15,7 +15,7 @@ from __future__ import annotations import asyncio -from typing import Optional +from typing import Callable, Optional class ServeControl: @@ -24,36 +24,70 @@ def __init__(self) -> None: self.token: Optional[str] = None self.cookie: Optional[str] = None self._stop: Optional[asyncio.Event] = None + self._gateway_stop: Optional[Callable[[], None]] = None + self._gateway_busy: Optional[Callable[[], Optional[dict]]] = None + self.supervisor_pid: Optional[int] = None self.hosted_by_gateway = False def arm(self, port: int, token: str, cookie: str, stop: asyncio.Event) -> None: self.port, self.token, self.cookie, self._stop = port, token, cookie, stop def arm_hosted(self, port: int, token: str, cookie: str) -> None: - """The gateway hosts the page: record the endpoint facts, no stop event. + """The gateway hosts the page: record the endpoint facts. - ``system.upgrade``'s restart flow replaces a `raven serve` process with - another `raven serve`; ending the gateway's loop that way would drop the - IM channels and bring back the wrong process. So a hosted page carries - no shutdown handle, and upgrade refuses with its own reason instead - (see ``raven.rpc.methods.system.system_upgrade``). + No stop handle yet. The page is mounted before the gateway has built + the graceful stop it answers the control plane with, so that handle + arrives later through ``hand_over``. Until it does, a hosted page is + not ``running`` and ``system.upgrade`` refuses rather than guess. """ self.port, self.token, self.cookie = port, token, cookie self.hosted_by_gateway = True + def hand_over( + self, + stop: Callable[[], None], + busy: Callable[[], Optional[dict]], + supervisor_pid: Optional[int], + ) -> None: + """The gateway's own graceful stop, and its own answer to "is work running?". + + Not the page mount's stop event: that one ends the announcer and + nothing else, and the process has to exit for anything to replace it. + ``stop`` is the path `gateway.shutdown` takes -- the whole teardown + chain, IM channels included, ending in a zero exit that the `raven web` + supervisor reads as "stand down" rather than as a crash to undo. + ``busy`` is the check the gateway already refuses a config swap on. + + ``supervisor_pid`` is the `raven web` supervisor that will stand down + with this process, or None when nothing would bring it back (started by + hand, or under `raven web --foreground`). The gateway answers it because + only the entrance layer can read the supervisor's state file. + """ + self._gateway_stop, self._gateway_busy = stop, busy + self.supervisor_pid = supervisor_pid + def disarm(self) -> None: self.port = self.token = self.cookie = self._stop = None + self._gateway_stop = self._gateway_busy = None + self.supervisor_pid = None self.hosted_by_gateway = False @property def running(self) -> bool: - return self._stop is not None + return self._stop is not None or self._gateway_stop is not None + + def busy(self) -> Optional[dict]: + """What is still running that a restart would cut off, or None.""" + return self._gateway_busy() if self._gateway_busy is not None else None def request_shutdown(self) -> bool: - if self._stop is None: - return False - self._stop.set() - return True + if self._stop is not None: + self._stop.set() + return True + if self._gateway_stop is not None: + self._gateway_stop() + return True + return False SERVE = ServeControl() diff --git a/raven/rpc/transports/ws.py b/raven/rpc/transports/ws.py index 7d24c6bdc..df18e364b 100644 --- a/raven/rpc/transports/ws.py +++ b/raven/rpc/transports/ws.py @@ -23,7 +23,7 @@ from pathlib import Path from typing import Any -from aiohttp import WSMsgType, web +from aiohttp import WSCloseCode, WSMsgType, web from loguru import logger DEFAULT_PORT = 18792 @@ -152,6 +152,21 @@ async def broadcast(self, frame: dict[str, Any] | bytes) -> None: except Exception: self._sockets.discard(ws) + async def close_all(self, _app: object = None) -> None: + """Close every open page socket; registered as the app's ``on_shutdown``. + + aiohttp's cleanup waits for live handlers rather than closing websockets + itself, so an open tab held the whole process up for most of a minute + after it was told to stop -- and a page-started upgrade cannot begin + installing until this process has exited. Closed here, the page sees + the drop at once and goes into its reconnect. + """ + for ws in list(self._sockets): + try: + await ws.close(code=WSCloseCode.GOING_AWAY, message=b"server shutdown") + except Exception: + self._sockets.discard(ws) + # ---- auth helpers ------------------------------------------------------- def _authorized(self, request: web.Request) -> bool: @@ -580,6 +595,7 @@ def build_app( app.router.add_get("/file", gateway.handle_file) app.router.add_get("/knowledge/file", gateway.handle_knowledge_file) app.router.add_get("/rpc", gateway.handle_ws) + app.on_shutdown.append(gateway.close_all) app.router.add_get("/oauth/callback", handle_oauth_callback) from raven.a2a.gate import mount_gateway_face diff --git a/raven/updates/install_guard.py b/raven/updates/install_guard.py index f5275c52b..86e730315 100644 --- a/raven/updates/install_guard.py +++ b/raven/updates/install_guard.py @@ -57,7 +57,7 @@ def marker_path() -> Path: return raven_home() / MARKER_NAME -def write_marker(*, to_version: str | None = None, pid: int | None = None) -> Path: +def write_marker(*, to_version: str | None = None, pid: int | None = None, port: int | None = None) -> Path: """Record that the environment is about to be replaced. Written by the side that still has a working Raven, before the helper is @@ -70,6 +70,10 @@ def write_marker(*, to_version: str | None = None, pid: int | None = None) -> Pa payload["to_version"] = to_version if pid is not None: payload["pid"] = pid + if port is not None: + # The page's port, for the helper to answer on with progress once the + # process that held it has exited. + payload["port"] = port path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload), encoding="utf-8") return path diff --git a/raven/updates/upgrade.py b/raven/updates/upgrade.py index 5f79b1691..2fffb1c44 100644 --- a/raven/updates/upgrade.py +++ b/raven/updates/upgrade.py @@ -16,6 +16,7 @@ import subprocess import sys import tomllib +import zlib from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -66,6 +67,7 @@ class ToolInstallTarget: import shutil import subprocess import sys +import threading import time # How long to wait for the process being replaced to exit. Sized for a real @@ -78,6 +80,124 @@ class ToolInstallTarget: # little costs the user a Raven they have to restart by hand. PARENT_EXIT_TIMEOUT_S = 300 +# How long one size probe may take. Deliberately well under the socket +# default: the sizes are a courtesy, and four unanswered probes must not add +# minutes of silence to the very wait they exist to explain. +SIZE_TIMEOUT_S = 5 + +# How long a failure stays readable on the page's port before the Raven it +# replaced is brought back. The page polls about once a second, so this is +# several chances to see it; the ceiling is what a reader waits for the old +# Raven when nobody is looking. +STATUS_HOLD_S = 5 + +WAITING_PAGE = ( + b'' + b"Raven

Raven is upgrading...

" +) + + +class Progress: + # What the page shows while this helper is the only thing on its port. + # Written by the download loop, read by the status server's threads. + + def __init__(self, to_version): + self._lock = threading.Lock() + self._state = { + "upgrading": True, + "to": to_version, + "phase": "starting", + "done": 0, + "total": 0, + "rate": 0, + "message": None, + } + self._samples = [] + self.failure_seen = threading.Event() + + def set(self, **fields): + with self._lock: + self._state.update(fields) + + def advance(self, count): + now = time.monotonic() + with self._lock: + self._state["done"] += count + self._samples.append((now, self._state["done"])) + self._samples = [sample for sample in self._samples if sample[0] >= now - 3.0] + (first_at, first_done), (last_at, last_done) = self._samples[0], self._samples[-1] + if last_at > first_at: + self._state["rate"] = int((last_done - first_done) / (last_at - first_at)) + + def snapshot(self): + with self._lock: + state = dict(self._state) + if state["phase"] == "failed": + self.failure_seen.set() + return state + + +def recorded_port(): + # The port the page is on, written into the marker by the side that spawned + # this helper. Absent for the terminal, which has no page to answer. + if not MARKER_PATH: + return None + try: + with open(MARKER_PATH, encoding="utf-8") as handle: + port = json.load(handle).get("port") + except (OSError, ValueError, AttributeError): + return None + return port if isinstance(port, int) and 0 < port < 65536 else None + + +def serve_status(port, progress): + # Once the Raven being replaced has exited, nothing answers on the port its + # page is pointed at, and the page can only spin. This answers in its place + # until the new Raven is started: `/upgrade/status` with the progress, and + # 503 for everything else, so the page's "is it back?" probe keeps waiting + # instead of reloading onto this helper. + import http.server + + allowed = {f"127.0.0.1:{port}", f"localhost:{port}"} + + class Handler(http.server.BaseHTTPRequestHandler): + def reply(self, status, body, content_type): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + if status == 503: + self.send_header("Retry-After", "2") + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def do_GET(self): + # Only the page's own origin: a site the reader has open could + # otherwise rebind a host name of its own to this port. + if self.headers.get("Host") not in allowed: + self.reply(421, b"", "text/plain") + return + if self.path.split("?", 1)[0] == "/upgrade/status": + self.reply(200, json.dumps(progress.snapshot()).encode("utf-8"), "application/json") + return + # A reload during the upgrade lands here, not on the page. It asks + # again every few seconds, so it becomes the page once Raven is back. + self.reply(503, WAITING_PAGE, "text/html; charset=utf-8") + + do_HEAD = do_GET + + def log_message(self, *args): + pass + + try: + server = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler) + except OSError: + return None + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + # Where the spawning side recorded that this environment is being replaced. The # helper runs under `python -I` outside the environment it is rewriting, so it # cannot import raven to ask; the path is handed over instead. @@ -279,6 +399,12 @@ def run(argv=None): if parent_status != 0: return parent_status + # Only once the parent is gone, which is what frees the port, and only for a + # page: the terminal has no port and sees the progress on its own screen. + progress = Progress(latest_version) + port = recorded_port() if relaunch is not None else None + status_server = serve_status(port, progress) if port is not None else None + def run_uv(requirement, mode, plugin_list): command = [uv_path, "tool", "install"] + mode if constraints_path: @@ -313,7 +439,7 @@ def install(requirement, plugin_list): return 0 return run_uv(requirement, ["--force"], plugin_list) - def restart(): + def restart(failure=None): # Called on every path out of the install, not just the successful one. # Once the parent has exited, this helper holds the only handle to the # surface the user was sitting in -- so a failed install must still put @@ -321,6 +447,16 @@ def restart(): # the page the upgrade was clicked from cannot even reconnect to say # what went wrong, and the user is left with a dead window. # + # A failure is held on the page's port until the page has read it, or + # for STATUS_HOLD_S: one nobody saw reads, once the old Raven is back, as + # an upgrade that quietly did nothing. Then the port is let go, because + # the Raven started below binds it strictly. + if status_server is not None: + if failure is not None: + progress.set(phase="failed", message=failure) + progress.failure_seen.wait(STATUS_HOLD_S) + status_server.shutdown() + status_server.server_close() # The marker goes first, before anything is launched: the process being # started here reads it, and would wait out an upgrade that is over. clear_marker() @@ -344,6 +480,7 @@ def restart(): # is uninstalled. Installing raven alone would be exactly that loss, so no # list means no upgrade. import base64 + import pathlib import socket import tempfile import urllib.parse @@ -362,22 +499,119 @@ def authorize(url): return url return urllib.parse.urlunsplit(parts._replace(netloc=wheel_parts.netloc)) - def download(url, prefix): + def unwrap_credentials(url): # urllib takes `user:token@host` for the host name, so the credentials # move into the header and out of the URL before the request is made. parts = urllib.parse.urlsplit(url) - headers = {} - if parts.username: - creds = urllib.parse.unquote(parts.username) + ":" + urllib.parse.unquote(parts.password or "") - headers["Authorization"] = "Basic " + base64.b64encode(creds.encode("utf-8")).decode("ascii") - host = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}" - url = urllib.parse.urlunsplit(parts._replace(netloc=host)) + if not parts.username: + return url, {} + creds = urllib.parse.unquote(parts.username) + ":" + urllib.parse.unquote(parts.password or "") + host = parts.hostname if parts.port is None else f"{parts.hostname}:{parts.port}" + headers = {"Authorization": "Basic " + base64.b64encode(creds.encode("utf-8")).decode("ascii")} + return urllib.parse.urlunsplit(parts._replace(netloc=host)), headers + + def download(url, prefix): + url, headers = unwrap_credentials(url) fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt") request = urllib.request.Request(url, headers=headers) with os.fdopen(fd, "wb") as handle, urllib.request.urlopen(request) as response: handle.write(response.read()) return path + def asset_size(url): + url, headers = unwrap_credentials(url) + request = urllib.request.Request(url, headers=headers, method="HEAD") + with urllib.request.urlopen(request, timeout=SIZE_TIMEOUT_S) as response: + return int(response.headers.get("Content-Length") or 0) + + def announce_download(assets): + # uv draws one bar per package and no overall total, so an upgrade whose + # engines are tens of megabytes reads exactly like one that has stopped. + # The size up front is what separates a slow link from a dead one, and + # the line comes before the probes so a probe that hangs adds no silence. + # + # Flushed by hand: from the page, this output goes to web.log, a file, + # where Python buffers it by the block -- and uv writes to the same file + # straight away, so an unflushed manifest landed after uv's own lines. + noun = "package" if len(assets) == 1 else "packages" + print(f"Downloading {len(assets)} {noun} for Raven {latest_version}.", flush=True) + sizes = [] + for name, url in assets: + try: + sizes.append((name, asset_size(url))) + except Exception: + sizes.append((name, 0)) + if not any(size for _, size in sizes): + return [0 for _ in sizes] + width = max(len(name) for name, _ in sizes) + for name, size in sizes: + print(f" {name.ljust(width)} " + (f"{size / 1048576:6.1f} MiB" if size else " unknown")) + print(f" {'total'.ljust(width)} {sum(size for _, size in sizes) / 1048576:6.1f} MiB", flush=True) + return [size for _, size in sizes] + + def progress_line(): + state = progress.snapshot() + of = f" / {state['total'] / 1048576:.1f}" if state["total"] else "" + # No rate until there is one: "0 KB/s" on the first frame reads as a stall. + rate = f" {state['rate'] / 1024:.0f} KB/s" if state["rate"] else "" + return f" {state['done'] / 1048576:.1f}{of} MiB{rate} " + + def fetch(url, path): + url, headers = unwrap_credentials(url) + request = urllib.request.Request(url, headers=headers) + partial = path + ".part" + live = sys.stdout.isatty() + shown = 0.0 + with urllib.request.urlopen(request) as response, open(partial, "wb") as handle: + while True: + chunk = response.read(65536) + if not chunk: + break + handle.write(chunk) + progress.advance(len(chunk)) + if live and time.monotonic() - shown > 0.25: + shown = time.monotonic() + print("\r" + progress_line(), end="", flush=True) + os.replace(partial, path) + + def download_assets(assets, sizes): + # Fetched here rather than by uv, because here the bytes can be counted. + # uv draws no progress when it is not writing to a terminal -- the page's + # case, where its output is a log file -- and tens of megabytes with + # nothing moving on screen reads as a hang. uv is handed the local files + # and only resolves dependencies. All or nothing: if any asset cannot be + # fetched, uv gets the original URLs and downloads them itself, exactly + # as it did before this existed. + home = os.path.dirname(MARKER_PATH) if MARKER_PATH else None + root = os.path.join(home, "cache", "upgrade") if home else tempfile.mkdtemp(prefix="raven-upgrade-") + target = os.path.join(root, latest_version) + progress.set(phase="downloading", done=0, total=sum(sizes)) + local = {} + try: + os.makedirs(target, exist_ok=True) + for _name, url in assets: + path = os.path.join(target, urllib.parse.urlsplit(url).path.rsplit("/", 1)[-1]) + fetch(url, path) + local[url] = pathlib.Path(path).as_uri() + except Exception as exc: + if sys.stdout.isatty(): + print() + print(f"Warning: could not download the release directly ({exc}); uv will fetch it.", file=sys.stderr) + shutil.rmtree(target if home else root, ignore_errors=True) + progress.set(phase="installing") + return None + if sys.stdout.isatty(): + print() + print(f"Downloaded {progress.snapshot()['done'] / 1048576:.1f} MiB.", flush=True) + # Earlier versions' files go. This version's stay: the tool's receipt + # names them as where it was installed from. + if home: + for entry in os.listdir(root): + if entry != latest_version: + shutil.rmtree(os.path.join(root, entry), ignore_errors=True) + progress.set(phase="installing") + return local + def write_list(lines, prefix): fd, path = tempfile.mkstemp(prefix=prefix, suffix=".txt") with os.fdopen(fd, "w", encoding="utf-8") as handle: @@ -397,12 +631,9 @@ def write_list(lines, prefix): with open(download(authorize(release_dir + "/raven-plugins.txt"), "raven-plugins-"), encoding="utf-8") as handle: plugin_lines = [line.strip() for line in handle if line.strip() and not line.lstrip().startswith("#")] except Exception as exc: - print( - f"Unable to upgrade Raven: could not download the plugin list for {latest_version} ({exc}). " - "Nothing was changed; retry later.", - file=sys.stderr, - ) - restart() + why = f"could not download the plugin list for {latest_version} ({exc}). Nothing was changed; retry later." + print(f"Unable to upgrade Raven: {why}", file=sys.stderr) + restart(why) return 1 def authorize_line(line): @@ -410,6 +641,22 @@ def authorize_line(line): return name + sep + authorize(url) if sep else line plugin_lines = [authorize_line(line) for line in plugin_lines] + + assets = [("raven", wheel_url)] + for line in plugin_lines: + name, sep, url = line.partition(" @ ") + if sep: + assets.append((name.strip(), url.strip())) + local = download_assets(assets, announce_download(assets)) + wheel_source = wheel_url + if local is not None: + wheel_source = local[wheel_url] + + def relocate(line): + name, sep, url = line.partition(" @ ") + return f"{name.strip()} @ {local[url.strip()]}" if sep and url.strip() in local else line + + plugin_lines = [relocate(line) for line in plugin_lines] memory_lines = [line for line in plugin_lines if line.partition(" @ ")[0].strip() == "everos-memory"] # Two independent things can fail: the channel extras, and the plugins @@ -449,26 +696,26 @@ def authorize_line(line): else "these processes are still running from the current install and Windows cannot " "replace a running executable: pid " + ", ".join(survivors) ) - print( - "Unable to upgrade Raven: " + what + ". Stop them and run the upgrade again. Nothing was changed.", - file=sys.stderr, - ) - restart() + why = what + ". Stop them and run the upgrade again. Nothing was changed." + print("Unable to upgrade Raven: " + why, file=sys.stderr) + restart(why) return 1 for plugin_list, spec, losses in rungs: - requirement = wheel_url if spec == "raven" else f"{spec} @ {wheel_url}" + requirement = wheel_source if spec == "raven" else f"{spec} @ {wheel_source}" status = install(requirement, plugin_list) if status == 0: for loss in losses: print(f"Warning: {loss}", file=sys.stderr) break else: - print(f"Unable to upgrade Raven: uv exited with status {status}.", file=sys.stderr) - restart() + why = f"uv exited with status {status}." + print(f"Unable to upgrade Raven: {why}", file=sys.stderr) + restart(why) return status except OSError as exc: - print(f"Unable to upgrade Raven: could not run uv: {exc}.", file=sys.stderr) - restart() + why = f"could not run uv: {exc}." + print(f"Unable to upgrade Raven: {why}", file=sys.stderr) + restart(why) return 1 if current_version == latest_version: @@ -487,8 +734,15 @@ def authorize_line(line): def _upgrade_helper_bootstrap() -> str: - encoded = base64.b64encode(_UPGRADE_HELPER_SOURCE.encode("utf-8")).decode("ascii") - return f'exec(compile(__import__("base64").b64decode("{encoded}"),"","exec"))' + # Compressed because the whole helper travels on the command line, and + # Windows caps a command line at 32,767 characters: base64 of the plain + # source grew past that and the helper could not even be started there + # (WinError 206). zlib ships with every Python, `-I` included. + encoded = base64.b64encode(zlib.compress(_UPGRADE_HELPER_SOURCE.encode("utf-8"), 9)).decode("ascii") + return ( + 'exec(compile(__import__("zlib").decompress(__import__("base64").b64decode(' + f'"{encoded}")),"","exec"))' + ) def _version_key(value: str) -> tuple[int, int, int]: @@ -984,6 +1238,7 @@ def spawn_detached_upgrade( parent_pid: int, relaunch: list[str] | None = None, extra_env: dict[str, str] | None = None, + status_port: int | None = None, ) -> None: """Start the upgrade helper as a process that outlives this one. @@ -1004,7 +1259,7 @@ def spawn_detached_upgrade( # Written here rather than by the helper: the window this marker exists for # opens the moment the caller lets go of the port, which is before the # helper has run its first instruction. - env["RAVEN_UPGRADE_MARKER"] = str(_install_guard.write_marker(to_version=plan.release.version)) + env["RAVEN_UPGRADE_MARKER"] = str(_install_guard.write_marker(to_version=plan.release.version, port=status_port)) if extra_env: env.update(extra_env) diff --git a/tests/test_cli_gateway_commands.py b/tests/test_cli_gateway_commands.py index 89395aaa4..53fcd420f 100644 --- a/tests/test_cli_gateway_commands.py +++ b/tests/test_cli_gateway_commands.py @@ -114,6 +114,135 @@ def test_run_starts_the_litellm_warm_up_before_the_first_request() -> None: assert "warm_up_in_background()" in run_body +def test_every_page_mount_hands_the_gateway_stop_to_the_page() -> None: + """The page's upgrade restarts this whole process, so it needs the same + graceful stop and busy check the control plane uses. Handed over on every + mount, because a swap tears the page down (which disarms it) and mounts it + again; a hand-over done once at boot would be gone after the first reload. + ``run()`` blocks forever, so its source is pinned rather than executed.""" + import inspect + + from raven.cli import gateway_commands + + src = inspect.getsource(gateway_commands.register) + bind = src.split("async def _bind_generation():", 1)[1].split("def _request_stop() -> None:", 1)[0] + mount_at = bind.index("page_mount = await mount_page(") + hand_at = bind.index("_hand_page_the_gateway(_request_stop, _busy)") + assert mount_at < hand_at + + +class TestHandingThePageTheGateway: + """What the page's upgrade needs from the gateway it runs in.""" + + @pytest.fixture(autouse=True) + def _disarm(self): + from raven.rpc.serve_control import SERVE + + yield + SERVE.disarm() + + def test_its_own_supervisor_is_handed_over(self, monkeypatch) -> None: + import os + + from raven.cli import gateway_commands, serve_commands + from raven.rpc.serve_control import SERVE + + monkeypatch.setattr(serve_commands, "_read_web_state", lambda: os.getppid()) + SERVE.arm_hosted(18792, "tok", "cookie") + + gateway_commands._hand_page_the_gateway(lambda: None, lambda: None) + + assert SERVE.supervisor_pid == os.getppid() + assert SERVE.running + + def test_a_supervisor_that_is_not_its_parent_is_not_trusted(self, monkeypatch) -> None: + """web.json outlives the run that wrote it. A supervisor that is not + this process's parent would never bring the gateway back, so treating + it as one would install an upgrade and leave nothing running.""" + import os + + from raven.cli import gateway_commands, serve_commands + from raven.rpc.serve_control import SERVE + + monkeypatch.setattr(serve_commands, "_read_web_state", lambda: os.getppid() + 1) + SERVE.arm_hosted(18792, "tok", "cookie") + + gateway_commands._hand_page_the_gateway(lambda: None, lambda: None) + + assert SERVE.supervisor_pid is None + + def test_no_supervisor_at_all_is_handed_over_as_none(self, monkeypatch) -> None: + from raven.cli import gateway_commands, serve_commands + from raven.rpc.serve_control import SERVE + + monkeypatch.setattr(serve_commands, "_read_web_state", lambda: None) + SERVE.arm_hosted(18792, "tok", "cookie") + + gateway_commands._hand_page_the_gateway(lambda: None, lambda: None) + + assert SERVE.supervisor_pid is None + + +class TestWorkInFlight: + """The one answer a config swap and an upgrade restart both refuse on.""" + + def _agent(self, *, processing=False, subagents=0): + from types import SimpleNamespace + + return SimpleNamespace( + is_processing=processing, + subagents=SimpleNamespace(get_running_count=lambda: subagents), + ) + + def _broker(self, pending): + from types import SimpleNamespace + + return SimpleNamespace(pending_count=lambda: pending) + + def test_an_idle_gateway_reports_nothing(self) -> None: + from raven.cli.gateway_commands import _work_in_flight + + assert _work_in_flight(self._agent(), [self._broker(0), None], None) is None + + def test_a_question_waiting_on_either_surface_counts(self) -> None: + """The IM round-trip and the page each hold their own broker, and a + restart drops what is pending on both.""" + from raven.cli.gateway_commands import _work_in_flight + + assert _work_in_flight(self._agent(), [self._broker(1), self._broker(2)], None) == { + "subagents": 0, + "questions": 3, + } + + def test_a_running_sub_agent_counts(self) -> None: + from raven.cli.gateway_commands import _work_in_flight + + assert _work_in_flight(self._agent(subagents=2), [], None) == {"subagents": 2, "questions": 0} + + def test_a_turn_in_flight_counts_whether_the_agent_or_the_scheduler_holds_it(self) -> None: + from types import SimpleNamespace + + from raven.cli.gateway_commands import _work_in_flight + + assert _work_in_flight(self._agent(processing=True), [], None) is not None + scheduler = SimpleNamespace(has_running=lambda: True) + assert _work_in_flight(self._agent(), [], scheduler) is not None + + +def test_the_swap_and_the_upgrade_refuse_on_one_busy_answer() -> None: + """Both cut off in-flight turns, sub-agents and pending questions. Two + copies of that check would drift, and the one that drifted would restart + over work the other would have protected.""" + import inspect + + from raven.cli import gateway_commands + + src = inspect.getsource(gateway_commands.register) + reload_body = src.split("async def _reload(force: bool) -> dict:", 1)[1].split("control_dispatcher", 1)[0] + assert "_busy()" in reload_body + assert "pending_count()" not in reload_body + + def test_run_warms_the_deck_template_covers_once_the_page_is_mounted() -> None: """`raven web` is `raven gateway --page-port` underneath, so the gallery's covers are drawn from here, after the page mount, not from `raven serve` @@ -164,6 +293,88 @@ def _raise(now: float): assert "4242" in r.stdout +class TestTheGatewayWillNotServeAHalfWrittenInstall: + """`raven web` supervises the gateway, and the supervisor restarts it the + moment it exits -- including during an upgrade, when uv has removed the old + environment and not yet written the new one. `build_app` picks the page + route once, so a gateway that came up in that window answers `/` with the + placeholder for the rest of its life, on an installation that was sound + seconds later.""" + + def test_it_refuses_instead_of_serving(self, monkeypatch) -> None: + from raven.cli import serve_commands + from raven.updates import install_guard as _install_guard + + monkeypatch.setattr( + _install_guard, + "inspect_install", + lambda: _install_guard.InstallFault("incomplete", "this installation is missing the packaged page"), + ) + + r = runner.invoke(app, ["gateway"]) + + assert r.exit_code == serve_commands.INCOMPLETE_INSTALL_EXIT + + def test_it_refuses_before_it_takes_the_instance_lock(self, monkeypatch) -> None: + """The wait inside the guard can last the whole install. Holding the + lock through it would block the gateway the finished install is meant + to bring back.""" + from raven.gateway import lock as _gateway_lock + from raven.updates import install_guard as _install_guard + + monkeypatch.setattr( + _install_guard, + "inspect_install", + lambda: _install_guard.InstallFault("incomplete", "this installation is missing the packaged page"), + ) + + def unreachable(**_kwargs): + raise AssertionError("the gateway took the lock on a half-written installation") + + monkeypatch.setattr(_gateway_lock, "acquire", unreachable) + + r = runner.invoke(app, ["gateway"]) + + assert r.exit_code != 0 + + def test_the_refusal_names_the_gateway_not_serve(self, monkeypatch) -> None: + """Both surfaces reach the same guard, and a reader told to restart + `raven serve` when the gateway refused would restart the wrong one.""" + from raven.updates import install_guard as _install_guard + + monkeypatch.setattr( + _install_guard, + "inspect_install", + lambda: _install_guard.InstallFault("incomplete", "this installation is missing the packaged page"), + ) + + r = runner.invoke(app, ["gateway"]) + + # The module runner mixes the streams, so the refusal is read off output. + assert "raven gateway:" in r.output + assert "raven serve:" not in r.output + + def test_a_sound_install_is_not_stopped_here(self, monkeypatch) -> None: + """The guard must be invisible on every normal start.""" + from raven.gateway import lock as _gateway_lock + from raven.updates import install_guard as _install_guard + + monkeypatch.setattr(_install_guard, "inspect_install", lambda: None) + reached: list[bool] = [] + + def _raise(now: float): + reached.append(True) + raise _gateway_lock.GatewayAlreadyRunningError( + _gateway_lock.LockInfo(pid=4242, started_at=0.0, config_path="/tmp/whatever.json") + ) + + monkeypatch.setattr(_gateway_lock, "acquire", _raise) + + runner.invoke(app, ["gateway"]) + + assert reached == [True] + + def test_gateway_log_config_defaults() -> None: from raven.config.schema import GatewayConfig diff --git a/tests/test_cli_tui_commands.py b/tests/test_cli_tui_commands.py index e7e9e8710..fc707c612 100644 --- a/tests/test_cli_tui_commands.py +++ b/tests/test_cli_tui_commands.py @@ -827,7 +827,7 @@ def test_every_interactive_spawn_names_the_binary(monkeypatch: pytest.MonkeyPatc def test_bare_raven_passes_a_plain_value_for_every_tui_option(monkeypatch: pytest.MonkeyPatch) -> None: - """Bare ``raven`` calls ``tui`` as a plain function, so typer never fills + """On the fallback path, bare ``raven`` calls ``tui`` as a plain function, so typer never fills its defaults: any option the call site forgets arrives as an ``OptionInfo`` sentinel instead. That is not inert -- ``--home``'s sentinel is truthy, so ``load_runtime_config`` assigns it over ``agents.defaults.workspace`` and @@ -854,6 +854,7 @@ def recorder(_ctx, **kwargs: Any) -> None: received.update(kwargs) monkeypatch.setattr(tui_commands, "tui", recorder) + monkeypatch.setattr(commands, "_can_open_a_browser", lambda: False) r = CliRunner(mix_stderr=False).invoke(commands.app, []) @@ -862,6 +863,42 @@ def recorder(_ctx, **kwargs: Any) -> None: assert [name for name, value in received.items() if isinstance(value, OptionInfo)] == [] +def test_a_machine_with_no_browser_is_told_apart_before_anything_starts(monkeypatch: pytest.MonkeyPatch) -> None: + """`webbrowser.get()` raising is the whole question: no browser registered + means the page would come up on a URL nobody on this machine can open.""" + import webbrowser + + from raven.cli import commands + + def none_registered(): + raise webbrowser.Error("could not locate runnable browser") + + monkeypatch.setattr(webbrowser, "get", none_registered) + assert commands._can_open_a_browser() is False + + monkeypatch.setattr(webbrowser, "get", lambda: object()) + assert commands._can_open_a_browser() is True + + +def test_bare_raven_opens_the_page_when_a_browser_is_there(monkeypatch: pytest.MonkeyPatch) -> None: + """The installer ends in `raven web`, so the bare command has to land on the + same surface rather than a second, quieter one.""" + from typer.testing import CliRunner + + from raven.cli import commands, serve_commands, tui_commands + from raven.rpc.transports.ws import DEFAULT_PORT + + opened: list[int] = [] + monkeypatch.setattr(commands, "_can_open_a_browser", lambda: True) + monkeypatch.setattr(serve_commands, "_web", lambda port, **kwargs: opened.append(port)) + monkeypatch.setattr(tui_commands, "tui", lambda *a, **k: pytest.fail("the TUI ran with a browser available")) + + r = CliRunner(mix_stderr=False).invoke(commands.app, []) + + assert r.exit_code == 0, r.output + assert opened == [DEFAULT_PORT] + + # --------------------------------------------------------------------------- # attaching to a running gateway (G2): which engine a launch gets # --------------------------------------------------------------------------- diff --git a/tests/test_rpc_serve_control.py b/tests/test_rpc_serve_control.py new file mode 100644 index 000000000..16bb5c100 --- /dev/null +++ b/tests/test_rpc_serve_control.py @@ -0,0 +1,66 @@ +"""Tests for the handle a running page host gives ``system.upgrade``.""" + +from __future__ import annotations + +import asyncio + +from raven.rpc.serve_control import ServeControl + + +def test_a_hosted_page_is_not_running_until_the_gateway_hands_over_its_stop() -> None: + """The page is mounted before the gateway has built its graceful stop, so + in that gap there is nothing that could end the process.""" + ctl = ServeControl() + ctl.arm_hosted(18792, "tok", "cookie") + + assert ctl.hosted_by_gateway + assert not ctl.running + assert ctl.request_shutdown() is False + + +def test_the_hand_over_makes_the_gateway_stoppable_through_its_own_stop() -> None: + stopped: list[bool] = [] + ctl = ServeControl() + ctl.arm_hosted(18792, "tok", "cookie") + ctl.hand_over(lambda: stopped.append(True), lambda: None, 4242) + + assert ctl.running + assert ctl.supervisor_pid == 4242 + assert ctl.request_shutdown() is True + assert stopped == [True] + + +def test_busy_is_the_gateway_answer_and_nothing_when_there_is_no_gateway() -> None: + ctl = ServeControl() + assert ctl.busy() is None + + ctl.arm_hosted(18792, "tok", "cookie") + ctl.hand_over(lambda: None, lambda: {"subagents": 2, "questions": 0}, None) + + assert ctl.busy() == {"subagents": 2, "questions": 0} + + +def test_disarm_forgets_the_gateway_handles_too() -> None: + """A swap tears the page down and mounts it again. A stop left over from + the torn-down generation would end the process on behalf of a page that + no longer exists.""" + ctl = ServeControl() + ctl.arm_hosted(18792, "tok", "cookie") + ctl.hand_over(lambda: None, lambda: {"subagents": 1, "questions": 0}, 4242) + + ctl.disarm() + + assert not ctl.running + assert not ctl.hosted_by_gateway + assert ctl.busy() is None + assert ctl.supervisor_pid is None + + +def test_standalone_serve_still_stops_through_its_event() -> None: + stop = asyncio.Event() + ctl = ServeControl() + ctl.arm(18792, "tok", "cookie", stop) + + assert ctl.running + assert ctl.request_shutdown() is True + assert stop.is_set() diff --git a/tests/test_rpc_system.py b/tests/test_rpc_system.py index 88629028b..c7a573c7c 100644 --- a/tests/test_rpc_system.py +++ b/tests/test_rpc_system.py @@ -213,22 +213,149 @@ async def test_upgrade_refuses_outside_serve(): assert excinfo.value.data["reason"] == "not_serving" -async def test_upgrade_refuses_on_a_gateway_hosted_page(): - """The relaunch flow replaces this process with a bare `raven serve`. On a - page the gateway hosts that would silently drop the IM channels, so until - the gateway can restart in place the page gets a structured refusal the - front end already renders (reason + human sentence).""" - from raven.cli.serve_commands import SERVE - from raven.rpc.methods.system import system_upgrade +def _plan(tmp_path, monkeypatch): + from raven.updates import upgrade as upgrade_commands - SERVE.arm_hosted(18792, "tok", "cookie") - try: - with pytest.raises(ConfigValidationError) as excinfo: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + plan = upgrade_commands.UpgradePlan( + current_version="0.1.3", + release=upgrade_commands.ReleaseInfo(version="0.1.4", wheel_url="https://example.invalid/x.whl"), + target=upgrade_commands.ToolInstallTarget(tool_dir=tmp_path / "tools", bin_dir=bin_dir), + ) + monkeypatch.setattr(upgrade_commands, "plan_upgrade", lambda: plan) + spawned: dict[str, object] = {} + monkeypatch.setattr( + upgrade_commands, + "spawn_detached_upgrade", + lambda p, **kw: spawned.update({"plan": p, **kw}), + ) + return bin_dir, spawned + + +class TestUpgradingFromAGatewayHostedPage: + """`raven web` runs `raven gateway`, so this is the page nearly every reader + has. The old relaunch brought back a bare `raven serve`, which builds no + ChannelManager: the IM channels would have vanished without a word. The + restart now goes through the supervisor instead.""" + + async def test_it_brings_back_the_supervised_gateway_not_a_bare_serve(self, monkeypatch, tmp_path): + import asyncio + + from raven.cli.serve_commands import SERVE + from raven.rpc.methods.system import system_upgrade + + bin_dir, spawned = _plan(tmp_path, monkeypatch) + stopped: list[bool] = [] + SERVE.arm_hosted(18792, "tok", "cookie") + SERVE.hand_over(lambda: stopped.append(True), lambda: None, 4242) + try: + result = await system_upgrade({}) + assert result["status"] == "started" + # The reply must land before the gateway goes down. + assert stopped == [] + await asyncio.sleep(0.9) + assert stopped == [True] + finally: + SERVE.disarm() + + assert spawned["relaunch"] == [str(bin_dir / "raven"), "web", "--supervise", "--port", "18792"] + assert spawned["extra_env"] == { + "RAVEN_SERVE_PORT_STRICT": "1", + "RAVEN_SERVE_TOKEN": "tok", + "RAVEN_SERVE_COOKIE": "cookie", + } + + async def test_the_helper_waits_for_the_supervisor_not_the_gateway(self, monkeypatch, tmp_path): + """The supervisor's own cleanup runs after the gateway exits and removes + web.json. A new supervisor started before that finishes has its file + removed from under it, and the next `raven web` starts a second one.""" + from raven.cli.serve_commands import SERVE + from raven.rpc.methods.system import system_upgrade + + _bin_dir, spawned = _plan(tmp_path, monkeypatch) + SERVE.arm_hosted(18792, "tok", "cookie") + SERVE.hand_over(lambda: None, lambda: None, 4242) + try: await system_upgrade({}) - finally: - SERVE.disarm() - assert excinfo.value.data["reason"] == "gateway_hosted" - assert "raven gateway" in excinfo.value.data["detail"] + finally: + SERVE.disarm() + + assert spawned["parent_pid"] == 4242 + + async def test_it_refuses_a_gateway_nothing_would_bring_back(self, monkeypatch, tmp_path): + """Started by hand, or under `raven web --foreground`: no supervisor. + Installing would end with nothing running at all.""" + from raven.cli.serve_commands import SERVE + from raven.rpc.methods.system import system_upgrade + + _bin_dir, spawned = _plan(tmp_path, monkeypatch) + SERVE.arm_hosted(18792, "tok", "cookie") + SERVE.hand_over(lambda: pytest.fail("stopped an unsupervised gateway"), lambda: None, None) + try: + with pytest.raises(ConfigValidationError) as excinfo: + await system_upgrade({}) + finally: + SERVE.disarm() + + assert excinfo.value.data["reason"] == "unsupervised" + assert spawned == {} + + async def test_it_refuses_while_work_the_page_cannot_see_is_running(self, monkeypatch, tmp_path): + """The page checks its own turn. An IM turn or a sub-agent on the same + engine is invisible to it, and the restart would cut it off.""" + from raven.cli.serve_commands import SERVE + from raven.rpc.methods.system import system_upgrade + + _bin_dir, spawned = _plan(tmp_path, monkeypatch) + SERVE.arm_hosted(18792, "tok", "cookie") + SERVE.hand_over( + lambda: pytest.fail("stopped a busy gateway"), + lambda: {"subagents": 1, "questions": 0}, + 4242, + ) + try: + with pytest.raises(ConfigValidationError) as excinfo: + await system_upgrade({}) + finally: + SERVE.disarm() + + assert excinfo.value.data["reason"] == "busy" + assert spawned == {} + + async def test_it_refuses_when_there_is_nothing_to_relaunch_from(self, monkeypatch, tmp_path): + """Under the gateway there is no degraded path: without a relaunch the + helper would install and leave nothing running.""" + from raven.cli.serve_commands import SERVE + from raven.rpc.methods.system import system_upgrade + + bin_dir, spawned = _plan(tmp_path, monkeypatch) + bin_dir.rmdir() + SERVE.arm_hosted(18792, "tok", "cookie") + SERVE.hand_over(lambda: pytest.fail("stopped with nothing to relaunch"), lambda: None, 4242) + try: + with pytest.raises(ConfigValidationError) as excinfo: + await system_upgrade({}) + finally: + SERVE.disarm() + + assert excinfo.value.data["reason"] == "not_upgradable" + assert spawned == {} + + async def test_a_page_mounted_before_the_hand_over_does_not_upgrade(self): + """The mount happens before the gateway has built its stop. A request in + that gap must not be treated as upgradable.""" + from raven.cli.serve_commands import SERVE + from raven.rpc.methods.system import system_upgrade + + SERVE.arm_hosted(18792, "tok", "cookie") + try: + with pytest.raises(ConfigValidationError) as excinfo: + await system_upgrade({}) + finally: + SERVE.disarm() + + assert excinfo.value.data["reason"] in {"unsupervised", "not_serving"} async def test_upgrade_refuses_when_the_install_cannot_self_upgrade(monkeypatch: pytest.MonkeyPatch): diff --git a/tests/test_rpc_transport.py b/tests/test_rpc_transport.py index 6708166f1..a9a514248 100644 --- a/tests/test_rpc_transport.py +++ b/tests/test_rpc_transport.py @@ -748,3 +748,58 @@ async def test_a_page_with_no_judgement_of_its_sources_says_nothing(tmp_path: Pa assert "X-Raven-Page-Behind" not in (await client.get("/")).headers finally: await client.close() + + +async def test_stopping_the_server_closes_an_open_page_instead_of_waiting_on_it() -> None: + """aiohttp's cleanup waits for live handlers rather than closing websockets, + so a tab left open held the whole process up for most of a minute after it + was told to stop. A page-started upgrade cannot begin installing until this + process exits, so that minute was dead air on the reader's screen.""" + import asyncio + + from aiohttp import WSCloseCode, WSMsgType + + gateway = WsGateway() + server = TestServer(build_app(gateway, None)) + client = TestClient(server) + await client.start_server() + gateway.port = server.port + ws = await client.ws_connect("/rpc", headers={"X-Raven-Token": gateway.session_token}) + # Reading already when the server stops, as a page always is: the close + # handshake needs the client to answer, and a client that only starts + # reading afterwards sees the transport drop (1006) instead of the close. + reading = asyncio.ensure_future(ws.receive()) + try: + await asyncio.wait_for(server.close(), timeout=5) + message = await asyncio.wait_for(reading, timeout=5) + finally: + reading.cancel() + await client.close() + + assert message.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED) + assert ws.close_code == WSCloseCode.GOING_AWAY + + +async def test_a_socket_that_will_not_close_does_not_stop_the_others() -> None: + """One tab whose socket errors on close must not keep the rest open, and is + forgotten rather than retried on every later broadcast.""" + + class Socket: + def __init__(self, fails: bool) -> None: + self.fails, self.closed_with = fails, None + + async def close(self, *, code, message): + if self.fails: + raise ConnectionResetError("peer went away") + self.closed_with = code + + from aiohttp import WSCloseCode + + gateway = WsGateway() + broken, fine = Socket(fails=True), Socket(fails=False) + gateway._sockets.update({broken, fine}) + + await gateway.close_all() + + assert fine.closed_with == WSCloseCode.GOING_AWAY + assert broken not in gateway._sockets diff --git a/tests/test_updates_upgrade.py b/tests/test_updates_upgrade.py index 30c14f0ab..3ae2d07cb 100644 --- a/tests/test_updates_upgrade.py +++ b/tests/test_updates_upgrade.py @@ -671,6 +671,14 @@ def test_uv_tool_target_rejects_malformed_target_fields( upgrade_commands._uv_tool_target() +class _Response(io.BytesIO): + """A body with the headers a size probe reads off it.""" + + def __init__(self, payload: bytes, headers: dict[str, str]) -> None: + super().__init__(payload) + self.headers = headers + + class _ReleaseDirectory: """The release directory beside the wheel, served in memory to the helper's downloads. Ships the plugin list by default and no constraints, so the @@ -679,15 +687,23 @@ class _ReleaseDirectory: def __init__(self) -> None: self.files: dict[str, bytes] = {"raven-plugins.txt": PLUGIN_LIST.encode("utf-8")} + # Sizes the helper's HEAD probes can see. Empty by default, so the + # manifest degrades to its one headline and the older assertions in + # this file keep observing the output they were written against. + self.sizes: dict[str, int] = {} self.requests: list[urllib.request.Request] = [] def urlopen(self, request: urllib.request.Request, *args: object, **kwargs: object) -> io.BytesIO: self.requests.append(request) url = request.full_url name = url.rsplit("/", 1)[1] + if request.get_method() == "HEAD": + if name not in self.sizes: + raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) # type: ignore[arg-type] + return _Response(b"", {"Content-Length": str(self.sizes[name])}) if name not in self.files: raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) # type: ignore[arg-type] - return io.BytesIO(self.files[name]) + return _Response(self.files[name], {"Content-Length": str(len(self.files[name]))}) @pytest.fixture(autouse=True) @@ -741,6 +757,25 @@ def _load_upgrade_helper() -> object: _PARENT_EXIT_TIMEOUT_S = _load_upgrade_helper_namespace()["PARENT_EXIT_TIMEOUT_S"] +def test_upgrade_helper_bootstrap_fits_a_windows_command_line() -> None: + """The whole helper travels as `python -I -c `, and Windows caps a + command line at 32,767 characters -- interpreter path, uv path, wheel URL + and relaunch argv included. Past it the helper cannot start at all, and only + a Windows machine notices, so the budget is held here, on every OS, with + room left for the helper to grow.""" + assert len(upgrade_commands._upgrade_helper_bootstrap()) < 20_000 + + +def test_upgrade_helper_bootstrap_carries_the_helper_unchanged() -> None: + import re + import zlib + + bootstrap = upgrade_commands._upgrade_helper_bootstrap() + encoded = re.search(r'b64decode\("([^"]+)"\)', bootstrap).group(1) + + assert zlib.decompress(base64.b64decode(encoded)).decode("utf-8") == upgrade_commands._UPGRADE_HELPER_SOURCE + + def test_upgrade_helper_bootstrap_runs_in_isolated_python() -> None: bootstrap = upgrade_commands._upgrade_helper_bootstrap() @@ -782,6 +817,336 @@ def test_upgrade_helper_installs_the_release_plugins_beside_the_wheel( assert "Raven upgraded: 0.1.3 -> 0.1.4" in capsys.readouterr().out +def test_upgrade_helper_names_the_total_size_before_uv_takes_the_terminal( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + release_directory: _ReleaseDirectory, +) -> None: + """uv reports bytes per package and never a total, so a release whose + engines are tens of megabytes looks the same as an install that stopped. + The manifest is the only place the whole cost is stated.""" + release_directory.sizes = { + "raven-0.1.4-py3-none-any.whl": 6 * 1048576, + "everos_memory-1.2.0-py3-none-any.whl": 1048576 // 2, + "design_engine-0.2.0-py3-none-any.whl": 17 * 1048576, + "ppt_engine-0.2.0-py3-none-any.whl": 48 * 1048576, + } + monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=0))) + helper_main = _load_upgrade_helper() + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert status == 0 + out = capsys.readouterr().out + assert "Downloading 4 packages for Raven 0.1.4." in out + for line in ("raven", "everos-memory", "design-engine", "ppt-engine"): + assert line in out + assert "48.0 MiB" in out + assert "total" in out and "71.5 MiB" in out + # Said before uv runs, not after: a total that arrives with the result is + # not the reassurance the wait needed. + assert out.index("Downloading 4 packages") < out.index("Raven upgraded") + + +def test_upgrade_helper_upgrades_when_no_size_can_be_probed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The sizes are a courtesy. A release directory that answers no HEAD -- + or a link that drops them -- must cost the upgrade nothing.""" + run = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(subprocess, "run", run) + helper_main = _load_upgrade_helper() + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert status == 0 + out = capsys.readouterr().out + assert "Downloading 4 packages for Raven 0.1.4." in out + assert "MiB" not in out + assert "Raven upgraded: 0.1.3 -> 0.1.4" in out + assert _uv_calls(run) == [ + { + "mode": REINSTALL, + "constraints": None, + "plugins": PLUGIN_LIST, + "requirement": f"raven[channels] @ {WHEEL_URL}", + }, + ] + + +def test_upgrade_helper_manifest_reaches_a_log_file_before_uv_does( + monkeypatch: pytest.MonkeyPatch, + release_directory: _ReleaseDirectory, +) -> None: + """From the page the helper's output is web.log, a file, which Python + buffers by the block, while uv writes to the same file at once. Unflushed, + the manifest landed after uv's lines -- the one place it exists to precede.""" + release_directory.sizes = {"raven-0.1.4-py3-none-any.whl": 6 * 1048576} + raw = io.BytesIO() + monkeypatch.setattr(sys, "stdout", io.TextIOWrapper(raw, encoding="utf-8", write_through=False)) + seen: dict[str, str] = {} + + def run(argv, check): + seen.setdefault("before_uv", raw.getvalue().decode("utf-8")) + return Mock(returncode=0) + + monkeypatch.setattr(subprocess, "run", run) + helper_main = _load_upgrade_helper() + + helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert "Downloading 4 packages for Raven 0.1.4." in seen["before_uv"] + assert "total" in seen["before_uv"] + + +def test_upgrade_helper_says_package_for_one( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + release_directory: _ReleaseDirectory, +) -> None: + release_directory.files["raven-plugins.txt"] = b"# no plugins in this release\n" + monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=0))) + helper_main = _load_upgrade_helper() + + helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert "Downloading 1 package for Raven 0.1.4." in capsys.readouterr().out + + +RELEASE_WHEELS = { + "raven-0.1.4-py3-none-any.whl": b"R" * 1000, + "everos_memory-1.2.0-py3-none-any.whl": b"E" * 10, + "design_engine-0.2.0-py3-none-any.whl": b"D" * 20, + "ppt_engine-0.2.0-py3-none-any.whl": b"P" * 30, +} + + +def _free_port() -> int: + import socket as _socket + + with _socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def _port_is_free(port: int) -> bool: + """Probed the way the relaunched Raven probes it (`pick_port`): with + SO_REUSEADDR, so connections the helper closed, still in TIME_WAIT, do not + read as the port being held. What matters is that nothing listens on it.""" + import socket as _socket + + with _socket.socket() as probe: + probe.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) + try: + probe.bind(("127.0.0.1", port)) + except OSError: + return False + return True + + +def _get(port: int, path: str, host: str | None = None) -> tuple[int, bytes]: + """Through http.client, not urllib: the release-directory fixture stands in + for urllib's urlopen for the whole file.""" + import http.client + + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3) + try: + connection.request("GET", path, headers={"Host": host} if host else {}) + response = connection.getresponse() + return response.status, response.read() + finally: + connection.close() + + +class TestTheHelperOwnsTheDownload: + """uv draws no progress when it is not writing to a terminal -- the page's + case -- so the helper fetches the release itself, where the bytes can be + counted, and hands uv the local files.""" + + @pytest.fixture + def home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + marker = tmp_path / "upgrade.json" + marker.write_text(json.dumps({"started_at": 1.0}), encoding="utf-8") + monkeypatch.setenv("RAVEN_UPGRADE_MARKER", str(marker)) + return tmp_path + + def test_uv_is_handed_the_files_the_helper_downloaded( + self, home: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory + ) -> None: + release_directory.files.update(RELEASE_WHEELS) + run = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(subprocess, "run", run) + + assert _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) == 0 + + folder = home / "cache" / "upgrade" / "0.1.4" + (call,) = _uv_calls(run) + assert call["requirement"] == f"raven[channels] @ {(folder / 'raven-0.1.4-py3-none-any.whl').as_uri()}" + assert call["plugins"] == "".join( + f"{name} @ {(folder / wheel).as_uri()}\n" + for name, wheel in ( + ("everos-memory", "everos_memory-1.2.0-py3-none-any.whl"), + ("design-engine", "design_engine-0.2.0-py3-none-any.whl"), + ("ppt-engine", "ppt_engine-0.2.0-py3-none-any.whl"), + ) + ) + for wheel, payload in RELEASE_WHEELS.items(): + assert (folder / wheel).read_bytes() == payload + + def test_one_asset_it_cannot_fetch_sends_every_asset_back_to_uv( + self, home: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory + ) -> None: + """All or nothing, with the missing asset last so the ones before it + were really downloaded and then discarded: uv gets the URLs and fetches + them itself, exactly as it did before the helper downloaded anything.""" + release_directory.files.update(RELEASE_WHEELS) + del release_directory.files["ppt_engine-0.2.0-py3-none-any.whl"] + run = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(subprocess, "run", run) + + assert _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) == 0 + + (call,) = _uv_calls(run) + assert call["requirement"] == f"raven[channels] @ {WHEEL_URL}" + assert call["plugins"] == PLUGIN_LIST + assert not (home / "cache" / "upgrade" / "0.1.4").exists() + + def test_an_earlier_version_s_files_are_cleared_and_this_one_s_kept( + self, home: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory + ) -> None: + """This version's stay because the tool's receipt names them as where + it was installed from; nothing names the earlier ones any more.""" + old = home / "cache" / "upgrade" / "0.1.3" + old.mkdir(parents=True) + (old / "raven-0.1.3-py3-none-any.whl").write_bytes(b"old") + release_directory.files.update(RELEASE_WHEELS) + monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=0))) + + _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert not old.exists() + assert (home / "cache" / "upgrade" / "0.1.4" / "raven-0.1.4-py3-none-any.whl").exists() + + +class TestTheHelperAnswersOnThePagePort: + """Once the Raven being replaced exits, nothing answers where its page is + pointed, and the page can only spin. The helper answers in its place.""" + + RELAUNCH = json.dumps(["raven", "web", "--supervise", "--port", "0"]) + + @pytest.fixture + def port(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, release_directory: _ReleaseDirectory) -> int: + port = _free_port() + marker = tmp_path / "upgrade.json" + marker.write_text(json.dumps({"started_at": 1.0, "port": port}), encoding="utf-8") + monkeypatch.setenv("RAVEN_UPGRADE_MARKER", str(marker)) + release_directory.files.update(RELEASE_WHEELS) + release_directory.sizes = {name: len(payload) for name, payload in RELEASE_WHEELS.items()} + return port + + def _helper(self) -> object: + helper = _load_upgrade_helper() + helper.__globals__["wait_for_parent"] = Mock(return_value=0) + return helper + + def test_it_reports_progress_and_holds_everything_else_at_503( + self, port: int, monkeypatch: pytest.MonkeyPatch + ) -> None: + """503, not 200 and not 401: the page reloads on an OK answer to `/` + and asks the reader to sign in again on 401 or 403.""" + seen: dict[str, tuple[int, bytes]] = {} + + def run(_argv, check): + seen["status"] = _get(port, "/upgrade/status") + seen["root"] = _get(port, "/") + return Mock(returncode=0) + + monkeypatch.setattr(subprocess, "run", run) + free_at_relaunch: list[bool] = [] + monkeypatch.setattr(subprocess, "Popen", lambda *_a, **_k: free_at_relaunch.append(_port_is_free(port))) + + assert self._helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4", "4242", self.RELAUNCH]) == 0 + + code, body = seen["status"] + state = json.loads(body) + assert code == 200 + assert state["upgrading"] is True + assert state["to"] == "0.1.4" + assert state["phase"] == "installing" + assert state["done"] == state["total"] == sum(len(payload) for payload in RELEASE_WHEELS.values()) + assert seen["root"][0] == 503 + # Let go before the relaunch, which binds the same port strictly. + assert free_at_relaunch == [True] + + def test_it_answers_only_the_page_s_own_origin(self, port: int, monkeypatch: pytest.MonkeyPatch) -> None: + """A site the reader has open could otherwise rebind a host name of its + own to this port and read the answer.""" + seen: list[int] = [] + + def run(_argv, check): + seen.append(_get(port, "/upgrade/status", host=f"attacker.example:{port}")[0]) + return Mock(returncode=0) + + monkeypatch.setattr(subprocess, "run", run) + monkeypatch.setattr(subprocess, "Popen", Mock()) + + self._helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4", "4242", self.RELAUNCH]) + + assert seen == [421] + + def test_a_failure_is_held_until_the_page_has_read_it(self, port: int, monkeypatch: pytest.MonkeyPatch) -> None: + """Otherwise the old Raven comes straight back and the page reloads onto + it: an upgrade that failed reads as one that quietly did nothing.""" + import threading + import time as _time + + monkeypatch.setattr(subprocess, "run", Mock(return_value=Mock(returncode=1))) + monkeypatch.setattr(subprocess, "Popen", Mock()) + helper = self._helper() + helper.__globals__["STATUS_HOLD_S"] = 20 + read: dict[str, object] = {} + + def page() -> None: + deadline = _time.monotonic() + 15 + while _time.monotonic() < deadline and "state" not in read: + try: + code, body = _get(port, "/upgrade/status") + except OSError: + _time.sleep(0.02) + continue + state = json.loads(body) + if state["phase"] == "failed": + read["state"] = state + _time.sleep(0.02) + + reader = threading.Thread(target=page) + reader.start() + started = _time.monotonic() + status = helper(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4", "4242", self.RELAUNCH]) + took = _time.monotonic() - started + reader.join() + + assert status == 1 + assert "uv exited with status 1" in read["state"]["message"] + # Released by the read, not by sitting out the whole hold. + assert took < 20 + + def test_the_terminal_gets_no_server(self, port: int, monkeypatch: pytest.MonkeyPatch) -> None: + """The terminal has no page to answer and sees the progress itself.""" + free_during_install: list[bool] = [] + + def run(_argv, check): + free_during_install.append(_port_is_free(port)) + return Mock(returncode=0) + + monkeypatch.setattr(subprocess, "run", run) + + _load_upgrade_helper()(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert free_during_install == [True] + + def test_upgrade_helper_refuses_to_upgrade_without_the_plugin_list( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -939,6 +1304,13 @@ def test_upgrade_helper_pins_constraints_when_download_succeeds( assert [request.full_url for request in release_directory.requests] == [ f"{RELEASE_DIR}/raven-constraints.txt", f"{RELEASE_DIR}/raven-plugins.txt", + WHEEL_URL, + f"{RELEASE_DIR}/everos_memory-1.2.0-py3-none-any.whl", + f"{RELEASE_DIR}/design_engine-0.2.0-py3-none-any.whl", + f"{RELEASE_DIR}/ppt_engine-0.2.0-py3-none-any.whl", + # The helper's own download of the wheel, which this directory does not + # serve, so uv is handed the URLs instead. + WHEEL_URL, ] assert _uv_calls(run) == [ { @@ -1009,7 +1381,12 @@ def test_upgrade_helper_carries_the_beta_credentials_to_the_release_directory( assert [request.full_url for request in release_directory.requests] == [ f"{BETA_DIR}/raven-constraints.txt", f"{BETA_DIR}/raven-plugins.txt", + f"{BETA_DIR}/raven-0.1.4b1-py3-none-any.whl", + f"{BETA_DIR}/everos_memory-1.2.0-py3-none-any.whl", + f"{BETA_DIR}/raven-0.1.4b1-py3-none-any.whl", ] + # The size probes and the helper's own download are on the same protected + # host, so they need the header for the same reason the lists do. assert all(request.get_header("Authorization") == expected_header for request in release_directory.requests) (call,) = _uv_calls(run) assert call["plugins"] == ( @@ -1849,6 +2226,22 @@ def popen(_argv, env=None, **_kwargs): assert at_spawn == [True] assert handed == [str(isolated_raven_home / "upgrade.json")] + def test_the_marker_names_the_page_port_for_the_helper_to_answer_on( + self, plan: object, isolated_raven_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Once the process holding the port exits, the helper answers there with + progress; the marker is how it learns which port that is.""" + recorded: list[dict[str, object]] = [] + + def popen(_argv, env=None, **_kwargs): + recorded.append(json.loads(Path(env["RAVEN_UPGRADE_MARKER"]).read_text(encoding="utf-8"))) + return Mock() + + monkeypatch.setattr(subprocess, "Popen", popen) + upgrade_commands.spawn_detached_upgrade(plan, parent_pid=1234, status_port=18950) + + assert recorded[0]["port"] == 18950 + def test_a_helper_that_never_started_leaves_no_marker( self, plan: object, isolated_raven_home: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 31d76ccf2..ea996c77f 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -521,7 +521,7 @@ export const UI_TEXT: Record> = { 'gui.collapse_ws': 'Collapse workspace', 'gui.commands': 'Commands', 'gui.brand.hi': "Hi, I'm Raven Agent", - 'gui.composer_ph': 'Describe your task, or have Raven build an agent for it', + 'gui.composer_ph': 'Describe your task', 'gui.compress.done': 'Archived {n} messages - {before} to {after} tokens', 'gui.compress.fail': 'Compact failed: {err}', 'gui.compress.noop': 'Nothing to compact yet', @@ -1287,8 +1287,16 @@ export const UI_TEXT: Record> = { 'gui.upg.manual': 'Run this in a terminal instead:', 'gui.upg.note': 'new version {v}', 'gui.upg.note_bare': 'new version available', + 'gui.upg.phase.download': 'Downloading the new version: {progress}', + 'gui.upg.phase.install': 'Installing the new version...', + 'gui.upg.phase.restart': 'Restarting Raven...', 'gui.upg.reauth': 'Raven restarted but this page is no longer signed in. Reopen Raven.', 'gui.upg.title': 'Upgrade Raven?', + 'gui.upg.why.busy': + 'Raven is still working on a turn, a sub-agent or a question waiting for an answer, possibly in an IM channel. Upgrading restarts Raven and would cut that off, so let it finish first.', + 'gui.upg.why.not_serving': 'This page is not being served by a running Raven, so it has nothing here to upgrade.', + 'gui.upg.why.unsupervised': + 'This Raven was started by hand in a terminal, so nothing would bring it back after an upgrade. Run raven upgrade there, then start Raven again.', 'gui.upg.working': 'Upgrading Raven...', 'gui.wd.back': 'Back', 'gui.wd.blocked': "Not a workspace: Raven's own data is here or beneath it. Its folders may still be.", @@ -2332,7 +2340,7 @@ export const UI_TEXT: Record> = { 'gui.tasks.copied_dispatch': 'Dispatch prompt copied', 'gui.tasks.copied_answer': 'Answer copied', 'gui.composer.ph_session': 'Continue the conversation...', - 'gui.composer.ph_busy': 'Continue the conversation; it is sent once this turn ends', + 'gui.composer.ph_busy': 'Continue the conversation; Raven will receive your new instruction directly', 'gui.plus.add': 'Add', 'gui.plus.upload': 'Upload files', 'gui.plus.template': 'Pick a deck template', @@ -3045,7 +3053,7 @@ export const UI_TEXT: Record> = { 'gui.collapse_ws': '收起工作区', 'gui.commands': '命令', 'gui.brand.hi': 'Hi,我是 Raven Agent', - 'gui.composer_ph': '描述你的任务,或让 Raven 搭一个智能体来做', + 'gui.composer_ph': '描述你的任务', 'gui.compress.done': '已归档 {n} 条消息 · {before} → {after} tokens', 'gui.compress.fail': '压缩失败:{err}', 'gui.compress.noop': '暂时没有可压缩的内容', @@ -3805,8 +3813,16 @@ export const UI_TEXT: Record> = { 'gui.upg.manual': '改在终端里跑这条命令:', 'gui.upg.note': '有新版 {v}', 'gui.upg.note_bare': '有新版本', + 'gui.upg.phase.download': '正在下载新版本:{progress}', + 'gui.upg.phase.install': '正在安装新版本…', + 'gui.upg.phase.restart': '正在重启 Raven…', 'gui.upg.reauth': 'Raven 已重启,但这个页面的登录失效了,请重新打开 Raven。', 'gui.upg.title': '升级 Raven?', + 'gui.upg.why.busy': + 'Raven 还在处理回合、子智能体或待回答的问题(可能在 IM 渠道里)。升级会重启 Raven 并打断它们,请等它们结束再升级。', + 'gui.upg.why.not_serving': '这个页面当前没有正在运行的 Raven 在托管,这里没有可以升级的对象。', + 'gui.upg.why.unsupervised': + '这个 Raven 是在终端里手动启动的,升级后没有守护进程能把它重新拉起来。请在那个终端里运行 raven upgrade,然后重新启动 Raven。', 'gui.upg.working': '正在升级 Raven…', 'gui.wd.back': '返回', 'gui.wd.blocked': '这里不能作为工作区:Raven 自己的数据在此处或其下;里面的文件夹可以', @@ -4824,7 +4840,7 @@ export const UI_TEXT: Record> = { 'gui.tasks.copied_dispatch': '已复制派单提示词', 'gui.tasks.copied_answer': '已复制这次的回答', 'gui.composer.ph_session': '继续对话…', - 'gui.composer.ph_busy': '继续对话,输入会排队到本轮结束再发', + 'gui.composer.ph_busy': '继续对话,Raven 将直接收到你的新指令', 'gui.plus.add': '添加', 'gui.plus.upload': '上传文件', 'gui.plus.template': '选一个 PPT 模板', diff --git a/ui-web/src/app/updates.test.ts b/ui-web/src/app/updates.test.ts index 11dd94002..7d4cd1668 100644 --- a/ui-web/src/app/updates.test.ts +++ b/ui-web/src/app/updates.test.ts @@ -115,3 +115,222 @@ describe('the behind-sources notice', () => { expect(note.hidden).toBe(true) }) }) + +/* A refusal is shown on a card whose title and command line are already in the + reader's language. The server's `detail` is one English sentence for every + caller, so a reason the page can name is named from the catalogue; a reason + whose detail carries facts from this run keeps them. */ +describe('an upgrade the server refused', () => { + it('names a refusal it knows in the reader language instead of the server sentence', async () => { + const mod = (await loadPart(() => import('./updates'), {})) as Updates + const err = { + data: { + reason: 'unsupervised', + detail: 'This Raven was started by hand, so nothing would bring it back after an upgrade.', + }, + } + + const text = mod.refusalText(err) + + expect(text).toBe(t('gui.upg.why.unsupervised')) + expect(text).not.toContain('This Raven was started by hand, so nothing') + }) + + it('keeps the detail when it carries facts from this run', async () => { + const mod = (await loadPart(() => import('./updates'), {})) as Updates + const detail = 'This Raven installation is not managed by uv.' + + expect(mod.refusalText({ data: { reason: 'not_upgradable', detail } })).toBe(detail) + }) + + it('still says something when the error carries no data at all', async () => { + const mod = (await loadPart(() => import('./updates'), {})) as Updates + + expect(mod.refusalText({ message: 'socket closed' })).toBe('socket closed') + }) + + it('answers busy work it cannot see with a notice to wait, not the terminal command', async () => { + const asked: string[][] = [] + const failed: string[] = [] + const shade = { say: () => {}, fail: (text: string) => failed.push(text), close: vi.fn() } + const mod = (await loadPart(() => import('./updates'), { + fakes: { + 'src/state/upgradeShade': { open: () => shade }, + 'src/state/confirm': { + ask: (title: string, body: string) => { + asked.push([title, body]) + }, + }, + 'src/rpc/gateway': { + gateway: () => ({ + call: () => Promise.reject({ data: { reason: 'busy', detail: 'Raven is still working' } }), + }), + }, + }, + })) as Updates + + await mod.runUpgrade() + + expect(failed).toEqual([]) + expect(shade.close).toHaveBeenCalled() + expect(asked).toEqual([[t('gui.upg.title'), t('gui.upg.why.busy')]]) + }) +}) + +describe('the progress the upgrade helper reports', () => { + const report = (fields: Partial): import('./updates').UpStatus => ({ + upgrading: true, + phase: 'downloading', + done: 0, + total: 0, + rate: 0, + message: null, + ...fields, + }) + + it('says how much, of how much, and how fast', async () => { + const mod = (await loadPart(() => import('./updates'), {})) as Updates + + const seen = mod.describeStatus(report({ done: 18.4 * 1048576, total: 71.6 * 1048576, rate: 52 * 1024 })) + + expect(seen.text).toBe(t('gui.upg.phase.download', { progress: '18.4 / 71.6 MiB · 52 KB/s' })) + expect(seen.fraction).toBeCloseTo(18.4 / 71.6, 5) + }) + + it('does not pretend to measure a download of unknown size', async () => { + const mod = (await loadPart(() => import('./updates'), {})) as Updates + + const seen = mod.describeStatus(report({ done: 3 * 1048576 })) + + expect(seen.text).toBe(t('gui.upg.phase.download', { progress: '3.0 MiB' })) + expect(seen.fraction).toBeNull() + }) + + it('slides while uv installs, since there are no bytes to count', async () => { + const mod = (await loadPart(() => import('./updates'), {})) as Updates + + expect(mod.describeStatus(report({ phase: 'installing', done: 9, total: 9 }))).toEqual({ + text: t('gui.upg.phase.install'), + fraction: null, + }) + }) +}) + +describe('waiting out an upgrade the page started', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + type Shade = { say: ReturnType; measure: ReturnType; fail: ReturnType; close: ReturnType } + const shade = (): Shade => ({ say: vi.fn(), measure: vi.fn(), fail: vi.fn(), close: vi.fn() }) + + /* The helper's port: `/upgrade/status` answers with the report, everything + else 503, exactly what the page sees between the two Ravens. `null` is + nothing listening at all. */ + function port(status: () => Record | null): void { + vi.stubGlobal('fetch', async (url: string) => { + const body = status() + if (body === null) throw new TypeError('connection refused') + if (url === '/upgrade/status') return { ok: true, status: 200, json: async () => body } + return { ok: false, status: 503 } + }) + } + + it('draws the helper report on the bar', async () => { + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), {})) as Updates + port(() => ({ upgrading: true, phase: 'downloading', done: 50, total: 100, rate: 0, message: null })) + const card = shade() + + mod.watchUpgrade(card as never) + await vi.advanceTimersByTimeAsync(2600) + + expect(card.measure).toHaveBeenCalledWith(t('gui.upg.phase.download', { progress: '0.0 / 0.0 MiB' }), 0.5) + expect(card.fail).not.toHaveBeenCalled() + }) + + it('shows a failure the helper holds instead of reloading onto the old Raven', async () => { + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), {})) as Updates + port(() => ({ upgrading: true, phase: 'failed', done: 0, total: 0, rate: 0, message: 'uv exited with status 1.' })) + const card = shade() + + mod.watchUpgrade(card as never) + await vi.advanceTimersByTimeAsync(2600) + + expect(card.fail).toHaveBeenCalledWith(t('gui.upg.failed'), 'uv exited with status 1.') + }) + + it('keeps waiting past the ceiling while the download keeps moving', async () => { + /* A slow link can take longer than the ceiling to download a release. */ + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), {})) as Updates + let done = 0 + port(() => ({ upgrading: true, phase: 'downloading', done: (done += 1), total: 1e9, rate: 1, message: null })) + const card = shade() + + mod.watchUpgrade(card as never) + await vi.advanceTimersByTimeAsync(21 * 60 * 1000) + + expect(card.fail).not.toHaveBeenCalled() + }) + + it('gives up on a helper that keeps answering while nothing moves', async () => { + /* The status server is its own thread: it answers while a download or uv + is stuck. An answer is not progress, or the card would never let go. */ + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), {})) as Updates + port(() => ({ upgrading: true, phase: 'downloading', done: 5, total: 100, rate: 0, message: null })) + const card = shade() + + mod.watchUpgrade(card as never) + await vi.advanceTimersByTimeAsync(21 * 60 * 1000) + + expect(card.fail).toHaveBeenCalledWith(t('gui.upg.failed'), t('gui.upg.gave_up')) + }) + + it('counts a new phase as progress even when the bytes stay put', async () => { + /* uv resolving after the download reports no bytes, and must not inherit + the time the download took. */ + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), {})) as Updates + let phase = 'downloading' + port(() => ({ upgrading: true, phase, done: 100, total: 100, rate: 0, message: null })) + const card = shade() + + mod.watchUpgrade(card as never) + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + phase = 'installing' + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + + expect(card.fail).not.toHaveBeenCalled() + }) + + it('still gives up on twenty minutes with no answer at all', async () => { + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), {})) as Updates + port(() => null) + const card = shade() + + mod.watchUpgrade(card as never) + await vi.advanceTimersByTimeAsync(21 * 60 * 1000) + + expect(card.fail).toHaveBeenCalledWith(t('gui.upg.failed'), t('gui.upg.gave_up')) + }) + + it('says the new Raven is starting once the helper lets the port go', async () => { + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), {})) as Updates + let helperUp = true + port(() => (helperUp ? { upgrading: true, phase: 'installing', done: 0, total: 0, rate: 0, message: null } : null)) + const card = shade() + + mod.watchUpgrade(card as never) + await vi.advanceTimersByTimeAsync(2600) + helperUp = false + await vi.advanceTimersByTimeAsync(1100) + + expect(card.measure).toHaveBeenLastCalledWith(t('gui.upg.phase.restart'), null) + }) +}) diff --git a/ui-web/src/app/updates.ts b/ui-web/src/app/updates.ts index aa8d3d992..bba794097 100644 --- a/ui-web/src/app/updates.ts +++ b/ui-web/src/app/updates.ts @@ -146,6 +146,22 @@ export function resumeUpgrade(): void { if (running) watchUpgrade(upShade(), running.t0) } +/* A refusal the page can name in the reader's language. The server's `detail` + is one English sentence for every surface that calls it; showing it raw + dropped an English paragraph into the middle of a Chinese card. Reasons whose + detail carries run-specific facts (the uv error, the failed lookup) keep it, + because translating the frame would throw the facts away. */ +const REFUSALS: Record = { + unsupervised: 'gui.upg.why.unsupervised', + not_serving: 'gui.upg.why.not_serving', +} + +export function refusalText(err: { data?: { reason?: string; detail?: string }; message?: string }): string { + const key = err.data && err.data.reason ? REFUSALS[err.data.reason] : undefined + if (key) return t(key) + return (err.data && err.data.detail) || err.message || String(err) +} + /* `system.upgrade` hands the install to a detached helper and then lets serve exit, so this page has to survive a gap with no backend: it polls until serve answers again and only then reloads (the new dist needs a reload anyway). The @@ -165,8 +181,17 @@ export async function runUpgrade(): Promise { watchUpgrade(shade) return } + /* Work is running that the page cannot see -- an IM turn, a sub-agent. + Same answer as the page's own busy turn: a notice to wait. The failure + card would offer the terminal command, which is not the fix. */ + if (err.data && err.data.reason === 'busy') { + upMarkClear() + shade.close() + confirmAsk(t('gui.upg.title'), t('gui.upg.why.busy'), t('gui.upg.close'), () => {}, 'notice') + return + } upMarkClear() - shade.fail(t('gui.upg.failed'), (err.data && err.data.detail) || err.message || String(e)) + shade.fail(t('gui.upg.failed'), refusalText(err)) return } upMark(upLatest) @@ -179,15 +204,85 @@ export async function runUpgrade(): Promise { byte-compiles every dependency: one measured cold run took nine. The old three-minute ceiling declared failure over a install that was still running, which is what taught the reader to click upgrade a second time. */ +/* What the upgrade helper reports while it is the only thing on this page's + port: from the moment the old Raven exits until the new one is started. + Anything without `upgrading` is not the helper -- the real server may well + answer this path with the page itself. */ +export interface UpStatus { + upgrading: true + phase: string + done: number + total: number + rate: number + message: string | null +} + +async function readStatus(): Promise { + try { + const r = await fetch('/upgrade/status', { cache: 'no-store' }) + if (!r.ok) return null + const body = (await r.json()) as Partial | null + return body && body.upgrading === true ? (body as UpStatus) : null + } catch { return null } +} + +const mib = (n: number): string => (n / 1048576).toFixed(1) +const speed = (n: number): string => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB/s` : `${Math.round(n / 1024)} KB/s`) + +/* The line and the bar for one report. Numbers and units are the same in every + language, so only the frame around them comes from the catalogue. */ +export function describeStatus(s: UpStatus): { text: string; fraction: number | null } { + if (s.phase === 'downloading') { + const amount = s.total > 0 ? `${mib(s.done)} / ${mib(s.total)} MiB` : `${mib(s.done)} MiB` + const rate = s.rate > 0 ? ` · ${speed(s.rate)}` : '' + return { text: t('gui.upg.phase.download', { progress: amount + rate }), fraction: s.total > 0 ? s.done / s.total : null } + } + if (s.phase === 'installing') return { text: t('gui.upg.phase.install'), fraction: null } + return { text: t('gui.upg.working'), fraction: null } +} + export function watchUpgrade(shade: UpgradeShade, since?: number): void { const t0 = since || Date.now() + /* The ceiling counts time without progress, not the whole upgrade. A slow + link can take longer than the ceiling to download a release, and giving up + on one that is visibly still moving would be the wrong answer. But the + helper's status server is its own thread and keeps answering while a + download or uv is stuck, so an answer alone is not progress: only a new + phase or more bytes move the deadline. Otherwise a stalled helper holds + the reader under a card with no way out, and a reload resumes it. */ + let moved: number | null = null + let last: { phase: string; done: number } | null = null + let helperSeen = false shade.say(t('gui.upg.working')) const tick = async (): Promise => { - if (Date.now() - t0 > UPG_CEILING_MS) { + if (Date.now() - (moved ?? t0) > UPG_CEILING_MS) { upMarkClear() shade.fail(t('gui.upg.failed'), t('gui.upg.gave_up')) return } + const status = await readStatus() + if (status) { + helperSeen = true + if (!last || status.phase !== last.phase || status.done > last.done) { + moved = Date.now() + last = { phase: status.phase, done: status.done } + } + if (status.phase === 'failed') { + /* The helper holds this until it has been read, then brings the old + Raven back; without the card the page would reload onto it and the + failed upgrade would read as one that did nothing. */ + upMarkClear() + shade.fail(t('gui.upg.failed'), status.message || '') + return + } + const seen = describeStatus(status) + shade.measure(seen.text, seen.fraction) + setTimeout(tick, 1000) + return + } + /* The helper answered and now does not: it has let the port go to the new + Raven, which is starting. */ + if (helperSeen) shade.measure(t('gui.upg.phase.restart'), null) let r: Response try { r = await fetch('/', { method: 'HEAD', cache: 'no-store' }) diff --git a/ui-web/src/chrome/UpgradeShade.tsx b/ui-web/src/chrome/UpgradeShade.tsx index 8b17af228..484ea5a69 100644 --- a/ui-web/src/chrome/UpgradeShade.tsx +++ b/ui-web/src/chrome/UpgradeShade.tsx @@ -4,9 +4,10 @@ * `.upshade` is the full-window shade itself, so a wrapper around it would take * the inset and the `--z` step the class carries. * - * While the install is running the card is a bar and a line of text. A failure - * hides the bar and adds the three things only the reader can act on: what - * went wrong, the command to run by hand, and a way to put the card down. + * While the install is running the card is a bar and a line of text. The bar + * slides until the helper reports bytes, then measures them. A failure hides + * the bar and adds the three things only the reader can act on: what went + * wrong, the command to run by hand, and a way to put the card down. */ import { useSyncExternalStore } from 'react' import { createPortal } from 'react-dom' @@ -22,7 +23,9 @@ export function UpgradeShade(): JSX.Element | null { return createPortal(
- +
{card.text}
{failure ? ( <> diff --git a/ui-web/src/features/composer/store.ts b/ui-web/src/features/composer/store.ts index 1a6b30618..1401887ee 100644 --- a/ui-web/src/features/composer/store.ts +++ b/ui-web/src/features/composer/store.ts @@ -225,7 +225,9 @@ export function goPaint(): void { /* What the empty field says: the task on a draft, a continuation once the conversation exists, and while a turn is running, that a message typed now - waits for it -- the one fact about the queue a reader cannot see. */ + goes into that turn (`sendMidTurn` merges it at the turn's next gap) rather + than waiting for it to end -- the one fact about sending mid-turn a reader + cannot see. */ function placeholder(): string { if (currentSession() === null) return t('gui.composer_ph') return t(turn.busy() ? 'gui.composer.ph_busy' : 'gui.composer.ph_session') diff --git a/ui-web/src/rpc/wsTransport.test.ts b/ui-web/src/rpc/wsTransport.test.ts index 696f2a4f1..165c47a75 100644 --- a/ui-web/src/rpc/wsTransport.test.ts +++ b/ui-web/src/rpc/wsTransport.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RpcError } from './transport' -import { WsTransport } from './wsTransport' +import { probeHealth, WsTransport } from './wsTransport' import type { ConnectionState } from './transport' @@ -475,3 +475,34 @@ describe('rejoin', () => { expect(states(h).at(-1)).toBe('closed') }) }) + +/* What decides "absent, keep waiting" against "back, and refused this session". + Only the second ends the reconnect, with a bar telling the reader to act. */ +describe('the health probe behind a refused socket', () => { + afterEach(() => { vi.unstubAllGlobals() }) + + const answering = (status: number): void => { + vi.stubGlobal('fetch', async () => ({ status, ok: status < 400 })) + } + + it('reads a live gateway as back', async () => { + answering(200) + expect(await probeHealth()).toBe(true) + }) + + it('reads an upgrade in progress as not back yet', async () => { + /* The helper holds the port and answers 503 while the new Raven installs. */ + answering(503) + expect(await probeHealth()).toBe(false) + }) + + it('reads nothing listening as not back yet', async () => { + vi.stubGlobal('fetch', async () => { throw new TypeError('connection refused') }) + expect(await probeHealth()).toBe(false) + }) + + it('does not read its own refusal as the process being back', async () => { + answering(401) + expect(await probeHealth()).toBe(false) + }) +}) diff --git a/ui-web/src/rpc/wsTransport.ts b/ui-web/src/rpc/wsTransport.ts index f57d348b3..bf528f1ea 100644 --- a/ui-web/src/rpc/wsTransport.ts +++ b/ui-web/src/rpc/wsTransport.ts @@ -99,11 +99,17 @@ const defaultSocket = (url: string): WebSocketLike => cookie the gateway has already forgotten, so an answer means the process is back and a socket closing anyway is a real auth refusal. No answer means keep waiting. (The old client probed '/' with HEAD; the two are equivalent, and - the dev server owns '/'.) */ -const probeHealth = async (): Promise => { + the dev server owns '/'.) + + A 5xx is not an answer either. During an upgrade the helper holds the page's + port and says 503 to everything but its own status: read as "the process is + back", that 503 made the transport give up on a gateway that was minutes + from returning and tell the reader to start Raven by hand -- the one thing + that, mid-upgrade, starts a second supervisor beside the one coming back. */ +export const probeHealth = async (): Promise => { try { const r = await fetch('/health', { cache: 'no-store' }) - return r.status !== 401 && r.status !== 403 + return r.status < 500 && r.status !== 401 && r.status !== 403 } catch { return false } diff --git a/ui-web/src/state/upgradeShade.test.ts b/ui-web/src/state/upgradeShade.test.ts index beb3c7021..93518b2f5 100644 --- a/ui-web/src/state/upgradeShade.test.ts +++ b/ui-web/src/state/upgradeShade.test.ts @@ -73,3 +73,35 @@ describe('the upgrade shade writer', () => { expect(current.isConnected).toBe(false) }) }) + +/* The bar slides until the helper reports bytes, measures them while it does, + and slides again when there is nothing to count -- uv resolving, the new + Raven starting. A bar that kept its last width then would be lying. */ +describe('the measured upgrade bar', () => { + it('measures what the helper reports', () => { + wire() + const shade = open() + shade.measure('Downloading', 0.5) + const bar = document.querySelector('.upshade .upbar') as HTMLElement + expect(bar.hasAttribute('data-measured')).toBe(true) + expect((bar.querySelector('i') as HTMLElement).style.width).toBe('50.0%') + expect(document.querySelector('.upshade .t')!.textContent).toBe('Downloading') + }) + + it('goes back to sliding when nothing can be measured', () => { + wire() + const shade = open() + shade.measure('Downloading', 0.9) + shade.measure('Installing', null) + const bar = document.querySelector('.upshade .upbar') as HTMLElement + expect(bar.hasAttribute('data-measured')).toBe(false) + expect((bar.querySelector('i') as HTMLElement).getAttribute('style')).toBeNull() + }) + + it('never draws past either end', () => { + wire() + const shade = open() + shade.measure('Downloading', 1.7) + expect((document.querySelector('.upshade .upbar i') as HTMLElement).style.width).toBe('100.0%') + }) +}) diff --git a/ui-web/src/state/upgradeShade.ts b/ui-web/src/state/upgradeShade.ts index e3b3b5ede..ce4cc558f 100644 --- a/ui-web/src/state/upgradeShade.ts +++ b/ui-web/src/state/upgradeShade.ts @@ -16,6 +16,9 @@ import { makeStore } from './store' export interface UpgradeShade { say(text: string): void + /** A line with how far the install has got: 0..1, or null while it cannot + be measured (the bar goes back to sliding). */ + measure(text: string, fraction: number | null): void fail(text: string, detail?: string): void close(): void } @@ -32,6 +35,10 @@ export interface Failure { export interface Card { readonly id: number readonly text: string + /** How far the download has got, 0..1. Absent while nothing reports it -- + the helper answers only once the old Raven has exited -- and the bar + then slides rather than pretend to measure. */ + readonly progress?: number readonly failure?: Failure } @@ -67,6 +74,11 @@ export function open(): UpgradeShade { say(text: string): void { if (mine()) set({ ...get()!, text }) }, + measure(text: string, fraction: number | null): void { + if (!mine()) return + const { progress: _drop, ...rest } = get()! + set(fraction === null ? { ...rest, text } : { ...rest, text, progress: Math.max(0, Math.min(1, fraction)) }) + }, fail(text: string, detail?: string): void { if (!mine()) return set({ diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index 7367ed0dc..9d5929e3d 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -1673,14 +1673,17 @@ button, [role="button"] { -webkit-user-select: none; user-select: none; } border: 1px solid var(--line); border-radius: 14px; padding: 18px 20px; box-shadow: var(--shadow); } -/* Same indeterminate bar the browser pane uses, for the same reason: the helper - reports nothing back until it relaunches serve, so there is no progress to - show and a bar that pretended to measure one would be lying. */ +/* Same indeterminate bar the browser pane uses while nothing can be measured: + until the old Raven has exited the helper cannot answer on this port, and + while uv resolves there are no bytes to count. `[data-measured]` is the + stretch in between, when the helper is downloading and reports how far it has + got. An attribute, not a class, so the bar's class stays a plain literal. */ .upshade .upbar { height: 2px; overflow: hidden; border-radius: 2px; background: var(--surface); } .upshade .upbar i { display: block; height: 100%; width: 34%; background: var(--amber); border-radius: 2px; animation: bslide 1.1s ease-in-out infinite; } +.upshade .upbar[data-measured] i { animation: none; transform: none; transition: width .4s ease-out; } @media (prefers-reduced-motion: reduce) { .upshade { backdrop-filter: none; } .upshade .upbar i { animation: none; width: 100%; opacity: .45; }