From 010d0969b4bc1c2f2291bbdae8ca1ca5a200ed32 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 3 Sep 2026 12:30:54 +0530 Subject: [PATCH] Repair Local Agent automatic update flow --- app/modules/documents/agent_package.py | 4 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/config.py | 15 +++- .../erp_local_agent/updater.py | 76 ++++++++++++++----- 4 files changed, 74 insertions(+), 23 deletions(-) diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index e078257..6ff504a 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.22.0" +ERP_LOCAL_AGENT_VERSION = "1.22.2" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) @@ -23,7 +23,7 @@ def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, stor f"STORAGE_ROOT={storage_root}\n" f"TENANT_ID={'' if tenant_id is None else tenant_id}\n" f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n" f"BRANCH_ID={'' if branch_id is None else branch_id}\n" f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n" f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n" f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n" - "AUTO_UPDATE=true\nAUTO_INSTALL_UPDATES=false\nUPDATE_CHECK_INTERVAL_SECONDS=300\nDASHBOARD_ENABLED=true\nDASHBOARD_HOST=127.0.0.1\nDASHBOARD_PORT=8788\n" + "AUTO_UPDATE=true\nAUTO_INSTALL_UPDATES=true\nMANUAL_UPDATE_ONLY=false\nUPDATE_CHECK_INTERVAL_SECONDS=300\nDASHBOARD_ENABLED=true\nDASHBOARD_HOST=127.0.0.1\nDASHBOARD_PORT=8788\n" ) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 529e47e..5f96756 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.22.1" +__version__ = "1.22.2" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/config.py b/app/modules/documents/local_agent_runtime/erp_local_agent/config.py index 12a0f6f..e55ab23 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/config.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/config.py @@ -29,7 +29,8 @@ class AgentConfig: tunnel_reconnect_seconds: int = 10 auto_update: bool = True update_check_interval_seconds: int = 300 - auto_install_updates: bool = False + auto_install_updates: bool = True + manual_update_only: bool = False dashboard_enabled: bool = True dashboard_host: str = "127.0.0.1" dashboard_port: int = 8788 @@ -107,6 +108,13 @@ def load_config(env_file: str | None = None) -> AgentConfig: storage_root = Path(storage_root_raw).expanduser().resolve() storage_root.mkdir(parents=True, exist_ok=True) + auto_update = _get_bool("AUTO_UPDATE", True) + # Packages through 1.22.1 generated AUTO_INSTALL_UPDATES=false even though the ERP + # described updates as automatic. Treat that legacy generated setting as migrated + # unless the administrator opts into the new explicit MANUAL_UPDATE_ONLY switch. + manual_update_only = _get_bool("MANUAL_UPDATE_ONLY", False) + auto_install_updates = bool(auto_update and not manual_update_only) + return AgentConfig( erp_base_url=erp_base_url, node_code=node_code, @@ -122,9 +130,10 @@ def load_config(env_file: str | None = None) -> AgentConfig: max_retries=_get_int("MAX_RETRIES", 3), tunnel_enabled=_get_bool("TUNNEL_ENABLED", True), tunnel_reconnect_seconds=_get_int("TUNNEL_RECONNECT_SECONDS", 10), - auto_update=_get_bool("AUTO_UPDATE", True), + auto_update=auto_update, update_check_interval_seconds=max(60, _get_int("UPDATE_CHECK_INTERVAL_SECONDS", 300)), - auto_install_updates=_get_bool("AUTO_INSTALL_UPDATES", False), + auto_install_updates=auto_install_updates, + manual_update_only=manual_update_only, dashboard_enabled=_get_bool("DASHBOARD_ENABLED", True), dashboard_host=os.getenv("DASHBOARD_HOST", "127.0.0.1").strip() or "127.0.0.1", dashboard_port=max(1, min(65535, _get_int("DASHBOARD_PORT", 8788))), diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py b/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py index 68081e9..eba7bf9 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/updater.py @@ -3,8 +3,8 @@ from __future__ import annotations import hashlib import os from pathlib import Path +import re import shutil -import subprocess import threading import time import zipfile @@ -12,11 +12,28 @@ import zipfile from . import __version__ -class AgentUpdater: - """Manual-install updater. +def _version_tuple(value: str) -> tuple[int, ...]: + """Return a stable numeric tuple for ERP Local Agent release versions.""" + parts = [int(piece) for piece in re.findall(r"\d+", str(value or ""))] + return tuple(parts or [0]) - Background work checks the ERP manifest only. Download and installation happen only - when the local user requests them from the dashboard. + +def _is_newer(latest: str, current: str) -> bool: + left = list(_version_tuple(latest)) + right = list(_version_tuple(current)) + width = max(len(left), len(right)) + left.extend([0] * (width - len(left))) + right.extend([0] * (width - len(right))) + return tuple(left) > tuple(right) + + +class AgentUpdater: + """Supervisor-controlled ERP Local Agent updater. + + The worker checks the ERP manifest in the background. When automatic installation + is enabled it downloads the hash-verified package, stages it, and hands installation + to the persistent desktop supervisor. Manual dashboard download/install actions are + preserved and use the same code paths. """ def __init__(self, config, client, logger, install_dir: Path, db=None): @@ -48,7 +65,7 @@ class AgentUpdater: latest = str(manifest.get("latest_version") or "").strip() expected = str(manifest.get("sha256") or "").lower().strip() self.latest_manifest = dict(manifest or {}) - available = bool(latest and latest != __version__) + available = bool(latest and _is_newer(latest, __version__)) if self.db is not None: self.db.set_meta("latest_agent_version", latest or __version__) self.db.set_meta("update_available", "1" if available else "0") @@ -65,8 +82,27 @@ class AgentUpdater: return False try: state = self.check_for_update(force=force) - return bool(state.get("update_available")) - except Exception: + available = bool(state.get("update_available")) + if not available: + return False + if not self.config.auto_install_updates: + return True + + latest = str(state.get("latest_version") or "").strip() + request_path = self.install_dir / "updates" / "supervisor_request.json" + if request_path.exists(): + # The persistent supervisor will consume the pending request. Avoid + # repeatedly downloading the same package while restart is underway. + return True + + staged_version = str(state.get("staged_version") or "").strip() + if not state.get("downloaded") or staged_version != latest: + state = self.download_latest() + if state.get("downloaded"): + self.install_downloaded() + return True + except Exception as exc: + self.logger.exception("ERP Local Agent automatic update failed: %s", exc) return False def download_latest(self) -> dict: @@ -79,7 +115,7 @@ class AgentUpdater: self.check_for_update(force=True) latest = str(self.latest_manifest.get("latest_version") or "").strip() expected = str(self.latest_manifest.get("sha256") or "").lower().strip() - if not latest or latest == __version__: + if not latest or not _is_newer(latest, __version__): return self.status() package = self.client.download_update_package() actual = hashlib.sha256(package).hexdigest().lower() @@ -97,6 +133,8 @@ class AgentUpdater: archive.extractall(staged) if not (staged / "erp_local_agent" / "__init__.py").exists(): raise RuntimeError("ERP Local Agent update package is incomplete.") + if not (staged / "requirements.txt").exists(): + raise RuntimeError("ERP Local Agent update package is missing requirements.txt.") self._record("download", latest, "downloaded", sha256=actual, package_path=str(package_path)) if self.db is not None: self.db.set_meta("staged_update_version", latest) @@ -110,11 +148,7 @@ class AgentUpdater: self.busy = False def install_downloaded(self) -> dict: - """Ask the persistent desktop supervisor to install the staged worker. - - The worker does not terminate itself. This lets the HTTP request finish cleanly, - then the supervisor owns stop/replace/restart/rollback. - """ + """Ask the persistent desktop supervisor to install the staged worker.""" with self.lock: latest = str( (self.db.get_meta("staged_update_version") if self.db else "") @@ -123,6 +157,8 @@ class AgentUpdater: ).strip() if not latest: raise RuntimeError("No downloaded ERP Local Agent update is available to install.") + if not _is_newer(latest, __version__): + return self.status() safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update" staged = self.install_dir / "updates" / f"staged_{safe}" @@ -144,6 +180,7 @@ class AgentUpdater: "staged_dir": str(staged.resolve()), "requested_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "requested_by_pid": os.getpid(), + "automatic": bool(self.config.auto_install_updates), } import json tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") @@ -164,7 +201,11 @@ class AgentUpdater: } def status(self) -> dict: - latest = str(self.latest_manifest.get("latest_version") or (self.db.get_meta("latest_agent_version") if self.db else "") or __version__).strip() + latest = str( + self.latest_manifest.get("latest_version") + or (self.db.get_meta("latest_agent_version") if self.db else "") + or __version__ + ).strip() staged_version = str((self.db.get_meta("staged_update_version") if self.db else "") or "").strip() staged_ok = False if staged_version: @@ -182,11 +223,12 @@ class AgentUpdater: return { "current_version": __version__, "latest_version": latest, - "update_available": bool(latest and latest != __version__), + "update_available": bool(latest and _is_newer(latest, __version__)), "downloaded": bool(staged_ok), "staged_version": staged_version if staged_ok else "", "busy": bool(self.busy), "automatic_check_enabled": bool(self.config.auto_update), - "automatic_install_enabled": False, + "automatic_install_enabled": bool(self.config.auto_update and self.config.auto_install_updates), + "manual_update_only": bool(getattr(self.config, "manual_update_only", False)), "supervisor": supervisor_state, }