From d24ff2850fb9b2f2fadf18c361e0ee2e2964d61f Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sat, 25 Jul 2026 14:33:46 +0800 Subject: [PATCH 1/8] feat(update): add installation-aware update checks Detect PyPI, full-source, CLI-only, and container installations and query only official stable releases. Add a read-only CLI check with deterministic unit, integration, and process-level E2E coverage. --- README.md | 1 + deeptutor/update/__init__.py | 361 ++++++++++++++++++ deeptutor_cli/README.md | 12 + deeptutor_cli/main.py | 2 + deeptutor_cli/update_cmd.py | 47 +++ packaging/deeptutor-cli/pyproject.toml | 1 + pyproject.toml | 1 + requirements/cli.txt | 1 + tests/cli/test_docs_contract.py | 1 + tests/cli/test_update_cli.py | 45 +++ tests/e2e/test_update_check_cli.py | 217 +++++++++++ tests/update/test_coordinator.py | 192 ++++++++++ tests/update/test_installation_integration.py | 10 + tests/update/test_release_provider.py | 64 ++++ .../test_release_provider_integration.py | 49 +++ 15 files changed, 1004 insertions(+) create mode 100644 deeptutor/update/__init__.py create mode 100644 deeptutor_cli/update_cmd.py create mode 100644 tests/cli/test_update_cli.py create mode 100644 tests/e2e/test_update_check_cli.py create mode 100644 tests/update/test_coordinator.py create mode 100644 tests/update/test_installation_integration.py create mode 100644 tests/update/test_release_provider.py create mode 100644 tests/update/test_release_provider_integration.py diff --git a/README.md b/README.md index 4ef4590744..4392d8605d 100644 --- a/README.md +++ b/README.md @@ -720,6 +720,7 @@ The repo ships a root [`SKILL.md`](SKILL.md) — a ~150-line handover doc that t | `deeptutor init` | Create or update `data/user/settings` for the current workspace | | `deeptutor start [--home PATH] [--dev]` | Launch backend + frontend together; `--dev` enables frontend HMR | | `deeptutor serve [--port PORT]` | Start only the FastAPI backend | +| `deeptutor update --check` | Detect the installation mode and check the latest stable release without changing the installation | | `deeptutor run ` | Run a single capability turn (`chat`, `deep_solve`, `deep_question`, `deep_research`, `visualize`, `math_animator`, `mastery_path`); add `--format json` for NDJSON output | | `deeptutor chat` | Interactive REPL with capability, tool, KB, notebook, and history controls | | `deeptutor partner list/create/start/stop` | Manage IM-connected partners | diff --git a/deeptutor/update/__init__.py b/deeptutor/update/__init__.py new file mode 100644 index 0000000000..e08454d2a7 --- /dev/null +++ b/deeptutor/update/__init__.py @@ -0,0 +1,361 @@ +"""Installation-aware update checks and execution.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from importlib import metadata +import json +import os +from pathlib import Path +from typing import Callable, Protocol +from urllib.parse import unquote, urlparse +from urllib.request import url2pathname + +import httpx +from packaging.version import Version + +from deeptutor.__version__ import __version__ + + +class InstallMode(str, Enum): + """Supported DeepTutor installation layouts.""" + + PYPI = "pypi" + SOURCE_WEB = "source_web" + SOURCE_CLI = "source_cli" + DOCKER = "docker" + UNSUPPORTED = "unsupported" + + +class UpdateStatus(str, Enum): + """Result of a completed update check.""" + + AVAILABLE = "available" + UP_TO_DATE = "up_to_date" + FAILED = "failed" + + +@dataclass(frozen=True) +class DistributionEvidence: + """Relevant metadata from one installed Python distribution.""" + + name: str + version: str + editable_root: Path | None = None + + +@dataclass(frozen=True) +class RuntimeEvidence: + """Runtime facts used to classify the current installation.""" + + current_version: str + package_root: Path + containerized: bool + deeptutor: DistributionEvidence | None + deeptutor_cli: DistributionEvidence | None + + +@dataclass(frozen=True) +class Installation: + """Detected installation details used to build an update plan.""" + + mode: InstallMode + current_version: str + package_name: str + source_root: Path | None = None + detail: str = "" + + @property + def can_auto_update(self) -> bool: + """Return whether this installation supports a managed update.""" + + return self.mode not in {InstallMode.DOCKER, InstallMode.UNSUPPORTED} + + +def detect_installation(evidence: RuntimeEvidence) -> Installation: + """Classify a runtime from explicit filesystem and distribution evidence.""" + + if evidence.containerized: + return Installation( + mode=InstallMode.DOCKER, + current_version=evidence.current_version, + package_name="deeptutor", + detail="container runtime", + ) + if evidence.deeptutor is not None and evidence.deeptutor_cli is not None: + return Installation( + mode=InstallMode.UNSUPPORTED, + current_version=evidence.current_version, + package_name="deeptutor", + detail="conflicting DeepTutor distributions", + ) + if evidence.deeptutor is not None: + if evidence.deeptutor.editable_root is not None: + return Installation( + mode=InstallMode.SOURCE_WEB, + current_version=evidence.current_version, + package_name="deeptutor", + source_root=evidence.deeptutor.editable_root, + detail="editable full installation", + ) + return Installation( + mode=InstallMode.PYPI, + current_version=evidence.current_version, + package_name="deeptutor", + detail="installed distribution", + ) + if evidence.deeptutor_cli is not None and evidence.deeptutor_cli.editable_root is not None: + return Installation( + mode=InstallMode.SOURCE_CLI, + current_version=evidence.current_version, + package_name="deeptutor-cli", + source_root=evidence.package_root, + detail="editable CLI-only installation", + ) + return Installation( + mode=InstallMode.UNSUPPORTED, + current_version=evidence.current_version, + package_name="deeptutor", + detail="installation metadata not found", + ) + + +def _editable_root(distribution: metadata.Distribution) -> Path | None: + raw = distribution.read_text("direct_url.json") + if not raw: + return None + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return None + if not payload.get("dir_info", {}).get("editable"): + return None + parsed = urlparse(str(payload.get("url", ""))) + if parsed.scheme != "file": + return None + path = url2pathname(unquote(parsed.path)) + if parsed.netloc: + path = f"//{parsed.netloc}{path}" + return Path(path).resolve() + + +def _distribution_evidence( + name: str, + *, + fallback_root: Path | None = None, +) -> DistributionEvidence | None: + try: + distribution = metadata.distribution(name) + except metadata.PackageNotFoundError: + return None + editable_root = _editable_root(distribution) + if editable_root is None and fallback_root is not None: + editable_root = fallback_root + return DistributionEvidence( + name=name, + version=distribution.version, + editable_root=editable_root, + ) + + +def _is_source_checkout(package_root: Path) -> bool: + return (package_root / "pyproject.toml").is_file() and (package_root / "deeptutor").is_dir() + + +def _is_containerized() -> bool: + value = os.getenv("DEEPTUTOR_CONTAINER", "").strip().lower() + return value in {"1", "true", "yes", "on"} or any( + marker.exists() for marker in (Path("/.dockerenv"), Path("/run/.containerenv")) + ) + + +def detect_current_installation() -> Installation: + """Detect the installation backing the current Python process.""" + + package_root = Path(__file__).resolve().parents[2] + source_root = package_root if _is_source_checkout(package_root) else None + full_distribution = _distribution_evidence( + "deeptutor", + fallback_root=source_root, + ) + cli_project = package_root / "packaging" / "deeptutor-cli" if source_root is not None else None + cli_distribution = _distribution_evidence( + "deeptutor-cli", + fallback_root=cli_project if cli_project and cli_project.is_dir() else None, + ) + if full_distribution is None and source_root is not None and cli_distribution is None: + full_distribution = DistributionEvidence( + name="deeptutor", + version=__version__, + editable_root=source_root, + ) + evidence = RuntimeEvidence( + current_version=__version__, + package_root=package_root, + containerized=_is_containerized(), + deeptutor=full_distribution, + deeptutor_cli=cli_distribution, + ) + return detect_installation(evidence) + + +@dataclass(frozen=True) +class ReleaseInfo: + """Latest stable release metadata.""" + + version: str + release_url: str + + +@dataclass(frozen=True) +class UpdateCheck: + """Serializable result returned by :class:`UpdateCoordinator`.""" + + status: UpdateStatus + current_version: str + latest_version: str | None + install_mode: InstallMode + can_auto_update: bool + release_url: str | None + detail: str = "" + + +class ReleaseProvider(Protocol): + """Boundary for querying release metadata.""" + + def latest(self, installation: Installation) -> ReleaseInfo: + """Return the latest stable release for *installation*.""" + + +class HttpReleaseProvider: + """Read stable DeepTutor release metadata from official endpoints.""" + + PYPI_URL = "https://pypi.org/pypi/deeptutor/json" + GITHUB_LATEST_URL = "https://api.github.com/repos/HKUDS/DeepTutor/releases/latest" + RELEASES_URL = "https://github.com/HKUDS/DeepTutor/releases" + + def __init__( + self, + *, + client: httpx.Client | None = None, + pypi_url: str | None = None, + github_latest_url: str | None = None, + ) -> None: + self._client = client or httpx.Client( + timeout=5.0, + follow_redirects=True, + headers={"User-Agent": "DeepTutor update checker"}, + ) + self._pypi_url = pypi_url or self.PYPI_URL + self._github_latest_url = github_latest_url or self.GITHUB_LATEST_URL + + def latest(self, installation: Installation) -> ReleaseInfo: + """Return the latest stable release usable by *installation*.""" + + if installation.mode is not InstallMode.PYPI: + response = self._client.get(self._github_latest_url) + response.raise_for_status() + payload = response.json() + if payload.get("draft") or payload.get("prerelease"): + raise ValueError("GitHub did not return a stable DeepTutor release") + raw_version = str(payload.get("tag_name", "")).removeprefix("v") + version = Version(raw_version) + if version.is_prerelease or version.is_devrelease: + raise ValueError("GitHub did not return a stable DeepTutor release") + release_url = str(payload.get("html_url") or "").strip() + if not release_url: + release_url = f"{self.RELEASES_URL}/tag/v{version}" + return ReleaseInfo(version=str(version), release_url=release_url) + + response = self._client.get(self._pypi_url) + response.raise_for_status() + releases = response.json().get("releases", {}) + versions: list[Version] = [] + for raw_version, files in releases.items(): + try: + version = Version(raw_version) + except ValueError: + continue + if version.is_prerelease or version.is_devrelease: + continue + if not files or not any(not item.get("yanked", False) for item in files): + continue + versions.append(version) + if not versions: + raise ValueError("PyPI did not return a stable DeepTutor release") + latest = str(max(versions)) + return ReleaseInfo( + version=latest, + release_url=f"{self.RELEASES_URL}/tag/v{latest}", + ) + + +class UpdateCoordinator: + """Coordinate installation detection and stable release checks.""" + + def __init__( + self, + *, + installation_provider: Callable[[], Installation], + release_provider: ReleaseProvider, + ) -> None: + self._installation_provider = installation_provider + self._release_provider = release_provider + + def check(self) -> UpdateCheck: + """Return update availability without changing the installation.""" + + installation = self._installation_provider() + try: + release = self._release_provider.latest(installation) + except (httpx.HTTPError, ValueError): + return UpdateCheck( + status=UpdateStatus.FAILED, + current_version=installation.current_version, + latest_version=None, + install_mode=installation.mode, + can_auto_update=installation.can_auto_update, + release_url=None, + detail="Unable to check for updates.", + ) + status = ( + UpdateStatus.AVAILABLE + if Version(release.version) > Version(installation.current_version) + else UpdateStatus.UP_TO_DATE + ) + return UpdateCheck( + status=status, + current_version=installation.current_version, + latest_version=release.version, + install_mode=installation.mode, + can_auto_update=installation.can_auto_update, + release_url=release.release_url, + detail=installation.detail, + ) + + +def create_update_coordinator() -> UpdateCoordinator: + """Build the production coordinator for the current process.""" + + return UpdateCoordinator( + installation_provider=detect_current_installation, + release_provider=HttpReleaseProvider(), + ) + + +__all__ = ( + "DistributionEvidence", + "HttpReleaseProvider", + "InstallMode", + "Installation", + "ReleaseInfo", + "ReleaseProvider", + "RuntimeEvidence", + "UpdateCheck", + "UpdateCoordinator", + "UpdateStatus", + "create_update_coordinator", + "detect_current_installation", + "detect_installation", +) diff --git a/deeptutor_cli/README.md b/deeptutor_cli/README.md index c692e04daa..a30705934e 100644 --- a/deeptutor_cli/README.md +++ b/deeptutor_cli/README.md @@ -155,6 +155,18 @@ deeptutor chat [options] --- +## `update --check` — 检查稳定版更新 + +```bash +deeptutor update --check +``` + +该命令只读取当前安装方式和官方稳定版元数据,不会修改环境。它会区分 +PyPI、完整源码、CLI-only 源码和 Docker 安装;Docker 只提示在宿主机 +更新镜像并重建服务。 + +--- + ## `serve` — 启动 API 服务 ```bash diff --git a/deeptutor_cli/main.py b/deeptutor_cli/main.py index d722f14224..ad23754faa 100644 --- a/deeptutor_cli/main.py +++ b/deeptutor_cli/main.py @@ -22,6 +22,7 @@ from .provider_cmd import register as register_provider from .session_cmd import register as register_session from .skill import register as register_skill +from .update_cmd import register as register_update set_mode(RunMode.CLI) configure_logging() @@ -70,6 +71,7 @@ register_provider(provider_app) register_book(book_app) register_init(app) +register_update(app) @app.command("run") diff --git a/deeptutor_cli/update_cmd.py b/deeptutor_cli/update_cmd.py new file mode 100644 index 0000000000..f1d4e08cc0 --- /dev/null +++ b/deeptutor_cli/update_cmd.py @@ -0,0 +1,47 @@ +"""CLI entry point for installation-aware updates.""" + +from __future__ import annotations + +import typer + +from deeptutor.update import InstallMode, UpdateStatus, create_update_coordinator + + +def register(app: typer.Typer) -> None: + """Register the top-level ``update`` command.""" + + @app.command("update") + def update( + check: bool = typer.Option( + False, + "--check", + help="Check the latest stable release without changing this installation.", + ), + ) -> None: + """Check for or install a stable DeepTutor update.""" + + if not check: + typer.echo("Automatic update execution is not available yet. Use --check.") + raise typer.Exit(code=2) + + result = create_update_coordinator().check() + status_label = { + UpdateStatus.AVAILABLE: "update available", + UpdateStatus.UP_TO_DATE: "up to date", + UpdateStatus.FAILED: "check failed", + }[result.status] + typer.echo(f"Installation: {result.install_mode.value}") + typer.echo(f"Current version: {result.current_version}") + typer.echo(f"Latest stable: {result.latest_version or 'unknown'}") + typer.echo(f"Status: {status_label}") + typer.echo(f"Automatic update: {'yes' if result.can_auto_update else 'no'}") + if result.release_url: + typer.echo(f"Release notes: {result.release_url}") + if result.install_mode is InstallMode.DOCKER: + typer.echo("Update the container image and recreate the service on the host.") + if result.status is UpdateStatus.FAILED: + typer.echo(result.detail) + raise typer.Exit(code=1) + + +__all__ = ("register",) diff --git a/packaging/deeptutor-cli/pyproject.toml b/packaging/deeptutor-cli/pyproject.toml index 2f8d9385ad..9710e49ec5 100644 --- a/packaging/deeptutor-cli/pyproject.toml +++ b/packaging/deeptutor-cli/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "python-pptx>=1.0.0", "pypdf>=4.0.0", "defusedxml>=0.7.1", + "packaging>=23.0", ] [project.scripts] diff --git a/pyproject.toml b/pyproject.toml index da05b979e7..d18c34173d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ dependencies = [ "pocketbase>=0.12.0", "loguru>=0.7.3,<1.0.0", "json-repair>=0.57.0,<1.0.0", + "packaging>=23.0", ] [project.scripts] diff --git a/requirements/cli.txt b/requirements/cli.txt index eb1636cd5a..3b28996353 100644 --- a/requirements/cli.txt +++ b/requirements/cli.txt @@ -35,6 +35,7 @@ tenacity>=8.0.0 pydantic>=2.0.0 pydantic-settings>=2.0.0 aiosqlite>=0.19.0 +packaging>=23.0 # --- RAG (LlamaIndex) --- llama-index>=0.14.12 diff --git a/tests/cli/test_docs_contract.py b/tests/cli/test_docs_contract.py index cc448b362a..3cd31b37fb 100644 --- a/tests/cli/test_docs_contract.py +++ b/tests/cli/test_docs_contract.py @@ -89,6 +89,7 @@ def test_documented_deeptutor_subcommands_exist() -> None: "session", "skill", "start", + "update", } provider_subcommands = {"login"} diff --git a/tests/cli/test_update_cli.py b/tests/cli/test_update_cli.py new file mode 100644 index 0000000000..5c376c674d --- /dev/null +++ b/tests/cli/test_update_cli.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import httpx +from typer.testing import CliRunner + +from deeptutor.__version__ import __version__ +from deeptutor_cli.main import app + + +def test_update_without_check_does_not_run_an_update(monkeypatch) -> None: + def unexpected_get(self, url: str) -> httpx.Response: + raise AssertionError("update command should not access the network") + + monkeypatch.setattr(httpx.Client, "get", unexpected_get) + + result = CliRunner().invoke(app, ["update"]) + + assert result.exit_code == 2 + assert "Use --check" in result.output + + +def test_update_check_reports_the_latest_stable_release(monkeypatch) -> None: + def fake_get(self, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response( + 200, + request=request, + json={ + "tag_name": "v1.6.0", + "html_url": ("https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0"), + "draft": False, + "prerelease": False, + }, + ) + + monkeypatch.setattr(httpx.Client, "get", fake_get) + + result = CliRunner().invoke(app, ["update", "--check"]) + + assert result.exit_code == 0, result.output + assert "Installation: source_web" in result.output + assert f"Current version: {__version__}" in result.output + assert "Latest stable: 1.6.0" in result.output + assert "Status: update available" in result.output + assert "Automatic update: yes" in result.output diff --git a/tests/e2e/test_update_check_cli.py b/tests/e2e/test_update_check_cli.py new file mode 100644 index 0000000000..05606a956b --- /dev/null +++ b/tests/e2e/test_update_check_cli.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +from threading import Thread + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +class _GitHubReleaseHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if self.path.startswith("/pypi/"): + body = {"releases": {"1.6.0": [{"yanked": False}]}} + else: + body = { + "tag_name": "v1.6.0", + "html_url": ("https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0"), + "draft": False, + "prerelease": False, + } + payload = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args: object) -> None: + return + + +@pytest.mark.parametrize( + ("container_marker", "expected_mode"), + [(False, "source_web"), (True, "docker")], +) +def test_user_can_check_for_updates_from_the_real_cli_process( + tmp_path: Path, + container_marker: bool, + expected_mode: str, +) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _GitHubReleaseHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + + hook_dir = tmp_path / "hook" + hook_dir.mkdir() + (hook_dir / "sitecustomize.py").write_text( + "\n".join( + [ + "import os", + "from deeptutor.update import HttpReleaseProvider", + ( + "HttpReleaseProvider.GITHUB_LATEST_URL = " + "os.environ['DEEPTUTOR_TEST_RELEASE_URL']" + ), + ] + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join((str(hook_dir), str(PROJECT_ROOT))) + env["DEEPTUTOR_TEST_RELEASE_URL"] = f"http://127.0.0.1:{server.server_port}/releases/latest" + if container_marker: + env["DEEPTUTOR_CONTAINER"] = "1" + else: + env.pop("DEEPTUTOR_CONTAINER", None) + + try: + completed = subprocess.run( + [sys.executable, "-m", "deeptutor_cli.main", "update", "--check"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert completed.returncode == 0, completed.stderr + assert f"Installation: {expected_mode}" in completed.stdout + assert "Latest stable: 1.6.0" in completed.stdout + if container_marker: + assert "recreate the service on the host" in completed.stdout + + +def test_wheel_user_can_check_for_updates_from_the_real_cli_process( + tmp_path: Path, +) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _GitHubReleaseHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + + installed = tmp_path / "site-packages" + shutil.copytree(PROJECT_ROOT / "deeptutor", installed / "deeptutor") + shutil.copytree(PROJECT_ROOT / "deeptutor_cli", installed / "deeptutor_cli") + dist_info = installed / "deeptutor-1.5.4.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + "Metadata-Version: 2.1\nName: deeptutor\nVersion: 1.5.4\n", + encoding="utf-8", + ) + + hook_dir = tmp_path / "hook" + hook_dir.mkdir() + (hook_dir / "sitecustomize.py").write_text( + "\n".join( + [ + "import os", + "from deeptutor.update import HttpReleaseProvider", + ("HttpReleaseProvider.PYPI_URL = os.environ['DEEPTUTOR_TEST_PYPI_URL']"), + ] + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join((str(hook_dir), str(installed))) + env["DEEPTUTOR_TEST_PYPI_URL"] = f"http://127.0.0.1:{server.server_port}/pypi/deeptutor/json" + env.pop("DEEPTUTOR_CONTAINER", None) + + try: + completed = subprocess.run( + [sys.executable, "-m", "deeptutor_cli.main", "update", "--check"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert completed.returncode == 0, completed.stderr + assert "Installation: pypi" in completed.stdout + assert "Latest stable: 1.6.0" in completed.stdout + + +def test_cli_only_user_can_check_for_updates_from_the_real_cli_process( + tmp_path: Path, +) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _GitHubReleaseHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + + hook_dir = tmp_path / "hook" + hook_dir.mkdir() + cli_project_uri = (PROJECT_ROOT / "packaging" / "deeptutor-cli").as_uri() + (hook_dir / "sitecustomize.py").write_text( + "\n".join( + [ + "import importlib.metadata as metadata", + "import json", + "import os", + "_real_distribution = metadata.distribution", + "class _CliDistribution:", + " version = '1.5.4'", + " def read_text(self, name):", + " if name != 'direct_url.json':", + " return None", + ( + " return json.dumps({'url': " + f"'{cli_project_uri}', " + "'dir_info': {'editable': True}})" + ), + "def _distribution(name):", + " normalized = name.lower().replace('_', '-')", + " if normalized == 'deeptutor':", + " raise metadata.PackageNotFoundError(name)", + " if normalized == 'deeptutor-cli':", + " return _CliDistribution()", + " return _real_distribution(name)", + "metadata.distribution = _distribution", + "from deeptutor.update import HttpReleaseProvider", + ( + "HttpReleaseProvider.GITHUB_LATEST_URL = " + "os.environ['DEEPTUTOR_TEST_RELEASE_URL']" + ), + ] + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join((str(hook_dir), str(PROJECT_ROOT))) + env["DEEPTUTOR_TEST_RELEASE_URL"] = f"http://127.0.0.1:{server.server_port}/releases/latest" + env.pop("DEEPTUTOR_CONTAINER", None) + + try: + completed = subprocess.run( + [sys.executable, "-m", "deeptutor_cli.main", "update", "--check"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert completed.returncode == 0, completed.stderr + assert "Installation: source_cli" in completed.stdout + assert "Latest stable: 1.6.0" in completed.stdout diff --git a/tests/update/test_coordinator.py b/tests/update/test_coordinator.py new file mode 100644 index 0000000000..64907775b0 --- /dev/null +++ b/tests/update/test_coordinator.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import httpx + +from deeptutor.update import ( + DistributionEvidence, + Installation, + InstallMode, + ReleaseInfo, + RuntimeEvidence, + UpdateCoordinator, + UpdateStatus, + detect_installation, +) + + +class _StaticReleases: + def latest(self, installation: Installation) -> ReleaseInfo: + return ReleaseInfo( + version="1.6.0", + release_url="https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + ) + + +def test_check_reports_a_stable_pypi_update() -> None: + installation = Installation( + mode=InstallMode.PYPI, + current_version="1.5.4", + package_name="deeptutor", + ) + coordinator = UpdateCoordinator( + installation_provider=lambda: installation, + release_provider=_StaticReleases(), + ) + + result = coordinator.check() + + assert result.status is UpdateStatus.AVAILABLE + assert result.current_version == "1.5.4" + assert result.latest_version == "1.6.0" + assert result.install_mode is InstallMode.PYPI + assert result.can_auto_update is True + assert result.release_url.endswith("/v1.6.0") + + +def test_check_reports_equal_stable_versions_as_up_to_date() -> None: + installation = Installation( + mode=InstallMode.PYPI, + current_version="1.6.0", + package_name="deeptutor", + ) + coordinator = UpdateCoordinator( + installation_provider=lambda: installation, + release_provider=_StaticReleases(), + ) + + result = coordinator.check() + + assert result.status is UpdateStatus.UP_TO_DATE + assert result.latest_version == "1.6.0" + + +def test_detect_installation_classifies_a_wheel_as_pypi(tmp_path) -> None: + evidence = RuntimeEvidence( + current_version="1.5.4", + package_root=tmp_path / "site-packages", + containerized=False, + deeptutor=DistributionEvidence(name="deeptutor", version="1.5.4"), + deeptutor_cli=None, + ) + + installation = detect_installation(evidence) + + assert installation == Installation( + mode=InstallMode.PYPI, + current_version="1.5.4", + package_name="deeptutor", + detail="installed distribution", + ) + + +def test_detect_installation_classifies_an_editable_full_checkout(tmp_path) -> None: + checkout = tmp_path / "DeepTutor" + evidence = RuntimeEvidence( + current_version="1.5.4", + package_root=checkout, + containerized=False, + deeptutor=DistributionEvidence( + name="deeptutor", + version="1.5.4", + editable_root=checkout, + ), + deeptutor_cli=None, + ) + + installation = detect_installation(evidence) + + assert installation == Installation( + mode=InstallMode.SOURCE_WEB, + current_version="1.5.4", + package_name="deeptutor", + source_root=checkout, + detail="editable full installation", + ) + + +def test_detect_installation_classifies_an_editable_cli_checkout(tmp_path) -> None: + checkout = tmp_path / "DeepTutor" + cli_project = checkout / "packaging" / "deeptutor-cli" + evidence = RuntimeEvidence( + current_version="1.5.4", + package_root=checkout, + containerized=False, + deeptutor=None, + deeptutor_cli=DistributionEvidence( + name="deeptutor-cli", + version="1.5.4", + editable_root=cli_project, + ), + ) + + installation = detect_installation(evidence) + + assert installation == Installation( + mode=InstallMode.SOURCE_CLI, + current_version="1.5.4", + package_name="deeptutor-cli", + source_root=checkout, + detail="editable CLI-only installation", + ) + + +def test_container_detection_wins_and_never_allows_automatic_updates(tmp_path) -> None: + evidence = RuntimeEvidence( + current_version="1.5.4", + package_root=tmp_path, + containerized=True, + deeptutor=DistributionEvidence(name="deeptutor", version="1.5.4"), + deeptutor_cli=None, + ) + coordinator = UpdateCoordinator( + installation_provider=lambda: detect_installation(evidence), + release_provider=_StaticReleases(), + ) + + result = coordinator.check() + + assert result.install_mode is InstallMode.DOCKER + assert result.status is UpdateStatus.AVAILABLE + assert result.can_auto_update is False + + +def test_detect_installation_refuses_conflicting_distributions(tmp_path) -> None: + evidence = RuntimeEvidence( + current_version="1.5.4", + package_root=tmp_path, + containerized=False, + deeptutor=DistributionEvidence(name="deeptutor", version="1.5.4"), + deeptutor_cli=DistributionEvidence( + name="deeptutor-cli", + version="1.5.4", + editable_root=tmp_path, + ), + ) + + installation = detect_installation(evidence) + + assert installation.mode is InstallMode.UNSUPPORTED + assert installation.can_auto_update is False + assert installation.detail == "conflicting DeepTutor distributions" + + +def test_check_reports_release_lookup_failures_without_crashing() -> None: + class OfflineReleases: + def latest(self, installation: Installation) -> ReleaseInfo: + raise httpx.ConnectError("offline") + + coordinator = UpdateCoordinator( + installation_provider=lambda: Installation( + mode=InstallMode.PYPI, + current_version="1.5.4", + package_name="deeptutor", + ), + release_provider=OfflineReleases(), + ) + + result = coordinator.check() + + assert result.status is UpdateStatus.FAILED + assert result.latest_version is None + assert result.release_url is None + assert result.detail == "Unable to check for updates." diff --git a/tests/update/test_installation_integration.py b/tests/update/test_installation_integration.py new file mode 100644 index 0000000000..d91f804d7b --- /dev/null +++ b/tests/update/test_installation_integration.py @@ -0,0 +1,10 @@ +from deeptutor.update import InstallMode, detect_current_installation + + +def test_current_checkout_is_detected_as_an_editable_full_installation() -> None: + installation = detect_current_installation() + + assert installation.mode is InstallMode.SOURCE_WEB + assert installation.package_name == "deeptutor" + assert installation.source_root is not None + assert (installation.source_root / "pyproject.toml").is_file() diff --git a/tests/update/test_release_provider.py b/tests/update/test_release_provider.py new file mode 100644 index 0000000000..ff16c3205c --- /dev/null +++ b/tests/update/test_release_provider.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import httpx + +from deeptutor.update import ( + HttpReleaseProvider, + Installation, + InstallMode, +) + + +def test_pypi_release_lookup_ignores_prereleases_and_yanked_files() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url == "https://pypi.org/pypi/deeptutor/json" + return httpx.Response( + 200, + json={ + "releases": { + "1.5.5": [{"yanked": False}], + "1.6.0rc1": [{"yanked": False}], + "1.6.0": [{"yanked": True}], + } + }, + ) + + provider = HttpReleaseProvider(client=httpx.Client(transport=httpx.MockTransport(handler))) + + release = provider.latest( + Installation( + mode=InstallMode.PYPI, + current_version="1.5.4", + package_name="deeptutor", + ) + ) + + assert release.version == "1.5.5" + assert release.release_url.endswith("/releases/tag/v1.5.5") + + +def test_source_release_lookup_uses_the_latest_stable_github_release() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url == ("https://api.github.com/repos/HKUDS/DeepTutor/releases/latest") + return httpx.Response( + 200, + json={ + "tag_name": "v1.6.0", + "html_url": ("https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0"), + "draft": False, + "prerelease": False, + }, + ) + + provider = HttpReleaseProvider(client=httpx.Client(transport=httpx.MockTransport(handler))) + + release = provider.latest( + Installation( + mode=InstallMode.SOURCE_WEB, + current_version="1.5.4", + package_name="deeptutor", + ) + ) + + assert release.version == "1.6.0" + assert release.release_url.endswith("/releases/tag/v1.6.0") diff --git a/tests/update/test_release_provider_integration.py b/tests/update/test_release_provider_integration.py new file mode 100644 index 0000000000..8c4c56dd6b --- /dev/null +++ b/tests/update/test_release_provider_integration.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from threading import Thread + +import httpx + +from deeptutor.update import HttpReleaseProvider, Installation, InstallMode + + +class _PyPIHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + payload = json.dumps( + {"releases": {"1.5.4": [{"yanked": False}], "1.6.0": [{"yanked": False}]}} + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args: object) -> None: + return + + +def test_pypi_lookup_crosses_a_real_http_boundary() -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _PyPIHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + provider = HttpReleaseProvider( + client=httpx.Client(), + pypi_url=f"http://127.0.0.1:{server.server_port}/pypi/deeptutor/json", + ) + + release = provider.latest( + Installation( + mode=InstallMode.PYPI, + current_version="1.5.4", + package_name="deeptutor", + ) + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert release.version == "1.6.0" From b91658dcacdd4a73cf018e8b6982591852fd4946 Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sat, 25 Jul 2026 18:00:22 +0800 Subject: [PATCH 2/8] feat(web): surface update availability in the version badge --- deeptutor/api/routers/system.py | 53 ++++++++++- tests/api/test_system_router.py | 54 +++++++++++ web/components/sidebar/VersionBadge.tsx | 88 ++++++++++++++--- web/lib/update-api.ts | 33 +++++++ web/lib/update-badge.ts | 38 ++++++++ web/locales/en/app.json | 4 + web/locales/zh/app.json | 4 + web/package.json | 1 + web/playwright.config.ts | 60 ++++++++++++ web/tests/e2e/update-badge.e2e.ts | 109 ++++++++++++++++++++++ web/tests/playwright-managed-home.test.ts | 48 ++++++++++ web/tests/update-api.test.ts | 35 +++++++ web/tests/update-badge.test.ts | 67 +++++++++++++ 13 files changed, 582 insertions(+), 12 deletions(-) create mode 100644 web/lib/update-api.ts create mode 100644 web/lib/update-badge.ts create mode 100644 web/tests/e2e/update-badge.e2e.ts create mode 100644 web/tests/playwright-managed-home.test.ts create mode 100644 web/tests/update-api.test.ts create mode 100644 web/tests/update-badge.test.ts diff --git a/deeptutor/api/routers/system.py b/deeptutor/api/routers/system.py index a4be0802b4..113e0bc7b4 100644 --- a/deeptutor/api/routers/system.py +++ b/deeptutor/api/routers/system.py @@ -3,10 +3,12 @@ Manages system status checks and model connection tests """ +import asyncio from datetime import datetime import time +from typing import Annotated, Protocol -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from deeptutor.multi_user.context import get_current_user @@ -15,6 +17,12 @@ from deeptutor.services.llm import complete as llm_complete from deeptutor.services.llm import get_llm_config, get_token_limit_kwargs from deeptutor.services.search import web_search +from deeptutor.update import ( + InstallMode, + UpdateCheck, + UpdateStatus, + create_update_coordinator, +) router = APIRouter() @@ -27,6 +35,49 @@ class TestResponse(BaseModel): error: str | None = None +class UpdateCheckResponse(BaseModel): + """Read-only update availability returned to Web clients.""" + + status: UpdateStatus + current_version: str + latest_version: str | None + install_mode: InstallMode + can_auto_update: bool + release_url: str | None + detail: str + + +class UpdateChecker(Protocol): + """Public check seam consumed by the system API.""" + + def check(self) -> UpdateCheck: + """Return current update availability.""" + + +def get_update_coordinator() -> UpdateChecker: + """Provide the process update coordinator for dependency injection.""" + + return create_update_coordinator() + + +@router.get("/update", response_model=UpdateCheckResponse) +async def get_update_status( + coordinator: Annotated[UpdateChecker, Depends(get_update_coordinator)], +) -> UpdateCheckResponse: + """Check the latest stable release without mutating the installation.""" + + result = await asyncio.to_thread(coordinator.check) + return UpdateCheckResponse( + status=result.status, + current_version=result.current_version, + latest_version=result.latest_version, + install_mode=result.install_mode, + can_auto_update=result.can_auto_update, + release_url=result.release_url, + detail=result.detail, + ) + + @router.get("/runtime-topology") async def get_runtime_topology(): """ diff --git a/tests/api/test_system_router.py b/tests/api/test_system_router.py index 738ce6239d..abb66a1b81 100644 --- a/tests/api/test_system_router.py +++ b/tests/api/test_system_router.py @@ -2,9 +2,12 @@ from types import SimpleNamespace +from fastapi import FastAPI +from fastapi.testclient import TestClient import pytest from deeptutor.api.routers import system as system_router +from deeptutor.update import InstallMode, UpdateCheck, UpdateStatus @pytest.mark.asyncio @@ -48,3 +51,54 @@ async def embed(self, texts: list[str]): assert response.success is False assert response.message == "Embeddings connection failed: Invalid response" + + +@pytest.mark.asyncio +async def test_update_endpoint_returns_the_coordinator_result() -> None: + class AvailableUpdate: + def check(self) -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.AVAILABLE, + current_version="1.5.4", + latest_version="1.6.0", + install_mode=InstallMode.PYPI, + can_auto_update=True, + release_url=("https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0"), + detail="installed distribution", + ) + + response = await system_router.get_update_status(AvailableUpdate()) + + assert response.model_dump(mode="json") == { + "status": "available", + "current_version": "1.5.4", + "latest_version": "1.6.0", + "install_mode": "pypi", + "can_auto_update": True, + "release_url": "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + "detail": "installed distribution", + } + + +def test_update_endpoint_is_available_through_the_system_http_route() -> None: + class UpToDate: + def check(self) -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.UP_TO_DATE, + current_version="1.5.4", + latest_version="1.5.4", + install_mode=InstallMode.SOURCE_WEB, + can_auto_update=True, + release_url=("https://github.com/HKUDS/DeepTutor/releases/tag/v1.5.4"), + detail="editable full installation", + ) + + app = FastAPI() + app.include_router(system_router.router, prefix="/api/v1/system") + app.dependency_overrides[system_router.get_update_coordinator] = UpToDate + + response = TestClient(app).get("/api/v1/system/update") + + assert response.status_code == 200 + assert response.json()["status"] == "up_to_date" + assert response.json()["install_mode"] == "source_web" diff --git a/web/components/sidebar/VersionBadge.tsx b/web/components/sidebar/VersionBadge.tsx index 10e0b48113..0a50059199 100644 --- a/web/components/sidebar/VersionBadge.tsx +++ b/web/components/sidebar/VersionBadge.tsx @@ -1,5 +1,13 @@ "use client"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { fetchUpdateStatus } from "@/lib/update-api"; +import { + presentUpdateBadge, + type UpdateBadgePresentation, +} from "@/lib/update-badge"; import { normalizeVersionTag } from "@/lib/version"; interface VersionBadgeProps { @@ -10,23 +18,81 @@ interface VersionBadgeProps { const RELEASES_URL = "https://github.com/HKUDS/DeepTutor/releases"; export function VersionBadge({ collapsed = false }: VersionBadgeProps) { + const { t } = useTranslation(); + const [update, setUpdate] = useState(null); + + useEffect(() => { + if (collapsed) return; + + const controller = new AbortController(); + void fetchUpdateStatus(controller.signal) + .then((result) => setUpdate(presentUpdateBadge(result))) + .catch(() => { + if (!controller.signal.aborted) setUpdate({ kind: "failed" }); + }); + return () => controller.abort(); + }, [collapsed]); + // Keep the collapsed sidebar entirely free of version chrome. if (collapsed) return null; + const upToDate = update?.kind === "up_to_date" ? update : null; const tag = normalizeVersionTag(process.env.NEXT_PUBLIC_APP_VERSION || ""); - const displayTag = tag ?? "—"; + const fallbackTag = upToDate?.version ?? null; + const displayTag = tag ?? fallbackTag ?? "—"; + const available = update?.kind === "available" ? update : null; + let statusText: string; + if (available?.hostManaged) statusText = t("Update on host") as string; + else if (available) statusText = t("Update available") as string; + else if (update === null) statusText = t("Checking for updates…") as string; + else if (update.kind === "up_to_date") statusText = t("Up to date") as string; + else statusText = t("Update check failed") as string; + const title = available?.hostManaged + ? (t( + "Update the image on the Docker host and recreate the container.", + ) as string) + : available + ? (t("Latest release") as string) + : statusText; + const ariaLabel = available + ? `${statusText}: ${displayTag} → ${available.version}. ${t("Latest release") as string}` + : `${displayTag}. ${statusText}`; return ( - - - {displayTag} - - + + + {displayTag} + + {available ? ( + <> + + + {available.version} + + {available.hostManaged ? ( + · {statusText} + ) : null} + + ) : ( + · {statusText} + )} + + ); } diff --git a/web/lib/update-api.ts b/web/lib/update-api.ts new file mode 100644 index 0000000000..c523d74e72 --- /dev/null +++ b/web/lib/update-api.ts @@ -0,0 +1,33 @@ +import { apiFetch, apiUrl } from "@/lib/api"; + +export type InstallMode = + | "pypi" + | "source_web" + | "source_cli" + | "docker" + | "unsupported"; + +export type UpdateStatus = "available" | "up_to_date" | "failed"; + +export interface UpdateCheckResponse { + status: UpdateStatus; + current_version: string; + latest_version: string | null; + install_mode: InstallMode; + can_auto_update: boolean; + release_url: string | null; + detail: string; +} + +export async function fetchUpdateStatus( + signal?: AbortSignal, +): Promise { + const response = await apiFetch(apiUrl("/api/v1/system/update"), { + cache: "no-store", + signal, + }); + if (!response.ok) { + throw new Error(`Update check failed (HTTP ${response.status})`); + } + return (await response.json()) as UpdateCheckResponse; +} diff --git a/web/lib/update-badge.ts b/web/lib/update-badge.ts new file mode 100644 index 0000000000..948ea50c44 --- /dev/null +++ b/web/lib/update-badge.ts @@ -0,0 +1,38 @@ +import type { UpdateCheckResponse } from "@/lib/update-api"; +import { normalizeVersionTag } from "@/lib/version"; + +export type UpdateBadgePresentation = + | { + kind: "available"; + version: string; + href: string; + hostManaged: boolean; + } + | { kind: "up_to_date"; version: string | null; href: string | null } + | { kind: "failed" }; + +export function presentUpdateBadge( + update: UpdateCheckResponse, +): UpdateBadgePresentation { + if ( + update.status === "available" && + update.latest_version && + update.release_url + ) { + return { + kind: "available", + version: + normalizeVersionTag(update.latest_version) ?? update.latest_version, + href: update.release_url, + hostManaged: update.install_mode === "docker", + }; + } + if (update.status === "up_to_date") { + return { + kind: "up_to_date", + version: normalizeVersionTag(update.latest_version ?? ""), + href: update.release_url, + }; + } + return { kind: "failed" }; +} diff --git a/web/locales/en/app.json b/web/locales/en/app.json index 1fd8706db3..4d43f3d071 100644 --- a/web/locales/en/app.json +++ b/web/locales/en/app.json @@ -1158,6 +1158,10 @@ "Latest release": "Latest release", "Up to date": "Up to date", "Update available": "Update available", + "Checking for updates…": "Checking for updates…", + "Update check failed": "Update check failed", + "Update on host": "Update on host", + "Update the image on the Docker host and recreate the container.": "Update the image on the Docker host and recreate the container.", "Development build": "Development build", "Run Tour": "Run Tour", "Tour": "Tour", diff --git a/web/locales/zh/app.json b/web/locales/zh/app.json index 826cf9b57a..63a936a45b 100644 --- a/web/locales/zh/app.json +++ b/web/locales/zh/app.json @@ -1190,6 +1190,10 @@ "Latest release": "最新版本", "Up to date": "已最新", "Update available": "有新版本", + "Checking for updates…": "正在检查更新…", + "Update check failed": "检查更新失败", + "Update on host": "请在宿主机更新", + "Update the image on the Docker host and recreate the container.": "请在 Docker 宿主机更新镜像并重新创建容器。", "Development build": "开发构建", "Run Tour": "运行引导", "Tour": "引导", diff --git a/web/package.json b/web/package.json index 6c388b34fb..df32768912 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "i18n:audit:strict": "node ./scripts/i18n_audit.mjs --strict", "i18n:check": "npm run i18n:parity && npm run i18n:audit", "audit": "playwright test --project=ui-audit", + "test:e2e": "playwright test --project=functional-e2e", "audit:ui": "playwright test --ui --project=ui-audit", "audit:report": "playwright show-report", "build:brand-icons": "tsx ./scripts/build-brand-icons.mts" diff --git a/web/playwright.config.ts b/web/playwright.config.ts index d77bf44ece..c6d2c1e32e 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,10 +1,33 @@ import { defineConfig, devices } from "@playwright/test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; const BASE_URL = process.env.WEB_BASE_URL || process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3000"; const SERIAL_MODE = process.env.PW_SERIAL === "1"; +const MANAGE_SERVERS = process.env.PW_MANAGED_SERVERS === "1"; +const FUNCTIONAL_BASE_URL = "http://127.0.0.1:3100"; +const WEB_ROOT = __dirname; +const REPO_ROOT = resolve(WEB_ROOT, ".."); +const E2E_HOME = MANAGE_SERVERS + ? mkdtempSync(join(tmpdir(), "deeptutor-e2e-")) + : ""; + +if (E2E_HOME) { + const cleanupE2EHome = () => { + rmSync(E2E_HOME, { force: true, recursive: true }); + }; + process.once("exit", cleanupE2EHome); + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + cleanupE2EHome(); + process.kill(process.pid, signal); + }); + } +} export default defineConfig({ testDir: "./tests", @@ -17,11 +40,48 @@ export default defineConfig({ baseURL: BASE_URL, trace: "on-first-retry", }, + webServer: MANAGE_SERVERS + ? [ + { + command: + "uv run --no-sync uvicorn deeptutor.api.main:app --host 127.0.0.1 --port 8101 --no-access-log", + cwd: REPO_ROOT, + env: { + DEEPTUTOR_HOME: E2E_HOME, + DEEPTUTOR_AUTH_ENABLED: "false", + }, + url: "http://127.0.0.1:8101/", + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: + "bun ./node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3100", + cwd: WEB_ROOT, + env: { + DEEPTUTOR_HOME: E2E_HOME, + DEEPTUTOR_API_BASE_URL: "http://127.0.0.1:8101", + DEEPTUTOR_AUTH_ENABLED: "false", + }, + url: FUNCTIONAL_BASE_URL, + reuseExistingServer: false, + timeout: 120_000, + }, + ] + : undefined, projects: [ { name: "ui-audit", testMatch: "**/*.audit.ts", use: { ...devices["Desktop Chrome"] }, }, + { + name: "functional-e2e", + testMatch: "**/*.e2e.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: MANAGE_SERVERS ? FUNCTIONAL_BASE_URL : BASE_URL, + }, + }, ], }); diff --git a/web/tests/e2e/update-badge.e2e.ts b/web/tests/e2e/update-badge.e2e.ts new file mode 100644 index 0000000000..b325f82f6f --- /dev/null +++ b/web/tests/e2e/update-badge.e2e.ts @@ -0,0 +1,109 @@ +import { expect, test } from "@playwright/test"; + +const RELEASE_URL = + "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0"; +const AVAILABLE_UPDATE = { + status: "available", + current_version: "1.5.4", + latest_version: "1.6.0", + install_mode: "pypi", + can_auto_update: true, + release_url: RELEASE_URL, + detail: "installed distribution", +}; + +test("version badge links to the latest release when an update is available", async ({ + page, +}) => { + let markRequestStarted = () => {}; + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve; + }); + let releaseResponse = () => {}; + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + await page.route("**/api/v1/system/update", async (route) => { + markRequestStarted(); + await responseGate; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(AVAILABLE_UPDATE), + }); + }); + + await page.goto("/"); + + await expect(page.getByTestId("version-badge")).toContainText( + "Checking for updates…", + ); + await requestStarted; + releaseResponse(); + + const updateStatus = page.getByTestId("update-status"); + await expect(updateStatus).toBeVisible(); + await expect(updateStatus).toContainText("v1.6.0"); + await expect(updateStatus).toHaveAttribute("href", RELEASE_URL); + await expect(updateStatus).toHaveAccessibleName(/update available.*v1\.6\.0/i); +}); + +test("version badge reports an up-to-date installation", async ({ page }) => { + await page.route("**/api/v1/system/update", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...AVAILABLE_UPDATE, + status: "up_to_date", + latest_version: "1.5.4", + release_url: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.5.4", + }), + }), + ); + + await page.goto("/"); + + const badge = page.getByTestId("version-badge"); + await expect(badge).toContainText("Up to date"); + await expect(page.getByTestId("update-status")).toHaveAttribute( + "href", + "https://github.com/HKUDS/DeepTutor/releases/tag/v1.5.4", + ); +}); + +test("version badge reports a failed update check", async ({ page }) => { + await page.route("**/api/v1/system/update", (route) => + route.fulfill({ status: 503, body: "service unavailable" }), + ); + + await page.goto("/"); + + await expect(page.getByTestId("version-badge")).toContainText( + "Update check failed", + ); +}); + +test("Docker installations direct updates to the host without an update action", async ({ + page, +}) => { + await page.route("**/api/v1/system/update", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...AVAILABLE_UPDATE, + install_mode: "docker", + can_auto_update: false, + detail: "Update the image on the Docker host and recreate the container.", + }), + }), + ); + + await page.goto("/"); + + const badge = page.getByTestId("version-badge"); + await expect(badge).toContainText("v1.6.0"); + await expect(badge).toContainText(/update on host/i); + await expect(badge.getByRole("button", { name: /update/i })).toHaveCount(0); +}); diff --git a/web/tests/playwright-managed-home.test.ts b/web/tests/playwright-managed-home.test.ts new file mode 100644 index 0000000000..e39148680b --- /dev/null +++ b/web/tests/playwright-managed-home.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const WEB_ROOT = process.cwd(); +const PLAYWRIGHT_CLI = resolve( + WEB_ROOT, + "node_modules", + "@playwright", + "test", + "cli.js", +); + +test("managed Playwright runs remove their temporary home", () => { + const isolatedTmp = mkdtempSync(join(tmpdir(), "deeptutor-pw-test-")); + + try { + const playwrightArgs = ["test", "--list", "--project=functional-e2e"]; + const result = spawnSync( + process.execPath, + "bun" in process.versions + ? ["x", "playwright", ...playwrightArgs] + : [PLAYWRIGHT_CLI, ...playwrightArgs], + { + cwd: WEB_ROOT, + encoding: "utf8", + env: { + ...process.env, + PW_MANAGED_SERVERS: "1", + TMPDIR: isolatedTmp, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.deepEqual( + readdirSync(isolatedTmp).filter((name) => + name.startsWith("deeptutor-e2e-"), + ), + [], + ); + } finally { + rmSync(isolatedTmp, { force: true, recursive: true }); + } +}); diff --git a/web/tests/update-api.test.ts b/web/tests/update-api.test.ts new file mode 100644 index 0000000000..25438995c6 --- /dev/null +++ b/web/tests/update-api.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { fetchUpdateStatus } from "../lib/update-api"; + +test("fetchUpdateStatus returns the system update payload", async () => { + const originalFetch = globalThis.fetch; + let requestedUrl = ""; + globalThis.fetch = async (input) => { + requestedUrl = String(input); + return new Response( + JSON.stringify({ + status: "available", + current_version: "1.5.4", + latest_version: "1.6.0", + install_mode: "pypi", + can_auto_update: true, + release_url: + "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + detail: "installed distribution", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + try { + const result = await fetchUpdateStatus(); + + assert.equal(requestedUrl, "/api/v1/system/update"); + assert.equal(result.status, "available"); + assert.equal(result.latest_version, "1.6.0"); + assert.equal(result.install_mode, "pypi"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/tests/update-badge.test.ts b/web/tests/update-badge.test.ts new file mode 100644 index 0000000000..92f7f39d31 --- /dev/null +++ b/web/tests/update-badge.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { presentUpdateBadge } from "../lib/update-badge"; + +test("presentUpdateBadge exposes an actionable release link for available updates", () => { + const presentation = presentUpdateBadge({ + status: "available", + current_version: "1.5.4", + latest_version: "1.6.0", + install_mode: "pypi", + can_auto_update: true, + release_url: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + detail: "installed distribution", + }); + + assert.deepEqual(presentation, { + kind: "available", + version: "v1.6.0", + href: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + hostManaged: false, + }); +}); + +test("presentUpdateBadge marks Docker updates as host-managed", () => { + const presentation = presentUpdateBadge({ + status: "available", + current_version: "1.5.4", + latest_version: "1.6.0", + install_mode: "docker", + can_auto_update: false, + release_url: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + detail: "Update the image on the Docker host and recreate the container.", + }); + + assert.deepEqual(presentation, { + kind: "available", + version: "v1.6.0", + href: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + hostManaged: true, + }); +}); + +test("presentUpdateBadge preserves up-to-date and failed states", () => { + const base = { + current_version: "1.5.4", + latest_version: "1.5.4", + install_mode: "pypi" as const, + can_auto_update: true, + release_url: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.5.4", + detail: "installed distribution", + }; + + assert.deepEqual(presentUpdateBadge({ ...base, status: "up_to_date" }), { + kind: "up_to_date", + version: "v1.5.4", + href: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.5.4", + }); + assert.deepEqual( + presentUpdateBadge({ + ...base, + status: "failed", + latest_version: null, + release_url: null, + }), + { kind: "failed" }, + ); +}); From b3532f1e2f24aa14d5ade479ca90f384dd27d3f4 Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sat, 25 Jul 2026 18:08:54 +0800 Subject: [PATCH 3/8] feat(update): apply PyPI upgrades through the CLI Persist a single trusted update job, execute a fixed deeptutor upgrade from a detached worker after the CLI exits, and retain terminal status without restarting the app. Cover confirmation, cancellation, duplicate jobs, failures, tamper rejection, and the real process boundary. --- README.md | 1 + deeptutor/update/jobs.py | 284 +++++++++++++++++++++++++++++ deeptutor/update/worker.py | 136 ++++++++++++++ deeptutor_cli/README.md | 12 +- deeptutor_cli/update_cmd.py | 69 +++++-- tests/cli/test_update_cli.py | 90 ++++++++- tests/e2e/test_update_check_cli.py | 94 ++++++++++ tests/update/test_jobs.py | 67 +++++++ tests/update/test_worker.py | 85 +++++++++ 9 files changed, 807 insertions(+), 31 deletions(-) create mode 100644 deeptutor/update/jobs.py create mode 100644 deeptutor/update/worker.py create mode 100644 tests/update/test_jobs.py create mode 100644 tests/update/test_worker.py diff --git a/README.md b/README.md index 4392d8605d..fb58ded38f 100644 --- a/README.md +++ b/README.md @@ -720,6 +720,7 @@ The repo ships a root [`SKILL.md`](SKILL.md) — a ~150-line handover doc that t | `deeptutor init` | Create or update `data/user/settings` for the current workspace | | `deeptutor start [--home PATH] [--dev]` | Launch backend + frontend together; `--dev` enables frontend HMR | | `deeptutor serve [--port PORT]` | Start only the FastAPI backend | +| `deeptutor update` | Confirm and schedule a PyPI upgrade in the current Python environment; the CLI exits before the worker runs and does not restart the app | | `deeptutor update --check` | Detect the installation mode and check the latest stable release without changing the installation | | `deeptutor run ` | Run a single capability turn (`chat`, `deep_solve`, `deep_question`, `deep_research`, `visualize`, `math_animator`, `mastery_path`); add `--format json` for NDJSON output | | `deeptutor chat` | Interactive REPL with capability, tool, KB, notebook, and history controls | diff --git a/deeptutor/update/jobs.py b/deeptutor/update/jobs.py new file mode 100644 index 0000000000..486637429b --- /dev/null +++ b/deeptutor/update/jobs.py @@ -0,0 +1,284 @@ +"""Persistent update jobs and detached worker launch.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, replace +from datetime import datetime, timezone +from enum import Enum +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Protocol +import uuid + +from packaging.version import Version + +from deeptutor.runtime.home import get_runtime_home +from deeptutor.services.file_io import atomic_write_json + + +class JobStatus(str, Enum): + """Durable lifecycle of one update job.""" + + PENDING = "pending" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class UpdateInProgressError(RuntimeError): + """Raised when another update job owns the active marker.""" + + +@dataclass(frozen=True) +class UpdateJob: + """Trusted data required to apply one PyPI update.""" + + id: str + status: JobStatus + current_version: str + target_version: str + created_at: str + started_at: str | None = None + finished_at: str | None = None + error: str | None = None + schema_version: int = 1 + kind: str = "pypi" + + def to_dict(self) -> dict[str, object]: + """Serialize the job for durable storage.""" + + payload = asdict(self) + payload["status"] = self.status.value + return payload + + @classmethod + def from_dict(cls, payload: dict[str, object]) -> UpdateJob: + """Validate and deserialize one stored job.""" + + if payload.get("schema_version") != 1 or payload.get("kind") != "pypi": + raise ValueError("Unsupported update job") + return cls( + id=str(payload["id"]), + status=JobStatus(str(payload["status"])), + current_version=str(payload["current_version"]), + target_version=str(payload["target_version"]), + created_at=str(payload["created_at"]), + started_at=_optional_string(payload.get("started_at")), + finished_at=_optional_string(payload.get("finished_at")), + error=_optional_string(payload.get("error")), + ) + + +def _optional_string(value: object) -> str | None: + return None if value is None else str(value) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _canonical_version(raw: str, *, stable: bool) -> str: + version = Version(raw) + if stable and (version.is_prerelease or version.is_devrelease): + raise ValueError("Update target must be a stable version") + return str(version) + + +class UpdateJobStore: + """Persist the current job and reserve the single active update slot.""" + + def __init__(self, root: Path) -> None: + self.root = Path(root) + self.state_path = self.root / "state.json" + self.active_path = self.root / "active" + self.log_path = self.root / "worker.log" + + def create_pypi(self, *, current_version: str, target_version: str) -> UpdateJob: + """Reserve the active slot for one PyPI update.""" + + job = UpdateJob( + id=uuid.uuid4().hex, + status=JobStatus.PENDING, + current_version=_canonical_version(current_version, stable=False), + target_version=_canonical_version(target_version, stable=True), + created_at=_now(), + ) + self.root.mkdir(parents=True, exist_ok=True) + try: + descriptor = os.open( + self.active_path, + os.O_CREAT | os.O_EXCL | os.O_WRONLY, + 0o600, + ) + except FileExistsError as exc: + raise UpdateInProgressError("Another update job is already active") from exc + try: + os.write(descriptor, job.id.encode("ascii")) + finally: + os.close(descriptor) + try: + self._write(job) + except Exception: + self.release(job.id) + raise + return job + + def load(self) -> UpdateJob: + """Load and validate the persisted job.""" + + payload = json.loads(self.state_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("Invalid update job") + return UpdateJob.from_dict(payload) + + def mark_running(self, job_id: str) -> UpdateJob: + """Mark a pending or handed-off job as running.""" + + return self._transition(job_id, JobStatus.RUNNING) + + def mark_succeeded(self, job_id: str) -> UpdateJob: + """Finish a job successfully and release its active slot.""" + + return self._transition(job_id, JobStatus.SUCCEEDED) + + def mark_failed(self, job_id: str, error: str) -> UpdateJob: + """Finish a failed job and persist its bounded error message.""" + + return self._transition(job_id, JobStatus.FAILED, error=error) + + def release(self, job_id: str) -> None: + """Release the active marker when it still belongs to *job_id*.""" + + try: + active_job_id = self.active_path.read_text(encoding="ascii") + except OSError: + return + if active_job_id == job_id: + self.active_path.unlink(missing_ok=True) + + def _transition( + self, + job_id: str, + status: JobStatus, + *, + error: str | None = None, + ) -> UpdateJob: + current = self.load() + if current.id != job_id: + raise RuntimeError("Update job changed while it was running") + timestamp = _now() + updated = replace( + current, + status=status, + started_at=timestamp if status is JobStatus.RUNNING else current.started_at, + finished_at=( + timestamp + if status in {JobStatus.SUCCEEDED, JobStatus.FAILED} + else current.finished_at + ), + error=error[:1000] if error else None, + ) + self._write(updated) + if status in {JobStatus.SUCCEEDED, JobStatus.FAILED}: + self.release(job_id) + return updated + + def _write(self, job: UpdateJob) -> None: + atomic_write_json(self.state_path, job.to_dict()) + + +class WorkerLauncher(Protocol): + """Boundary for starting the detached updater process.""" + + def launch(self, store_root: Path, *, parent_pid: int) -> None: + """Start a worker for the persisted job.""" + + +class SubprocessWorkerLauncher: + """Start the trusted update worker outside the current CLI process.""" + + def __init__(self, python_executable: str | None = None) -> None: + self._python_executable = python_executable or sys.executable + + def launch(self, store_root: Path, *, parent_pid: int) -> None: + """Launch the fixed update worker as a detached process.""" + + command = [ + self._python_executable, + "-m", + "deeptutor.update.worker", + "--store-root", + str(store_root.resolve()), + "--parent-pid", + str(parent_pid), + ] + store_root.mkdir(parents=True, exist_ok=True) + log_path = store_root / "worker.log" + kwargs: dict[str, object] = { + "stdin": subprocess.DEVNULL, + "stderr": subprocess.STDOUT, + "close_fds": True, + "shell": False, + } + if os.name == "nt": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + | subprocess.DETACHED_PROCESS # type: ignore[attr-defined] + ) + else: + kwargs["start_new_session"] = True + with log_path.open("a", encoding="utf-8") as log: + subprocess.Popen(command, stdout=log, **kwargs) # type: ignore[arg-type,call-overload] + + +class UpdateScheduler: + """Reserve and launch one PyPI update job.""" + + def __init__(self, *, store: UpdateJobStore, launcher: WorkerLauncher) -> None: + self._store = store + self._launcher = launcher + + def schedule_pypi( + self, + *, + current_version: str, + target_version: str, + parent_pid: int, + ) -> UpdateJob: + """Persist and launch one PyPI update after the CLI exits.""" + + job = self._store.create_pypi( + current_version=current_version, + target_version=target_version, + ) + try: + self._launcher.launch(self._store.root, parent_pid=parent_pid) + except Exception as exc: + self._store.mark_failed(job.id, f"worker launch failed: {exc}") + raise + return job + + +def create_update_scheduler(home: str | Path | None = None) -> UpdateScheduler: + """Build the production scheduler for the active runtime home.""" + + root = get_runtime_home(home) / "data" / "user" / "update" + return UpdateScheduler( + store=UpdateJobStore(root), + launcher=SubprocessWorkerLauncher(), + ) + + +__all__ = ( + "JobStatus", + "SubprocessWorkerLauncher", + "UpdateInProgressError", + "UpdateJob", + "UpdateJobStore", + "UpdateScheduler", + "WorkerLauncher", + "create_update_scheduler", +) diff --git a/deeptutor/update/worker.py b/deeptutor/update/worker.py new file mode 100644 index 0000000000..2f1abfdea3 --- /dev/null +++ b/deeptutor/update/worker.py @@ -0,0 +1,136 @@ +"""Out-of-process executor for persisted update jobs.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import subprocess +import sys +import time +from typing import Callable, Protocol + +from packaging.version import Version + +from .jobs import JobStatus, UpdateJobStore + + +class CommandExecutor(Protocol): + """Boundary for executing the fixed update command.""" + + def run(self, command: list[str], *, log_path: Path) -> int: + """Run *command* without a shell and return its exit status.""" + + +class SubprocessCommandExecutor: + """Execute an update while appending output to the worker log.""" + + def run(self, command: list[str], *, log_path: Path) -> int: + """Run the fixed PyPI command and append its combined output.""" + + with log_path.open("a", encoding="utf-8") as log: + completed = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + check=False, + shell=False, + ) + return completed.returncode + + +def build_pypi_update_command(target_version: str) -> list[str]: + """Build the only command a PyPI update job may execute.""" + + version = Version(target_version) + if version.is_prerelease or version.is_devrelease: + raise ValueError("Update target must be a stable version") + return [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "--no-input", + f"deeptutor=={version}", + ] + + +def _pid_is_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except PermissionError: + return True + except OSError: + return False + return True + + +def wait_for_process_exit(pid: int, *, timeout: float = 60.0) -> None: + """Wait for the scheduling CLI process to release imported files.""" + + if pid <= 0 or pid == os.getpid(): + raise ValueError("Invalid parent process") + deadline = time.monotonic() + timeout + while _pid_is_alive(pid): + if time.monotonic() >= deadline: + raise TimeoutError("CLI process did not exit before update timeout") + time.sleep(0.05) + + +def run_update_worker( + *, + store_root: Path, + parent_pid: int | None, + executor: CommandExecutor | None = None, + wait_for_parent: Callable[[int], None] = wait_for_process_exit, +) -> int: + """Apply one persisted PyPI job and persist its terminal status.""" + + store = UpdateJobStore(store_root) + try: + job = store.load() + except Exception: + return 1 + try: + if job.status is not JobStatus.PENDING: + raise RuntimeError("Update job is not pending") + if parent_pid is not None: + wait_for_parent(parent_pid) + store.mark_running(job.id) + command = build_pypi_update_command(job.target_version) + exit_code = (executor or SubprocessCommandExecutor()).run( + command, + log_path=store.log_path, + ) + if exit_code != 0: + store.mark_failed(job.id, f"pip exited with status {exit_code}") + return 1 + store.mark_succeeded(job.id) + return 0 + except Exception as exc: + try: + store.mark_failed(job.id, str(exc) or type(exc).__name__) + except Exception: + pass + return 1 + + +def main() -> None: + """Run one update worker from trusted persisted arguments.""" + + parser = argparse.ArgumentParser(description="DeepTutor update worker") + parser.add_argument("--store-root", type=Path, required=True) + parser.add_argument("--parent-pid", type=int, required=True) + args = parser.parse_args() + raise SystemExit( + run_update_worker( + store_root=args.store_root, + parent_pid=args.parent_pid, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/deeptutor_cli/README.md b/deeptutor_cli/README.md index a30705934e..c0a095b4aa 100644 --- a/deeptutor_cli/README.md +++ b/deeptutor_cli/README.md @@ -155,15 +155,17 @@ deeptutor chat [options] --- -## `update --check` — 检查稳定版更新 +## `update` — 检查或安装稳定版更新 ```bash -deeptutor update --check +deeptutor update # PyPI 安装:确认后更新当前 Python 环境 +deeptutor update --check # 仅检查,不修改环境 ``` -该命令只读取当前安装方式和官方稳定版元数据,不会修改环境。它会区分 -PyPI、完整源码、CLI-only 源码和 Docker 安装;Docker 只提示在宿主机 -更新镜像并重建服务。 +`--check` 只读取当前安装方式和官方稳定版元数据。PyPI 安装执行更新时, +当前 CLI 会先退出,再由独立 Worker 升级固定的 `deeptutor` 包;完成后不会 +自动启动应用。源码安装目前仍只检查,Docker 只提示在宿主机更新镜像并 +重建服务。 --- diff --git a/deeptutor_cli/update_cmd.py b/deeptutor_cli/update_cmd.py index f1d4e08cc0..a0dafa5046 100644 --- a/deeptutor_cli/update_cmd.py +++ b/deeptutor_cli/update_cmd.py @@ -2,9 +2,29 @@ from __future__ import annotations +import os + import typer -from deeptutor.update import InstallMode, UpdateStatus, create_update_coordinator +from deeptutor.update import InstallMode, UpdateCheck, UpdateStatus, create_update_coordinator +from deeptutor.update.jobs import UpdateInProgressError, create_update_scheduler + + +def _print_check(result: UpdateCheck) -> None: + status_label = { + UpdateStatus.AVAILABLE: "update available", + UpdateStatus.UP_TO_DATE: "up to date", + UpdateStatus.FAILED: "check failed", + }[result.status] + typer.echo(f"Installation: {result.install_mode.value}") + typer.echo(f"Current version: {result.current_version}") + typer.echo(f"Latest stable: {result.latest_version or 'unknown'}") + typer.echo(f"Status: {status_label}") + typer.echo(f"Automatic update: {'yes' if result.can_auto_update else 'no'}") + if result.release_url: + typer.echo(f"Release notes: {result.release_url}") + if result.install_mode is InstallMode.DOCKER: + typer.echo("Update the container image and recreate the service on the host.") def register(app: typer.Typer) -> None: @@ -20,28 +40,39 @@ def update( ) -> None: """Check for or install a stable DeepTutor update.""" - if not check: - typer.echo("Automatic update execution is not available yet. Use --check.") - raise typer.Exit(code=2) - result = create_update_coordinator().check() - status_label = { - UpdateStatus.AVAILABLE: "update available", - UpdateStatus.UP_TO_DATE: "up to date", - UpdateStatus.FAILED: "check failed", - }[result.status] - typer.echo(f"Installation: {result.install_mode.value}") - typer.echo(f"Current version: {result.current_version}") - typer.echo(f"Latest stable: {result.latest_version or 'unknown'}") - typer.echo(f"Status: {status_label}") - typer.echo(f"Automatic update: {'yes' if result.can_auto_update else 'no'}") - if result.release_url: - typer.echo(f"Release notes: {result.release_url}") - if result.install_mode is InstallMode.DOCKER: - typer.echo("Update the container image and recreate the service on the host.") + _print_check(result) if result.status is UpdateStatus.FAILED: typer.echo(result.detail) raise typer.Exit(code=1) + if check or result.status is UpdateStatus.UP_TO_DATE: + return + if result.install_mode is not InstallMode.PYPI: + typer.echo(f"Automatic updates are not available for {result.install_mode.value} yet.") + raise typer.Exit(code=2) + if not result.latest_version: + typer.echo("The stable target version is unavailable.") + raise typer.Exit(code=1) + if not typer.confirm( + f"Update deeptutor from {result.current_version} to {result.latest_version}?", + default=False, + ): + typer.echo("Update cancelled.") + return + try: + job = create_update_scheduler().schedule_pypi( + current_version=result.current_version, + target_version=result.latest_version, + parent_pid=os.getpid(), + ) + except UpdateInProgressError as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) from exc + except OSError as exc: + typer.echo(f"Unable to start update worker: {exc}") + raise typer.Exit(code=1) from exc + typer.echo(f"Update scheduled: {job.id}") + typer.echo("DeepTutor will not restart automatically after this CLI update.") __all__ = ("register",) diff --git a/tests/cli/test_update_cli.py b/tests/cli/test_update_cli.py index 5c376c674d..9bb9516982 100644 --- a/tests/cli/test_update_cli.py +++ b/tests/cli/test_update_cli.py @@ -4,19 +4,95 @@ from typer.testing import CliRunner from deeptutor.__version__ import __version__ +from deeptutor.update import InstallMode, UpdateCheck, UpdateStatus +from deeptutor.update.jobs import UpdateInProgressError +from deeptutor_cli import update_cmd from deeptutor_cli.main import app -def test_update_without_check_does_not_run_an_update(monkeypatch) -> None: - def unexpected_get(self, url: str) -> httpx.Response: - raise AssertionError("update command should not access the network") +def _available_pypi_update() -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.AVAILABLE, + current_version="1.5.4", + latest_version="1.6.0", + install_mode=InstallMode.PYPI, + can_auto_update=True, + release_url="https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + detail="installed distribution", + ) - monkeypatch.setattr(httpx.Client, "get", unexpected_get) - result = CliRunner().invoke(app, ["update"]) +def test_update_cancelled_by_user_does_not_create_a_job(monkeypatch) -> None: + monkeypatch.setattr( + update_cmd, + "create_update_coordinator", + lambda: type("Coordinator", (), {"check": lambda self: _available_pypi_update()})(), + ) - assert result.exit_code == 2 - assert "Use --check" in result.output + class UnexpectedScheduler: + def schedule_pypi(self, **kwargs): + raise AssertionError("cancelled update must not create a job") + + monkeypatch.setattr( + update_cmd, + "create_update_scheduler", + lambda: UnexpectedScheduler(), + raising=False, + ) + + result = CliRunner().invoke(app, ["update"], input="n\n") + + assert result.exit_code == 0 + assert "Update cancelled" in result.output + + +def test_confirmed_update_schedules_pypi_worker(monkeypatch) -> None: + monkeypatch.setattr( + update_cmd, + "create_update_coordinator", + lambda: type("Coordinator", (), {"check": lambda self: _available_pypi_update()})(), + ) + scheduled: dict[str, object] = {} + + class Scheduler: + def schedule_pypi(self, **kwargs): + scheduled.update(kwargs) + return type("Job", (), {"id": "job-123"})() + + monkeypatch.setattr( + update_cmd, + "create_update_scheduler", + lambda: Scheduler(), + raising=False, + ) + + result = CliRunner().invoke(app, ["update"], input="y\n") + + assert result.exit_code == 0, result.output + assert scheduled["current_version"] == "1.5.4" + assert scheduled["target_version"] == "1.6.0" + assert isinstance(scheduled["parent_pid"], int) + assert "job-123" in result.output + assert "will not restart" in result.output + + +def test_confirmed_update_reports_an_existing_active_job(monkeypatch) -> None: + monkeypatch.setattr( + update_cmd, + "create_update_coordinator", + lambda: type("Coordinator", (), {"check": lambda self: _available_pypi_update()})(), + ) + + class BusyScheduler: + def schedule_pypi(self, **kwargs): + raise UpdateInProgressError("Another update job is already active") + + monkeypatch.setattr(update_cmd, "create_update_scheduler", lambda: BusyScheduler()) + + result = CliRunner().invoke(app, ["update"], input="y\n") + + assert result.exit_code == 1 + assert "Another update job is already active" in result.output def test_update_check_reports_the_latest_stable_release(monkeypatch) -> None: diff --git a/tests/e2e/test_update_check_cli.py b/tests/e2e/test_update_check_cli.py index 05606a956b..301da262e7 100644 --- a/tests/e2e/test_update_check_cli.py +++ b/tests/e2e/test_update_check_cli.py @@ -8,6 +8,7 @@ import subprocess import sys from threading import Thread +import time import pytest @@ -215,3 +216,96 @@ def test_cli_only_user_can_check_for_updates_from_the_real_cli_process( assert completed.returncode == 0, completed.stderr assert "Installation: source_cli" in completed.stdout assert "Latest stable: 1.6.0" in completed.stdout + + +def test_pypi_user_can_confirm_an_update_that_runs_after_cli_exit(tmp_path: Path) -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _GitHubReleaseHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + + installed = tmp_path / "site-packages" + shutil.copytree(PROJECT_ROOT / "deeptutor", installed / "deeptutor") + shutil.copytree(PROJECT_ROOT / "deeptutor_cli", installed / "deeptutor_cli") + dist_info = installed / "deeptutor-1.5.4.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + "Metadata-Version: 2.1\nName: deeptutor\nVersion: 1.5.4\n", + encoding="utf-8", + ) + + hook_dir = tmp_path / "hook" + hook_dir.mkdir() + (hook_dir / "sitecustomize.py").write_text( + "\n".join( + [ + "import os", + "from deeptutor.update import HttpReleaseProvider", + "HttpReleaseProvider.PYPI_URL = os.environ['DEEPTUTOR_TEST_PYPI_URL']", + ] + ), + encoding="utf-8", + ) + fake_modules = tmp_path / "fake-modules" + fake_pip = fake_modules / "pip" + fake_pip.mkdir(parents=True) + (fake_pip / "__init__.py").write_text("", encoding="utf-8") + (fake_pip / "__main__.py").write_text( + "\n".join( + [ + "import json", + "import os", + "from pathlib import Path", + "import sys", + ( + "Path(os.environ['DEEPTUTOR_TEST_PIP_COMMAND']).write_text(" + "json.dumps(sys.argv[1:]), encoding='utf-8')" + ), + ] + ), + encoding="utf-8", + ) + + runtime_home = tmp_path / "home" + command_path = tmp_path / "pip-command.json" + state_path = runtime_home / "data" / "user" / "update" / "state.json" + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join((str(hook_dir), str(fake_modules), str(installed))) + env["DEEPTUTOR_HOME"] = str(runtime_home) + env["DEEPTUTOR_TEST_PYPI_URL"] = f"http://127.0.0.1:{server.server_port}/pypi/deeptutor/json" + env["DEEPTUTOR_TEST_PIP_COMMAND"] = str(command_path) + env.pop("DEEPTUTOR_CONTAINER", None) + + try: + completed = subprocess.run( + [sys.executable, "-m", "deeptutor_cli.main", "update"], + cwd=tmp_path, + env=env, + input="y\n", + capture_output=True, + text=True, + timeout=20, + check=False, + ) + deadline = time.monotonic() + 20 + state = {} + while time.monotonic() < deadline: + if state_path.is_file(): + state = json.loads(state_path.read_text(encoding="utf-8")) + if state.get("status") in {"succeeded", "failed"}: + break + time.sleep(0.05) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert completed.returncode == 0, completed.stderr + assert "Update scheduled:" in completed.stdout + assert "will not restart" in completed.stdout + assert state.get("status") == "succeeded", state + assert json.loads(command_path.read_text(encoding="utf-8")) == [ + "install", + "--upgrade", + "--no-input", + "deeptutor==1.6.0", + ] diff --git a/tests/update/test_jobs.py b/tests/update/test_jobs.py new file mode 100644 index 0000000000..6ba0869948 --- /dev/null +++ b/tests/update/test_jobs.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from deeptutor.update.jobs import ( + JobStatus, + UpdateInProgressError, + UpdateJobStore, + UpdateScheduler, +) + + +def test_only_one_update_job_can_be_active(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + first = store.create_pypi(current_version="1.5.4", target_version="1.6.0") + + with pytest.raises(UpdateInProgressError): + store.create_pypi(current_version="1.5.4", target_version="1.6.0") + + assert store.load() == first + + +def test_scheduler_persists_job_before_launching_worker(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + observed = {} + + class Launcher: + def launch(self, store_root: Path, *, parent_pid: int) -> None: + observed["job"] = store.load() + observed["store_root"] = store_root + observed["parent_pid"] = parent_pid + + job = UpdateScheduler(store=store, launcher=Launcher()).schedule_pypi( + current_version="1.5.4", + target_version="1.6.0", + parent_pid=123, + ) + + assert observed == { + "job": job, + "store_root": tmp_path, + "parent_pid": 123, + } + assert job.status is JobStatus.PENDING + + +def test_scheduler_records_launch_failure_and_releases_active_job(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + + class BrokenLauncher: + def launch(self, store_root: Path, *, parent_pid: int) -> None: + raise OSError("worker could not start") + + scheduler = UpdateScheduler(store=store, launcher=BrokenLauncher()) + + with pytest.raises(OSError, match="worker could not start"): + scheduler.schedule_pypi( + current_version="1.5.4", + target_version="1.6.0", + parent_pid=123, + ) + + assert store.load().status is JobStatus.FAILED + replacement = store.create_pypi(current_version="1.5.4", target_version="1.6.0") + assert replacement.status is JobStatus.PENDING diff --git a/tests/update/test_worker.py b/tests/update/test_worker.py new file mode 100644 index 0000000000..ddccb5d5d0 --- /dev/null +++ b/tests/update/test_worker.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys + +from deeptutor.update.jobs import JobStatus, UpdateJobStore +from deeptutor.update.worker import run_update_worker + + +class RecordingExecutor: + def __init__(self, exit_code: int) -> None: + self.exit_code = exit_code + self.commands: list[list[str]] = [] + + def run(self, command: list[str], *, log_path: Path) -> int: + self.commands.append(command) + return self.exit_code + + +def test_worker_waits_for_cli_exit_then_runs_fixed_pypi_upgrade(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + store.create_pypi(current_version="1.5.4", target_version="1.6.0") + executor = RecordingExecutor(exit_code=0) + events: list[str] = [] + + exit_code = run_update_worker( + store_root=tmp_path, + parent_pid=123, + executor=executor, + wait_for_parent=lambda pid: events.append(f"wait:{pid}"), + ) + + assert exit_code == 0 + assert events == ["wait:123"] + assert executor.commands == [ + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "--no-input", + "deeptutor==1.6.0", + ] + ] + assert store.load().status is JobStatus.SUCCEEDED + + +def test_worker_persists_failed_command_status(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + store.create_pypi(current_version="1.5.4", target_version="1.6.0") + executor = RecordingExecutor(exit_code=7) + + exit_code = run_update_worker( + store_root=tmp_path, + parent_pid=None, + executor=executor, + wait_for_parent=lambda pid: None, + ) + + job = store.load() + assert exit_code == 1 + assert job.status is JobStatus.FAILED + assert job.error == "pip exited with status 7" + + +def test_worker_rejects_a_tampered_target_without_running_a_command(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + store.create_pypi(current_version="1.5.4", target_version="1.6.0") + payload = json.loads(store.state_path.read_text(encoding="utf-8")) + payload["target_version"] = "1.6.0 --extra-index-url https://example.invalid" + store.state_path.write_text(json.dumps(payload), encoding="utf-8") + executor = RecordingExecutor(exit_code=0) + + exit_code = run_update_worker( + store_root=tmp_path, + parent_pid=None, + executor=executor, + wait_for_parent=lambda pid: None, + ) + + assert exit_code == 1 + assert executor.commands == [] + assert store.load().status is JobStatus.FAILED From b4cf780610549bc7e58a20f94f5cd9d7095c1fea Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sat, 25 Jul 2026 18:26:56 +0800 Subject: [PATCH 4/8] feat(web): add PyPI update and restart flow --- README.md | 2 +- deeptutor/api/routers/system.py | 142 ++++++++++++- deeptutor/runtime/launcher.py | 67 ++++++- deeptutor/services/session/turn_runtime.py | 9 + deeptutor/update/jobs.py | 99 ++++++++- deeptutor/update/worker.py | 62 +++++- deeptutor_cli/README.md | 3 + tests/api/test_system_router.py | 130 +++++++++++- tests/e2e/test_update_check_cli.py | 71 +++++++ tests/runtime/test_launcher.py | 59 ++++++ .../session/test_turn_runtime_subscribe.py | 17 ++ tests/update/test_jobs.py | 22 ++ tests/update/test_web_restart_integration.py | 89 +++++++++ tests/update/test_worker.py | 45 +++++ web/components/sidebar/UpdateAction.tsx | 189 ++++++++++++++++++ web/components/sidebar/VersionBadge.tsx | 14 +- web/lib/update-api.ts | 52 ++++- web/lib/update-badge.ts | 4 + web/locales/en/app.json | 8 + web/locales/zh/app.json | 8 + web/tests/e2e/update-badge.e2e.ts | 157 ++++++++++++++- web/tests/update-api.test.ts | 74 ++++++- web/tests/update-badge.test.ts | 4 + 23 files changed, 1307 insertions(+), 20 deletions(-) create mode 100644 tests/update/test_web_restart_integration.py create mode 100644 web/components/sidebar/UpdateAction.tsx diff --git a/README.md b/README.md index fb58ded38f..bec60d4dd6 100644 --- a/README.md +++ b/README.md @@ -718,7 +718,7 @@ The repo ships a root [`SKILL.md`](SKILL.md) — a ~150-line handover doc that t | Command | Description | |:---|:---| | `deeptutor init` | Create or update `data/user/settings` for the current workspace | -| `deeptutor start [--home PATH] [--dev]` | Launch backend + frontend together; `--dev` enables frontend HMR | +| `deeptutor start [--home PATH] [--dev]` | Launch backend + frontend together; `--dev` enables frontend HMR, and PyPI installs can update and restart from the Web version badge | | `deeptutor serve [--port PORT]` | Start only the FastAPI backend | | `deeptutor update` | Confirm and schedule a PyPI upgrade in the current Python environment; the CLI exits before the worker runs and does not restart the app | | `deeptutor update --check` | Detect the installation mode and check the latest stable release without changing the installation | diff --git a/deeptutor/api/routers/system.py b/deeptutor/api/routers/system.py index 113e0bc7b4..786bbaea11 100644 --- a/deeptutor/api/routers/system.py +++ b/deeptutor/api/routers/system.py @@ -5,13 +5,17 @@ import asyncio from datetime import datetime +import json +import os import time -from typing import Annotated, Protocol +from typing import Annotated, Literal, Protocol -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from deeptutor.api.routers.auth import require_admin from deeptutor.multi_user.context import get_current_user +from deeptutor.runtime.home import get_runtime_home from deeptutor.services.config import resolve_search_runtime_config from deeptutor.services.embedding import get_embedding_client, get_embedding_config from deeptutor.services.llm import complete as llm_complete @@ -23,6 +27,12 @@ UpdateStatus, create_update_coordinator, ) +from deeptutor.update.jobs import ( + JobStatus, + UpdateInProgressError, + UpdateJob, + UpdateJobStore, +) router = APIRouter() @@ -54,12 +64,75 @@ def check(self) -> UpdateCheck: """Return current update availability.""" +class ConversationActivity(Protocol): + """Conversation liveness needed before a disruptive update.""" + + async def has_live_executions(self) -> bool: + """Return whether a turn is currently running.""" + + +class WebUpdateRequest(BaseModel): + """Explicit second confirmation for an update and restart.""" + + confirmation: Literal["update-and-restart"] + + +class UpdateJobResponse(BaseModel): + """Persisted update state consumed across application restarts.""" + + id: str + status: JobStatus + current_version: str + target_version: str + error: str | None + restart_count: int + + def get_update_coordinator() -> UpdateChecker: """Provide the process update coordinator for dependency injection.""" return create_update_coordinator() +def get_conversation_activity() -> ConversationActivity: + from deeptutor.services.session import get_turn_runtime_manager + + return get_turn_runtime_manager() + + +def get_update_job_store() -> UpdateJobStore: + root = get_runtime_home() / "data" / "user" / "update" + return UpdateJobStore(root) + + +def is_launcher_available() -> bool: + raw_pid = os.getenv("DEEPTUTOR_LAUNCHER_PID", "").strip() + try: + launcher_pid = int(raw_pid) + except ValueError: + return False + if launcher_pid <= 0: + return False + try: + os.kill(launcher_pid, 0) + except PermissionError: + return True + except OSError: + return False + return True + + +def _job_response(job: UpdateJob) -> UpdateJobResponse: + return UpdateJobResponse( + id=job.id, + status=job.status, + current_version=job.current_version, + target_version=job.target_version, + error=job.error, + restart_count=job.restart_count, + ) + + @router.get("/update", response_model=UpdateCheckResponse) async def get_update_status( coordinator: Annotated[UpdateChecker, Depends(get_update_coordinator)], @@ -78,6 +151,71 @@ async def get_update_status( ) +@router.get("/update/job", response_model=UpdateJobResponse | None) +def get_update_job( + store: Annotated[UpdateJobStore, Depends(get_update_job_store)], +) -> UpdateJobResponse | None: + """Read the durable job state before or after a restart.""" + + try: + return _job_response(store.load()) + except (OSError, ValueError, json.JSONDecodeError): + return None + + +@router.post( + "/update", + response_model=UpdateJobResponse, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) +async def request_web_update( + request: WebUpdateRequest, + coordinator: Annotated[UpdateChecker, Depends(get_update_coordinator)], + conversations: Annotated[ + ConversationActivity, + Depends(get_conversation_activity), + ], + store: Annotated[UpdateJobStore, Depends(get_update_job_store)], + launcher_ready: Annotated[bool, Depends(is_launcher_available)], +) -> UpdateJobResponse: + """Request a trusted PyPI update for the managing Launcher to apply.""" + + del request + if not launcher_ready: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Web updates require the app to be running under `deeptutor start`.", + ) + if await conversations.has_live_executions(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An active conversation must finish before updating.", + ) + result = await asyncio.to_thread(coordinator.check) + if ( + result.status is not UpdateStatus.AVAILABLE + or result.install_mode is not InstallMode.PYPI + or not result.latest_version + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="No automatic PyPI update is currently available.", + ) + try: + job = store.create_pypi( + current_version=result.current_version, + target_version=result.latest_version, + restart_requested=True, + ) + except UpdateInProgressError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + return _job_response(job) + + @router.get("/runtime-topology") async def get_runtime_topology(): """ diff --git a/deeptutor/runtime/launcher.py b/deeptutor/runtime/launcher.py index b25e84b588..e522a4b483 100644 --- a/deeptutor/runtime/launcher.py +++ b/deeptutor/runtime/launcher.py @@ -15,7 +15,7 @@ import sys import threading import time -from typing import Callable +from typing import Callable, Sequence from urllib import error as urlerror from urllib import parse as urlparse from urllib import request as urlrequest @@ -926,10 +926,67 @@ def _handler(signum: int, _frame) -> None: continue +def _handoff_pending_update( + runtime_home: Path, + *, + restart_argv: Sequence[str], + worker_launcher=None, + parent_pid: int | None = None, +) -> bool: + """Hand one pending Web update to a worker before this launcher exits.""" + + from deeptutor.update.jobs import ( + JobStatus, + SubprocessWorkerLauncher, + UpdateJobStore, + ) + + store = UpdateJobStore(runtime_home / "data" / "user" / "update") + try: + job = store.load() + except (OSError, ValueError, json.JSONDecodeError): + return False + if job.status is not JobStatus.PENDING or not job.restart_requested: + return False + try: + store.prepare_restart( + job.id, + home=runtime_home, + restart_argv=restart_argv, + ) + (worker_launcher or SubprocessWorkerLauncher()).launch( + store.root, + parent_pid=parent_pid or os.getpid(), + ) + except Exception as exc: + store.mark_failed(job.id, f"launcher handoff failed: {exc}") + return False + return True + + +def _complete_restarted_update(runtime_home: Path) -> bool: + """Mark a restart successful after both managed servers are ready.""" + + from deeptutor.update.jobs import JobStatus, UpdateJobStore + + store = UpdateJobStore(runtime_home / "data" / "user" / "update") + try: + job = store.load() + except (OSError, ValueError, json.JSONDecodeError): + return False + if job.status is not JobStatus.RESTARTING or job.restart_home != str(runtime_home.resolve()): + return False + store.mark_succeeded(job.id) + return True + + def start(home: str | Path | None = None, *, dev: bool = False) -> None: _relax_console_encoding() runtime_home = get_runtime_home(home) runtime_home.mkdir(parents=True, exist_ok=True) + restart_argv = ["start", "--home", str(runtime_home.resolve())] + if dev: + restart_argv.append("--dev") os.environ[DEEPTUTOR_HOME_ENV] = str(runtime_home) _reset_runtime_singletons() @@ -1036,6 +1093,7 @@ def start(home: str | Path | None = None, *, dev: bool = False) -> None: # use backend_url (not api_base, which may be an external browser URL). common_env["DEEPTUTOR_API_BASE_URL"] = backend_url common_env["DEEPTUTOR_AUTH_ENABLED"] = "true" if auth_enabled else "false" + common_env["DEEPTUTOR_LAUNCHER_PID"] = str(os.getpid()) common_env["PYTHONUNBUFFERED"] = "1" common_env["PYTHONIOENCODING"] = "utf-8:replace" _apply_single_user_allocator_env(common_env) @@ -1132,9 +1190,16 @@ def cleanup() -> None: timeout=FRONTEND_READY_TIMEOUT, should_stop=lambda: shutdown_requested, ) + _complete_restarted_update(runtime_home) _log(_t("start.open_in_browser", url=frontend_url)) while not shutdown_requested: + if _handoff_pending_update( + runtime_home, + restart_argv=restart_argv, + ): + shutdown_requested = True + break for proc in processes: if proc.process.poll() is not None: _log(_t("start.exited", name=proc.name, code=proc.process.returncode)) diff --git a/deeptutor/services/session/turn_runtime.py b/deeptutor/services/session/turn_runtime.py index 8d069c8ee1..9619a44d9f 100644 --- a/deeptutor/services/session/turn_runtime.py +++ b/deeptutor/services/session/turn_runtime.py @@ -593,6 +593,15 @@ async def has_live_execution(self, turn_id: str) -> bool: """ return await self._has_live_execution(turn_id) + async def has_live_executions(self) -> bool: + """Return whether any conversation task is still owned by this process.""" + + async with self._lock: + return any( + execution.task is None or not execution.task.done() + for execution in self._executions.values() + ) + async def _has_live_execution(self, turn_id: str) -> bool: """Whether this process still owns the turn's in-memory runner.""" async with self._lock: diff --git a/deeptutor/update/jobs.py b/deeptutor/update/jobs.py index 486637429b..a3d33c0e9c 100644 --- a/deeptutor/update/jobs.py +++ b/deeptutor/update/jobs.py @@ -10,7 +10,7 @@ from pathlib import Path import subprocess import sys -from typing import Protocol +from typing import Protocol, Sequence import uuid from packaging.version import Version @@ -23,7 +23,9 @@ class JobStatus(str, Enum): """Durable lifecycle of one update job.""" PENDING = "pending" + HANDOFF = "handoff" RUNNING = "running" + RESTARTING = "restarting" SUCCEEDED = "succeeded" FAILED = "failed" @@ -44,6 +46,10 @@ class UpdateJob: started_at: str | None = None finished_at: str | None = None error: str | None = None + restart_requested: bool = False + restart_home: str | None = None + restart_argv: tuple[str, ...] = () + restart_count: int = 0 schema_version: int = 1 kind: str = "pypi" @@ -60,6 +66,7 @@ def from_dict(cls, payload: dict[str, object]) -> UpdateJob: if payload.get("schema_version") != 1 or payload.get("kind") != "pypi": raise ValueError("Unsupported update job") + restart_home = _optional_string(payload.get("restart_home")) return cls( id=str(payload["id"]), status=JobStatus(str(payload["status"])), @@ -69,6 +76,13 @@ def from_dict(cls, payload: dict[str, object]) -> UpdateJob: started_at=_optional_string(payload.get("started_at")), finished_at=_optional_string(payload.get("finished_at")), error=_optional_string(payload.get("error")), + restart_requested=payload.get("restart_requested") is True, + restart_home=restart_home, + restart_argv=_validated_restart_argv( + payload.get("restart_argv"), + home=restart_home, + ), + restart_count=int(str(payload.get("restart_count") or 0)), ) @@ -76,6 +90,31 @@ def _optional_string(value: object) -> str | None: return None if value is None else str(value) +def _validated_restart_argv( + value: object, + *, + home: str | None, +) -> tuple[str, ...]: + if value is None: + return ("start", "--home", home) if home else () + if not isinstance(value, (list, tuple)) or any( + not isinstance(argument, str) or not argument for argument in value + ): + raise ValueError("Invalid restart arguments") + restart_argv = tuple(value) + if not restart_argv: + return ("start", "--home", home) if home else () + if ( + home is None + or restart_argv[:3] != ("start", "--home", home) + or any( + argument == "--home" or argument.startswith("--home=") for argument in restart_argv[3:] + ) + ): + raise ValueError("Invalid restart arguments") + return restart_argv + + def _now() -> str: return datetime.now(timezone.utc).isoformat() @@ -96,7 +135,13 @@ def __init__(self, root: Path) -> None: self.active_path = self.root / "active" self.log_path = self.root / "worker.log" - def create_pypi(self, *, current_version: str, target_version: str) -> UpdateJob: + def create_pypi( + self, + *, + current_version: str, + target_version: str, + restart_requested: bool = False, + ) -> UpdateJob: """Reserve the active slot for one PyPI update.""" job = UpdateJob( @@ -105,6 +150,7 @@ def create_pypi(self, *, current_version: str, target_version: str) -> UpdateJob current_version=_canonical_version(current_version, stable=False), target_version=_canonical_version(target_version, stable=True), created_at=_now(), + restart_requested=restart_requested, ) self.root.mkdir(parents=True, exist_ok=True) try: @@ -139,6 +185,55 @@ def mark_running(self, job_id: str) -> UpdateJob: return self._transition(job_id, JobStatus.RUNNING) + def prepare_restart( + self, + job_id: str, + *, + home: Path, + restart_argv: Sequence[str] | None = None, + ) -> UpdateJob: + """Persist the runtime home and launch arguments before handoff.""" + + current = self.load() + if ( + current.id != job_id + or current.status is not JobStatus.PENDING + or not current.restart_requested + ): + raise RuntimeError("Update job is not awaiting launcher handoff") + resolved_home = str(home.resolve()) + updated = replace( + current, + status=JobStatus.HANDOFF, + restart_home=resolved_home, + restart_argv=_validated_restart_argv( + restart_argv, + home=resolved_home, + ), + ) + self._write(updated) + return updated + + def mark_restarting(self, job_id: str) -> UpdateJob: + """Record the single managed restart attempt.""" + + current = self.load() + if ( + current.id != job_id + or current.status is not JobStatus.RUNNING + or not current.restart_requested + or not current.restart_home + or not current.restart_argv + ): + raise RuntimeError("Update job is not ready to restart") + updated = replace( + current, + status=JobStatus.RESTARTING, + restart_count=current.restart_count + 1, + ) + self._write(updated) + return updated + def mark_succeeded(self, job_id: str) -> UpdateJob: """Finish a job successfully and release its active slot.""" diff --git a/deeptutor/update/worker.py b/deeptutor/update/worker.py index 2f1abfdea3..24a954ef3b 100644 --- a/deeptutor/update/worker.py +++ b/deeptutor/update/worker.py @@ -40,6 +40,37 @@ def run(self, command: list[str], *, log_path: Path) -> int: return completed.returncode +class RestartLauncher(Protocol): + """Boundary for starting the updated application.""" + + def launch(self, command: list[str], *, cwd: Path, log_path: Path) -> None: + """Start the fixed restart command without waiting for it to exit.""" + + +class SubprocessRestartLauncher: + """Start the updated application as a detached process.""" + + def launch(self, command: list[str], *, cwd: Path, log_path: Path) -> None: + """Launch the fixed application command as a detached process.""" + + kwargs: dict[str, object] = { + "stdin": subprocess.DEVNULL, + "stderr": subprocess.STDOUT, + "close_fds": True, + "shell": False, + "cwd": str(cwd), + } + if os.name == "nt": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + | subprocess.DETACHED_PROCESS # type: ignore[attr-defined] + ) + else: + kwargs["start_new_session"] = True + with log_path.open("a", encoding="utf-8") as log: + subprocess.Popen(command, stdout=log, **kwargs) # type: ignore[arg-type,call-overload] + + def build_pypi_update_command(target_version: str) -> list[str]: """Build the only command a PyPI update job may execute.""" @@ -57,6 +88,19 @@ def build_pypi_update_command(target_version: str) -> list[str]: ] +def build_restart_command(restart_argv: tuple[str, ...]) -> list[str]: + """Build the only application restart command the worker may launch.""" + + if len(restart_argv) < 3 or restart_argv[:2] != ("start", "--home"): + raise ValueError("Invalid restart arguments") + return [ + sys.executable, + "-m", + "deeptutor_cli.main", + *restart_argv, + ] + + def _pid_is_alive(pid: int) -> bool: try: os.kill(pid, 0) @@ -84,6 +128,7 @@ def run_update_worker( store_root: Path, parent_pid: int | None, executor: CommandExecutor | None = None, + restart_launcher: RestartLauncher | None = None, wait_for_parent: Callable[[int], None] = wait_for_process_exit, ) -> int: """Apply one persisted PyPI job and persist its terminal status.""" @@ -94,7 +139,7 @@ def run_update_worker( except Exception: return 1 try: - if job.status is not JobStatus.PENDING: + if job.status not in {JobStatus.PENDING, JobStatus.HANDOFF}: raise RuntimeError("Update job is not pending") if parent_pid is not None: wait_for_parent(parent_pid) @@ -107,7 +152,20 @@ def run_update_worker( if exit_code != 0: store.mark_failed(job.id, f"pip exited with status {exit_code}") return 1 - store.mark_succeeded(job.id) + if job.restart_requested: + if not job.restart_home: + raise RuntimeError("Update job is missing its restart home") + if not job.restart_argv: + raise RuntimeError("Update job is missing its restart arguments") + home = Path(job.restart_home).resolve() + store.mark_restarting(job.id) + (restart_launcher or SubprocessRestartLauncher()).launch( + build_restart_command(job.restart_argv), + cwd=home, + log_path=store.log_path, + ) + else: + store.mark_succeeded(job.id) return 0 except Exception as exc: try: diff --git a/deeptutor_cli/README.md b/deeptutor_cli/README.md index c0a095b4aa..93a4c17a96 100644 --- a/deeptutor_cli/README.md +++ b/deeptutor_cli/README.md @@ -167,6 +167,9 @@ deeptutor update --check # 仅检查,不修改环境 自动启动应用。源码安装目前仍只检查,Docker 只提示在宿主机更新镜像并 重建服务。 +通过 `deeptutor start` 启动的 PyPI 完整版也可在网页侧栏版本徽标中确认 +“更新并重启”。有对话任务运行时会拒绝更新;重启沿用同一 `--home` 与端口设置。 + --- ## `serve` — 启动 API 服务 diff --git a/tests/api/test_system_router.py b/tests/api/test_system_router.py index abb66a1b81..032ab1a747 100644 --- a/tests/api/test_system_router.py +++ b/tests/api/test_system_router.py @@ -1,13 +1,15 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient import pytest from deeptutor.api.routers import system as system_router from deeptutor.update import InstallMode, UpdateCheck, UpdateStatus +from deeptutor.update.jobs import JobStatus, UpdateJobStore @pytest.mark.asyncio @@ -102,3 +104,129 @@ def check(self) -> UpdateCheck: assert response.status_code == 200 assert response.json()["status"] == "up_to_date" assert response.json()["install_mode"] == "source_web" + + +class _AvailablePypiUpdate: + def check(self) -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.AVAILABLE, + current_version="1.5.4", + latest_version="1.6.0", + install_mode=InstallMode.PYPI, + can_auto_update=True, + release_url="https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + detail="installed distribution", + ) + + +class _IdleConversations: + async def has_live_executions(self) -> bool: + return False + + +@pytest.mark.asyncio +async def test_web_update_refuses_to_interrupt_a_live_conversation(tmp_path: Path) -> None: + class BusyConversations: + async def has_live_executions(self) -> bool: + return True + + with pytest.raises(HTTPException) as exc_info: + await system_router.request_web_update( + system_router.WebUpdateRequest(confirmation="update-and-restart"), + coordinator=_AvailablePypiUpdate(), + conversations=BusyConversations(), + store=UpdateJobStore(tmp_path), + launcher_ready=True, + ) + + assert exc_info.value.status_code == 409 + assert "conversation" in str(exc_info.value.detail).lower() + assert not (tmp_path / "state.json").exists() + + +@pytest.mark.asyncio +async def test_web_update_persists_a_restart_request(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + + response = await system_router.request_web_update( + system_router.WebUpdateRequest(confirmation="update-and-restart"), + coordinator=_AvailablePypiUpdate(), + conversations=_IdleConversations(), + store=store, + launcher_ready=True, + ) + + assert response.status is JobStatus.PENDING + assert response.target_version == "1.6.0" + assert store.load().restart_requested is True + + +def test_web_update_http_route_is_admin_only( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from deeptutor.api.routers import auth as auth_router + + app = FastAPI() + app.include_router(system_router.router, prefix="/api/v1/system") + app.dependency_overrides[system_router.get_update_coordinator] = _AvailablePypiUpdate + app.dependency_overrides[system_router.get_conversation_activity] = _IdleConversations + app.dependency_overrides[system_router.get_update_job_store] = lambda: UpdateJobStore(tmp_path) + app.dependency_overrides[system_router.is_launcher_available] = lambda: True + monkeypatch.setattr(auth_router, "AUTH_ENABLED", True) + + response = TestClient(app).post( + "/api/v1/system/update", + json={"confirmation": "update-and-restart"}, + ) + + assert response.status_code == 401 + + +def test_web_update_http_route_requires_confirmation_and_creates_job( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from deeptutor.api.routers import auth as auth_router + + app = FastAPI() + app.include_router(system_router.router, prefix="/api/v1/system") + app.dependency_overrides[system_router.get_update_coordinator] = _AvailablePypiUpdate + app.dependency_overrides[system_router.get_conversation_activity] = _IdleConversations + app.dependency_overrides[system_router.get_update_job_store] = lambda: UpdateJobStore(tmp_path) + app.dependency_overrides[system_router.is_launcher_available] = lambda: True + monkeypatch.setattr(auth_router, "AUTH_ENABLED", False) + client = TestClient(app) + + rejected = client.post( + "/api/v1/system/update", + json={"confirmation": "yes"}, + ) + accepted = client.post( + "/api/v1/system/update", + json={"confirmation": "update-and-restart"}, + ) + + assert rejected.status_code == 422 + assert accepted.status_code == 202 + assert accepted.json()["status"] == "pending" + assert UpdateJobStore(tmp_path).load().restart_requested is True + + +def test_update_job_status_survives_a_router_recreation(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path) + job = store.create_pypi( + current_version="1.5.4", + target_version="1.6.0", + restart_requested=True, + ) + store.prepare_restart(job.id, home=tmp_path / "home") + store.mark_running(job.id) + store.mark_restarting(job.id) + + response = system_router.get_update_job(store) + + assert response is not None + assert response.id == job.id + assert response.status is JobStatus.RESTARTING + assert response.restart_count == 1 diff --git a/tests/e2e/test_update_check_cli.py b/tests/e2e/test_update_check_cli.py index 301da262e7..8ddea8007e 100644 --- a/tests/e2e/test_update_check_cli.py +++ b/tests/e2e/test_update_check_cli.py @@ -12,6 +12,8 @@ import pytest +from deeptutor.update.jobs import JobStatus, UpdateJobStore + PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -309,3 +311,72 @@ def test_pypi_user_can_confirm_an_update_that_runs_after_cli_exit(tmp_path: Path "--no-input", "deeptutor==1.6.0", ] + + +def test_web_worker_restarts_with_persisted_launcher_arguments(tmp_path: Path) -> None: + fake_modules = tmp_path / "fake-modules" + fake_pip = fake_modules / "pip" + fake_pip.mkdir(parents=True) + (fake_pip / "__init__.py").write_text("", encoding="utf-8") + (fake_pip / "__main__.py").write_text("raise SystemExit(0)\n", encoding="utf-8") + fake_cli = fake_modules / "deeptutor_cli" + fake_cli.mkdir() + (fake_cli / "__init__.py").write_text("", encoding="utf-8") + (fake_cli / "main.py").write_text( + "\n".join( + [ + "import json", + "import os", + "from pathlib import Path", + "import sys", + ( + "Path(os.environ['DEEPTUTOR_TEST_RESTART_ARGV']).write_text(" + "json.dumps(sys.argv[1:]), encoding='utf-8')" + ), + ] + ), + encoding="utf-8", + ) + + home = tmp_path / "runtime home" + home.mkdir() + restart_argv = ("start", "--home", str(home.resolve()), "--dev") + store = UpdateJobStore(tmp_path / "update") + job = store.create_pypi( + current_version="1.5.4", + target_version="1.6.0", + restart_requested=True, + ) + store.prepare_restart(job.id, home=home, restart_argv=restart_argv) + exited_parent = subprocess.Popen([sys.executable, "-c", "pass"]) + exited_parent.wait(timeout=10) + command_path = tmp_path / "restart-argv.json" + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join((str(fake_modules), str(PROJECT_ROOT))) + env["DEEPTUTOR_TEST_RESTART_ARGV"] = str(command_path) + + completed = subprocess.run( + [ + sys.executable, + "-m", + "deeptutor.update.worker", + "--store-root", + str(store.root), + "--parent-pid", + str(exited_parent.pid), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not command_path.is_file(): + time.sleep(0.05) + + assert completed.returncode == 0, completed.stderr + assert json.loads(command_path.read_text(encoding="utf-8")) == list(restart_argv) + assert store.load().status is JobStatus.RESTARTING + assert store.load().restart_count == 1 diff --git a/tests/runtime/test_launcher.py b/tests/runtime/test_launcher.py index 910228eae5..fb2b8f8952 100644 --- a/tests/runtime/test_launcher.py +++ b/tests/runtime/test_launcher.py @@ -6,6 +6,7 @@ import pytest from deeptutor.runtime import launcher +from deeptutor.update.jobs import JobStatus, UpdateJobStore class _FakeTty: @@ -13,6 +14,64 @@ def isatty(self) -> bool: return True +class _RecordingWorkerLauncher: + def __init__(self) -> None: + self.calls: list[tuple[Path, int]] = [] + + def launch(self, store_root: Path, *, parent_pid: int) -> None: + self.calls.append((store_root, parent_pid)) + + +def test_launcher_hands_a_web_update_to_one_worker(tmp_path: Path) -> None: + home = tmp_path / "home" + restart_argv = ("start", "--home", str(home.resolve()), "--dev") + store = UpdateJobStore(home / "data" / "user" / "update") + store.create_pypi( + current_version="1.5.4", + target_version="1.6.0", + restart_requested=True, + ) + worker_launcher = _RecordingWorkerLauncher() + + first = launcher._handoff_pending_update( + home, + restart_argv=restart_argv, + worker_launcher=worker_launcher, + parent_pid=123, + ) + second = launcher._handoff_pending_update( + home, + restart_argv=restart_argv, + worker_launcher=worker_launcher, + parent_pid=123, + ) + + assert first is True + assert second is False + assert worker_launcher.calls == [(store.root, 123)] + job = store.load() + assert job.status is JobStatus.HANDOFF + assert job.restart_home == str(home.resolve()) + assert job.restart_argv == restart_argv + + +def test_launcher_marks_restart_complete_only_after_new_app_is_ready(tmp_path: Path) -> None: + home = tmp_path / "home" + store = UpdateJobStore(home / "data" / "user" / "update") + job = store.create_pypi( + current_version="1.5.4", + target_version="1.6.0", + restart_requested=True, + ) + store.prepare_restart(job.id, home=home) + store.mark_running(job.id) + store.mark_restarting(job.id) + + assert launcher._complete_restarted_update(home) is True + assert launcher._complete_restarted_update(home) is False + assert store.load().status is JobStatus.SUCCEEDED + + def test_packaged_web_cache_replaces_next_public_placeholders(tmp_path: Path) -> None: packaged = tmp_path / "pkg" (packaged / ".next" / "static").mkdir(parents=True) diff --git a/tests/services/session/test_turn_runtime_subscribe.py b/tests/services/session/test_turn_runtime_subscribe.py index 6da94844f0..da809a22c0 100644 --- a/tests/services/session/test_turn_runtime_subscribe.py +++ b/tests/services/session/test_turn_runtime_subscribe.py @@ -8,6 +8,23 @@ from deeptutor.services.session.turn_runtime import TurnRuntimeManager, _TurnExecution +@pytest.mark.asyncio +async def test_runtime_reports_whether_any_conversation_is_live(tmp_path) -> None: + store = SQLiteSessionStore(tmp_path / "chat_history.db") + runtime = TurnRuntimeManager(store) + + assert await runtime.has_live_executions() is False + + runtime._executions["turn-1"] = _TurnExecution( + turn_id="turn-1", + session_id="session-1", + capability="chat", + payload={}, + ) + + assert await runtime.has_live_executions() is True + + @pytest.mark.asyncio async def test_subscribe_turn_does_not_synthesize_done_for_running_turn(tmp_path) -> None: """A paused/replaced subscription must not make the UI think the turn ended.""" diff --git a/tests/update/test_jobs.py b/tests/update/test_jobs.py index 6ba0869948..cc807b1fc2 100644 --- a/tests/update/test_jobs.py +++ b/tests/update/test_jobs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -22,6 +23,27 @@ def test_only_one_update_job_can_be_active(tmp_path: Path) -> None: assert store.load() == first +def test_restart_handoff_rejects_a_tampered_command(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path / "jobs") + home = tmp_path / "home" + job = store.create_pypi( + current_version="1.5.4", + target_version="1.6.0", + restart_requested=True, + ) + store.prepare_restart( + job.id, + home=home, + restart_argv=("start", "--home", str(home.resolve()), "--dev"), + ) + payload = json.loads(store.state_path.read_text(encoding="utf-8")) + payload["restart_argv"].extend(["--home", str(tmp_path / "other-home")]) + store.state_path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ValueError, match="Invalid restart arguments"): + store.load() + + def test_scheduler_persists_job_before_launching_worker(tmp_path: Path) -> None: store = UpdateJobStore(tmp_path) observed = {} diff --git a/tests/update/test_web_restart_integration.py b/tests/update/test_web_restart_integration.py new file mode 100644 index 0000000000..9daf170749 --- /dev/null +++ b/tests/update/test_web_restart_integration.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +from deeptutor.runtime import launcher +from deeptutor.update.jobs import JobStatus, UpdateJobStore +from deeptutor.update.worker import run_update_worker + + +class _WorkerLauncher: + def __init__(self) -> None: + self.calls: list[tuple[Path, int]] = [] + + def launch(self, store_root: Path, *, parent_pid: int) -> None: + self.calls.append((store_root, parent_pid)) + + +class _SuccessfulUpgrade: + def run(self, command: list[str], *, log_path: Path) -> int: + return 0 + + +class _RestartLauncher: + def __init__(self) -> None: + self.calls: list[tuple[list[str], Path]] = [] + + def launch(self, command: list[str], *, cwd: Path, log_path: Path) -> None: + self.calls.append((command, cwd)) + + +def test_web_update_handoff_preserves_runtime_data_and_completes_once( + tmp_path: Path, +) -> None: + home = tmp_path / "runtime home" + restart_argv = ("start", "--home", str(home.resolve()), "--dev") + preserved = { + home / "data" / "user" / "settings" / "system.json": ( + '{"backend_port": 8019, "frontend_port": 3799}' + ), + home / "data" / "knowledge_bases" / "algebra" / "meta.json": ('{"name": "Algebra"}'), + home / "data" / "user" / "workspace" / "memory" / "profile.md": ("prefers worked examples"), + } + for path, content in preserved.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + store = UpdateJobStore(home / "data" / "user" / "update") + job = store.create_pypi( + current_version="1.5.4", + target_version="1.6.0", + restart_requested=True, + ) + worker_launcher = _WorkerLauncher() + + assert launcher._handoff_pending_update( + home, + restart_argv=restart_argv, + worker_launcher=worker_launcher, + parent_pid=123, + ) + assert worker_launcher.calls == [(store.root, 123)] + + restart_launcher = _RestartLauncher() + assert ( + run_update_worker( + store_root=store.root, + parent_pid=None, + executor=_SuccessfulUpgrade(), + restart_launcher=restart_launcher, + ) + == 0 + ) + assert len(restart_launcher.calls) == 1 + restart_command, restart_cwd = restart_launcher.calls[0] + assert restart_command == [ + sys.executable, + "-m", + "deeptutor_cli.main", + *restart_argv, + ] + assert restart_cwd == home.resolve() + + assert launcher._complete_restarted_update(home) + assert launcher._complete_restarted_update(home) is False + assert store.load().status is JobStatus.SUCCEEDED + assert store.load().restart_count == 1 + for path, content in preserved.items(): + assert path.read_text(encoding="utf-8") == content diff --git a/tests/update/test_worker.py b/tests/update/test_worker.py index ddccb5d5d0..2a87e5ffa1 100644 --- a/tests/update/test_worker.py +++ b/tests/update/test_worker.py @@ -18,6 +18,14 @@ def run(self, command: list[str], *, log_path: Path) -> int: return self.exit_code +class RecordingRestartLauncher: + def __init__(self) -> None: + self.commands: list[tuple[list[str], Path]] = [] + + def launch(self, command: list[str], *, cwd: Path, log_path: Path) -> None: + self.commands.append((command, cwd)) + + def test_worker_waits_for_cli_exit_then_runs_fixed_pypi_upgrade(tmp_path: Path) -> None: store = UpdateJobStore(tmp_path) store.create_pypi(current_version="1.5.4", target_version="1.6.0") @@ -83,3 +91,40 @@ def test_worker_rejects_a_tampered_target_without_running_a_command(tmp_path: Pa assert exit_code == 1 assert executor.commands == [] assert store.load().status is JobStatus.FAILED + + +def test_web_worker_restarts_the_same_home_exactly_once_after_upgrade(tmp_path: Path) -> None: + home = tmp_path / "runtime home" + restart_argv = ("start", "--home", str(home.resolve()), "--dev") + store = UpdateJobStore(tmp_path / "jobs") + job = store.create_pypi( + current_version="1.5.4", + target_version="1.6.0", + restart_requested=True, + ) + store.prepare_restart(job.id, home=home, restart_argv=restart_argv) + restart_launcher = RecordingRestartLauncher() + + exit_code = run_update_worker( + store_root=store.root, + parent_pid=None, + executor=RecordingExecutor(exit_code=0), + restart_launcher=restart_launcher, + wait_for_parent=lambda pid: None, + ) + + assert exit_code == 0 + assert restart_launcher.commands == [ + ( + [ + sys.executable, + "-m", + "deeptutor_cli.main", + *restart_argv, + ], + home.resolve(), + ) + ] + restarted = store.load() + assert restarted.status is JobStatus.RESTARTING + assert restarted.restart_count == 1 diff --git a/web/components/sidebar/UpdateAction.tsx b/web/components/sidebar/UpdateAction.tsx new file mode 100644 index 0000000000..440cb3b0ac --- /dev/null +++ b/web/components/sidebar/UpdateAction.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Check, Download, RefreshCw, TriangleAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { useAuthStatus } from "@/hooks/useAuthStatus"; +import { + fetchUpdateJob, + requestWebUpdate, + type UpdateJobStatus, +} from "@/lib/update-api"; +import { notify } from "@/lib/notifications"; +import { normalizeVersionTag } from "@/lib/version"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; + +interface UpdateActionProps { + targetVersion: string; +} + +type UpdatePhase = + | "idle" + | "requesting" + | "updating" + | "restarting" + | "reconnected" + | "failed"; + +const POLL_INTERVAL_MS = 750; +const POLL_TIMEOUT_MS = 120_000; + +function phaseForStatus(status: UpdateJobStatus): UpdatePhase { + if (status === "restarting") return "restarting"; + if (status === "succeeded") return "reconnected"; + if (status === "failed") return "failed"; + return "updating"; +} + +function isActiveStatus(status: UpdateJobStatus): boolean { + return ["pending", "handoff", "running", "restarting"].includes(status); +} + +export function UpdateAction({ targetVersion }: UpdateActionProps) { + const { t } = useTranslation(); + const { enabled, isAdmin, loading } = useAuthStatus(); + const [dialogOpen, setDialogOpen] = useState(false); + const [phase, setPhase] = useState("idle"); + const [polling, setPolling] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + void fetchUpdateJob(controller.signal) + .then((job) => { + if (!job) return; + const sameTarget = + (normalizeVersionTag(job.target_version) ?? job.target_version) === + targetVersion; + if (isActiveStatus(job.status) || sameTarget) { + setPhase(phaseForStatus(job.status)); + setPolling(isActiveStatus(job.status)); + } + }) + .catch(() => undefined); + return () => controller.abort(); + }, [targetVersion]); + + useEffect(() => { + if (!polling) return; + + let cancelled = false; + let timer: ReturnType | undefined; + const deadline = Date.now() + POLL_TIMEOUT_MS; + + const poll = async () => { + try { + const job = await fetchUpdateJob(); + if (cancelled) return; + if (job) { + const nextPhase = phaseForStatus(job.status); + setPhase(nextPhase); + if (nextPhase === "reconnected" || nextPhase === "failed") { + setPolling(false); + return; + } + } + } catch { + if (cancelled) return; + setPhase("restarting"); + } + + if (Date.now() >= deadline) { + setPhase("failed"); + setPolling(false); + return; + } + timer = setTimeout(poll, POLL_INTERVAL_MS); + }; + + void poll(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [polling]); + + const startUpdate = useCallback(async () => { + setPhase("requesting"); + try { + const job = await requestWebUpdate(); + setDialogOpen(false); + setPhase(phaseForStatus(job.status)); + setPolling(true); + } catch (error) { + const message = + error instanceof Error ? error.message : (t("Update failed") as string); + setPhase("failed"); + notify(message, { tone: "error" }); + } + }, [t]); + + if (loading || (enabled && !isAdmin)) return null; + + const labels: Record = { + idle: t("Update and restart") as string, + requesting: t("Starting update…") as string, + updating: t("Updating…") as string, + restarting: t("Restarting…") as string, + reconnected: t("Reconnected") as string, + failed: t("Update failed") as string, + }; + const busy = ["requesting", "updating", "restarting"].includes(phase); + const Icon = + phase === "idle" + ? Download + : phase === "reconnected" + ? Check + : phase === "failed" + ? TriangleAlert + : RefreshCw; + const buttonLabel = + phase === "idle" && targetVersion + ? `${labels[phase]}: ${targetVersion}` + : labels[phase]; + const tone = + phase === "reconnected" + ? "bg-emerald-500/10 text-emerald-700 shadow-[0_0_0_1px_rgba(16,185,129,0.18),0_1px_2px_rgba(0,0,0,0.05)] dark:text-emerald-300" + : phase === "failed" + ? "bg-rose-500/10 text-rose-700 shadow-[0_0_0_1px_rgba(244,63,94,0.18),0_1px_2px_rgba(0,0,0,0.05)] hover:bg-rose-500/20 dark:text-rose-300" + : "bg-sky-500/10 text-sky-700 shadow-[0_0_0_1px_rgba(14,165,233,0.18),0_1px_2px_rgba(0,0,0,0.05)] hover:bg-sky-500/20 hover:shadow-[0_0_0_1px_rgba(14,165,233,0.28),0_2px_4px_rgba(0,0,0,0.07)] dark:text-sky-300"; + + return ( + <> + + + void startUpdate()} + onCancel={() => setDialogOpen(false)} + > + {t( + "DeepTutor will stop briefly, install {{version}}, and restart with the same settings.", + { version: targetVersion }, + ) as string} + + + ); +} diff --git a/web/components/sidebar/VersionBadge.tsx b/web/components/sidebar/VersionBadge.tsx index 0a50059199..dfe295926f 100644 --- a/web/components/sidebar/VersionBadge.tsx +++ b/web/components/sidebar/VersionBadge.tsx @@ -9,6 +9,7 @@ import { type UpdateBadgePresentation, } from "@/lib/update-badge"; import { normalizeVersionTag } from "@/lib/version"; +import { UpdateAction } from "@/components/sidebar/UpdateAction"; interface VersionBadgeProps { /** Render the compact variant for the collapsed sidebar (currently hidden). */ @@ -63,7 +64,7 @@ export function VersionBadge({ collapsed = false }: VersionBadgeProps) { data-testid="version-badge" aria-live="polite" aria-atomic="true" - className="flex min-w-0 flex-1" + className="flex min-w-0 flex-1 items-center gap-1" > {displayTag} - {available ? ( + {available && !supportsWebUpdate ? ( <> + {available?.canAutoUpdate && available.installMode === "pypi" ? ( + + ) : null} ); } diff --git a/web/lib/update-api.ts b/web/lib/update-api.ts index c523d74e72..db3792a296 100644 --- a/web/lib/update-api.ts +++ b/web/lib/update-api.ts @@ -19,6 +19,33 @@ export interface UpdateCheckResponse { detail: string; } +export type UpdateJobStatus = + | "pending" + | "handoff" + | "running" + | "restarting" + | "succeeded" + | "failed"; + +export interface UpdateJobResponse { + id: string; + status: UpdateJobStatus; + current_version: string; + target_version: string; + error: string | null; + restart_count: number; +} + +async function responseError(response: Response): Promise { + try { + const payload = (await response.json()) as { detail?: unknown }; + if (typeof payload.detail === "string") return new Error(payload.detail); + } catch { + // Fall through to the status-only message for non-JSON responses. + } + return new Error(`Update request failed (HTTP ${response.status})`); +} + export async function fetchUpdateStatus( signal?: AbortSignal, ): Promise { @@ -26,8 +53,27 @@ export async function fetchUpdateStatus( cache: "no-store", signal, }); - if (!response.ok) { - throw new Error(`Update check failed (HTTP ${response.status})`); - } + if (!response.ok) throw await responseError(response); return (await response.json()) as UpdateCheckResponse; } + +export async function requestWebUpdate(): Promise { + const response = await apiFetch(apiUrl("/api/v1/system/update"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirmation: "update-and-restart" }), + }); + if (!response.ok) throw await responseError(response); + return (await response.json()) as UpdateJobResponse; +} + +export async function fetchUpdateJob( + signal?: AbortSignal, +): Promise { + const response = await apiFetch(apiUrl("/api/v1/system/update/job"), { + cache: "no-store", + signal, + }); + if (!response.ok) throw await responseError(response); + return (await response.json()) as UpdateJobResponse | null; +} diff --git a/web/lib/update-badge.ts b/web/lib/update-badge.ts index 948ea50c44..922afecda7 100644 --- a/web/lib/update-badge.ts +++ b/web/lib/update-badge.ts @@ -7,6 +7,8 @@ export type UpdateBadgePresentation = version: string; href: string; hostManaged: boolean; + installMode: UpdateCheckResponse["install_mode"]; + canAutoUpdate: boolean; } | { kind: "up_to_date"; version: string | null; href: string | null } | { kind: "failed" }; @@ -25,6 +27,8 @@ export function presentUpdateBadge( normalizeVersionTag(update.latest_version) ?? update.latest_version, href: update.release_url, hostManaged: update.install_mode === "docker", + installMode: update.install_mode, + canAutoUpdate: update.can_auto_update, }; } if (update.status === "up_to_date") { diff --git a/web/locales/en/app.json b/web/locales/en/app.json index 4d43f3d071..5cf6f9696f 100644 --- a/web/locales/en/app.json +++ b/web/locales/en/app.json @@ -1162,6 +1162,14 @@ "Update check failed": "Update check failed", "Update on host": "Update on host", "Update the image on the Docker host and recreate the container.": "Update the image on the Docker host and recreate the container.", + "Update and restart": "Update and restart", + "Update and restart DeepTutor?": "Update and restart DeepTutor?", + "DeepTutor will stop briefly, install {{version}}, and restart with the same settings.": "DeepTutor will stop briefly, install {{version}}, and restart with the same settings.", + "Starting update…": "Starting update…", + "Updating…": "Updating…", + "Restarting…": "Restarting…", + "Reconnected": "Reconnected", + "Update failed": "Update failed", "Development build": "Development build", "Run Tour": "Run Tour", "Tour": "Tour", diff --git a/web/locales/zh/app.json b/web/locales/zh/app.json index 63a936a45b..8c9a409893 100644 --- a/web/locales/zh/app.json +++ b/web/locales/zh/app.json @@ -1194,6 +1194,14 @@ "Update check failed": "检查更新失败", "Update on host": "请在宿主机更新", "Update the image on the Docker host and recreate the container.": "请在 Docker 宿主机更新镜像并重新创建容器。", + "Update and restart": "更新并重启", + "Update and restart DeepTutor?": "更新并重启 DeepTutor?", + "DeepTutor will stop briefly, install {{version}}, and restart with the same settings.": "DeepTutor 将短暂停止,安装 {{version}},并使用相同设置重新启动。", + "Starting update…": "正在启动更新…", + "Updating…": "正在更新…", + "Restarting…": "正在重启…", + "Reconnected": "已重新连接", + "Update failed": "更新失败", "Development build": "开发构建", "Run Tour": "运行引导", "Tour": "引导", diff --git a/web/tests/e2e/update-badge.e2e.ts b/web/tests/e2e/update-badge.e2e.ts index b325f82f6f..41fb0bd712 100644 --- a/web/tests/e2e/update-badge.e2e.ts +++ b/web/tests/e2e/update-badge.e2e.ts @@ -43,9 +43,19 @@ test("version badge links to the latest release when an update is available", as const updateStatus = page.getByTestId("update-status"); await expect(updateStatus).toBeVisible(); - await expect(updateStatus).toContainText("v1.6.0"); await expect(updateStatus).toHaveAttribute("href", RELEASE_URL); - await expect(updateStatus).toHaveAccessibleName(/update available.*v1\.6\.0/i); + await expect(updateStatus).toHaveAccessibleName( + /update available.*v1\.6\.0/i, + ); + const updateAction = page.getByTestId("update-action"); + await expect(updateAction).toBeVisible(); + await expect(updateAction).toHaveAccessibleName(/v1\.6\.0/i); + expect((await updateAction.boundingBox())?.width).toBeLessThanOrEqual(32); + expect( + await updateStatus.evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ).toBe(true); }); test("version badge reports an up-to-date installation", async ({ page }) => { @@ -107,3 +117,146 @@ test("Docker installations direct updates to the host without an update action", await expect(badge).toContainText(/update on host/i); await expect(badge.getByRole("button", { name: /update/i })).toHaveCount(0); }); + +test("admin can confirm an update and reconnect after the managed restart", async ({ + page, +}) => { + let requested = false; + let confirmation: unknown; + let poll = 0; + await page.route("**/api/v1/auth/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: true, + authenticated: true, + role: "admin", + is_admin: true, + }), + }), + ); + await page.route("**/api/v1/system/update", async (route) => { + if (route.request().method() === "POST") { + requested = true; + confirmation = route.request().postDataJSON(); + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + id: "job-1", + status: "pending", + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: 0, + }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(AVAILABLE_UPDATE), + }); + }); + await page.route("**/api/v1/system/update/job", async (route) => { + if (!requested) { + await route.fulfill({ status: 200, contentType: "application/json", body: "null" }); + return; + } + poll += 1; + if (poll === 2) { + await route.abort("connectionfailed"); + return; + } + const status = poll === 1 ? "running" : poll === 3 ? "restarting" : "succeeded"; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "job-1", + status, + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: status === "succeeded" ? 1 : 0, + }), + }); + }); + + await page.goto("/"); + await page.getByTestId("update-action").click(); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toContainText("v1.6.0"); + await dialog.getByRole("button", { name: /update and restart/i }).click(); + + await expect(page.getByTestId("update-action")).toContainText(/reconnected/i); + expect(confirmation).toEqual({ confirmation: "update-and-restart" }); + + await page.reload(); + await expect(page.getByTestId("update-action")).toContainText(/reconnected/i); +}); + +test("failed Web update remains visible to the user", async ({ page }) => { + let requested = false; + await page.route("**/api/v1/auth/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: false, + authenticated: true, + role: "admin", + is_admin: true, + }), + }), + ); + await page.route("**/api/v1/system/update", (route) => { + if (route.request().method() === "POST") { + requested = true; + return route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + id: "job-2", + status: "pending", + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: 0, + }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(AVAILABLE_UPDATE), + }); + }); + await page.route("**/api/v1/system/update/job", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: requested + ? JSON.stringify({ + id: "job-2", + status: "failed", + current_version: "1.5.4", + target_version: "1.6.0", + error: "pip exited with status 1", + restart_count: 0, + }) + : "null", + }), + ); + + await page.goto("/"); + await page.getByTestId("update-action").click(); + await page + .getByRole("alertdialog") + .getByRole("button", { name: /update and restart/i }) + .click(); + + await expect(page.getByTestId("update-action")).toContainText(/update failed/i); +}); diff --git a/web/tests/update-api.test.ts b/web/tests/update-api.test.ts index 25438995c6..53952c0904 100644 --- a/web/tests/update-api.test.ts +++ b/web/tests/update-api.test.ts @@ -1,6 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { fetchUpdateStatus } from "../lib/update-api"; +import { + fetchUpdateJob, + fetchUpdateStatus, + requestWebUpdate, +} from "../lib/update-api"; test("fetchUpdateStatus returns the system update payload", async () => { const originalFetch = globalThis.fetch; @@ -33,3 +37,71 @@ test("fetchUpdateStatus returns the system update payload", async () => { globalThis.fetch = originalFetch; } }); + +test("fetchUpdateStatus preserves API error details", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "Release provider unavailable" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + + try { + await assert.rejects(fetchUpdateStatus(), { + message: "Release provider unavailable", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("requestWebUpdate sends the fixed restart confirmation", async () => { + const originalFetch = globalThis.fetch; + let request: RequestInit | undefined; + globalThis.fetch = async (_input, init) => { + request = init; + return new Response( + JSON.stringify({ + id: "job-1", + status: "pending", + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: 0, + }), + { status: 202, headers: { "Content-Type": "application/json" } }, + ); + }; + + try { + const job = await requestWebUpdate(); + + assert.equal(request?.method, "POST"); + assert.equal(request?.body, JSON.stringify({ confirmation: "update-and-restart" })); + assert.equal(job.status, "pending"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("fetchUpdateJob restores a persisted restart state", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + id: "job-1", + status: "restarting", + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: 1, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + + try { + assert.equal((await fetchUpdateJob())?.status, "restarting"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/tests/update-badge.test.ts b/web/tests/update-badge.test.ts index 92f7f39d31..a6f5dd9689 100644 --- a/web/tests/update-badge.test.ts +++ b/web/tests/update-badge.test.ts @@ -18,6 +18,8 @@ test("presentUpdateBadge exposes an actionable release link for available update version: "v1.6.0", href: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", hostManaged: false, + installMode: "pypi", + canAutoUpdate: true, }); }); @@ -37,6 +39,8 @@ test("presentUpdateBadge marks Docker updates as host-managed", () => { version: "v1.6.0", href: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", hostManaged: true, + installMode: "docker", + canAutoUpdate: false, }); }); From 907729f119f3c9be19358ce15cc20efb71573db8 Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sat, 25 Jul 2026 18:41:24 +0800 Subject: [PATCH 5/8] feat(update): safely refresh editable source installs --- README.md | 2 +- deeptutor/update/source.py | 352 ++++++++++++++++++++++++++++ deeptutor_cli/README.md | 6 +- deeptutor_cli/update_cmd.py | 37 ++- tests/cli/test_update_cli.py | 69 +++++- tests/e2e/test_update_check_cli.py | 169 +++++++++++++ tests/update/test_source_updater.py | 246 +++++++++++++++++++ 7 files changed, 874 insertions(+), 7 deletions(-) create mode 100644 deeptutor/update/source.py create mode 100644 tests/update/test_source_updater.py diff --git a/README.md b/README.md index bec60d4dd6..d7fab120bc 100644 --- a/README.md +++ b/README.md @@ -720,7 +720,7 @@ The repo ships a root [`SKILL.md`](SKILL.md) — a ~150-line handover doc that t | `deeptutor init` | Create or update `data/user/settings` for the current workspace | | `deeptutor start [--home PATH] [--dev]` | Launch backend + frontend together; `--dev` enables frontend HMR, and PyPI installs can update and restart from the Web version badge | | `deeptutor serve [--port PORT]` | Start only the FastAPI backend | -| `deeptutor update` | Confirm and schedule a PyPI upgrade in the current Python environment; the CLI exits before the worker runs and does not restart the app | +| `deeptutor update` | Update a PyPI install or safely fast-forward a clean editable checkout to the latest stable release; CLI updates do not restart the app | | `deeptutor update --check` | Detect the installation mode and check the latest stable release without changing the installation | | `deeptutor run ` | Run a single capability turn (`chat`, `deep_solve`, `deep_question`, `deep_research`, `visualize`, `math_animator`, `mastery_path`); add `--format json` for NDJSON output | | `deeptutor chat` | Interactive REPL with capability, tool, KB, notebook, and history controls | diff --git a/deeptutor/update/source.py b/deeptutor/update/source.py new file mode 100644 index 0000000000..ef963f85e1 --- /dev/null +++ b/deeptutor/update/source.py @@ -0,0 +1,352 @@ +"""Safe fast-forward updates for editable source installations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import shutil +import subprocess +import sys +from typing import Protocol + +from packaging.version import Version + +from . import Installation, InstallMode + + +class SourceUpdateError(RuntimeError): + """Raised when a source checkout cannot be updated safely.""" + + +@dataclass(frozen=True) +class CommandResult: + """Captured result of one trusted source-update command.""" + + returncode: int + stdout: str = "" + stderr: str = "" + + +class SourceCommandRunner(Protocol): + """Boundary for commands generated by SourceUpdater.""" + + def run(self, command: list[str], *, cwd: Path) -> CommandResult: + """Run command without a shell and capture its result.""" + + +class SubprocessSourceCommandRunner: + """Execute trusted Git, Python, and Bun commands.""" + + def run(self, command: list[str], *, cwd: Path) -> CommandResult: + """Run one trusted source command without a shell.""" + + completed = subprocess.run( + command, + cwd=cwd, + capture_output=True, + check=False, + shell=False, + text=True, + ) + return CommandResult(completed.returncode, completed.stdout, completed.stderr) + + +@dataclass(frozen=True) +class SourceUpdateResult: + """Summary of a completed source update.""" + + previous_commit: str + target_commit: str + frontend_dependencies_refreshed: bool + + +_FRONTEND_LOCKS = ( + "web/bun.lock", + "web/bun.lockb", + "web/package-lock.json", +) + + +class SourceUpdater: + """Fast-forward one clean editable checkout to a stable release tag.""" + + def __init__( + self, + *, + runner: SourceCommandRunner | None = None, + python_executable: str | None = None, + bun_executable: str | None = None, + ) -> None: + self._runner = runner or SubprocessSourceCommandRunner() + self._python = python_executable or sys.executable + self._bun = bun_executable + + def update( + self, + installation: Installation, + target_version: str, + ) -> SourceUpdateResult: + """Apply a stable source update or refuse before changing the checkout.""" + + if installation.mode not in {InstallMode.SOURCE_WEB, InstallMode.SOURCE_CLI}: + raise SourceUpdateError("This is not an editable source installation") + if installation.source_root is None: + raise SourceUpdateError("The editable source root is unavailable") + + source_root = installation.source_root.resolve() + editable_root = ( + source_root + if installation.mode is InstallMode.SOURCE_WEB + else source_root / "packaging" / "deeptutor-cli" + ) + if not (editable_root / "pyproject.toml").is_file(): + raise SourceUpdateError("The editable source project is incomplete") + + version = Version(target_version) + if version.is_prerelease or version.is_devrelease: + raise SourceUpdateError("The source update target must be stable") + tag = f"v{version}" + + repository_root = Path( + self._git_required( + source_root, + ["rev-parse", "--show-toplevel"], + "Git checkout", + ) + ).resolve() + if repository_root != source_root: + raise SourceUpdateError("The editable source root is not the Git checkout root") + + branch_result = self._git( + source_root, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + if branch_result.returncode != 0 or not branch_result.stdout.strip(): + raise SourceUpdateError("Source update refused: detached HEAD") + branch = branch_result.stdout.strip() + self._require_clean(source_root) + previous_commit = self._git_required( + source_root, + ["rev-parse", "HEAD"], + "current source revision", + ) + + upstream = self._git_required( + source_root, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + "configured upstream branch", + ) + remote = self._git_required( + source_root, + ["config", "--get", f"branch.{branch}.remote"], + "configured upstream remote", + ) + if remote == ".": + raise SourceUpdateError("Source update requires a configured remote upstream") + + self._git_required( + source_root, + ["fetch", "--no-tags", remote], + "upstream fetch", + allow_empty=True, + ) + self._git_required( + source_root, + ["fetch", "--no-tags", remote, f"refs/tags/{tag}:refs/tags/{tag}"], + f"stable release tag {tag}", + allow_empty=True, + ) + + current_branch = self._git_required( + source_root, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + "current branch", + ) + if current_branch != branch: + raise SourceUpdateError("Source branch changed while checking for updates") + self._require_clean(source_root) + if ( + self._git_required( + source_root, + ["rev-parse", "HEAD"], + "current revision", + ) + != previous_commit + ): + raise SourceUpdateError("Source revision changed while checking for updates") + + counts = self._git_required( + source_root, + ["rev-list", "--left-right", "--count", f"HEAD...{upstream}"], + "upstream branch comparison", + ).split() + if len(counts) != 2: + raise SourceUpdateError("Unable to compare the configured upstream branch") + try: + local_only, upstream_only = (int(value) for value in counts) + except ValueError as exc: + raise SourceUpdateError("Unable to compare the configured upstream branch") from exc + if local_only and upstream_only: + raise SourceUpdateError("Source update refused: branch has diverged from upstream") + + target_commit = self._git_required( + source_root, + ["rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], + f"stable release tag {tag}", + ) + ancestor = self._git( + source_root, + ["merge-base", "--is-ancestor", previous_commit, target_commit], + ) + if ancestor.returncode == 1: + raise SourceUpdateError( + f"Source update refused: {tag} cannot be fast-forwarded from HEAD" + ) + if ancestor.returncode != 0: + self._raise_command_error("release ancestry check", ancestor) + + frontend_changed = False + if installation.mode is InstallMode.SOURCE_WEB: + lock_diff = self._git( + source_root, + [ + "diff", + "--quiet", + previous_commit, + target_commit, + "--", + *_FRONTEND_LOCKS, + ], + ) + if lock_diff.returncode not in {0, 1}: + self._raise_command_error("frontend lock comparison", lock_diff) + frontend_changed = lock_diff.returncode == 1 + + self._required( + [self._python, "-m", "pip", "--version"], + cwd=editable_root, + purpose="Python environment preflight", + ) + bun = self._bun or shutil.which("bun") + if frontend_changed: + if not bun: + raise SourceUpdateError( + "Frontend dependencies changed, but Bun is not available on PATH" + ) + self._required( + [bun, "--version"], + cwd=source_root / "web", + purpose="Bun preflight", + ) + + self._git_required( + source_root, + [ + "pull", + "--ff-only", + "--no-rebase", + "--no-tags", + remote, + f"refs/tags/{tag}", + ], + f"fast-forward to {tag}", + allow_empty=True, + ) + if ( + self._git_required( + source_root, + ["rev-parse", "HEAD"], + "updated revision", + ) + != target_commit + ): + raise SourceUpdateError("Git did not finish at the stable release commit") + + self._required( + [ + self._python, + "-m", + "pip", + "install", + "--no-deps", + "--editable", + str(editable_root), + ], + cwd=editable_root, + purpose="editable Python refresh", + ) + if frontend_changed and bun: + self._required( + [bun, "install", "--no-save"], + cwd=source_root / "web", + purpose="Bun dependency refresh", + ) + + return SourceUpdateResult( + previous_commit=previous_commit, + target_commit=target_commit, + frontend_dependencies_refreshed=frontend_changed, + ) + + def _require_clean(self, source_root: Path) -> None: + status = self._git_required( + source_root, + ["status", "--porcelain=v1", "--untracked-files=all"], + "working tree status", + allow_empty=True, + ) + if status: + raise SourceUpdateError("Source update refused: working tree is not clean") + + def _git(self, source_root: Path, arguments: list[str]) -> CommandResult: + return self._run(["git", *arguments], cwd=source_root) + + def _git_required( + self, + source_root: Path, + arguments: list[str], + purpose: str, + *, + allow_empty: bool = False, + ) -> str: + result = self._git(source_root, arguments) + if result.returncode != 0: + self._raise_command_error(purpose, result) + output = result.stdout.strip() + if not output and not allow_empty: + raise SourceUpdateError(f"Unable to resolve {purpose}") + return output + + def _required(self, command: list[str], *, cwd: Path, purpose: str) -> None: + result = self._run(command, cwd=cwd) + if result.returncode != 0: + self._raise_command_error(purpose, result) + + def _run(self, command: list[str], *, cwd: Path) -> CommandResult: + try: + return self._runner.run(command, cwd=cwd) + except OSError as exc: + raise SourceUpdateError(str(exc) or type(exc).__name__) from exc + + @staticmethod + def _raise_command_error(purpose: str, result: CommandResult) -> None: + detail = (result.stderr or result.stdout).strip() + suffix = f": {detail}" if detail else "" + raise SourceUpdateError(f"Unable to complete {purpose}{suffix}") + + +def create_source_updater() -> SourceUpdater: + """Build the production source updater for the active Python environment.""" + + return SourceUpdater() + + +__all__ = ( + "CommandResult", + "SourceCommandRunner", + "SourceUpdateError", + "SourceUpdateResult", + "SourceUpdater", + "SubprocessSourceCommandRunner", + "create_source_updater", +) diff --git a/deeptutor_cli/README.md b/deeptutor_cli/README.md index 93a4c17a96..dcb0c11161 100644 --- a/deeptutor_cli/README.md +++ b/deeptutor_cli/README.md @@ -164,8 +164,10 @@ deeptutor update --check # 仅检查,不修改环境 `--check` 只读取当前安装方式和官方稳定版元数据。PyPI 安装执行更新时, 当前 CLI 会先退出,再由独立 Worker 升级固定的 `deeptutor` 包;完成后不会 -自动启动应用。源码安装目前仍只检查,Docker 只提示在宿主机更新镜像并 -重建服务。 +自动启动应用。editable 源码安装只会在工作区干净、分支未分叉且最新稳定 +Release tag 可从当前 HEAD 快进时更新;随后以 `--no-deps` 刷新原 editable +安装,不推断 extras。完整源码仅在前端锁文件变化时用 Bun 刷新依赖。 +Docker 只提示在宿主机更新镜像并重建服务。 通过 `deeptutor start` 启动的 PyPI 完整版也可在网页侧栏版本徽标中确认 “更新并重启”。有对话任务运行时会拒绝更新;重启沿用同一 `--home` 与端口设置。 diff --git a/deeptutor_cli/update_cmd.py b/deeptutor_cli/update_cmd.py index a0dafa5046..574ada269b 100644 --- a/deeptutor_cli/update_cmd.py +++ b/deeptutor_cli/update_cmd.py @@ -6,8 +6,15 @@ import typer -from deeptutor.update import InstallMode, UpdateCheck, UpdateStatus, create_update_coordinator +from deeptutor.update import ( + InstallMode, + UpdateCheck, + UpdateStatus, + create_update_coordinator, + detect_current_installation, +) from deeptutor.update.jobs import UpdateInProgressError, create_update_scheduler +from deeptutor.update.source import SourceUpdateError, create_source_updater def _print_check(result: UpdateCheck) -> None: @@ -47,18 +54,42 @@ def update( raise typer.Exit(code=1) if check or result.status is UpdateStatus.UP_TO_DATE: return - if result.install_mode is not InstallMode.PYPI: + if result.install_mode not in { + InstallMode.PYPI, + InstallMode.SOURCE_WEB, + InstallMode.SOURCE_CLI, + }: typer.echo(f"Automatic updates are not available for {result.install_mode.value} yet.") raise typer.Exit(code=2) if not result.latest_version: typer.echo("The stable target version is unavailable.") raise typer.Exit(code=1) + package_name = ( + "deeptutor-cli" if result.install_mode is InstallMode.SOURCE_CLI else "deeptutor" + ) if not typer.confirm( - f"Update deeptutor from {result.current_version} to {result.latest_version}?", + f"Update {package_name} from {result.current_version} to {result.latest_version}?", default=False, ): typer.echo("Update cancelled.") return + if result.install_mode in {InstallMode.SOURCE_WEB, InstallMode.SOURCE_CLI}: + installation = detect_current_installation() + if installation.mode is not result.install_mode or installation.source_root is None: + typer.echo("The editable source installation changed during the update check.") + raise typer.Exit(code=1) + try: + source_result = create_source_updater().update( + installation, + result.latest_version, + ) + except (SourceUpdateError, ValueError) as exc: + typer.echo(str(exc)) + raise typer.Exit(code=1) from exc + typer.echo("Source update complete.") + if source_result.frontend_dependencies_refreshed: + typer.echo("Frontend dependencies refreshed with Bun.") + return try: job = create_update_scheduler().schedule_pypi( current_version=result.current_version, diff --git a/tests/cli/test_update_cli.py b/tests/cli/test_update_cli.py index 9bb9516982..2d13ed7d39 100644 --- a/tests/cli/test_update_cli.py +++ b/tests/cli/test_update_cli.py @@ -4,7 +4,7 @@ from typer.testing import CliRunner from deeptutor.__version__ import __version__ -from deeptutor.update import InstallMode, UpdateCheck, UpdateStatus +from deeptutor.update import Installation, InstallMode, UpdateCheck, UpdateStatus from deeptutor.update.jobs import UpdateInProgressError from deeptutor_cli import update_cmd from deeptutor_cli.main import app @@ -22,6 +22,18 @@ def _available_pypi_update() -> UpdateCheck: ) +def _available_source_update(mode: InstallMode) -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.AVAILABLE, + current_version="1.5.4", + latest_version="1.6.0", + install_mode=mode, + can_auto_update=True, + release_url="https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + detail="editable source installation", + ) + + def test_update_cancelled_by_user_does_not_create_a_job(monkeypatch) -> None: monkeypatch.setattr( update_cmd, @@ -46,6 +58,23 @@ def schedule_pypi(self, **kwargs): assert "Update cancelled" in result.output +def test_cli_only_source_confirmation_names_deeptutor_cli(monkeypatch) -> None: + monkeypatch.setattr( + update_cmd, + "create_update_coordinator", + lambda: type( + "Coordinator", + (), + {"check": lambda self: _available_source_update(InstallMode.SOURCE_CLI)}, + )(), + ) + + result = CliRunner().invoke(app, ["update"], input="n\n") + + assert result.exit_code == 0 + assert "Update deeptutor-cli from 1.5.4 to 1.6.0?" in result.output + + def test_confirmed_update_schedules_pypi_worker(monkeypatch) -> None: monkeypatch.setattr( update_cmd, @@ -95,6 +124,44 @@ def schedule_pypi(self, **kwargs): assert "Another update job is already active" in result.output +def test_confirmed_source_update_uses_the_detected_editable_checkout( + tmp_path, + monkeypatch, +) -> None: + check = _available_source_update(InstallMode.SOURCE_WEB) + installation = Installation( + mode=InstallMode.SOURCE_WEB, + current_version="1.5.4", + package_name="deeptutor", + source_root=tmp_path, + detail="editable full installation", + ) + monkeypatch.setattr( + update_cmd, + "create_update_coordinator", + lambda: type("Coordinator", (), {"check": lambda self: check})(), + ) + monkeypatch.setattr(update_cmd, "detect_current_installation", lambda: installation) + updated: dict[str, object] = {} + + class Updater: + def update(self, detected, target_version): + updated["installation"] = detected + updated["target_version"] = target_version + return type("Result", (), {"frontend_dependencies_refreshed": False})() + + monkeypatch.setattr(update_cmd, "create_source_updater", Updater) + + result = CliRunner().invoke(app, ["update"], input="y\n") + + assert result.exit_code == 0, result.output + assert updated == { + "installation": installation, + "target_version": "1.6.0", + } + assert "Source update complete" in result.output + + def test_update_check_reports_the_latest_stable_release(monkeypatch) -> None: def fake_get(self, url: str) -> httpx.Response: request = httpx.Request("GET", url) diff --git a/tests/e2e/test_update_check_cli.py b/tests/e2e/test_update_check_cli.py index 8ddea8007e..d38a868a14 100644 --- a/tests/e2e/test_update_check_cli.py +++ b/tests/e2e/test_update_check_cli.py @@ -17,6 +17,17 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] +def _run_git(cwd: Path, *args: str) -> str: + completed = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + check=True, + text=True, + ) + return completed.stdout.strip() + + class _GitHubReleaseHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 if self.path.startswith("/pypi/"): @@ -380,3 +391,161 @@ def test_web_worker_restarts_with_persisted_launcher_arguments(tmp_path: Path) - assert json.loads(command_path.read_text(encoding="utf-8")) == list(restart_argv) assert store.load().status is JobStatus.RESTARTING assert store.load().restart_count == 1 + + +@pytest.mark.parametrize("cli_only", [False, True], ids=["full-source", "cli-only-source"]) +def test_source_user_can_fast_forward_from_the_real_cli_process( + tmp_path: Path, + cli_only: bool, +) -> None: + remote = tmp_path / "remote.git" + seed = tmp_path / "seed" + checkout = tmp_path / "checkout" + _run_git(tmp_path, "init", "--bare", str(remote)) + _run_git(tmp_path, "init", "--initial-branch=main", str(seed)) + _run_git(seed, "config", "user.email", "tests@example.com") + _run_git(seed, "config", "user.name", "DeepTutor Tests") + (seed / "deeptutor").mkdir() + (seed / "deeptutor" / "__init__.py").write_text("", encoding="utf-8") + (seed / "pyproject.toml").write_text( + "[project]\nname='deeptutor'\n", + encoding="utf-8", + ) + cli_project = seed / "packaging" / "deeptutor-cli" + cli_project.mkdir(parents=True) + (cli_project / "pyproject.toml").write_text( + "[project]\nname='deeptutor-cli'\n", + encoding="utf-8", + ) + (seed / "web").mkdir() + (seed / "web" / "package-lock.json").write_text("unchanged\n", encoding="utf-8") + (seed / "release.txt").write_text("base\n", encoding="utf-8") + _run_git(seed, "add", ".") + _run_git(seed, "commit", "-m", "base") + _run_git(seed, "remote", "add", "origin", str(remote)) + _run_git(seed, "push", "-u", "origin", "main") + _run_git(tmp_path, "clone", str(remote), str(checkout)) + (seed / "release.txt").write_text("stable\n", encoding="utf-8") + _run_git(seed, "add", ".") + _run_git(seed, "commit", "-m", "stable release") + target = _run_git(seed, "rev-parse", "HEAD") + _run_git(seed, "tag", "v1.6.0") + _run_git(seed, "push", "origin", "main", "v1.6.0") + + server = ThreadingHTTPServer(("127.0.0.1", 0), _GitHubReleaseHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + hook_dir = tmp_path / "hook" + hook_dir.mkdir() + checkout_uri = checkout.as_uri() + if cli_only: + detection_hook = [ + "from pathlib import Path", + "import deeptutor.update as update_module", + "from deeptutor.update import Installation, InstallMode", + "def _detect_installation():", + ( + " return Installation(mode=InstallMode.SOURCE_CLI, " + "current_version='1.5.4', package_name='deeptutor-cli', " + f"source_root=Path({str(checkout)!r}))" + ), + "update_module.detect_current_installation = _detect_installation", + ] + else: + detection_hook = [ + "import importlib.metadata as metadata", + "import json", + "_real_distribution = metadata.distribution", + "class _SourceDistribution:", + " version = '1.5.4'", + " def read_text(self, name):", + " if name != 'direct_url.json':", + " return None", + ( + " return json.dumps({'url': " + f"'{checkout_uri}', " + "'dir_info': {'editable': True}})" + ), + "def _distribution(name):", + " normalized = name.lower().replace('_', '-')", + " if normalized == 'deeptutor':", + " return _SourceDistribution()", + " if normalized == 'deeptutor-cli':", + " raise metadata.PackageNotFoundError(name)", + " return _real_distribution(name)", + "metadata.distribution = _distribution", + ] + (hook_dir / "sitecustomize.py").write_text( + "\n".join( + [ + "import os", + *detection_hook, + "from deeptutor.update import HttpReleaseProvider", + ( + "HttpReleaseProvider.GITHUB_LATEST_URL = " + "os.environ['DEEPTUTOR_TEST_RELEASE_URL']" + ), + ] + ), + encoding="utf-8", + ) + fake_modules = tmp_path / "fake-modules" + fake_pip = fake_modules / "pip" + fake_pip.mkdir(parents=True) + (fake_pip / "__init__.py").write_text("", encoding="utf-8") + (fake_pip / "__main__.py").write_text( + "\n".join( + [ + "import json", + "import os", + "from pathlib import Path", + "import sys", + "if sys.argv[1:] == ['--version']:", + " print('pip test')", + "else:", + ( + " Path(os.environ['DEEPTUTOR_TEST_PIP_COMMAND']).write_text(" + "json.dumps(sys.argv[1:]), encoding='utf-8')" + ), + ] + ), + encoding="utf-8", + ) + command_path = tmp_path / "source-pip-command.json" + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join((str(hook_dir), str(fake_modules), str(PROJECT_ROOT))) + env["DEEPTUTOR_TEST_RELEASE_URL"] = f"http://127.0.0.1:{server.server_port}/releases/latest" + env["DEEPTUTOR_TEST_PIP_COMMAND"] = str(command_path) + env["DEEPTUTOR_HOME"] = str(tmp_path / "runtime-home") + env.pop("DEEPTUTOR_CONTAINER", None) + + try: + completed = subprocess.run( + [sys.executable, "-m", "deeptutor_cli.main", "update"], + cwd=checkout, + env=env, + input="y\n", + capture_output=True, + text=True, + timeout=20, + check=False, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert completed.returncode == 0, completed.stderr + package_name = "deeptutor-cli" if cli_only else "deeptutor" + assert f"Update {package_name} from " in completed.stdout + assert " to 1.6.0?" in completed.stdout + assert "Source update complete" in completed.stdout + assert _run_git(checkout, "rev-parse", "HEAD") == target + assert json.loads(command_path.read_text(encoding="utf-8")) == [ + "install", + "--no-deps", + "--editable", + str( + (checkout / "packaging" / "deeptutor-cli").resolve() if cli_only else checkout.resolve() + ), + ] diff --git a/tests/update/test_source_updater.py b/tests/update/test_source_updater.py new file mode 100644 index 0000000000..2364560662 --- /dev/null +++ b/tests/update/test_source_updater.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess + +import pytest + +from deeptutor.update import Installation, InstallMode +from deeptutor.update.source import ( + CommandResult, + SourceUpdateError, + SourceUpdater, +) + + +def _git(cwd: Path, *args: str) -> str: + completed = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + check=True, + text=True, + ) + return completed.stdout.strip() + + +def _commit(repo: Path, message: str) -> str: + _git(repo, "add", ".") + _git(repo, "commit", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +def _source_checkout(tmp_path: Path, *, lock_changed: bool = True) -> tuple[Path, str]: + remote = tmp_path / "remote.git" + seed = tmp_path / "seed" + checkout = tmp_path / "checkout" + _git(tmp_path, "init", "--bare", str(remote)) + _git(tmp_path, "init", "--initial-branch=main", str(seed)) + _git(seed, "config", "user.email", "tests@example.com") + _git(seed, "config", "user.name", "DeepTutor Tests") + (seed / "deeptutor").mkdir() + (seed / "deeptutor" / "__init__.py").write_text("", encoding="utf-8") + (seed / "pyproject.toml").write_text("[project]\nname='deeptutor'\n", encoding="utf-8") + cli_project = seed / "packaging" / "deeptutor-cli" + cli_project.mkdir(parents=True) + (cli_project / "pyproject.toml").write_text( + "[project]\nname='deeptutor-cli'\n", + encoding="utf-8", + ) + (seed / "web").mkdir() + (seed / "web" / "package-lock.json").write_text("base\n", encoding="utf-8") + (seed / "release.txt").write_text("base\n", encoding="utf-8") + _commit(seed, "base") + _git(seed, "remote", "add", "origin", str(remote)) + _git(seed, "push", "-u", "origin", "main") + _git(tmp_path, "clone", str(remote), str(checkout)) + + (seed / "release.txt").write_text("stable\n", encoding="utf-8") + if lock_changed: + (seed / "web" / "package-lock.json").write_text("stable\n", encoding="utf-8") + target = _commit(seed, "stable release") + _git(seed, "tag", "v1.6.0") + _git(seed, "push", "origin", "main", "v1.6.0") + return checkout, target + + +class _Runner: + def __init__(self, *, fail_fast_forward: bool = False) -> None: + self.commands: list[tuple[list[str], Path]] = [] + self.fail_fast_forward = fail_fast_forward + + def run(self, command: list[str], *, cwd: Path) -> CommandResult: + self.commands.append((command, cwd)) + if command[:2] == ["git", "pull"] and self.fail_fast_forward: + return CommandResult(1, stderr="simulated fast-forward failure") + if command[0] != "git": + return CommandResult(0) + completed = subprocess.run( + command, + cwd=cwd, + capture_output=True, + check=False, + text=True, + ) + return CommandResult(completed.returncode, completed.stdout, completed.stderr) + + +def _installation(checkout: Path, mode: InstallMode) -> Installation: + return Installation( + mode=mode, + current_version="1.5.4", + package_name="deeptutor" if mode is InstallMode.SOURCE_WEB else "deeptutor-cli", + source_root=checkout, + detail="editable source installation", + ) + + +def test_full_source_update_fast_forwards_and_refreshes_changed_dependencies( + tmp_path: Path, +) -> None: + checkout, target = _source_checkout(tmp_path) + runner = _Runner() + + result = SourceUpdater( + runner=runner, + python_executable="python-under-test", + bun_executable="bun", + ).update(_installation(checkout, InstallMode.SOURCE_WEB), "1.6.0") + + assert _git(checkout, "rev-parse", "HEAD") == target + assert result.frontend_dependencies_refreshed is True + assert ( + [ + "python-under-test", + "-m", + "pip", + "install", + "--no-deps", + "--editable", + str(checkout.resolve()), + ], + checkout.resolve(), + ) in runner.commands + assert (["bun", "install", "--no-save"], checkout.resolve() / "web") in runner.commands + git_verbs = {command[1] for command, _cwd in runner.commands if command[0] == "git"} + assert git_verbs.isdisjoint({"stash", "reset", "rebase", "merge"}) + + +def test_cli_source_update_refreshes_only_the_existing_cli_editable( + tmp_path: Path, +) -> None: + checkout, target = _source_checkout(tmp_path) + runner = _Runner() + + result = SourceUpdater( + runner=runner, + python_executable="python-under-test", + bun_executable="bun", + ).update(_installation(checkout, InstallMode.SOURCE_CLI), "1.6.0") + + assert _git(checkout, "rev-parse", "HEAD") == target + assert result.frontend_dependencies_refreshed is False + editable = checkout.resolve() / "packaging" / "deeptutor-cli" + assert ( + [ + "python-under-test", + "-m", + "pip", + "install", + "--no-deps", + "--editable", + str(editable), + ], + editable, + ) in runner.commands + assert not any(command[0][0] == "bun" for command in runner.commands) + + +def test_source_update_rejects_a_dirty_worktree(tmp_path: Path) -> None: + checkout, _ = _source_checkout(tmp_path) + original = _git(checkout, "rev-parse", "HEAD") + (checkout / "untracked.txt").write_text("local work\n", encoding="utf-8") + + with pytest.raises(SourceUpdateError, match="working tree is not clean"): + SourceUpdater(runner=_Runner()).update( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + + assert _git(checkout, "rev-parse", "HEAD") == original + + +def test_source_update_rejects_detached_head(tmp_path: Path) -> None: + checkout, _ = _source_checkout(tmp_path) + _git(checkout, "checkout", "--detach") + + with pytest.raises(SourceUpdateError, match="detached HEAD"): + SourceUpdater(runner=_Runner()).update( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + + +def test_source_update_rejects_a_branch_without_configured_upstream( + tmp_path: Path, +) -> None: + checkout, _ = _source_checkout(tmp_path) + original = _git(checkout, "rev-parse", "HEAD") + _git(checkout, "branch", "--unset-upstream") + + with pytest.raises(SourceUpdateError, match="configured upstream branch"): + SourceUpdater(runner=_Runner()).update( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + + assert _git(checkout, "rev-parse", "HEAD") == original + + +def test_source_update_rejects_a_diverged_branch(tmp_path: Path) -> None: + checkout, _ = _source_checkout(tmp_path) + _git(checkout, "config", "user.email", "tests@example.com") + _git(checkout, "config", "user.name", "DeepTutor Tests") + (checkout / "local.txt").write_text("local\n", encoding="utf-8") + original = _commit(checkout, "local work") + + with pytest.raises(SourceUpdateError, match="branch has diverged"): + SourceUpdater(runner=_Runner()).update( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + + assert _git(checkout, "rev-parse", "HEAD") == original + + +def test_source_update_rejects_a_non_fast_forward_release_tag(tmp_path: Path) -> None: + checkout, _ = _source_checkout(tmp_path) + _git(checkout, "config", "user.email", "tests@example.com") + _git(checkout, "config", "user.name", "DeepTutor Tests") + _git(checkout, "pull", "--ff-only") + (checkout / "local.txt").write_text("published branch work\n", encoding="utf-8") + original = _commit(checkout, "branch advanced beyond release") + _git(checkout, "push", "origin", "main") + + with pytest.raises(SourceUpdateError, match="cannot be fast-forwarded"): + SourceUpdater(runner=_Runner()).update( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + + assert _git(checkout, "rev-parse", "HEAD") == original + + +def test_failed_fast_forward_leaves_the_checkout_unchanged(tmp_path: Path) -> None: + checkout, _ = _source_checkout(tmp_path, lock_changed=False) + original = _git(checkout, "rev-parse", "HEAD") + runner = _Runner(fail_fast_forward=True) + + with pytest.raises(SourceUpdateError, match="simulated fast-forward failure"): + SourceUpdater(runner=runner).update( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + + assert _git(checkout, "rev-parse", "HEAD") == original + assert not any("install" in command for command, _cwd in runner.commands) From e3f073a11723e7fb5c753617907904875e473a6b Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sat, 25 Jul 2026 18:58:49 +0800 Subject: [PATCH 6/8] feat(web): enable source update and restart flow --- README.md | 2 +- deeptutor/api/routers/system.py | 94 ++++++-- deeptutor/runtime/launcher.py | 7 +- deeptutor/update/jobs.py | 36 +++- deeptutor/update/source.py | 166 +++++++++++++-- deeptutor/update/worker.py | 92 ++++++-- deeptutor_cli/README.md | 6 +- tests/api/test_system_router.py | 160 +++++++++++++- tests/update/test_jobs.py | 15 ++ tests/update/test_source_updater.py | 28 ++- tests/update/test_web_restart_integration.py | 111 ++++++++++ tests/update/test_worker.py | 67 ++++++ web/components/sidebar/UpdateAction.tsx | 54 +++-- web/components/sidebar/VersionBadge.tsx | 51 ++++- web/lib/update-api.ts | 13 +- web/lib/update-badge.ts | 4 - web/tests/e2e/update-badge.e2e.ts | 213 ++++++++++++++++++- web/tests/update-api.test.ts | 8 +- web/tests/update-badge.test.ts | 4 - 19 files changed, 1007 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index d7fab120bc..ad51d196c1 100644 --- a/README.md +++ b/README.md @@ -718,7 +718,7 @@ The repo ships a root [`SKILL.md`](SKILL.md) — a ~150-line handover doc that t | Command | Description | |:---|:---| | `deeptutor init` | Create or update `data/user/settings` for the current workspace | -| `deeptutor start [--home PATH] [--dev]` | Launch backend + frontend together; `--dev` enables frontend HMR, and PyPI installs can update and restart from the Web version badge | +| `deeptutor start [--home PATH] [--dev]` | Launch backend + frontend together; `--dev` enables frontend HMR, and PyPI or clean editable source installs can update and restart from the Web version badge | | `deeptutor serve [--port PORT]` | Start only the FastAPI backend | | `deeptutor update` | Update a PyPI install or safely fast-forward a clean editable checkout to the latest stable release; CLI updates do not restart the app | | `deeptutor update --check` | Detect the installation mode and check the latest stable release without changing the installation | diff --git a/deeptutor/api/routers/system.py b/deeptutor/api/routers/system.py index 786bbaea11..14302e4f09 100644 --- a/deeptutor/api/routers/system.py +++ b/deeptutor/api/routers/system.py @@ -22,10 +22,12 @@ from deeptutor.services.llm import get_llm_config, get_token_limit_kwargs from deeptutor.services.search import web_search from deeptutor.update import ( + Installation, InstallMode, UpdateCheck, UpdateStatus, create_update_coordinator, + detect_current_installation, ) from deeptutor.update.jobs import ( JobStatus, @@ -33,6 +35,11 @@ UpdateJob, UpdateJobStore, ) +from deeptutor.update.source import ( + SourceUpdateError, + SourceUpdatePlan, + create_source_updater, +) router = APIRouter() @@ -71,6 +78,17 @@ async def has_live_executions(self) -> bool: """Return whether a turn is currently running.""" +class SourcePreflight(Protocol): + """Non-mutating source checks required before the Launcher exits.""" + + def preflight( + self, + installation: Installation, + target_version: str, + ) -> SourceUpdatePlan: + """Validate one source update without changing the checkout.""" + + class WebUpdateRequest(BaseModel): """Explicit second confirmation for an update and restart.""" @@ -95,17 +113,35 @@ def get_update_coordinator() -> UpdateChecker: def get_conversation_activity() -> ConversationActivity: + """Provide live conversation state for disruptive update checks.""" + from deeptutor.services.session import get_turn_runtime_manager return get_turn_runtime_manager() def get_update_job_store() -> UpdateJobStore: + """Provide the update job store under the active runtime home.""" + root = get_runtime_home() / "data" / "user" / "update" return UpdateJobStore(root) +def get_current_installation() -> Installation: + """Provide current installation evidence for source preflight.""" + + return detect_current_installation() + + +def get_source_preflight() -> SourcePreflight: + """Provide the source updater's non-mutating preflight boundary.""" + + return create_source_updater() + + def is_launcher_available() -> bool: + """Return whether the backend is managed by a live Web launcher.""" + raw_pid = os.getenv("DEEPTUTOR_LAUNCHER_PID", "").strip() try: launcher_pid = int(raw_pid) @@ -178,8 +214,10 @@ async def request_web_update( ], store: Annotated[UpdateJobStore, Depends(get_update_job_store)], launcher_ready: Annotated[bool, Depends(is_launcher_available)], + installation: Annotated[Installation, Depends(get_current_installation)], + source_preflight: Annotated[SourcePreflight, Depends(get_source_preflight)], ) -> UpdateJobResponse: - """Request a trusted PyPI update for the managing Launcher to apply.""" + """Request a trusted update for the managing Launcher to apply.""" del request if not launcher_ready: @@ -193,21 +231,53 @@ async def request_web_update( detail="An active conversation must finish before updating.", ) result = await asyncio.to_thread(coordinator.check) - if ( - result.status is not UpdateStatus.AVAILABLE - or result.install_mode is not InstallMode.PYPI - or not result.latest_version - ): + if result.status is not UpdateStatus.AVAILABLE or not result.latest_version: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="No automatic PyPI update is currently available.", + detail="No automatic update is currently available.", ) - try: - job = store.create_pypi( - current_version=result.current_version, - target_version=result.latest_version, - restart_requested=True, + source_root = installation.source_root + if result.install_mode is InstallMode.SOURCE_WEB: + if installation.mode is not InstallMode.SOURCE_WEB or source_root is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="The editable source installation changed during the update check.", + ) + try: + await asyncio.to_thread( + source_preflight.preflight, + installation, + result.latest_version, + ) + except (SourceUpdateError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + elif result.install_mode is not InstallMode.PYPI: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This installation cannot be updated from the Web app.", ) + try: + if result.install_mode is InstallMode.SOURCE_WEB: + if source_root is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="The editable source root is unavailable.", + ) + job = store.create_source( + current_version=result.current_version, + target_version=result.latest_version, + source_root=source_root, + restart_requested=True, + ) + else: + job = store.create_pypi( + current_version=result.current_version, + target_version=result.latest_version, + restart_requested=True, + ) except UpdateInProgressError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, diff --git a/deeptutor/runtime/launcher.py b/deeptutor/runtime/launcher.py index e522a4b483..b677a32854 100644 --- a/deeptutor/runtime/launcher.py +++ b/deeptutor/runtime/launcher.py @@ -15,7 +15,7 @@ import sys import threading import time -from typing import Callable, Sequence +from typing import TYPE_CHECKING, Callable, Sequence from urllib import error as urlerror from urllib import parse as urlparse from urllib import request as urlrequest @@ -23,6 +23,9 @@ from deeptutor.runtime.banner import labels_for, print_banner, resolve_language from deeptutor.runtime.home import DEEPTUTOR_HOME_ENV, PACKAGE_ROOT, get_runtime_home +if TYPE_CHECKING: + from deeptutor.update.jobs import WorkerLauncher + BACKEND_READY_TIMEOUT = 60 FRONTEND_READY_TIMEOUT = 120 FRONTEND_REUSE_PROBE_TIMEOUT = 2 @@ -930,7 +933,7 @@ def _handoff_pending_update( runtime_home: Path, *, restart_argv: Sequence[str], - worker_launcher=None, + worker_launcher: WorkerLauncher | None = None, parent_pid: int | None = None, ) -> bool: """Hand one pending Web update to a worker before this launcher exits.""" diff --git a/deeptutor/update/jobs.py b/deeptutor/update/jobs.py index a3d33c0e9c..d2ea923236 100644 --- a/deeptutor/update/jobs.py +++ b/deeptutor/update/jobs.py @@ -36,7 +36,7 @@ class UpdateInProgressError(RuntimeError): @dataclass(frozen=True) class UpdateJob: - """Trusted data required to apply one PyPI update.""" + """Trusted data required to apply one persisted update.""" id: str status: JobStatus @@ -50,6 +50,7 @@ class UpdateJob: restart_home: str | None = None restart_argv: tuple[str, ...] = () restart_count: int = 0 + source_root: str | None = None schema_version: int = 1 kind: str = "pypi" @@ -64,8 +65,12 @@ def to_dict(self) -> dict[str, object]: def from_dict(cls, payload: dict[str, object]) -> UpdateJob: """Validate and deserialize one stored job.""" - if payload.get("schema_version") != 1 or payload.get("kind") != "pypi": + kind = str(payload.get("kind")) + if payload.get("schema_version") != 1 or kind not in {"pypi", "source"}: raise ValueError("Unsupported update job") + source_root = _optional_string(payload.get("source_root")) + if kind == "source" and not source_root: + raise ValueError("Source update job is missing its checkout") restart_home = _optional_string(payload.get("restart_home")) return cls( id=str(payload["id"]), @@ -83,6 +88,8 @@ def from_dict(cls, payload: dict[str, object]) -> UpdateJob: home=restart_home, ), restart_count=int(str(payload.get("restart_count") or 0)), + source_root=source_root, + kind=kind, ) @@ -152,6 +159,31 @@ def create_pypi( created_at=_now(), restart_requested=restart_requested, ) + return self._create(job) + + def create_source( + self, + *, + current_version: str, + target_version: str, + source_root: Path, + restart_requested: bool = True, + ) -> UpdateJob: + """Reserve the active slot for one editable source update.""" + + job = UpdateJob( + id=uuid.uuid4().hex, + status=JobStatus.PENDING, + current_version=_canonical_version(current_version, stable=False), + target_version=_canonical_version(target_version, stable=True), + created_at=_now(), + restart_requested=restart_requested, + source_root=str(source_root.resolve()), + kind="source", + ) + return self._create(job) + + def _create(self, job: UpdateJob) -> UpdateJob: self.root.mkdir(parents=True, exist_ok=True) try: descriptor = os.open( diff --git a/deeptutor/update/source.py b/deeptutor/update/source.py index ef963f85e1..df0884dac2 100644 --- a/deeptutor/update/source.py +++ b/deeptutor/update/source.py @@ -3,10 +3,12 @@ from __future__ import annotations from dataclasses import dataclass +import os from pathlib import Path import shutil import subprocess import sys +import sysconfig from typing import Protocol from packaging.version import Version @@ -60,6 +62,21 @@ class SourceUpdateResult: frontend_dependencies_refreshed: bool +@dataclass(frozen=True) +class SourceUpdatePlan: + """Preflighted source state that must still match when applying an update.""" + + source_root: Path + editable_root: Path + previous_commit: str + target_commit: str + branch: str + remote: str + tag: str + frontend_dependencies_changed: bool + bun_executable: str | None + + _FRONTEND_LOCKS = ( "web/bun.lock", "web/bun.lockb", @@ -86,8 +103,49 @@ def update( installation: Installation, target_version: str, ) -> SourceUpdateResult: - """Apply a stable source update or refuse before changing the checkout.""" + """Preflight and apply one stable source update.""" + + return self.apply(self.preflight(installation, target_version)) + + def preflight( + self, + installation: Installation, + target_version: str, + ) -> SourceUpdatePlan: + """Validate a source update without moving HEAD or changing dependencies.""" + + source_root, editable_root = self._resolve_editable_checkout(installation) + version = Version(target_version) + if version.is_prerelease or version.is_devrelease: + raise SourceUpdateError("The source update target must be stable") + tag = f"v{version}" + previous_commit, target_commit, branch, remote = self._resolve_git_target( + source_root, + tag, + ) + frontend_changed, bun = self._preflight_dependencies( + installation.mode, + source_root, + editable_root, + previous_commit, + target_commit, + ) + return SourceUpdatePlan( + source_root=source_root, + editable_root=editable_root, + previous_commit=previous_commit, + target_commit=target_commit, + branch=branch, + remote=remote, + tag=tag, + frontend_dependencies_changed=frontend_changed, + bun_executable=bun, + ) + def _resolve_editable_checkout( + self, + installation: Installation, + ) -> tuple[Path, Path]: if installation.mode not in {InstallMode.SOURCE_WEB, InstallMode.SOURCE_CLI}: raise SourceUpdateError("This is not an editable source installation") if installation.source_root is None: @@ -101,12 +159,6 @@ def update( ) if not (editable_root / "pyproject.toml").is_file(): raise SourceUpdateError("The editable source project is incomplete") - - version = Version(target_version) - if version.is_prerelease or version.is_devrelease: - raise SourceUpdateError("The source update target must be stable") - tag = f"v{version}" - repository_root = Path( self._git_required( source_root, @@ -116,7 +168,34 @@ def update( ).resolve() if repository_root != source_root: raise SourceUpdateError("The editable source root is not the Git checkout root") + git_dir_raw = self._git_required( + source_root, + ["rev-parse", "--git-dir"], + "Git metadata directory", + ) + git_dir = Path(git_dir_raw) + if not git_dir.is_absolute(): + git_dir = source_root / git_dir + python_site = Path(sysconfig.get_paths()["purelib"]) + if not all( + os.access(path, os.W_OK) + for path in ( + source_root, + git_dir.resolve(), + editable_root, + python_site, + ) + ): + raise SourceUpdateError( + "Source update refused: checkout or Python environment is not writable" + ) + return source_root, editable_root + def _resolve_git_target( + self, + source_root: Path, + tag: str, + ) -> tuple[str, str, str, str]: branch_result = self._git( source_root, ["symbolic-ref", "--quiet", "--short", "HEAD"], @@ -204,9 +283,18 @@ def update( ) if ancestor.returncode != 0: self._raise_command_error("release ancestry check", ancestor) + return previous_commit, target_commit, branch, remote + def _preflight_dependencies( + self, + mode: InstallMode, + source_root: Path, + editable_root: Path, + previous_commit: str, + target_commit: str, + ) -> tuple[bool, str | None]: frontend_changed = False - if installation.mode is InstallMode.SOURCE_WEB: + if mode is InstallMode.SOURCE_WEB: lock_diff = self._git( source_root, [ @@ -229,6 +317,8 @@ def update( ) bun = self._bun or shutil.which("bun") if frontend_changed: + if not os.access(source_root / "web", os.W_OK): + raise SourceUpdateError("Source update refused: frontend directory is not writable") if not bun: raise SourceUpdateError( "Frontend dependencies changed, but Bun is not available on PATH" @@ -238,27 +328,58 @@ def update( cwd=source_root / "web", purpose="Bun preflight", ) + return frontend_changed, bun + + def apply(self, plan: SourceUpdatePlan) -> SourceUpdateResult: + """Apply a preflighted plan after rechecking its non-mutating invariants.""" + + current_branch = self._git_required( + plan.source_root, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + "current branch", + ) + if current_branch != plan.branch: + raise SourceUpdateError("Source branch changed after preflight") + self._require_clean(plan.source_root) + if ( + self._git_required( + plan.source_root, + ["rev-parse", "HEAD"], + "current revision", + ) + != plan.previous_commit + ): + raise SourceUpdateError("Source revision changed after preflight") + if ( + self._git_required( + plan.source_root, + ["rev-parse", "--verify", f"refs/tags/{plan.tag}^{{commit}}"], + f"stable release tag {plan.tag}", + ) + != plan.target_commit + ): + raise SourceUpdateError("Stable release tag changed after preflight") self._git_required( - source_root, + plan.source_root, [ "pull", "--ff-only", "--no-rebase", "--no-tags", - remote, - f"refs/tags/{tag}", + plan.remote, + f"refs/tags/{plan.tag}", ], - f"fast-forward to {tag}", + f"fast-forward to {plan.tag}", allow_empty=True, ) if ( self._git_required( - source_root, + plan.source_root, ["rev-parse", "HEAD"], "updated revision", ) - != target_commit + != plan.target_commit ): raise SourceUpdateError("Git did not finish at the stable release commit") @@ -270,22 +391,22 @@ def update( "install", "--no-deps", "--editable", - str(editable_root), + str(plan.editable_root), ], - cwd=editable_root, + cwd=plan.editable_root, purpose="editable Python refresh", ) - if frontend_changed and bun: + if plan.frontend_dependencies_changed and plan.bun_executable: self._required( - [bun, "install", "--no-save"], - cwd=source_root / "web", + [plan.bun_executable, "install", "--no-save"], + cwd=plan.source_root / "web", purpose="Bun dependency refresh", ) return SourceUpdateResult( - previous_commit=previous_commit, - target_commit=target_commit, - frontend_dependencies_refreshed=frontend_changed, + previous_commit=plan.previous_commit, + target_commit=plan.target_commit, + frontend_dependencies_refreshed=plan.frontend_dependencies_changed, ) def _require_clean(self, source_root: Path) -> None: @@ -345,6 +466,7 @@ def create_source_updater() -> SourceUpdater: "CommandResult", "SourceCommandRunner", "SourceUpdateError", + "SourceUpdatePlan", "SourceUpdateResult", "SourceUpdater", "SubprocessSourceCommandRunner", diff --git a/deeptutor/update/worker.py b/deeptutor/update/worker.py index 24a954ef3b..b15d417cae 100644 --- a/deeptutor/update/worker.py +++ b/deeptutor/update/worker.py @@ -12,7 +12,9 @@ from packaging.version import Version -from .jobs import JobStatus, UpdateJobStore +from . import Installation, InstallMode +from .jobs import JobStatus, UpdateJob, UpdateJobStore +from .source import create_source_updater class CommandExecutor(Protocol): @@ -22,6 +24,13 @@ def run(self, command: list[str], *, log_path: Path) -> int: """Run *command* without a shell and return its exit status.""" +class SourceUpdateExecutor(Protocol): + """Boundary for applying one preflighted source update.""" + + def update(self, installation: Installation, target_version: str) -> object: + """Update the detected source checkout.""" + + class SubprocessCommandExecutor: """Execute an update while appending output to the worker log.""" @@ -123,46 +132,75 @@ def wait_for_process_exit(pid: int, *, timeout: float = 60.0) -> None: time.sleep(0.05) +def _restart_application( + job: UpdateJob, + *, + store: UpdateJobStore, + launcher: RestartLauncher | None, +) -> None: + if not job.restart_home: + raise RuntimeError("Update job is missing its restart home") + if not job.restart_argv: + raise RuntimeError("Update job is missing its restart arguments") + home = Path(job.restart_home).resolve() + (launcher or SubprocessRestartLauncher()).launch( + build_restart_command(job.restart_argv), + cwd=home, + log_path=store.log_path, + ) + + def run_update_worker( *, store_root: Path, parent_pid: int | None, executor: CommandExecutor | None = None, restart_launcher: RestartLauncher | None = None, + source_updater: SourceUpdateExecutor | None = None, wait_for_parent: Callable[[int], None] = wait_for_process_exit, ) -> int: - """Apply one persisted PyPI job and persist its terminal status.""" + """Apply one persisted update job and persist its terminal status.""" store = UpdateJobStore(store_root) try: job = store.load() except Exception: return 1 + restart_attempted = False try: if job.status not in {JobStatus.PENDING, JobStatus.HANDOFF}: raise RuntimeError("Update job is not pending") if parent_pid is not None: wait_for_parent(parent_pid) store.mark_running(job.id) - command = build_pypi_update_command(job.target_version) - exit_code = (executor or SubprocessCommandExecutor()).run( - command, - log_path=store.log_path, - ) - if exit_code != 0: - store.mark_failed(job.id, f"pip exited with status {exit_code}") - return 1 + if job.kind == "source": + if not job.source_root: + raise RuntimeError("Source update job is missing its checkout") + (source_updater or create_source_updater()).update( + Installation( + mode=InstallMode.SOURCE_WEB, + current_version=job.current_version, + package_name="deeptutor", + source_root=Path(job.source_root).resolve(), + detail="editable full installation", + ), + job.target_version, + ) + else: + command = build_pypi_update_command(job.target_version) + exit_code = (executor or SubprocessCommandExecutor()).run( + command, + log_path=store.log_path, + ) + if exit_code != 0: + raise RuntimeError(f"pip exited with status {exit_code}") if job.restart_requested: - if not job.restart_home: - raise RuntimeError("Update job is missing its restart home") - if not job.restart_argv: - raise RuntimeError("Update job is missing its restart arguments") - home = Path(job.restart_home).resolve() store.mark_restarting(job.id) - (restart_launcher or SubprocessRestartLauncher()).launch( - build_restart_command(job.restart_argv), - cwd=home, - log_path=store.log_path, + restart_attempted = True + _restart_application( + job, + store=store, + launcher=restart_launcher, ) else: store.mark_succeeded(job.id) @@ -172,6 +210,22 @@ def run_update_worker( store.mark_failed(job.id, str(exc) or type(exc).__name__) except Exception: pass + if job.restart_requested and job.restart_home and not restart_attempted: + try: + restart_attempted = True + _restart_application( + job, + store=store, + launcher=restart_launcher, + ) + except Exception as restart_exc: + try: + store.mark_failed( + job.id, + f"{str(exc) or type(exc).__name__}; app restart failed: {restart_exc}", + ) + except Exception: + pass return 1 diff --git a/deeptutor_cli/README.md b/deeptutor_cli/README.md index dcb0c11161..bcb9e8e372 100644 --- a/deeptutor_cli/README.md +++ b/deeptutor_cli/README.md @@ -169,8 +169,10 @@ Release tag 可从当前 HEAD 快进时更新;随后以 `--no-deps` 刷新原 安装,不推断 extras。完整源码仅在前端锁文件变化时用 Bun 刷新依赖。 Docker 只提示在宿主机更新镜像并重建服务。 -通过 `deeptutor start` 启动的 PyPI 完整版也可在网页侧栏版本徽标中确认 -“更新并重启”。有对话任务运行时会拒绝更新;重启沿用同一 `--home` 与端口设置。 +通过 `deeptutor start` 启动的 PyPI 完整版或 editable 完整源码也可在网页 +侧栏版本徽标中确认“更新并重启”。有对话任务运行时会拒绝更新;源码模式会在 +停止服务前完成安全预检,重启沿用同一 `--home` 与端口设置。更新失败时会 +尝试恢复应用运行,但不自动回滚 Git 或依赖。 --- diff --git a/tests/api/test_system_router.py b/tests/api/test_system_router.py index 032ab1a747..cc51e16ea0 100644 --- a/tests/api/test_system_router.py +++ b/tests/api/test_system_router.py @@ -8,8 +8,9 @@ import pytest from deeptutor.api.routers import system as system_router -from deeptutor.update import InstallMode, UpdateCheck, UpdateStatus +from deeptutor.update import Installation, InstallMode, UpdateCheck, UpdateStatus from deeptutor.update.jobs import JobStatus, UpdateJobStore +from deeptutor.update.source import SourceUpdateError @pytest.mark.asyncio @@ -124,6 +125,19 @@ async def has_live_executions(self) -> bool: return False +def _pypi_installation() -> Installation: + return Installation( + mode=InstallMode.PYPI, + current_version="1.5.4", + package_name="deeptutor", + ) + + +class _NoSourcePreflight: + def preflight(self, installation, target_version): + raise AssertionError("PyPI updates must not run source preflight") + + @pytest.mark.asyncio async def test_web_update_refuses_to_interrupt_a_live_conversation(tmp_path: Path) -> None: class BusyConversations: @@ -137,6 +151,8 @@ async def has_live_executions(self) -> bool: conversations=BusyConversations(), store=UpdateJobStore(tmp_path), launcher_ready=True, + installation=_pypi_installation(), + source_preflight=_NoSourcePreflight(), ) assert exc_info.value.status_code == 409 @@ -154,6 +170,8 @@ async def test_web_update_persists_a_restart_request(tmp_path: Path) -> None: conversations=_IdleConversations(), store=store, launcher_ready=True, + installation=_pypi_installation(), + source_preflight=_NoSourcePreflight(), ) assert response.status is JobStatus.PENDING @@ -161,6 +179,95 @@ async def test_web_update_persists_a_restart_request(tmp_path: Path) -> None: assert store.load().restart_requested is True +@pytest.mark.asyncio +async def test_source_web_update_preflights_and_persists_the_checkout( + tmp_path: Path, +) -> None: + checkout = tmp_path / "checkout" + installation = Installation( + mode=InstallMode.SOURCE_WEB, + current_version="1.5.4", + package_name="deeptutor", + source_root=checkout, + ) + + class AvailableSourceUpdate: + def check(self) -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.AVAILABLE, + current_version="1.5.4", + latest_version="1.6.0", + install_mode=InstallMode.SOURCE_WEB, + can_auto_update=True, + release_url="https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + detail="editable full installation", + ) + + seen: list[tuple[Installation, str]] = [] + + class SourcePreflight: + def preflight(self, detected, target_version): + seen.append((detected, target_version)) + + store = UpdateJobStore(tmp_path / "jobs") + response = await system_router.request_web_update( + system_router.WebUpdateRequest(confirmation="update-and-restart"), + coordinator=AvailableSourceUpdate(), + conversations=_IdleConversations(), + store=store, + launcher_ready=True, + installation=installation, + source_preflight=SourcePreflight(), + ) + + assert response.status is JobStatus.PENDING + assert seen == [(installation, "1.6.0")] + assert store.load().kind == "source" + assert store.load().source_root == str(checkout.resolve()) + + +@pytest.mark.asyncio +async def test_source_web_update_refuses_a_failed_preflight(tmp_path: Path) -> None: + installation = Installation( + mode=InstallMode.SOURCE_WEB, + current_version="1.5.4", + package_name="deeptutor", + source_root=tmp_path / "checkout", + ) + + class AvailableSourceUpdate: + def check(self) -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.AVAILABLE, + current_version="1.5.4", + latest_version="1.6.0", + install_mode=InstallMode.SOURCE_WEB, + can_auto_update=True, + release_url=None, + detail="editable full installation", + ) + + class BrokenPreflight: + def preflight(self, detected, target_version): + raise SourceUpdateError("working tree is not clean") + + store = UpdateJobStore(tmp_path / "jobs") + with pytest.raises(HTTPException) as exc_info: + await system_router.request_web_update( + system_router.WebUpdateRequest(confirmation="update-and-restart"), + coordinator=AvailableSourceUpdate(), + conversations=_IdleConversations(), + store=store, + launcher_ready=True, + installation=installation, + source_preflight=BrokenPreflight(), + ) + + assert exc_info.value.status_code == 409 + assert "working tree is not clean" in str(exc_info.value.detail) + assert not store.state_path.exists() + + def test_web_update_http_route_is_admin_only( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -213,6 +320,57 @@ def test_web_update_http_route_requires_confirmation_and_creates_job( assert UpdateJobStore(tmp_path).load().restart_requested is True +def test_source_web_update_is_available_through_the_http_route( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from deeptutor.api.routers import auth as auth_router + + checkout = tmp_path / "checkout" + installation = Installation( + mode=InstallMode.SOURCE_WEB, + current_version="1.5.4", + package_name="deeptutor", + source_root=checkout, + ) + + class AvailableSourceUpdate: + def check(self) -> UpdateCheck: + return UpdateCheck( + status=UpdateStatus.AVAILABLE, + current_version="1.5.4", + latest_version="1.6.0", + install_mode=InstallMode.SOURCE_WEB, + can_auto_update=True, + release_url=None, + detail="editable full installation", + ) + + class SourcePreflight: + def preflight(self, detected, target_version): + return None + + store = UpdateJobStore(tmp_path / "jobs") + app = FastAPI() + app.include_router(system_router.router, prefix="/api/v1/system") + app.dependency_overrides[system_router.get_update_coordinator] = AvailableSourceUpdate + app.dependency_overrides[system_router.get_conversation_activity] = _IdleConversations + app.dependency_overrides[system_router.get_update_job_store] = lambda: store + app.dependency_overrides[system_router.is_launcher_available] = lambda: True + app.dependency_overrides[system_router.get_current_installation] = lambda: installation + app.dependency_overrides[system_router.get_source_preflight] = SourcePreflight + monkeypatch.setattr(auth_router, "AUTH_ENABLED", False) + + response = TestClient(app).post( + "/api/v1/system/update", + json={"confirmation": "update-and-restart"}, + ) + + assert response.status_code == 202 + assert store.load().kind == "source" + assert store.load().source_root == str(checkout.resolve()) + + def test_update_job_status_survives_a_router_recreation(tmp_path: Path) -> None: store = UpdateJobStore(tmp_path) job = store.create_pypi( diff --git a/tests/update/test_jobs.py b/tests/update/test_jobs.py index cc807b1fc2..6be1209cf4 100644 --- a/tests/update/test_jobs.py +++ b/tests/update/test_jobs.py @@ -23,6 +23,21 @@ def test_only_one_update_job_can_be_active(tmp_path: Path) -> None: assert store.load() == first +def test_source_job_persists_the_detected_checkout(tmp_path: Path) -> None: + store = UpdateJobStore(tmp_path / "jobs") + checkout = tmp_path / "checkout" + + job = store.create_source( + current_version="1.5.4", + target_version="1.6.0", + source_root=checkout, + ) + + assert job.kind == "source" + assert job.source_root == str(checkout.resolve()) + assert store.load() == job + + def test_restart_handoff_rejects_a_tampered_command(tmp_path: Path) -> None: store = UpdateJobStore(tmp_path / "jobs") home = tmp_path / "home" diff --git a/tests/update/test_source_updater.py b/tests/update/test_source_updater.py index 2364560662..d56c333f03 100644 --- a/tests/update/test_source_updater.py +++ b/tests/update/test_source_updater.py @@ -101,11 +101,19 @@ def test_full_source_update_fast_forwards_and_refreshes_changed_dependencies( checkout, target = _source_checkout(tmp_path) runner = _Runner() - result = SourceUpdater( + updater = SourceUpdater( runner=runner, python_executable="python-under-test", bun_executable="bun", - ).update(_installation(checkout, InstallMode.SOURCE_WEB), "1.6.0") + ) + original = _git(checkout, "rev-parse", "HEAD") + + plan = updater.preflight( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + assert _git(checkout, "rev-parse", "HEAD") == original + result = updater.apply(plan) assert _git(checkout, "rev-parse", "HEAD") == target assert result.frontend_dependencies_refreshed is True @@ -126,6 +134,22 @@ def test_full_source_update_fast_forwards_and_refreshes_changed_dependencies( assert git_verbs.isdisjoint({"stash", "reset", "rebase", "merge"}) +def test_source_preflight_rejects_a_read_only_checkout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from deeptutor.update import source as source_module + + checkout, _ = _source_checkout(tmp_path) + monkeypatch.setattr(source_module.os, "access", lambda path, mode: False) + + with pytest.raises(SourceUpdateError, match="not writable"): + SourceUpdater(runner=_Runner()).preflight( + _installation(checkout, InstallMode.SOURCE_WEB), + "1.6.0", + ) + + def test_cli_source_update_refreshes_only_the_existing_cli_editable( tmp_path: Path, ) -> None: diff --git a/tests/update/test_web_restart_integration.py b/tests/update/test_web_restart_integration.py index 9daf170749..fed6d6b28a 100644 --- a/tests/update/test_web_restart_integration.py +++ b/tests/update/test_web_restart_integration.py @@ -1,10 +1,12 @@ from __future__ import annotations from pathlib import Path +import subprocess import sys from deeptutor.runtime import launcher from deeptutor.update.jobs import JobStatus, UpdateJobStore +from deeptutor.update.source import CommandResult, SourceUpdater from deeptutor.update.worker import run_update_worker @@ -29,6 +31,35 @@ def launch(self, command: list[str], *, cwd: Path, log_path: Path) -> None: self.calls.append((command, cwd)) +def _git(cwd: Path, *args: str) -> str: + completed = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + check=True, + text=True, + ) + return completed.stdout.strip() + + +class _SourceRunner: + def __init__(self) -> None: + self.commands: list[tuple[list[str], Path]] = [] + + def run(self, command: list[str], *, cwd: Path) -> CommandResult: + self.commands.append((command, cwd)) + if command[0] != "git": + return CommandResult(0) + completed = subprocess.run( + command, + cwd=cwd, + capture_output=True, + check=False, + text=True, + ) + return CommandResult(completed.returncode, completed.stdout, completed.stderr) + + def test_web_update_handoff_preserves_runtime_data_and_completes_once( tmp_path: Path, ) -> None: @@ -87,3 +118,83 @@ def test_web_update_handoff_preserves_runtime_data_and_completes_once( assert store.load().restart_count == 1 for path, content in preserved.items(): assert path.read_text(encoding="utf-8") == content + + +def test_source_web_job_updates_dependencies_and_restarts(tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + seed = tmp_path / "seed" + checkout = tmp_path / "checkout" + _git(tmp_path, "init", "--bare", str(remote)) + _git(tmp_path, "init", "--initial-branch=main", str(seed)) + _git(seed, "config", "user.email", "tests@example.com") + _git(seed, "config", "user.name", "DeepTutor Tests") + (seed / "deeptutor").mkdir() + (seed / "deeptutor" / "__init__.py").write_text("", encoding="utf-8") + (seed / "pyproject.toml").write_text( + "[project]\nname='deeptutor'\n", + encoding="utf-8", + ) + (seed / "web").mkdir() + (seed / "web" / "package-lock.json").write_text("base\n", encoding="utf-8") + (seed / "release.txt").write_text("base\n", encoding="utf-8") + _git(seed, "add", ".") + _git(seed, "commit", "-m", "base") + _git(seed, "remote", "add", "origin", str(remote)) + _git(seed, "push", "-u", "origin", "main") + _git(tmp_path, "clone", str(remote), str(checkout)) + (seed / "release.txt").write_text("stable\n", encoding="utf-8") + (seed / "web" / "package-lock.json").write_text("stable\n", encoding="utf-8") + _git(seed, "add", ".") + _git(seed, "commit", "-m", "stable release") + target = _git(seed, "rev-parse", "HEAD") + _git(seed, "tag", "v1.6.0") + _git(seed, "push", "origin", "main", "v1.6.0") + + home = tmp_path / "home" + restart_argv = ("start", "--home", str(home.resolve())) + store = UpdateJobStore(home / "data" / "user" / "update") + job = store.create_source( + current_version="1.5.4", + target_version="1.6.0", + source_root=checkout, + restart_requested=True, + ) + worker_launcher = _WorkerLauncher() + assert launcher._handoff_pending_update( + home, + restart_argv=restart_argv, + worker_launcher=worker_launcher, + parent_pid=123, + ) + + source_runner = _SourceRunner() + restart_launcher = _RestartLauncher() + assert ( + run_update_worker( + store_root=store.root, + parent_pid=None, + source_updater=SourceUpdater( + runner=source_runner, + python_executable="python-under-test", + bun_executable="bun", + ), + restart_launcher=restart_launcher, + ) + == 0 + ) + + assert _git(checkout, "rev-parse", "HEAD") == target + assert any( + command[:5] == ["python-under-test", "-m", "pip", "install", "--no-deps"] + for command, _cwd in source_runner.commands + ) + assert (["bun", "install", "--no-save"], checkout / "web") in source_runner.commands + assert restart_launcher.calls == [ + ( + [sys.executable, "-m", "deeptutor_cli.main", *restart_argv], + home.resolve(), + ) + ] + assert store.load().status is JobStatus.RESTARTING + assert launcher._complete_restarted_update(home) + assert store.load().status is JobStatus.SUCCEEDED diff --git a/tests/update/test_worker.py b/tests/update/test_worker.py index 2a87e5ffa1..03cde93f70 100644 --- a/tests/update/test_worker.py +++ b/tests/update/test_worker.py @@ -4,7 +4,9 @@ from pathlib import Path import sys +from deeptutor.update import InstallMode from deeptutor.update.jobs import JobStatus, UpdateJobStore +from deeptutor.update.source import SourceUpdateError from deeptutor.update.worker import run_update_worker @@ -128,3 +130,68 @@ def test_web_worker_restarts_the_same_home_exactly_once_after_upgrade(tmp_path: restarted = store.load() assert restarted.status is JobStatus.RESTARTING assert restarted.restart_count == 1 + + +def test_web_worker_applies_a_source_job_before_restarting(tmp_path: Path) -> None: + home = tmp_path / "home" + checkout = tmp_path / "checkout" + store = UpdateJobStore(tmp_path / "jobs") + job = store.create_source( + current_version="1.5.4", + target_version="1.6.0", + source_root=checkout, + restart_requested=True, + ) + store.prepare_restart(job.id, home=home) + seen: dict[str, object] = {} + + class SourceUpdater: + def update(self, installation, target_version): + seen["installation"] = installation + seen["target_version"] = target_version + + restart_launcher = RecordingRestartLauncher() + exit_code = run_update_worker( + store_root=store.root, + parent_pid=None, + source_updater=SourceUpdater(), + restart_launcher=restart_launcher, + ) + + assert exit_code == 0 + installation = seen["installation"] + assert installation.mode is InstallMode.SOURCE_WEB + assert installation.source_root == checkout.resolve() + assert seen["target_version"] == "1.6.0" + assert len(restart_launcher.commands) == 1 + assert store.load().status is JobStatus.RESTARTING + + +def test_failed_source_job_attempts_to_restore_the_app_once(tmp_path: Path) -> None: + home = tmp_path / "home" + store = UpdateJobStore(tmp_path / "jobs") + job = store.create_source( + current_version="1.5.4", + target_version="1.6.0", + source_root=tmp_path / "checkout", + restart_requested=True, + ) + store.prepare_restart(job.id, home=home) + + class BrokenSourceUpdater: + def update(self, installation, target_version): + raise SourceUpdateError("dependency refresh failed") + + restart_launcher = RecordingRestartLauncher() + exit_code = run_update_worker( + store_root=store.root, + parent_pid=None, + source_updater=BrokenSourceUpdater(), + restart_launcher=restart_launcher, + ) + + failed = store.load() + assert exit_code == 1 + assert failed.status is JobStatus.FAILED + assert failed.error == "dependency refresh failed" + assert len(restart_launcher.commands) == 1 diff --git a/web/components/sidebar/UpdateAction.tsx b/web/components/sidebar/UpdateAction.tsx index 440cb3b0ac..d0e31b3e92 100644 --- a/web/components/sidebar/UpdateAction.tsx +++ b/web/components/sidebar/UpdateAction.tsx @@ -15,16 +15,12 @@ import { normalizeVersionTag } from "@/lib/version"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; interface UpdateActionProps { - targetVersion: string; + targetVersion: string | null; + actionAvailable: boolean; } type UpdatePhase = - | "idle" - | "requesting" - | "updating" - | "restarting" - | "reconnected" - | "failed"; + "idle" | "requesting" | "updating" | "restarting" | "reconnected" | "failed"; const POLL_INTERVAL_MS = 750; const POLL_TIMEOUT_MS = 120_000; @@ -40,7 +36,10 @@ function isActiveStatus(status: UpdateJobStatus): boolean { return ["pending", "handoff", "running", "restarting"].includes(status); } -export function UpdateAction({ targetVersion }: UpdateActionProps) { +export function UpdateAction({ + targetVersion, + actionAvailable, +}: UpdateActionProps) { const { t } = useTranslation(); const { enabled, isAdmin, loading } = useAuthStatus(); const [dialogOpen, setDialogOpen] = useState(false); @@ -49,20 +48,32 @@ export function UpdateAction({ targetVersion }: UpdateActionProps) { useEffect(() => { const controller = new AbortController(); - void fetchUpdateJob(controller.signal) - .then((job) => { + let retryTimer: ReturnType | undefined; + const deadline = Date.now() + POLL_TIMEOUT_MS; + const restoreJob = async () => { + try { + const job = await fetchUpdateJob(controller.signal); if (!job) return; const sameTarget = + targetVersion === null || (normalizeVersionTag(job.target_version) ?? job.target_version) === - targetVersion; + targetVersion; if (isActiveStatus(job.status) || sameTarget) { setPhase(phaseForStatus(job.status)); setPolling(isActiveStatus(job.status)); } - }) - .catch(() => undefined); - return () => controller.abort(); - }, [targetVersion]); + } catch { + if (!controller.signal.aborted && Date.now() < deadline) { + retryTimer = setTimeout(restoreJob, POLL_INTERVAL_MS); + } + } + }; + void restoreJob(); + return () => { + controller.abort(); + if (retryTimer) clearTimeout(retryTimer); + }; + }, [actionAvailable, targetVersion]); useEffect(() => { if (!polling) return; @@ -119,6 +130,7 @@ export function UpdateAction({ targetVersion }: UpdateActionProps) { }, [t]); if (loading || (enabled && !isAdmin)) return null; + if (!actionAvailable && phase === "idle") return null; const labels: Record = { idle: t("Update and restart") as string, @@ -155,7 +167,7 @@ export function UpdateAction({ targetVersion }: UpdateActionProps) { data-testid="update-action" data-phase={phase} onClick={() => setDialogOpen(true)} - disabled={busy || phase === "reconnected"} + disabled={!actionAvailable || busy || phase === "reconnected"} title={buttonLabel} aria-label={buttonLabel} className={`relative flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-[background-color,color,box-shadow,scale,opacity] duration-150 ease-out active:not-disabled:scale-[0.96] disabled:cursor-default disabled:opacity-70 ${tone}`} @@ -179,10 +191,12 @@ export function UpdateAction({ targetVersion }: UpdateActionProps) { onConfirm={() => void startUpdate()} onCancel={() => setDialogOpen(false)} > - {t( - "DeepTutor will stop briefly, install {{version}}, and restart with the same settings.", - { version: targetVersion }, - ) as string} + { + t( + "DeepTutor will stop briefly, install {{version}}, and restart with the same settings.", + { version: targetVersion ?? "" }, + ) as string + } ); diff --git a/web/components/sidebar/VersionBadge.tsx b/web/components/sidebar/VersionBadge.tsx index dfe295926f..53763db450 100644 --- a/web/components/sidebar/VersionBadge.tsx +++ b/web/components/sidebar/VersionBadge.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { fetchUpdateStatus } from "@/lib/update-api"; +import { fetchUpdateStatus, type UpdateCheckResponse } from "@/lib/update-api"; import { presentUpdateBadge, type UpdateBadgePresentation, @@ -17,21 +17,42 @@ interface VersionBadgeProps { } const RELEASES_URL = "https://github.com/HKUDS/DeepTutor/releases"; +const UPDATE_CHECK_RETRY_MS = 750; +const UPDATE_CHECK_RETRY_LIMIT = 3; export function VersionBadge({ collapsed = false }: VersionBadgeProps) { const { t } = useTranslation(); const [update, setUpdate] = useState(null); + const [check, setCheck] = useState(null); useEffect(() => { if (collapsed) return; const controller = new AbortController(); - void fetchUpdateStatus(controller.signal) - .then((result) => setUpdate(presentUpdateBadge(result))) - .catch(() => { - if (!controller.signal.aborted) setUpdate({ kind: "failed" }); - }); - return () => controller.abort(); + let retryTimer: ReturnType | undefined; + let retries = 0; + const checkForUpdate = async () => { + try { + const result = await fetchUpdateStatus(controller.signal); + if (controller.signal.aborted) return; + setCheck(result); + setUpdate(presentUpdateBadge(result)); + } catch { + if (!controller.signal.aborted) { + setCheck(null); + setUpdate({ kind: "failed" }); + if (retries < UPDATE_CHECK_RETRY_LIMIT) { + retries += 1; + retryTimer = setTimeout(checkForUpdate, UPDATE_CHECK_RETRY_MS); + } + } + } + }; + void checkForUpdate(); + return () => { + controller.abort(); + if (retryTimer) clearTimeout(retryTimer); + }; }, [collapsed]); // Keep the collapsed sidebar entirely free of version chrome. @@ -58,6 +79,15 @@ export function VersionBadge({ collapsed = false }: VersionBadgeProps) { const ariaLabel = available ? `${statusText}: ${displayTag} → ${available.version}. ${t("Latest release") as string}` : `${displayTag}. ${statusText}`; + const checkedTarget = normalizeVersionTag(check?.latest_version ?? ""); + const webManagedInstall = + check?.install_mode === "pypi" || check?.install_mode === "source_web"; + const supportsWebUpdate = Boolean( + check?.can_auto_update && webManagedInstall, + ); + const canRunUpdate = Boolean(available && supportsWebUpdate && checkedTarget); + const shouldRecoverJob = + webManagedInstall || (update?.kind === "failed" && check === null); return (
· {statusText} ) : null} - {available?.canAutoUpdate && available.installMode === "pypi" ? ( - + {shouldRecoverJob ? ( + ) : null}
); diff --git a/web/lib/update-api.ts b/web/lib/update-api.ts index db3792a296..2ca246a3d4 100644 --- a/web/lib/update-api.ts +++ b/web/lib/update-api.ts @@ -1,11 +1,7 @@ import { apiFetch, apiUrl } from "@/lib/api"; export type InstallMode = - | "pypi" - | "source_web" - | "source_cli" - | "docker" - | "unsupported"; + "pypi" | "source_web" | "source_cli" | "docker" | "unsupported"; export type UpdateStatus = "available" | "up_to_date" | "failed"; @@ -20,12 +16,7 @@ export interface UpdateCheckResponse { } export type UpdateJobStatus = - | "pending" - | "handoff" - | "running" - | "restarting" - | "succeeded" - | "failed"; + "pending" | "handoff" | "running" | "restarting" | "succeeded" | "failed"; export interface UpdateJobResponse { id: string; diff --git a/web/lib/update-badge.ts b/web/lib/update-badge.ts index 922afecda7..948ea50c44 100644 --- a/web/lib/update-badge.ts +++ b/web/lib/update-badge.ts @@ -7,8 +7,6 @@ export type UpdateBadgePresentation = version: string; href: string; hostManaged: boolean; - installMode: UpdateCheckResponse["install_mode"]; - canAutoUpdate: boolean; } | { kind: "up_to_date"; version: string | null; href: string | null } | { kind: "failed" }; @@ -27,8 +25,6 @@ export function presentUpdateBadge( normalizeVersionTag(update.latest_version) ?? update.latest_version, href: update.release_url, hostManaged: update.install_mode === "docker", - installMode: update.install_mode, - canAutoUpdate: update.can_auto_update, }; } if (update.status === "up_to_date") { diff --git a/web/tests/e2e/update-badge.e2e.ts b/web/tests/e2e/update-badge.e2e.ts index 41fb0bd712..a79bb909f8 100644 --- a/web/tests/e2e/update-badge.e2e.ts +++ b/web/tests/e2e/update-badge.e2e.ts @@ -1,7 +1,6 @@ import { expect, test } from "@playwright/test"; -const RELEASE_URL = - "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0"; +const RELEASE_URL = "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0"; const AVAILABLE_UPDATE = { status: "available", current_version: "1.5.4", @@ -82,16 +81,79 @@ test("version badge reports an up-to-date installation", async ({ page }) => { ); }); -test("version badge reports a failed update check", async ({ page }) => { - await page.route("**/api/v1/system/update", (route) => - route.fulfill({ status: 503, body: "service unavailable" }), - ); +test("version badge reports a failed check and stops retrying", async ({ + page, +}) => { + let checks = 0; + await page.route("**/api/v1/system/update", (route) => { + checks += 1; + return route.fulfill({ status: 503, body: "service unavailable" }); + }); await page.goto("/"); await expect(page.getByTestId("version-badge")).toContainText( "Update check failed", ); + await expect.poll(() => checks).toBe(4); + await page.waitForTimeout(1_000); + expect(checks).toBe(4); +}); + +test("a refreshed page recovers the persisted job after the backend restarts", async ({ + page, +}) => { + let checks = 0; + let jobChecks = 0; + await page.route("**/api/v1/auth/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: false, + authenticated: true, + role: "admin", + is_admin: true, + }), + }), + ); + await page.route("**/api/v1/system/update", (route) => { + checks += 1; + if (checks === 1) return route.abort("connectionfailed"); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...AVAILABLE_UPDATE, + status: "failed", + current_version: "1.6.0", + latest_version: null, + release_url: null, + }), + }); + }); + await page.route("**/api/v1/system/update/job", (route) => { + jobChecks += 1; + if (jobChecks < 3) return route.abort("connectionfailed"); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "job-1", + status: "succeeded", + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: 1, + }), + }); + }); + + await page.goto("/"); + + await expect(page.getByTestId("update-action")).toContainText(/reconnected/i); + await expect.poll(() => checks).toBeGreaterThan(1); + expect(jobChecks).toBeGreaterThan(2); }); test("Docker installations direct updates to the host without an update action", async ({ @@ -105,7 +167,8 @@ test("Docker installations direct updates to the host without an update action", ...AVAILABLE_UPDATE, install_mode: "docker", can_auto_update: false, - detail: "Update the image on the Docker host and recreate the container.", + detail: + "Update the image on the Docker host and recreate the container.", }), }), ); @@ -118,6 +181,129 @@ test("Docker installations direct updates to the host without an update action", await expect(badge.getByRole("button", { name: /update/i })).toHaveCount(0); }); +test("editable Web installations expose the managed update action", async ({ + page, +}) => { + let requested = false; + await page.route("**/api/v1/auth/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: false, + authenticated: true, + role: "admin", + is_admin: true, + }), + }), + ); + await page.route("**/api/v1/system/update", (route) => { + if (route.request().method() === "POST") { + requested = true; + return route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + id: "source-job", + status: "pending", + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: 0, + }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...AVAILABLE_UPDATE, + install_mode: "source_web", + detail: "editable full installation", + }), + }); + }); + await page.route("**/api/v1/system/update/job", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: requested + ? JSON.stringify({ + id: "source-job", + status: "succeeded", + current_version: "1.5.4", + target_version: "1.6.0", + error: null, + restart_count: 1, + }) + : "null", + }), + ); + + await page.goto("/"); + + await expect(page.getByTestId("update-action")).toContainText( + /update and restart/i, + ); + await page.getByTestId("update-action").click(); + await page + .getByRole("alertdialog") + .getByRole("button", { name: /update and restart/i }) + .click(); + await expect(page.getByTestId("update-action")).toContainText(/reconnected/i); +}); + +test("a failed source refresh remains visible after the code fast-forwards", async ({ + page, +}) => { + await page.route("**/api/v1/auth/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + enabled: false, + authenticated: true, + role: "admin", + is_admin: true, + }), + }), + ); + await page.route("**/api/v1/system/update", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...AVAILABLE_UPDATE, + status: "up_to_date", + current_version: "1.6.0", + latest_version: "1.6.0", + install_mode: "source_web", + }), + }), + ); + await page.route("**/api/v1/system/update/job", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "source-job", + status: "failed", + current_version: "1.5.4", + target_version: "1.6.0", + error: "Bun dependency refresh failed", + restart_count: 0, + }), + }), + ); + + await page.goto("/"); + + await expect(page.getByTestId("update-action")).toContainText( + /update failed/i, + ); + await expect(page.getByTestId("update-action")).toBeDisabled(); +}); + test("admin can confirm an update and reconnect after the managed restart", async ({ page, }) => { @@ -162,7 +348,11 @@ test("admin can confirm an update and reconnect after the managed restart", asyn }); await page.route("**/api/v1/system/update/job", async (route) => { if (!requested) { - await route.fulfill({ status: 200, contentType: "application/json", body: "null" }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: "null", + }); return; } poll += 1; @@ -170,7 +360,8 @@ test("admin can confirm an update and reconnect after the managed restart", asyn await route.abort("connectionfailed"); return; } - const status = poll === 1 ? "running" : poll === 3 ? "restarting" : "succeeded"; + const status = + poll === 1 ? "running" : poll === 3 ? "restarting" : "succeeded"; await route.fulfill({ status: 200, contentType: "application/json", @@ -258,5 +449,7 @@ test("failed Web update remains visible to the user", async ({ page }) => { .getByRole("button", { name: /update and restart/i }) .click(); - await expect(page.getByTestId("update-action")).toContainText(/update failed/i); + await expect(page.getByTestId("update-action")).toContainText( + /update failed/i, + ); }); diff --git a/web/tests/update-api.test.ts b/web/tests/update-api.test.ts index 53952c0904..c48993da7e 100644 --- a/web/tests/update-api.test.ts +++ b/web/tests/update-api.test.ts @@ -18,8 +18,7 @@ test("fetchUpdateStatus returns the system update payload", async () => { latest_version: "1.6.0", install_mode: "pypi", can_auto_update: true, - release_url: - "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", + release_url: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", detail: "installed distribution", }), { status: 200, headers: { "Content-Type": "application/json" } }, @@ -77,7 +76,10 @@ test("requestWebUpdate sends the fixed restart confirmation", async () => { const job = await requestWebUpdate(); assert.equal(request?.method, "POST"); - assert.equal(request?.body, JSON.stringify({ confirmation: "update-and-restart" })); + assert.equal( + request?.body, + JSON.stringify({ confirmation: "update-and-restart" }), + ); assert.equal(job.status, "pending"); } finally { globalThis.fetch = originalFetch; diff --git a/web/tests/update-badge.test.ts b/web/tests/update-badge.test.ts index a6f5dd9689..92f7f39d31 100644 --- a/web/tests/update-badge.test.ts +++ b/web/tests/update-badge.test.ts @@ -18,8 +18,6 @@ test("presentUpdateBadge exposes an actionable release link for available update version: "v1.6.0", href: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", hostManaged: false, - installMode: "pypi", - canAutoUpdate: true, }); }); @@ -39,8 +37,6 @@ test("presentUpdateBadge marks Docker updates as host-managed", () => { version: "v1.6.0", href: "https://github.com/HKUDS/DeepTutor/releases/tag/v1.6.0", hostManaged: true, - installMode: "docker", - canAutoUpdate: false, }); }); From d86625de4c0636a7eac1d49716ff50f369d6b8fe Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sun, 2 Aug 2026 19:57:55 +0800 Subject: [PATCH 7/8] fix(web): isolate compiled Node test builds --- web/scripts/register-node-test-aliases.cjs | 5 ++- web/scripts/run-node-tests.mjs | 50 ++++++++++++++++------ web/tests/run-node-tests-windows.test.ts | 8 ++++ 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/web/scripts/register-node-test-aliases.cjs b/web/scripts/register-node-test-aliases.cjs index 465b428c5e..14f7e3727d 100644 --- a/web/scripts/register-node-test-aliases.cjs +++ b/web/scripts/register-node-test-aliases.cjs @@ -1,7 +1,10 @@ const Module = require("node:module"); const path = require("node:path"); -const distRoot = path.join(process.cwd(), "dist", "node-tests"); +const distRoot = process.env.DEEPTUTOR_NODE_TESTS_DIST_ROOT; +if (!distRoot) { + throw new Error("DEEPTUTOR_NODE_TESTS_DIST_ROOT is required"); +} const originalResolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, parent, isMain, options) { diff --git a/web/scripts/run-node-tests.mjs b/web/scripts/run-node-tests.mjs index 2ba21dbad8..ab6a5b57be 100644 --- a/web/scripts/run-node-tests.mjs +++ b/web/scripts/run-node-tests.mjs @@ -1,4 +1,10 @@ -import { readdirSync, rmSync, statSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,14 +12,28 @@ import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const webRoot = path.resolve(__dirname, ".."); -const distRoot = path.join(webRoot, "dist", "node-tests"); +const cacheRoot = path.join(webRoot, "node_modules", ".cache"); +mkdirSync(cacheRoot, { recursive: true }); +const distRoot = mkdtempSync(path.join(cacheRoot, "deeptutor-node-tests-")); const testRoot = path.join(distRoot, "tests"); -function run(cmd, args) { +function cleanupDistRoot() { + rmSync(distRoot, { recursive: true, force: true }); +} + +process.once("exit", cleanupDistRoot); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + cleanupDistRoot(); + process.kill(process.pid, signal); + }); +} + +function run(cmd, args, env = process.env) { const result = spawnSync(cmd, args, { cwd: webRoot, stdio: "inherit", - env: process.env, + env, }); if (result.status !== 0) { process.exit(result.status ?? 1); @@ -38,12 +58,12 @@ function collectTests(dir) { return files; } -rmSync(distRoot, { recursive: true, force: true }); - run(process.execPath, [ path.join(webRoot, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.node-tests.json", + "--outDir", + distRoot, ]); const testFiles = collectTests(testRoot); @@ -52,9 +72,15 @@ if (testFiles.length === 0) { process.exit(1); } -run(process.execPath, [ - "-r", - "./scripts/register-node-test-aliases.cjs", - "--test", - ...testFiles, -]); +run( + process.execPath, + [ + "-r", + "./scripts/register-node-test-aliases.cjs", + "--test", + ...testFiles, + ], + { ...process.env, DEEPTUTOR_NODE_TESTS_DIST_ROOT: distRoot }, +); + +cleanupDistRoot(); diff --git a/web/tests/run-node-tests-windows.test.ts b/web/tests/run-node-tests-windows.test.ts index ab5ec8ec3a..847f81c5a1 100644 --- a/web/tests/run-node-tests-windows.test.ts +++ b/web/tests/run-node-tests-windows.test.ts @@ -11,3 +11,11 @@ test("node test runner launches TypeScript through the current Node runtime", () assert.match(source, /run\(process\.execPath/); assert.match(source, /"typescript", "bin", "tsc"/); }); + +test("node test runner isolates compiled tests from Bun discovery", () => { + const source = readFileSync(runnerPath, "utf8"); + + assert.match(source, /mkdtempSync/); + assert.match(source, /DEEPTUTOR_NODE_TESTS_DIST_ROOT/); + assert.doesNotMatch(source, /path\.join\(webRoot, "dist", "node-tests"\)/); +}); From fc1d0bbe25801a346b6d55ec381aebd8a7cddfa3 Mon Sep 17 00:00:00 2001 From: Venna <1597412551@qq.com> Date: Sun, 2 Aug 2026 20:02:33 +0800 Subject: [PATCH 8/8] fix(update): validate PyPI Web update consistency --- deeptutor/api/routers/system.py | 8 +++++++- tests/api/test_system_router.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/deeptutor/api/routers/system.py b/deeptutor/api/routers/system.py index 14302e4f09..ca7578e8b2 100644 --- a/deeptutor/api/routers/system.py +++ b/deeptutor/api/routers/system.py @@ -254,7 +254,13 @@ async def request_web_update( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc - elif result.install_mode is not InstallMode.PYPI: + elif result.install_mode is InstallMode.PYPI: + if installation.mode is not InstallMode.PYPI: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="The PyPI installation changed during the update check.", + ) + else: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="This installation cannot be updated from the Web app.", diff --git a/tests/api/test_system_router.py b/tests/api/test_system_router.py index cc51e16ea0..3eef0a775e 100644 --- a/tests/api/test_system_router.py +++ b/tests/api/test_system_router.py @@ -179,6 +179,34 @@ async def test_web_update_persists_a_restart_request(tmp_path: Path) -> None: assert store.load().restart_requested is True +@pytest.mark.asyncio +async def test_web_update_refuses_when_pypi_installation_changes( + tmp_path: Path, +) -> None: + store = UpdateJobStore(tmp_path) + installation = Installation( + mode=InstallMode.SOURCE_WEB, + current_version="1.5.4", + package_name="deeptutor", + source_root=tmp_path / "checkout", + ) + + with pytest.raises(HTTPException) as exc_info: + await system_router.request_web_update( + system_router.WebUpdateRequest(confirmation="update-and-restart"), + coordinator=_AvailablePypiUpdate(), + conversations=_IdleConversations(), + store=store, + launcher_ready=True, + installation=installation, + source_preflight=_NoSourcePreflight(), + ) + + assert exc_info.value.status_code == 409 + assert "changed" in str(exc_info.value.detail).lower() + assert not store.state_path.exists() + + @pytest.mark.asyncio async def test_source_web_update_preflights_and_persists_the_checkout( tmp_path: Path, @@ -302,6 +330,7 @@ def test_web_update_http_route_requires_confirmation_and_creates_job( app.dependency_overrides[system_router.get_conversation_activity] = _IdleConversations app.dependency_overrides[system_router.get_update_job_store] = lambda: UpdateJobStore(tmp_path) app.dependency_overrides[system_router.is_launcher_available] = lambda: True + app.dependency_overrides[system_router.get_current_installation] = _pypi_installation monkeypatch.setattr(auth_router, "AUTH_ENABLED", False) client = TestClient(app)