mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 17:55:31 -04:00
feat(cli): live progress UI and periodic recheck for self-updates (#3280)
Adds a live progress modal for self-updates, hourly background rechecks, and debug tooling to make the update flow observable and non-disruptive. Upgrade output is now streamed in real-time and persisted to timestamped logs with automatic cleanup.
This commit is contained in:
+207
-41
@@ -115,6 +115,7 @@ if TYPE_CHECKING:
|
||||
from deepagents_cli.widgets.approval import ApprovalMenu
|
||||
from deepagents_cli.widgets.ask_user import AskUserMenu
|
||||
from deepagents_cli.widgets.notification_center import NotificationSuppressRequested
|
||||
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
|
||||
|
||||
_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS = 60.0
|
||||
"""Upper bound on waiting for server readiness during onboarding model switch.
|
||||
@@ -123,6 +124,9 @@ Server startup is normally seconds; this ceiling exists only so a stuck
|
||||
backend cannot trap the user inside a finished onboarding modal forever.
|
||||
"""
|
||||
|
||||
_UPDATE_RECHECK_INTERVAL_SECONDS = 60 * 60
|
||||
"""How often long-running TUI sessions quietly re-check for CLI updates."""
|
||||
|
||||
|
||||
def _load_theme_preference() -> str:
|
||||
"""Load the forced or saved theme name, or return the default.
|
||||
@@ -1160,6 +1164,9 @@ class DeepAgentsApp(App):
|
||||
`CACHE_TTL`) leaves this clear so missing-dep toasts still fire.
|
||||
"""
|
||||
|
||||
self._update_install_running = False
|
||||
"""True while a self-update command is running."""
|
||||
|
||||
# Skills cache
|
||||
self._discovered_skills: list[ExtendedSkillMetadata] = []
|
||||
"""Cached skill metadata (populated by startup discovery worker,
|
||||
@@ -1579,6 +1586,14 @@ class DeepAgentsApp(App):
|
||||
exclusive=True,
|
||||
group="startup-update-check",
|
||||
)
|
||||
self.set_interval(
|
||||
_UPDATE_RECHECK_INTERVAL_SECONDS,
|
||||
lambda: self.run_worker(
|
||||
self._check_for_updates(periodic=True),
|
||||
exclusive=True,
|
||||
group="periodic-update-check",
|
||||
),
|
||||
)
|
||||
self.run_worker(
|
||||
self._show_whats_new,
|
||||
exclusive=True,
|
||||
@@ -2264,21 +2279,24 @@ class DeepAgentsApp(App):
|
||||
except Exception:
|
||||
logger.warning("Could not prewarm model caches", exc_info=True)
|
||||
|
||||
async def _check_for_updates(self) -> None:
|
||||
async def _check_for_updates(self, *, periodic: bool = False) -> None:
|
||||
"""Run the update check and signal completion for downstream waiters.
|
||||
|
||||
Wraps `_check_for_updates_impl` so `_update_check_done.set()`
|
||||
always fires — lets `_check_optional_tools_background` unblock
|
||||
after the PyPI round-trip regardless of success, failure, or no-op.
|
||||
|
||||
Args:
|
||||
periodic: Whether this is a quiet in-session recheck.
|
||||
"""
|
||||
try:
|
||||
await self._check_for_updates_impl()
|
||||
await self._check_for_updates_impl(periodic=periodic)
|
||||
finally:
|
||||
# Always signal completion — the optional-tools worker
|
||||
# waits on this before deciding whether to post toasts.
|
||||
self._update_check_done.set()
|
||||
|
||||
async def _check_for_updates_impl(self) -> None:
|
||||
async def _check_for_updates_impl(self, *, periodic: bool = False) -> None:
|
||||
"""Check PyPI for a newer version and either auto-update or queue a modal.
|
||||
|
||||
Phase 1 contacts PyPI and records the latest version on the app.
|
||||
@@ -2296,7 +2314,9 @@ class DeepAgentsApp(App):
|
||||
upgrade_command,
|
||||
)
|
||||
|
||||
available, latest = await asyncio.to_thread(is_update_available)
|
||||
available, latest = await asyncio.to_thread(
|
||||
is_update_available, bypass_cache=periodic
|
||||
)
|
||||
if not available or latest is None:
|
||||
return
|
||||
|
||||
@@ -2310,14 +2330,29 @@ class DeepAgentsApp(App):
|
||||
from deepagents_cli._version import __version__ as cli_version
|
||||
|
||||
if is_auto_update_enabled():
|
||||
from deepagents_cli.update_check import perform_upgrade
|
||||
from deepagents_cli._env_vars import DEBUG_UPDATE
|
||||
from deepagents_cli.update_check import (
|
||||
create_update_log_path,
|
||||
perform_upgrade,
|
||||
)
|
||||
|
||||
if os.environ.get(DEBUG_UPDATE):
|
||||
self.notify(
|
||||
"Skipped update install (debug mode).",
|
||||
severity="information",
|
||||
timeout=4,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
|
||||
log_path = create_update_log_path()
|
||||
self.notify(
|
||||
f"Updating to v{latest}...",
|
||||
f"Updating to v{latest}... Logs: {log_path}",
|
||||
severity="information",
|
||||
timeout=5,
|
||||
markup=False,
|
||||
)
|
||||
success, output = await perform_upgrade()
|
||||
success, output = await perform_upgrade(log_path=log_path)
|
||||
if success:
|
||||
self.notify(
|
||||
f"Updated to v{latest}. Restart to use the new version.",
|
||||
@@ -2332,7 +2367,9 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
cmd = upgrade_command()
|
||||
snippet = _truncate(output, limit=160) if output else ""
|
||||
message = f"Auto-update failed. Run manually: {cmd}"
|
||||
message = (
|
||||
f"Auto-update failed. Run manually: {cmd}\nLog: {log_path}"
|
||||
)
|
||||
if snippet:
|
||||
message = f"{message}\n{snippet}"
|
||||
self.notify(
|
||||
@@ -2366,6 +2403,15 @@ class DeepAgentsApp(App):
|
||||
installed_age=installed_age,
|
||||
upgrade_cmd=cmd,
|
||||
)
|
||||
if periodic:
|
||||
self._notify_actionable(
|
||||
notification,
|
||||
severity="information",
|
||||
timeout=12,
|
||||
action_hint="Press ctrl+n to install.",
|
||||
)
|
||||
await asyncio.to_thread(mark_update_notified, latest)
|
||||
return
|
||||
# Register without a toast: the dedicated modal is
|
||||
# the update's UI, so a parallel toast would be
|
||||
# redundant. Registration still makes the entry
|
||||
@@ -2409,7 +2455,8 @@ class DeepAgentsApp(App):
|
||||
"""
|
||||
body = (
|
||||
f"v{latest} is available{release_age}.\n"
|
||||
f"Currently installed: {cli_version}{installed_age}."
|
||||
f"Currently installed: {cli_version}{installed_age}.\n"
|
||||
"Your session will not be interrupted."
|
||||
)
|
||||
return PendingNotification(
|
||||
key="update:available",
|
||||
@@ -2462,6 +2509,7 @@ class DeepAgentsApp(App):
|
||||
"""Handle the `/update` slash command — check for and install updates."""
|
||||
await self._mount_message(UserMessage("/update"))
|
||||
try:
|
||||
from deepagents_cli._env_vars import DEBUG_UPDATE
|
||||
from deepagents_cli._version import __version__ as cli_version
|
||||
from deepagents_cli.config import _is_editable_install
|
||||
from deepagents_cli.update_check import (
|
||||
@@ -2517,6 +2565,11 @@ class DeepAgentsApp(App):
|
||||
"Upgrading..."
|
||||
)
|
||||
)
|
||||
if os.environ.get(DEBUG_UPDATE):
|
||||
await self._mount_message(
|
||||
AppMessage("Skipped update install (debug mode).")
|
||||
)
|
||||
return
|
||||
success, output = await perform_upgrade()
|
||||
if success:
|
||||
self._update_available = (False, None)
|
||||
@@ -6867,6 +6920,7 @@ class DeepAgentsApp(App):
|
||||
*,
|
||||
severity: Literal["information", "warning", "error"] = "information",
|
||||
timeout: float | None = None,
|
||||
action_hint: str = "Press ctrl+n to review and take action.",
|
||||
) -> None:
|
||||
"""Register *notification* and post its actionable toast.
|
||||
|
||||
@@ -6878,10 +6932,11 @@ class DeepAgentsApp(App):
|
||||
severity: Toast severity banner color.
|
||||
timeout: Seconds the toast stays on screen (defaults to
|
||||
`App.NOTIFICATION_TIMEOUT`).
|
||||
action_hint: Final call-to-action line for the toast.
|
||||
"""
|
||||
self._notice_registry.add(notification)
|
||||
|
||||
toast_body = f"{notification.body}\n\nctrl+n for options"
|
||||
toast_body = f"{notification.body}\n\n{action_hint}"
|
||||
effective_timeout = (
|
||||
timeout if timeout is not None else self.NOTIFICATION_TIMEOUT
|
||||
)
|
||||
@@ -7061,8 +7116,8 @@ class DeepAgentsApp(App):
|
||||
# the user how to reach it.
|
||||
self._update_modal_pending.clear()
|
||||
self.notify(
|
||||
"Update available. Close the current dialog, "
|
||||
"then press ctrl+n to review it.",
|
||||
"Update available. Your session will not be interrupted. "
|
||||
"Press ctrl+n to review it.",
|
||||
severity="information",
|
||||
timeout=8,
|
||||
markup=False,
|
||||
@@ -7254,6 +7309,7 @@ class DeepAgentsApp(App):
|
||||
"""
|
||||
from deepagents_cli.update_check import (
|
||||
clear_update_notified,
|
||||
create_update_log_path,
|
||||
mark_update_notified,
|
||||
perform_upgrade,
|
||||
upgrade_command,
|
||||
@@ -7262,58 +7318,168 @@ class DeepAgentsApp(App):
|
||||
if action_id == ActionId.INSTALL:
|
||||
from deepagents_cli._env_vars import DEBUG_UPDATE
|
||||
|
||||
if os.environ.get(DEBUG_UPDATE):
|
||||
self._notice_registry.remove(entry.key)
|
||||
if self._update_install_running:
|
||||
self.notify(
|
||||
"Skipped update install (debug mode).",
|
||||
"Update already running.",
|
||||
severity="information",
|
||||
timeout=4,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
|
||||
self.notify(
|
||||
f"Updating to v{payload.latest}...",
|
||||
severity="information",
|
||||
timeout=5,
|
||||
markup=False,
|
||||
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
|
||||
|
||||
cmd = upgrade_command()
|
||||
log_path = create_update_log_path()
|
||||
screen = UpdateProgressScreen(
|
||||
latest=payload.latest,
|
||||
command=cmd,
|
||||
log_path=log_path,
|
||||
)
|
||||
success, output = await perform_upgrade()
|
||||
if success:
|
||||
self._notice_registry.remove(entry.key)
|
||||
progress_modal_visible = not isinstance(self.screen, ModalScreen)
|
||||
if progress_modal_visible:
|
||||
self.push_screen(screen)
|
||||
else:
|
||||
self.notify(
|
||||
f"Updated to v{payload.latest}. Restart to use the new version.",
|
||||
f"Updating to v{payload.latest}... Logs: {log_path}",
|
||||
severity="information",
|
||||
timeout=10,
|
||||
timeout=8,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Auto-upgrade failed for v%s. Output:\n%s", payload.latest, output
|
||||
)
|
||||
self._notice_registry.remove(entry.key)
|
||||
cmd = upgrade_command()
|
||||
snippet = _truncate(output, limit=160) if output else ""
|
||||
message = f"Auto-update failed. Run manually: {cmd}"
|
||||
if snippet:
|
||||
message = f"{message}\n{snippet}"
|
||||
self.notify(
|
||||
message,
|
||||
severity="warning",
|
||||
timeout=15,
|
||||
markup=False,
|
||||
)
|
||||
self._update_install_running = True
|
||||
try:
|
||||
if os.environ.get(DEBUG_UPDATE):
|
||||
await self._run_debug_update_install(
|
||||
entry=entry,
|
||||
payload=payload,
|
||||
screen=screen,
|
||||
log_path=log_path,
|
||||
show_toast=not progress_modal_visible,
|
||||
)
|
||||
return
|
||||
success, output = await perform_upgrade(
|
||||
progress=screen.append_line,
|
||||
log_path=log_path,
|
||||
)
|
||||
if success:
|
||||
self._notice_registry.remove(entry.key)
|
||||
screen.mark_success()
|
||||
if not progress_modal_visible:
|
||||
self.notify(
|
||||
f"Updated to v{payload.latest}. "
|
||||
"Restart to use the new version.",
|
||||
severity="information",
|
||||
timeout=10,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Auto-upgrade failed for v%s. Output:\n%s",
|
||||
payload.latest,
|
||||
output,
|
||||
)
|
||||
self._notice_registry.remove(entry.key)
|
||||
screen.mark_failure(cmd)
|
||||
snippet = _truncate(output, limit=160) if output else ""
|
||||
message = f"Auto-update failed. Run manually: {cmd}"
|
||||
if snippet:
|
||||
message = f"{message}\n{snippet}"
|
||||
self.notify(
|
||||
message,
|
||||
severity="warning",
|
||||
timeout=15,
|
||||
markup=False,
|
||||
)
|
||||
finally:
|
||||
self._update_install_running = False
|
||||
return
|
||||
if action_id == ActionId.SKIP_VERSION:
|
||||
await asyncio.to_thread(mark_update_notified, payload.latest)
|
||||
self._notice_registry.remove(entry.key)
|
||||
self.notify(
|
||||
f"Skipped v{payload.latest}.",
|
||||
severity="information",
|
||||
timeout=4,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
if action_id == ActionId.SKIP_ONCE:
|
||||
await asyncio.to_thread(clear_update_notified)
|
||||
self._notice_registry.remove(entry.key)
|
||||
self.notify(
|
||||
"We'll remind you next launch.",
|
||||
severity="information",
|
||||
timeout=4,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
self._log_unknown_action(entry, action_id)
|
||||
|
||||
async def _run_debug_update_install(
|
||||
self,
|
||||
*,
|
||||
entry: PendingNotification,
|
||||
payload: UpdateAvailablePayload,
|
||||
screen: UpdateProgressScreen,
|
||||
log_path: Path,
|
||||
show_toast: bool,
|
||||
) -> None:
|
||||
"""Exercise the update progress UI without invoking a package manager.
|
||||
|
||||
Args:
|
||||
entry: The update notification entry to clear when complete.
|
||||
payload: Update payload with the mocked target version.
|
||||
screen: Progress modal to update.
|
||||
log_path: Debug log path to write mock output into.
|
||||
show_toast: Whether to show a completion toast.
|
||||
"""
|
||||
steps = (
|
||||
("Debug mode: no package manager command was started.", 0.3),
|
||||
(f"Resolving deepagents-cli v{payload.latest}...", 0.8),
|
||||
("Looking up compatible build tags...", 0.2),
|
||||
("Downloading wheel metadata...", 0.5),
|
||||
("Downloading deepagents_cli-9.9.9-py3-none-any.whl...", 0.2),
|
||||
("Downloading dependency metadata...", 0.2),
|
||||
("Unpacking wheel...", 0.9),
|
||||
("Checking installed entry points...", 0.2),
|
||||
("Removing previous console script...", 0.2),
|
||||
("Installing files...", 0.7),
|
||||
("Writing dist-info metadata...", 0.2),
|
||||
("Rebuilding executable shims...", 0.2),
|
||||
("Validating import metadata...", 0.2),
|
||||
("Verifying console script...", 0.4),
|
||||
("Cleaning temporary build directory...", 0.2),
|
||||
("Recording update receipt...", 0.2),
|
||||
("Update complete.", 0.2),
|
||||
)
|
||||
wrote_log = False
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log_path.open("w", encoding="utf-8") as log:
|
||||
log.write("$ debug mock update\n")
|
||||
for line, delay in steps:
|
||||
log.write(f"{line}\n")
|
||||
log.flush()
|
||||
screen.append_line(line)
|
||||
await asyncio.sleep(delay)
|
||||
wrote_log = True
|
||||
except OSError:
|
||||
logger.debug("Could not write debug update log", exc_info=True)
|
||||
|
||||
if not wrote_log:
|
||||
for line, delay in steps:
|
||||
screen.append_line(line)
|
||||
await asyncio.sleep(delay)
|
||||
self._notice_registry.remove(entry.key)
|
||||
screen.mark_success()
|
||||
if show_toast:
|
||||
self.notify(
|
||||
"Mock update complete (debug mode).",
|
||||
severity="information",
|
||||
timeout=5,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
async def _show_mcp_viewer(self) -> None:
|
||||
"""Show read-only MCP server/tool viewer as a modal screen."""
|
||||
from deepagents_cli.widgets.mcp_viewer import MCPViewerScreen
|
||||
|
||||
@@ -1682,9 +1682,11 @@ def cli_main() -> None:
|
||||
try:
|
||||
from rich.markup import escape
|
||||
|
||||
from deepagents_cli._env_vars import DEBUG_UPDATE
|
||||
from deepagents_cli._version import __version__ as cli_version
|
||||
from deepagents_cli.config import _is_editable_install
|
||||
from deepagents_cli.update_check import (
|
||||
create_update_log_path,
|
||||
format_age_suffix,
|
||||
format_installed_age_suffix,
|
||||
format_release_age_parenthetical,
|
||||
@@ -1725,7 +1727,17 @@ def cli_main() -> None:
|
||||
f"Currently installed: {cli_version}{installed_age}. "
|
||||
"Upgrading..."
|
||||
)
|
||||
success, output = asyncio.run(perform_upgrade())
|
||||
if os.environ.get(DEBUG_UPDATE):
|
||||
console.print("Skipped update install (debug mode).", style="dim")
|
||||
sys.exit(0)
|
||||
log_path = create_update_log_path()
|
||||
console.print(
|
||||
f"Update log: {log_path}\nTail progress: tail -f {log_path}",
|
||||
style="dim",
|
||||
highlight=False,
|
||||
markup=False,
|
||||
)
|
||||
success, output = asyncio.run(perform_upgrade(log_path=log_path))
|
||||
if success:
|
||||
console.print(f"[green]Updated to v{latest}.[/green]")
|
||||
else:
|
||||
|
||||
@@ -14,23 +14,25 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal, TextIO
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from deepagents_cli._version import PYPI_URL, SDK_PYPI_URL, USER_AGENT, __version__
|
||||
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_FILE: Path = DEFAULT_STATE_DIR / "latest_version.json"
|
||||
@@ -88,6 +90,17 @@ no fallback chain.
|
||||
|
||||
_UPGRADE_TIMEOUT = 120 # seconds
|
||||
|
||||
UPDATE_LOG_DIR: Path = DEFAULT_STATE_DIR / "update_logs"
|
||||
"""Directory for persisted update command logs."""
|
||||
|
||||
UPDATE_LOG_RETENTION_DAYS = 14
|
||||
"""Delete update logs older than this many days."""
|
||||
|
||||
UPDATE_LOG_MAX_FILES = 10
|
||||
"""Keep at most this many newest update logs."""
|
||||
|
||||
UpgradeProgressCallback = Callable[[str], Awaitable[None] | None]
|
||||
|
||||
|
||||
def _parse_version(v: str) -> Version:
|
||||
"""Parse a PEP 440 version string into a comparable `Version` object.
|
||||
@@ -669,12 +682,88 @@ def upgrade_command(method: InstallMethod | None = None) -> str:
|
||||
return _UPGRADE_COMMANDS.get(method, _UPGRADE_COMMANDS["pip"])
|
||||
|
||||
|
||||
async def perform_upgrade() -> tuple[bool, str]:
|
||||
def cleanup_update_logs(
|
||||
*,
|
||||
retention_days: int = UPDATE_LOG_RETENTION_DAYS,
|
||||
max_files: int = UPDATE_LOG_MAX_FILES,
|
||||
) -> None:
|
||||
"""Remove old update logs while preserving the newest recent logs.
|
||||
|
||||
Args:
|
||||
retention_days: Maximum age in days to keep.
|
||||
max_files: Maximum number of newest log files to keep.
|
||||
"""
|
||||
try:
|
||||
if not UPDATE_LOG_DIR.exists():
|
||||
return
|
||||
logs = sorted(
|
||||
(
|
||||
(p, p.stat().st_mtime)
|
||||
for p in UPDATE_LOG_DIR.glob("*.log")
|
||||
if p.is_file()
|
||||
),
|
||||
key=operator.itemgetter(1),
|
||||
reverse=True,
|
||||
)
|
||||
cutoff = time.time() - (retention_days * 86_400)
|
||||
for idx, (path, mtime) in enumerate(logs):
|
||||
if idx >= max_files or mtime < cutoff:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.debug("Failed to clean up update logs", exc_info=True)
|
||||
|
||||
|
||||
def create_update_log_path() -> Path:
|
||||
"""Return a new timestamped update log path and clean stale logs."""
|
||||
cleanup_update_logs()
|
||||
stamp = datetime.now(tz=UTC).strftime("%Y%m%d-%H%M%S")
|
||||
return UPDATE_LOG_DIR / f"{stamp}-update.log"
|
||||
|
||||
|
||||
async def _emit_progress(callback: UpgradeProgressCallback | None, line: str) -> None:
|
||||
"""Send a progress line to *callback*, supporting sync or async callbacks."""
|
||||
if callback is None:
|
||||
return
|
||||
result = callback(line)
|
||||
if isinstance(result, Awaitable):
|
||||
await result
|
||||
|
||||
|
||||
async def _read_stream(
|
||||
stream: asyncio.StreamReader,
|
||||
*,
|
||||
lines: list[str],
|
||||
log_file: TextIO | None,
|
||||
progress: UpgradeProgressCallback | None,
|
||||
) -> None:
|
||||
"""Read subprocess output, append it to the log file, and emit progress."""
|
||||
while True:
|
||||
raw = await stream.readline()
|
||||
if not raw:
|
||||
return
|
||||
line = raw.decode(errors="replace").rstrip("\n")
|
||||
lines.append(line)
|
||||
if log_file is not None:
|
||||
with suppress(OSError):
|
||||
log_file.write(f"{line}\n")
|
||||
log_file.flush()
|
||||
await _emit_progress(progress, line)
|
||||
|
||||
|
||||
async def perform_upgrade(
|
||||
*,
|
||||
progress: UpgradeProgressCallback | None = None,
|
||||
log_path: Path | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Attempt to upgrade `deepagents-cli` using the detected install method.
|
||||
|
||||
Only tries the detected method — does not fall back to other package
|
||||
managers to avoid cross-environment contamination.
|
||||
|
||||
Args:
|
||||
progress: Optional callback invoked for each output line.
|
||||
log_path: Optional path to persist command output.
|
||||
|
||||
Returns:
|
||||
`(success, output)` — *output* is the combined stdout/stderr.
|
||||
"""
|
||||
@@ -690,6 +779,21 @@ async def perform_upgrade() -> tuple[bool, str]:
|
||||
if method == "brew" and not shutil.which("brew"):
|
||||
return False, "brew not found on PATH."
|
||||
|
||||
if log_path is None:
|
||||
log_path = create_update_log_path()
|
||||
|
||||
output_lines: list[str] = []
|
||||
proc: asyncio.subprocess.Process | None = None
|
||||
log_file: TextIO | None = None
|
||||
try:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_path.open("w", encoding="utf-8")
|
||||
log_file.write(f"$ {cmd}\n")
|
||||
log_file.flush()
|
||||
except OSError:
|
||||
logger.debug("Could not create update log at %s", log_path, exc_info=True)
|
||||
log_file = None
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
@@ -697,29 +801,57 @@ async def perform_upgrade() -> tuple[bool, str]:
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=_UPGRADE_TIMEOUT
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
_read_stream(
|
||||
proc.stdout, # type: ignore[arg-type]
|
||||
lines=output_lines,
|
||||
log_file=log_file,
|
||||
progress=progress,
|
||||
),
|
||||
_read_stream(
|
||||
proc.stderr, # type: ignore[arg-type]
|
||||
lines=output_lines,
|
||||
log_file=log_file,
|
||||
progress=progress,
|
||||
),
|
||||
proc.wait(),
|
||||
),
|
||||
timeout=_UPGRADE_TIMEOUT,
|
||||
)
|
||||
output = (stdout or b"").decode() + (stderr or b"").decode()
|
||||
if proc.returncode == 0:
|
||||
return True, output.strip()
|
||||
logger.warning(
|
||||
"Upgrade via %s exited with code %d: %s",
|
||||
method,
|
||||
proc.returncode,
|
||||
output.strip(),
|
||||
)
|
||||
return False, output.strip()
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
if proc is not None:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
msg = f"Upgrade command timed out after {_UPGRADE_TIMEOUT}s: {cmd}"
|
||||
if log_file is not None:
|
||||
with suppress(OSError):
|
||||
log_file.write(f"{msg}\n")
|
||||
log_file.close()
|
||||
await _emit_progress(progress, msg)
|
||||
logger.warning(msg)
|
||||
return False, msg
|
||||
except OSError:
|
||||
if log_file is not None:
|
||||
with suppress(OSError):
|
||||
log_file.close()
|
||||
logger.warning("Failed to execute upgrade command: %s", cmd, exc_info=True)
|
||||
return False, f"Failed to execute: {cmd}"
|
||||
|
||||
if log_file is not None:
|
||||
with suppress(OSError):
|
||||
log_file.close()
|
||||
output = "\n".join(output_lines).strip()
|
||||
if proc.returncode == 0:
|
||||
return True, output
|
||||
logger.warning(
|
||||
"Upgrade via %s exited with code %d: %s",
|
||||
method,
|
||||
proc.returncode,
|
||||
output,
|
||||
)
|
||||
return False, output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Progress modal for CLI self-update installs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Log, Static
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.timer import Timer
|
||||
|
||||
from deepagents_cli import theme
|
||||
from deepagents_cli.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_cli.widgets.loading import Spinner
|
||||
|
||||
|
||||
class UpdateProgressScreen(ModalScreen[None]):
|
||||
"""Modal that shows self-update progress and a bounded log tail."""
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("d", "toggle_details", "Details", show=False),
|
||||
Binding("c", "copy_log_path", "Copy log path", show=False),
|
||||
Binding("escape", "cancel", "Close", show=False),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
UpdateProgressScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
UpdateProgressScreen > Vertical {
|
||||
width: 78;
|
||||
max-width: 92%;
|
||||
height: auto;
|
||||
max-height: 85%;
|
||||
background: $surface;
|
||||
border: solid $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-status {
|
||||
color: $text;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-status-row {
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-spinner {
|
||||
width: auto;
|
||||
color: $primary;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-details,
|
||||
UpdateProgressScreen .up-log,
|
||||
UpdateProgressScreen Log.up-tail,
|
||||
UpdateProgressScreen .up-help {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-details {
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
UpdateProgressScreen Log.up-tail {
|
||||
height: 10;
|
||||
min-height: 10;
|
||||
max-height: 10;
|
||||
background: $surface-lighten-1;
|
||||
border: solid $surface-lighten-1;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-log {
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
UpdateProgressScreen .up-help {
|
||||
height: 1;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
text-align: center;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
latest: str,
|
||||
command: str,
|
||||
log_path: Path,
|
||||
tail_limit: int = 30,
|
||||
) -> None:
|
||||
"""Initialize the progress modal.
|
||||
|
||||
Args:
|
||||
latest: Version being installed.
|
||||
command: Upgrade command being run.
|
||||
log_path: Persisted log file path.
|
||||
tail_limit: Maximum output lines to keep in memory.
|
||||
"""
|
||||
super().__init__()
|
||||
self._latest = latest
|
||||
self._command = command
|
||||
self._log_path = log_path
|
||||
self._tail_limit = tail_limit
|
||||
self._tail: deque[str] = deque(maxlen=tail_limit)
|
||||
self._details_visible = False
|
||||
self._done = False
|
||||
self._status = f"Installing v{latest}..."
|
||||
self._status_widget: Static | None = None
|
||||
self._spinner = Spinner()
|
||||
self._spinner_widget: Static | None = None
|
||||
self._spinner_timer: Timer | None = None
|
||||
self._command_widget: Static | None = None
|
||||
self._log_path_widget: Static | None = None
|
||||
self._tail_widget: Log | None = None
|
||||
self._help_widget: Static | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the modal.
|
||||
|
||||
Yields:
|
||||
Static widgets for status, command, output tail, log path, and help.
|
||||
"""
|
||||
with Vertical():
|
||||
yield Static("Updating Deep Agents CLI", classes="up-title")
|
||||
with Horizontal(classes="up-status-row"):
|
||||
self._spinner_widget = Static(
|
||||
self._spinner.current_frame(), classes="up-spinner"
|
||||
)
|
||||
yield self._spinner_widget
|
||||
self._status_widget = Static(
|
||||
self._status, classes="up-status", markup=False
|
||||
)
|
||||
yield self._status_widget
|
||||
self._command_widget = Static(
|
||||
f"Running command: {self._command}",
|
||||
classes="up-details",
|
||||
markup=False,
|
||||
)
|
||||
self._command_widget.display = False
|
||||
yield self._command_widget
|
||||
self._tail_widget = Log(
|
||||
highlight=False,
|
||||
max_lines=self._tail_limit,
|
||||
auto_scroll=True,
|
||||
classes="up-tail",
|
||||
)
|
||||
self._tail_widget.display = False
|
||||
yield self._tail_widget
|
||||
self._log_path_widget = Static(
|
||||
f"Log: {self._log_path}",
|
||||
classes="up-log",
|
||||
markup=False,
|
||||
)
|
||||
self._log_path_widget.display = False
|
||||
yield self._log_path_widget
|
||||
self._help_widget = Static(self._help_text(), classes="up-help")
|
||||
yield self._help_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Apply ASCII border when configured."""
|
||||
if is_ascii_mode():
|
||||
container = self.query_one(Vertical)
|
||||
colors = theme.get_theme_colors(self)
|
||||
container.styles.border = ("ascii", colors.primary)
|
||||
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
|
||||
|
||||
def append_line(self, line: str) -> None:
|
||||
"""Append a command output line to the in-memory tail."""
|
||||
self._tail.append(line)
|
||||
if self._tail_widget is not None:
|
||||
self._tail_widget.write_line(line)
|
||||
|
||||
def mark_success(self) -> None:
|
||||
"""Render the completed-success state."""
|
||||
self._done = True
|
||||
self._status = f"Update complete. Restart the CLI to use v{self._latest}."
|
||||
self._stop_spinner_timer()
|
||||
self._refresh_status()
|
||||
|
||||
def mark_failure(self, command: str) -> None:
|
||||
"""Render the completed-failure state.
|
||||
|
||||
Args:
|
||||
command: Manual command users can run to retry.
|
||||
"""
|
||||
self._done = True
|
||||
self._status = f"Update failed. Try manually: {command}"
|
||||
self._details_visible = True
|
||||
self._stop_spinner_timer()
|
||||
self._refresh_status()
|
||||
self._apply_details_visibility()
|
||||
|
||||
def action_toggle_details(self) -> None:
|
||||
"""Show or hide the live log tail."""
|
||||
self._details_visible = not self._details_visible
|
||||
self._apply_details_visibility()
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Close the modal only after the update command has finished."""
|
||||
if self._done:
|
||||
self.dismiss(None)
|
||||
|
||||
def action_copy_log_path(self) -> None:
|
||||
"""Copy the persisted log path when details are visible."""
|
||||
if not self._details_visible:
|
||||
return
|
||||
self.app.copy_to_clipboard(str(self._log_path))
|
||||
self.app.notify(
|
||||
"Copied log path.",
|
||||
severity="information",
|
||||
timeout=3,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
def _refresh_status(self) -> None:
|
||||
if self._status_widget is not None:
|
||||
self._status_widget.update(self._status)
|
||||
if self._spinner_widget is not None:
|
||||
if self._done:
|
||||
self._spinner_widget.update(get_glyphs().checkmark)
|
||||
self._spinner_widget.display = True
|
||||
else:
|
||||
self._spinner_widget.display = True
|
||||
if self._help_widget is not None:
|
||||
self._help_widget.update(self._help_text())
|
||||
|
||||
def _apply_details_visibility(self) -> None:
|
||||
if self._tail_widget is None:
|
||||
return
|
||||
if self._command_widget is not None:
|
||||
self._command_widget.display = self._details_visible
|
||||
if self._log_path_widget is not None:
|
||||
self._log_path_widget.display = self._details_visible
|
||||
self._tail_widget.display = self._details_visible
|
||||
if self._help_widget is not None:
|
||||
self._help_widget.update(self._help_text())
|
||||
|
||||
def _stop_spinner_timer(self) -> None:
|
||||
if self._spinner_timer is not None:
|
||||
self._spinner_timer.stop()
|
||||
self._spinner_timer = None
|
||||
|
||||
def _help_text(self) -> str:
|
||||
glyphs = get_glyphs()
|
||||
details = "Hide details" if self._details_visible else "Show details"
|
||||
close = "Esc close" if self._done else "Esc close when complete"
|
||||
parts = [f"d {details}", close]
|
||||
if self._details_visible:
|
||||
parts.insert(1, "c copy log path")
|
||||
return f" {glyphs.bullet} ".join(parts)
|
||||
|
||||
def _update_spinner(self) -> None:
|
||||
"""Advance the glyph spinner while the update is running."""
|
||||
if self._done or self._spinner_widget is None:
|
||||
return
|
||||
self._spinner_widget.update(self._spinner.next_frame())
|
||||
@@ -5365,7 +5365,8 @@ def test_build_update_notification_uses_release_and_installed_age_copy() -> None
|
||||
|
||||
assert notification.body == (
|
||||
"v2.0.0 is available (released 3d ago).\n"
|
||||
"Currently installed: 1.0.0 (8 days old)."
|
||||
"Currently installed: 1.0.0 (8 days old).\n"
|
||||
"Your session will not be interrupted."
|
||||
)
|
||||
assert notification.title == "Update available"
|
||||
|
||||
@@ -5859,8 +5860,8 @@ class TestNotificationCenterIntegration:
|
||||
"""Debug update modal can exercise Install now without changing packages."""
|
||||
from deepagents_cli._env_vars import DEBUG_UPDATE
|
||||
from deepagents_cli.notifications import ActionId
|
||||
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
|
||||
|
||||
monkeypatch.setenv(DEBUG_UPDATE, "1")
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
entry = _update_entry()
|
||||
app._notice_registry.add(entry)
|
||||
@@ -5876,16 +5877,22 @@ class TestNotificationCenterIntegration:
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
monkeypatch.setenv(DEBUG_UPDATE, "1")
|
||||
with patch(
|
||||
"deepagents_cli.update_check.perform_upgrade",
|
||||
new=AsyncMock(return_value=(True, "Updated deepagents-cli")),
|
||||
) as mock_upgrade:
|
||||
await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
|
||||
with patch(
|
||||
"deepagents_cli.app.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
):
|
||||
await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, UpdateProgressScreen)
|
||||
|
||||
mock_upgrade.assert_not_called()
|
||||
assert app._notice_registry.get("update:available") is None
|
||||
assert any("debug mode" in m for m in notified)
|
||||
assert not any("Mock update complete" in m for m in notified)
|
||||
|
||||
async def test_install_failure_removes_entry_and_toasts_manual(self) -> None:
|
||||
"""Failed install removes the stale entry and surfaces the manual command."""
|
||||
@@ -5924,6 +5931,14 @@ class TestNotificationCenterIntegration:
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
entry = _update_entry()
|
||||
app._notice_registry.add(entry)
|
||||
notified: list[str] = []
|
||||
original_notify = app.notify
|
||||
|
||||
def capture_notify(message: str, **kwargs: Any) -> None:
|
||||
notified.append(message)
|
||||
original_notify(message, **kwargs)
|
||||
|
||||
app.notify = capture_notify # type: ignore[method-assign]
|
||||
|
||||
with patch(
|
||||
"deepagents_cli.update_check.clear_update_notified",
|
||||
@@ -5935,6 +5950,7 @@ class TestNotificationCenterIntegration:
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
assert app._notice_registry.get("update:available") is None
|
||||
assert any("remind you next launch" in m for m in notified)
|
||||
|
||||
async def test_update_skip_version_marks_notified_for_latest(self) -> None:
|
||||
"""'Skip this version' marks the version notified and removes the entry."""
|
||||
@@ -5943,6 +5959,14 @@ class TestNotificationCenterIntegration:
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
entry = _update_entry(latest="3.1.4")
|
||||
app._notice_registry.add(entry)
|
||||
notified: list[str] = []
|
||||
original_notify = app.notify
|
||||
|
||||
def capture_notify(message: str, **kwargs: Any) -> None:
|
||||
notified.append(message)
|
||||
original_notify(message, **kwargs)
|
||||
|
||||
app.notify = capture_notify # type: ignore[method-assign]
|
||||
|
||||
with patch(
|
||||
"deepagents_cli.update_check.mark_update_notified",
|
||||
@@ -5956,6 +5980,7 @@ class TestNotificationCenterIntegration:
|
||||
|
||||
mock_mark.assert_called_once_with("3.1.4")
|
||||
assert app._notice_registry.get("update:available") is None
|
||||
assert any("Skipped v3.1.4" in m for m in notified)
|
||||
|
||||
async def test_dispatcher_handler_exception_surfaces_action_label(self) -> None:
|
||||
"""A handler raising OSError produces a warning toast naming the action."""
|
||||
@@ -6189,6 +6214,59 @@ class TestNotificationCenterIntegration:
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, UpdateAvailableScreen)
|
||||
|
||||
async def test_periodic_update_check_toasts_without_opening_modal(self) -> None:
|
||||
"""Hourly rechecks surface updates without interrupting the session."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
bodies: list[str] = []
|
||||
original_notify_actionable = app._notify_actionable
|
||||
|
||||
def capture_notify_actionable(
|
||||
entry: PendingNotification, **kwargs: Any
|
||||
) -> None:
|
||||
bodies.append(f"{entry.body}\n\n{kwargs.get('action_hint', '')}")
|
||||
original_notify_actionable(entry, **kwargs)
|
||||
|
||||
app._notify_actionable = capture_notify_actionable # type: ignore[method-assign]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_cli.update_check.is_update_available",
|
||||
return_value=(True, "9.9.9"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_cli.update_check.is_auto_update_enabled",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_cli.update_check.should_notify_update",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_cli.update_check.mark_update_notified",
|
||||
),
|
||||
patch(
|
||||
"deepagents_cli.update_check.format_release_age_parenthetical",
|
||||
return_value="",
|
||||
),
|
||||
patch(
|
||||
"deepagents_cli.update_check.format_installed_age_suffix",
|
||||
return_value="",
|
||||
),
|
||||
patch(
|
||||
"deepagents_cli.update_check.upgrade_command",
|
||||
return_value="pip install -U deepagents-cli",
|
||||
),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await app._check_for_updates(periodic=True)
|
||||
await pilot.pause()
|
||||
|
||||
entry = app._notice_registry.get("update:available")
|
||||
assert entry is not None
|
||||
assert any("session will not be interrupted" in body for body in bodies)
|
||||
assert any("Press ctrl+n to install." in body for body in bodies)
|
||||
|
||||
async def test_open_update_available_modal_over_modal_toasts_hint(self) -> None:
|
||||
"""Another modal already open: update modal is deferred with a hint toast."""
|
||||
from deepagents_cli.widgets.update_available import UpdateAvailableScreen
|
||||
@@ -6221,7 +6299,12 @@ class TestNotificationCenterIntegration:
|
||||
|
||||
# Hint toast surfaced with ctrl+n pointer; pending event cleared so
|
||||
# subsequent missing-dep toasts aren't suppressed.
|
||||
assert any("Update available" in m and "ctrl+n" in m for m in notified)
|
||||
assert any(
|
||||
"Update available" in m
|
||||
and "session will not be interrupted" in m
|
||||
and "ctrl+n" in m
|
||||
for m in notified
|
||||
)
|
||||
assert not app._update_modal_pending.is_set()
|
||||
assert app._notice_registry.get(entry.key) is entry
|
||||
|
||||
|
||||
@@ -876,15 +876,19 @@ class TestUpdateSubcommand:
|
||||
@staticmethod
|
||||
def _run_update(
|
||||
*,
|
||||
debug: bool = False,
|
||||
editable: bool,
|
||||
is_update_available_return: tuple[bool, str | None],
|
||||
log_path: str = "/tmp/deepagents-update.log",
|
||||
) -> tuple[int, MagicMock, MagicMock]:
|
||||
"""Invoke `cli_main()` with `update` subcommand; return exit code + mocks."""
|
||||
from deepagents_cli._env_vars import DEBUG_UPDATE
|
||||
from deepagents_cli.main import cli_main
|
||||
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdin.isatty.return_value = True
|
||||
with (
|
||||
patch.dict(os.environ, {DEBUG_UPDATE: "1" if debug else ""}),
|
||||
patch.object(sys, "argv", ["deepagents", "update"]),
|
||||
patch.object(sys, "stdin", mock_stdin),
|
||||
patch("deepagents_cli.main.check_cli_dependencies"),
|
||||
@@ -893,6 +897,10 @@ class TestUpdateSubcommand:
|
||||
"deepagents_cli.update_check.is_update_available",
|
||||
return_value=is_update_available_return,
|
||||
) as is_update_mock,
|
||||
patch(
|
||||
"deepagents_cli.update_check.create_update_log_path",
|
||||
return_value=log_path,
|
||||
),
|
||||
patch(
|
||||
"deepagents_cli.update_check.perform_upgrade",
|
||||
new_callable=AsyncMock,
|
||||
@@ -935,6 +943,26 @@ class TestUpdateSubcommand:
|
||||
assert code == 0
|
||||
perform_upgrade_mock.assert_not_called()
|
||||
|
||||
def test_debug_update_skips_upgrade(self) -> None:
|
||||
"""Debug update mode exits 0 without invoking the installer."""
|
||||
code, _, perform_upgrade_mock = self._run_update(
|
||||
debug=True,
|
||||
editable=False,
|
||||
is_update_available_return=(True, "99.0.0"),
|
||||
)
|
||||
assert code == 0
|
||||
perform_upgrade_mock.assert_not_called()
|
||||
|
||||
def test_markup_like_log_path_does_not_break_output(self) -> None:
|
||||
"""Dynamic log paths are printed without Rich markup parsing."""
|
||||
code, _, perform_upgrade_mock = self._run_update(
|
||||
editable=False,
|
||||
is_update_available_return=(True, "99.0.0"),
|
||||
log_path="/tmp/[/red]/deepagents-update.log",
|
||||
)
|
||||
assert code == 0
|
||||
perform_upgrade_mock.assert_awaited_once()
|
||||
|
||||
def test_update_available_runs_upgrade(self) -> None:
|
||||
"""`(True, "x.y.z")` triggers `perform_upgrade` and exits 0 on success."""
|
||||
code, _, perform_upgrade_mock = self._run_update(
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import tomllib
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
from packaging.version import InvalidVersion, Version
|
||||
@@ -16,7 +17,9 @@ from deepagents_cli.update_check import (
|
||||
_extract_release_times,
|
||||
_latest_from_releases,
|
||||
_parse_version,
|
||||
cleanup_update_logs,
|
||||
clear_update_notified,
|
||||
create_update_log_path,
|
||||
format_age_suffix,
|
||||
format_installed_age_suffix,
|
||||
format_release_age,
|
||||
@@ -31,6 +34,7 @@ from deepagents_cli.update_check import (
|
||||
is_update_available,
|
||||
mark_update_notified,
|
||||
mark_version_seen,
|
||||
perform_upgrade,
|
||||
set_auto_update,
|
||||
should_notify_update,
|
||||
)
|
||||
@@ -44,6 +48,14 @@ def cache_file(tmp_path):
|
||||
yield path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def update_log_dir(tmp_path):
|
||||
"""Override UPDATE_LOG_DIR to use a temporary directory."""
|
||||
path = tmp_path / "update_logs"
|
||||
with patch("deepagents_cli.update_check.UPDATE_LOG_DIR", path):
|
||||
yield path
|
||||
|
||||
|
||||
def _mock_pypi_response(
|
||||
version: str = "99.0.0",
|
||||
releases: dict[str, list[dict[str, object]]] | None = None,
|
||||
@@ -772,6 +784,76 @@ class TestFormatInstalledAgeSuffix:
|
||||
assert format_installed_age_suffix("1.0.0") == ""
|
||||
|
||||
|
||||
class TestUpdateLogs:
|
||||
def test_create_update_log_path_uses_log_dir(self, update_log_dir) -> None:
|
||||
path = create_update_log_path()
|
||||
assert path.parent == update_log_dir
|
||||
assert path.name.endswith("-update.log")
|
||||
|
||||
def test_cleanup_update_logs_removes_old_and_excess(self, update_log_dir) -> None:
|
||||
update_log_dir.mkdir(parents=True)
|
||||
now = time.time()
|
||||
paths = []
|
||||
for idx in range(4):
|
||||
path = update_log_dir / f"{idx}-update.log"
|
||||
path.write_text(str(idx))
|
||||
os.utime(path, (now - idx, now - idx))
|
||||
paths.append(path)
|
||||
old = update_log_dir / "old-update.log"
|
||||
old.write_text("old")
|
||||
os.utime(old, (now - 30 * 86_400, now - 30 * 86_400))
|
||||
|
||||
cleanup_update_logs(retention_days=14, max_files=2)
|
||||
|
||||
remaining = {path.name for path in update_log_dir.glob("*.log")}
|
||||
assert remaining == {paths[0].name, paths[1].name}
|
||||
|
||||
async def test_perform_upgrade_runs_when_log_cannot_be_created(
|
||||
self, tmp_path
|
||||
) -> None:
|
||||
"""Log persistence is best-effort and must not block the updater."""
|
||||
blocked_parent = tmp_path / "not-a-dir"
|
||||
blocked_parent.write_text("file")
|
||||
log_path = blocked_parent / "update.log"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_cli.update_check.detect_install_method",
|
||||
return_value="pip",
|
||||
),
|
||||
patch.dict(
|
||||
"deepagents_cli.update_check._UPGRADE_COMMANDS",
|
||||
{"pip": "printf 'ok\\n'"},
|
||||
),
|
||||
):
|
||||
success, output = await perform_upgrade(log_path=log_path)
|
||||
|
||||
assert success is True
|
||||
assert output == "ok"
|
||||
|
||||
async def test_perform_upgrade_ignores_log_close_failure(self, tmp_path) -> None:
|
||||
"""A close-time log flush failure must not fail a successful upgrade."""
|
||||
log_path = tmp_path / "update.log"
|
||||
opener = mock_open()
|
||||
opener.return_value.close.side_effect = OSError("flush failed")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_cli.update_check.detect_install_method",
|
||||
return_value="pip",
|
||||
),
|
||||
patch.dict(
|
||||
"deepagents_cli.update_check._UPGRADE_COMMANDS",
|
||||
{"pip": "printf 'ok\\n'"},
|
||||
),
|
||||
patch("pathlib.Path.open", opener),
|
||||
):
|
||||
success, output = await perform_upgrade(log_path=log_path)
|
||||
|
||||
assert success is True
|
||||
assert output == "ok"
|
||||
|
||||
|
||||
def _mock_sdk_pypi_response(
|
||||
releases: dict[str, list[dict[str, object]]] | None = None,
|
||||
) -> MagicMock:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for `UpdateProgressScreen`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App
|
||||
from textual.widgets import Log, Static
|
||||
|
||||
from deepagents_cli.config import get_glyphs
|
||||
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
|
||||
|
||||
|
||||
async def test_update_progress_screen_shows_tail_when_details_toggle(tmp_path) -> None:
|
||||
"""The progress modal keeps a bounded tail hidden until details are toggled."""
|
||||
screen = UpdateProgressScreen(
|
||||
latest="2.0.0",
|
||||
command="pip install --upgrade deepagents-cli",
|
||||
log_path=tmp_path / "update.log",
|
||||
tail_limit=2,
|
||||
)
|
||||
|
||||
app = App()
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
details = screen.query(Static).filter(".up-details").first()
|
||||
log_path = screen.query(Static).filter(".up-log").first()
|
||||
assert details.display is False
|
||||
assert log_path.display is False
|
||||
assert "Running command: pip install --upgrade deepagents-cli" in str(
|
||||
details.render()
|
||||
)
|
||||
assert "tail -f" not in str(details.render())
|
||||
|
||||
screen.append_line("first")
|
||||
screen.append_line("second")
|
||||
screen.append_line("third")
|
||||
await pilot.press("d")
|
||||
await pilot.pause()
|
||||
|
||||
tail = screen.query(Log).filter(".up-tail").first()
|
||||
assert details.display is True
|
||||
assert log_path.display is True
|
||||
assert tail.display is True
|
||||
assert tail.line_count == 2
|
||||
assert list(screen._tail) == ["second", "third"]
|
||||
|
||||
|
||||
async def test_update_progress_screen_copies_log_path_only_in_details(tmp_path) -> None:
|
||||
"""Pressing c copies the log path only when details are visible."""
|
||||
log_path = tmp_path / "update.log"
|
||||
screen = UpdateProgressScreen(
|
||||
latest="2.0.0",
|
||||
command="pip install --upgrade deepagents-cli",
|
||||
log_path=log_path,
|
||||
)
|
||||
|
||||
copied: list[str] = []
|
||||
app = App()
|
||||
app.copy_to_clipboard = copied.append # type: ignore[method-assign]
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
assert copied == []
|
||||
|
||||
await pilot.press("d")
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
assert copied == [str(log_path)]
|
||||
|
||||
|
||||
async def test_update_progress_screen_renders_markup_path_plainly(tmp_path) -> None:
|
||||
"""Dynamic command and log path text must not be parsed as Rich markup."""
|
||||
log_path = tmp_path / "[/red]" / "update.log"
|
||||
screen = UpdateProgressScreen(
|
||||
latest="2.0.0",
|
||||
command="echo [/red]",
|
||||
log_path=log_path,
|
||||
)
|
||||
|
||||
app = App()
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
await pilot.press("d")
|
||||
await pilot.pause()
|
||||
|
||||
command = screen.query(Static).filter(".up-details").first()
|
||||
log = screen.query(Static).filter(".up-log").first()
|
||||
assert "[/red]" in str(command.render())
|
||||
assert "[/red]" in str(log.render())
|
||||
|
||||
|
||||
async def test_update_progress_screen_close_waits_until_done(tmp_path) -> None:
|
||||
"""Esc is ignored while the update is running and closes after completion."""
|
||||
screen = UpdateProgressScreen(
|
||||
latest="2.0.0",
|
||||
command="pip install --upgrade deepagents-cli",
|
||||
log_path=tmp_path / "update.log",
|
||||
)
|
||||
|
||||
app = App()
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.screen is screen
|
||||
|
||||
screen.mark_success()
|
||||
spinner = screen.query(Static).filter(".up-spinner").first()
|
||||
assert str(spinner.render()) == get_glyphs().checkmark
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.screen is app.screen_stack[0]
|
||||
Reference in New Issue
Block a user