From 7bc97be1b1c3f4e11ff49d0cd138ebd6d8706606 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Fri, 4 Sep 2026 13:33:34 +0530 Subject: [PATCH] Repair Local Agent supervisor and add update progress UI --- app/modules/documents/agent_package.py | 4 +- .../local_agent_runtime/ERPAgentDashboard.pyw | 98 ++----- .../ERPAgentSupervisor.pyw | 255 +++++++----------- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/dashboard.py | 27 +- .../erp_local_agent/desktop_runtime.py | 43 +-- .../erp_local_agent/updater.py | 192 ++++++++----- .../install_task_scheduler.bat | 4 +- 8 files changed, 282 insertions(+), 343 deletions(-) diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 259cc80..a59c405 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.11" +ERP_LOCAL_AGENT_VERSION = "1.22.12" 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) @@ -66,7 +66,7 @@ def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" "Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n" "Existing master/transaction sync and depreciation are preserved. Phase 5 adds neutral historical-ledger evidence read capability for server-side learning.\n" - "Dashboard: http://127.0.0.1:8788\n" + "Dashboard: http://127.0.0.1:8788\nUpdate dashboard now shows check, download, install, restart and completion progress.\n" ) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8")) return buffer.getvalue() diff --git a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw index 996f5d6..b4fa950 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw @@ -1,14 +1,12 @@ from __future__ import annotations +import atexit import ctypes from ctypes import wintypes import hashlib import os -import atexit from pathlib import Path import subprocess -import sys -import threading import time import urllib.request @@ -16,8 +14,6 @@ from tkinter import Tk, messagebox INSTALL_ROOT = Path(__file__).resolve().parent -DATA_DIR = INSTALL_ROOT / "data" -SUPERVISOR_LOCK = DATA_DIR / "supervisor.lock" SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw" URL = "http://127.0.0.1:8788" ERROR_ALREADY_EXISTS = 183 @@ -69,7 +65,7 @@ def _focus_existing_dashboard() -> None: buf = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, buf, length + 1) if "ERP Local Agent Dashboard" in buf.value: - user32.ShowWindow(hwnd, 9) # SW_RESTORE + user32.ShowWindow(hwnd, 9) user32.SetForegroundWindow(hwnd) return False return True @@ -93,42 +89,6 @@ def _popup(title: str, message: str, error: bool = False) -> None: root.destroy() -def _pid_alive(pid: int) -> bool: - if pid <= 0: - return False - if os.name == "nt": - try: - import ctypes - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - STILL_ACTIVE = 259 - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)) - if not handle: - return False - try: - exit_code = ctypes.c_ulong() - if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): - return False - return exit_code.value == STILL_ACTIVE - finally: - kernel32.CloseHandle(handle) - except Exception: - return False - try: - os.kill(pid, 0) - return True - except OSError: - return False - -def _supervisor_running() -> bool: - try: - if not SUPERVISOR_LOCK.exists(): - return False - return _pid_alive(int(SUPERVISOR_LOCK.read_text(encoding="utf-8").strip())) - except Exception: - return False - - def _pythonw() -> Path: pyw = INSTALL_ROOT / ".venv" / "Scripts" / "pythonw.exe" if pyw.exists(): @@ -139,18 +99,30 @@ def _pythonw() -> Path: raise RuntimeError("ERP Local Agent Python environment was not found.") +def _ready(timeout: float = 1.5) -> bool: + try: + with urllib.request.urlopen(URL + "/api/status", timeout=timeout) as response: + return int(getattr(response, "status", 200)) < 500 + except Exception: + return False + + def _ensure_supervisor() -> None: - if _supervisor_running(): + # If the dashboard is already healthy, never start another supervisor or worker. + if _ready(): return - # The Scheduled Task is the canonical owner/launcher. Starting it repeatedly - # is safe because the task is configured with MultipleInstances=IgnoreNew, - # and the supervisor itself also owns a machine-wide Global mutex. started_by_task = False if os.name == "nt": result = subprocess.run( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", - "Start-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction Stop"], + [ + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Start-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction Stop", + ], cwd=str(INSTALL_ROOT), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -160,8 +132,7 @@ def _ensure_supervisor() -> None: started_by_task = result.returncode == 0 if not started_by_task: - # Recovery fallback for a workstation where the scheduled task was not - # installed yet. The Global mutex still prevents a second supervisor. + # Recovery fallback. The Supervisor Global mutex makes repeated launches harmless. subprocess.Popen( [str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"], cwd=str(INSTALL_ROOT), @@ -171,23 +142,8 @@ def _ensure_supervisor() -> None: close_fds=True, ) - deadline = time.time() + 20 - while time.time() < deadline: - if _supervisor_running(): - return - time.sleep(0.5) - raise RuntimeError("ERP Local Agent supervisor did not start.") - -def _ready(timeout: float = 1.5) -> bool: - try: - with urllib.request.urlopen(URL + "/api/status", timeout=timeout) as response: - return int(getattr(response, "status", 200)) < 500 - except Exception: - return False - - -def _wait_ready(seconds: int = 75) -> bool: +def _wait_ready(seconds: int = 90) -> bool: deadline = time.time() + seconds while time.time() < deadline: if _ready(): @@ -202,6 +158,7 @@ def main() -> None: _focus_existing_dashboard() return atexit.register(_close_dashboard_mutex, dashboard_mutex) + try: _ensure_supervisor() except Exception as exc: @@ -211,8 +168,9 @@ def main() -> None: if not _wait_ready(): _popup( "ERP Local Agent", - "The Local Agent supervisor is running, but the dashboard did not become ready.\n\n" - f"Check logs under:\n{INSTALL_ROOT / 'logs'}", + "The Local Agent did not become ready.\n\n" + "Check the 'ERP Local Agent' Scheduled Task and the logs under:\n" + f"{INSTALL_ROOT / 'logs'}", error=True, ) return @@ -252,9 +210,9 @@ def main() -> None: pass outage_seen = False was_ready = current - time.sleep(1.5) + time.sleep(1.0) except Exception: - time.sleep(2) + time.sleep(1.5) webview.start(monitor) diff --git a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw index 5160de4..fc755f6 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw @@ -10,7 +10,6 @@ from pathlib import Path import shutil import signal import subprocess -import sys import time import urllib.request @@ -23,14 +22,14 @@ LOCK_FILE = DATA_DIR / "supervisor.lock" STATE_FILE = DATA_DIR / "supervisor_state.json" OWNER_FILE = DATA_DIR / "supervisor_owner.json" REQUEST_FILE = UPDATES_DIR / "supervisor_request.json" +UPDATE_PROGRESS_FILE = DATA_DIR / "update_progress.json" WORKER_LOG = LOG_DIR / "worker-supervisor.log" SUPERVISOR_LOG = LOG_DIR / "supervisor.log" DASHBOARD_URL = "http://127.0.0.1:8788" WORKER_MODULE = "erp_local_agent.main" RESTART_DELAY_SECONDS = 3 -HEALTH_TIMEOUT_SECONDS = 60 +HEALTH_TIMEOUT_SECONDS = 90 ERROR_ALREADY_EXISTS = 183 -SINGLETON_WATCHDOG_SECONDS = 10 def _supervisor_mutex_name() -> str: @@ -64,28 +63,6 @@ def _close_os_mutex(handle) -> None: pass -def _kill_duplicate_supervisors() -> None: - if os.name != "nt": - return - root = str(INSTALL_ROOT).replace("'", "''") - script = ( - "$root='" + root + "';$self=" + str(os.getpid()) + ";" - "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | " - "Where-Object { $_.ProcessId -ne $self -and $_.CommandLine -and " - "$_.CommandLine -match 'ERPAgentSupervisor\\.pyw' -and " - "$_.CommandLine -like ('*'+$root+'*') } | " - "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }" - ) - subprocess.run( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], - cwd=str(INSTALL_ROOT), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=_create_no_window(), - check=False, - ) - - def _create_no_window() -> int: return getattr(subprocess, "CREATE_NO_WINDOW", 0) @@ -100,16 +77,6 @@ def _log(message: str) -> None: pass -def _pid_alive(pid: int) -> bool: - if pid <= 0: - return False - try: - os.kill(pid, 0) - return True - except OSError: - return False - - def _write_json_atomic(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") @@ -129,6 +96,20 @@ def _state(status: str, message: str, **extra) -> None: _log(f"{status}: {message}") +def _update_progress(phase: str, percent: int, message: str, *, version: str = "", status: str = "installing") -> None: + payload = { + "operation": "install", + "status": status, + "phase": phase, + "percent": max(0, min(100, int(percent))), + "message": message, + "target_version": version, + "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "supervisor_pid": os.getpid(), + } + _write_json_atomic(UPDATE_PROGRESS_FILE, payload) + + def _dashboard_ready(timeout: float = 1.5) -> bool: try: with urllib.request.urlopen(DASHBOARD_URL + "/api/status", timeout=timeout) as response: @@ -149,9 +130,7 @@ def _python(console: bool = True) -> Path: def _terminate_process_tree(process: subprocess.Popen | None) -> None: - if process is None: - return - if process.poll() is not None: + if process is None or process.poll() is not None: return try: subprocess.run( @@ -167,91 +146,41 @@ def _terminate_process_tree(process: subprocess.Popen | None) -> None: except Exception: pass try: - process.wait(timeout=15) + process.wait(timeout=20) except Exception: pass -def _kill_unmanaged_workers() -> None: - script = ( - "$root='" + str(INSTALL_ROOT).replace("'", "''") + "';" - "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | " - "Where-Object { $_.CommandLine -and $_.CommandLine -match 'erp_local_agent\\.main' " - "-and $_.CommandLine -like ('*'+$root+'*') } | " - "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }" - ) - subprocess.run( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], - cwd=str(INSTALL_ROOT), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=_create_no_window(), - check=False, - ) - - -def _enforce_singleton_processes(*, keep_worker_pid: int | None = None) -> None: - """Kill stale/duplicate supervisors and workers for this install only. - - The Global named mutex is the primary atomic guard. This watchdog is a - recovery layer for legacy processes started before the hardened runtime. - """ - if os.name != "nt": - return - root = str(INSTALL_ROOT.resolve()).replace("'", "''") - keep_worker = int(keep_worker_pid or 0) - script = rf""" -$root='{root}' -$selfPid={os.getpid()} -$keepWorker={keep_worker} -Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{ - $cmd=[string]$_.CommandLine - if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }} - if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $selfPid)) {{ - Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue - return - }} - if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $keepWorker)) {{ - Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue - }} -}} -""" - subprocess.run( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], - cwd=str(INSTALL_ROOT), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=_create_no_window(), - check=False, - ) - - class Supervisor: def __init__(self): self.worker: subprocess.Popen | None = None self.running = True self._os_mutex = None + self._worker_log_handle = None def acquire_lock(self) -> bool: - # The named mutex is atomic at the Windows kernel level and prevents - # the lock-file race that allowed multiple supervisors before 1.22.3. + """Acquire the only authoritative supervisor singleton guard. + + The Global Windows mutex is atomic across SYSTEM and user sessions. PID/owner + files are diagnostics only and must never veto a successfully acquired mutex. + """ + _log("startup: acquiring Global supervisor mutex") self._os_mutex = _acquire_os_mutex() if self._os_mutex is None: + _log("startup: another supervisor already owns the Global mutex; exiting") return False DATA_DIR.mkdir(parents=True, exist_ok=True) - if LOCK_FILE.exists(): - try: - existing = int(LOCK_FILE.read_text(encoding="utf-8").strip() or "0") - except Exception: - existing = 0 - if existing and existing != os.getpid() and _pid_alive(existing): - return False LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8") - _write_json_atomic(OWNER_FILE, { - "supervisor_pid": os.getpid(), - "install_root": str(INSTALL_ROOT), - "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - }) + _write_json_atomic( + OWNER_FILE, + { + "supervisor_pid": os.getpid(), + "install_root": str(INSTALL_ROOT), + "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "singleton": "global_mutex", + }, + ) + _log("startup: Global supervisor mutex acquired") return True def release_lock(self) -> None: @@ -273,26 +202,32 @@ class Supervisor: def start_worker(self) -> None: if self.worker is not None and self.worker.poll() is None: return - _kill_unmanaged_workers() LOG_DIR.mkdir(parents=True, exist_ok=True) py = _python(console=True) - handle = WORKER_LOG.open("a", encoding="utf-8") - handle.write(f"\n--- worker start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n") - handle.flush() + self._worker_log_handle = WORKER_LOG.open("a", encoding="utf-8") + self._worker_log_handle.write(f"\n--- worker start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n") + self._worker_log_handle.flush() self.worker = subprocess.Popen( [str(py), "-m", WORKER_MODULE], cwd=str(INSTALL_ROOT), - stdout=handle, - stderr=handle, + stdout=self._worker_log_handle, + stderr=self._worker_log_handle, creationflags=_create_no_window(), ) _state("running", "ERP Local Agent worker started.", worker_pid=self.worker.pid) def stop_worker(self) -> None: + if self.worker is None: + return _state("stopping", "Stopping ERP Local Agent worker.") _terminate_process_tree(self.worker) self.worker = None - _kill_unmanaged_workers() + if self._worker_log_handle is not None: + try: + self._worker_log_handle.close() + except Exception: + pass + self._worker_log_handle = None @staticmethod def _read_request() -> dict | None: @@ -345,6 +280,8 @@ class Supervisor: "stop_task_scheduler.bat", "status_task_scheduler.bat", "uninstall_task_scheduler.bat", + "ERPAgentSupervisor.pyw", + "ERPAgentDashboard.pyw", ): src = INSTALL_ROOT / name if src.exists(): @@ -356,11 +293,9 @@ class Supervisor: target = INSTALL_ROOT / "erp_local_agent" new_target = INSTALL_ROOT / "erp_local_agent.__new__" old_target = INSTALL_ROOT / "erp_local_agent.__old__" - shutil.rmtree(new_target, ignore_errors=True) shutil.rmtree(old_target, ignore_errors=True) shutil.copytree(staged / "erp_local_agent", new_target) - if target.exists(): target.rename(old_target) try: @@ -371,9 +306,6 @@ class Supervisor: raise shutil.rmtree(old_target, ignore_errors=True) - # Root helper files are safe to refresh while the stable supervisor is running. - # Supervisor/dashboard launchers themselves are intentionally excluded here; - # they are updated only by the explicit direct installer. for name in ( "requirements.txt", "README_ERP_LOCAL_AGENT.txt", @@ -387,6 +319,8 @@ class Supervisor: "stop_task_scheduler.bat", "status_task_scheduler.bat", "uninstall_task_scheduler.bat", + "ERPAgentSupervisor.pyw", + "ERPAgentDashboard.pyw", ): src = staged / name if src.exists(): @@ -407,7 +341,6 @@ class Supervisor: ) if result.returncode != 0: raise RuntimeError("Dependency installation failed:\n" + result.stdout[-4000:]) - code = ( "import erp_local_agent;" "from erp_local_agent import accounting_store,commands,tally,updater;" @@ -443,16 +376,26 @@ class Supervisor: staged_text = str(request.get("staged_dir") or "").strip() if not version or not staged_text: raise RuntimeError("Supervisor update request is missing version/staged_dir.") - staged = Path(staged_text).resolve() self._validate_staged(staged) - _state("installing", f"Installing ERP Local Agent {version}.", target_version=version) - self.stop_worker() - backup = self._backup_runtime(version) + _update_progress("preparing", 8, f"Preparing ERP Local Agent {version} update.", version=version) + _state("installing", f"Installing ERP Local Agent {version}.", target_version=version, progress_pct=8) + backup = None try: + _update_progress("stopping", 18, "Stopping the current Local Agent worker safely.", version=version) + self.stop_worker() + + _update_progress("backup", 30, "Creating rollback backup.", version=version) + backup = self._backup_runtime(version) + + _update_progress("replacing", 48, "Replacing Local Agent runtime files.", version=version) self._replace_worker_runtime(staged) + + _update_progress("dependencies", 66, "Verifying runtime dependencies and imports.", version=version) self._install_requirements_and_verify() + + _update_progress("starting", 82, "Starting the updated Local Agent.", version=version) self.start_worker() deadline = time.time() + HEALTH_TIMEOUT_SECONDS @@ -460,42 +403,36 @@ class Supervisor: if self.worker is not None and self.worker.poll() is not None: raise RuntimeError(f"Updated worker exited with code {self.worker.returncode}.") if _dashboard_ready(): + _update_progress("complete", 100, f"ERP Local Agent {version} updated successfully.", version=version, status="updated") _state( "updated", f"ERP Local Agent {version} installed and restarted successfully.", target_version=version, worker_pid=(self.worker.pid if self.worker else None), backup_path=str(backup), + progress_pct=100, ) return + _update_progress("health_check", 90, "Waiting for the updated dashboard to become ready.", version=version) time.sleep(1) - raise RuntimeError("Updated worker did not become healthy within the restart timeout.") - except Exception: + except Exception as exc: + _update_progress("rollback", 92, f"Update failed; restoring the previous runtime: {exc}", version=version, status="rollback") _state("rollback", f"Update {version} failed. Restoring previous worker.", target_version=version) self.stop_worker() - self._restore_backup(backup) + if backup is not None: + self._restore_backup(backup) self.start_worker() + _update_progress("error", 100, f"Update failed and the previous version was restored: {exc}", version=version, status="error") raise def run(self) -> int: if not self.acquire_lock(): return 0 - try: - _kill_duplicate_supervisors() _state("starting", "ERP Local Agent supervisor starting.") self.start_worker() - last_singleton_watchdog = 0.0 - while self.running: - now = time.time() - if now - last_singleton_watchdog >= SINGLETON_WATCHDOG_SECONDS: - _enforce_singleton_processes( - keep_worker_pid=(self.worker.pid if self.worker is not None and self.worker.poll() is None else None) - ) - last_singleton_watchdog = now - request = self._read_request() if request and str(request.get("action") or "") == "install_update": self._clear_request() @@ -503,37 +440,34 @@ class Supervisor: self.apply_update(request) except Exception as exc: _state("error", f"Update failed and worker was restored/restarted: {exc}") - if self.worker is None or self.worker.poll() is not None: code = None if self.worker is None else self.worker.returncode _state("restarting", f"ERP Local Agent worker stopped (exit={code}); restarting.") time.sleep(RESTART_DELAY_SECONDS) self.start_worker() - time.sleep(1) - return 0 + except BaseException as exc: + _log(f"fatal: {type(exc).__name__}: {exc}") + try: + _state("fatal", f"Supervisor stopped unexpectedly: {exc}") + except Exception: + pass + raise finally: - self.stop_worker() - self.release_lock() - _state("stopped", "ERP Local Agent supervisor stopped.") + try: + self.stop_worker() + finally: + self.release_lock() + _state("stopped", "ERP Local Agent supervisor stopped.") def main() -> int: parser = argparse.ArgumentParser(description="ERP Local Agent desktop/background supervisor") parser.add_argument("--background", action="store_true") - parser.add_argument("--replace", action="store_true", help="Explicit controlled replacement of an older supervisor instance") - args = parser.parse_args() - - if args.replace and os.name == "nt": - # Explicit restart/update only. Normal duplicate launches never disturb - # the healthy owner; they simply fail the Global mutex and exit. - _kill_duplicate_supervisors() - deadline = time.time() + 15 - while time.time() < deadline: - time.sleep(0.25) - break - + parser.add_argument("--replace", action="store_true", help="Compatibility flag; the Global mutex remains authoritative") + parser.parse_args() + _log(f"startup: supervisor process entered main pid={os.getpid()} root={INSTALL_ROOT}") supervisor = Supervisor() def stop_handler(*_args): @@ -544,9 +478,14 @@ def main() -> int: signal.signal(signal.SIGINT, stop_handler) except Exception: pass - return supervisor.run() if __name__ == "__main__": - raise SystemExit(main()) + try: + raise SystemExit(main()) + except SystemExit: + raise + except BaseException as exc: + _log(f"startup-fatal: {type(exc).__name__}: {exc}") + raise 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 8e5115c..8de4d95 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.11" +__version__ = "1.22.12" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py b/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py index bef88e0..77ec812 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py @@ -63,7 +63,7 @@ class AgentDashboard: if self.path == "/api/update/check": return self._json({"ok": True, "update": dashboard.updater.check_for_update(force=True)}) if self.path == "/api/update/download": - return self._json({"ok": True, "update": dashboard.updater.download_latest()}) + return self._json({"ok": True, "update": dashboard.updater.start_download()}) if self.path == "/api/update/install": state = dashboard.updater.status() if not state.get("downloaded"): @@ -136,7 +136,8 @@ class AgentDashboard: ERP Local Agent +body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;margin:0}.wrap{max-width:1100px;margin:28px auto;padding:0 18px}h1{margin:0 0 4px}.muted{color:#6b7280}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:22px 0}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;box-shadow:0 1px 2px rgba(0,0,0,.04)}.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b7280}.value{font-size:22px;font-weight:650;margin-top:8px}.ok{color:#087f5b}.bad{color:#c92a2a}.warn{color:#b26a00}button{border:0;border-radius:8px;padding:10px 14px;margin:4px 6px 4px 0;font-weight:600;cursor:pointer;background:#111827;color:#fff}button.secondary{background:#e5e7eb;color:#111827}button:disabled{opacity:.5;cursor:not-allowed}.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;margin:14px 0}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #eee;vertical-align:top}#message{margin-top:10px;padding:10px;border-radius:8px;display:none}.msgok{display:block!important;background:#e6fcf5;color:#087f5b}.msgbad{display:block!important;background:#fff5f5;color:#c92a2a}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}.progressbox{margin-top:14px;padding:14px;border:1px solid #dbe3ea;border-radius:10px;background:#f8fafc}.progresshead{display:flex;justify-content:space-between;gap:12px;align-items:center}.progressbar{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-top:10px}.progressfill{height:100%;width:0%;background:#111827;transition:width .45s ease}.progressmsg{margin-top:8px;font-size:13px;color:#4b5563}.pulse{animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.65}50%{opacity:1}} +

ERP Local Agent

Local dashboard — available only on this computer
ERP Connection
Loading…
@@ -144,24 +145,32 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg
TallyPrime
—
Storage Free
—
-

Updates

The agent checks for new versions automatically. Installation occurs only when a local user clicks Install; the persistent supervisor then restarts the worker safely.

-
+

Updates

Check, download and apply Local Agent updates here. The dashboard stays open and shows progress while the worker restarts.

+
+

Tally

Tally is checked locally at 127.0.0.1:9000. Port 9000 is not exposed to the internet.

Recent History

Loading…
''' diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/desktop_runtime.py b/app/modules/documents/local_agent_runtime/erp_local_agent/desktop_runtime.py index 3fd0eeb..982977c 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/desktop_runtime.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/desktop_runtime.py @@ -5,7 +5,6 @@ from ctypes import wintypes import hashlib import os from pathlib import Path -import subprocess ERROR_ALREADY_EXISTS = 183 @@ -69,39 +68,11 @@ def sync_desktop_helpers(root: Path, logger=None) -> bool: return changed -def _run_powershell(script: str) -> None: - if os.name != "nt": - return - subprocess.run( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - check=False, - timeout=30, - ) - - def cleanup_legacy_duplicates(root: Path, *, keep_supervisor_pid: int, close_dashboards: bool) -> None: - """Remove duplicates left behind by pre-1.22.3 launchers on this installation only.""" - if os.name != "nt": - return - root_text = str(root.resolve()).replace("'", "''") - dashboard_clause = "" - if close_dashboards: - dashboard_clause = " -or ($cmd -match 'ERPAgentDashboard\\.pyw')" - script = rf""" -$root='{root_text}' -$keepSupervisor={int(keep_supervisor_pid or 0)} -$selfPid={os.getpid()} -Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{ - $cmd = [string]$_.CommandLine - if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }} - $kill = $false - if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $keepSupervisor)) {{ $kill = $true }} - if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $selfPid)) {{ $kill = $true }} - if (($cmd -match 'ERPAgentDashboard\.pyw') -and {str(bool(close_dashboards)).lower()}) {{ $kill = $true }} - if ($kill) {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }} -}} -""" - _run_powershell(script) + """Compatibility no-op for pre-1.22.12 callers. + + Process-command-line cleanup is intentionally disabled. On Windows launcher/interpreter + pairs can share the same command line and do not represent duplicate logical workers. + Supervisor and worker singleton ownership is enforced only by Global named mutexes. + """ + return 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 eba7bf9..3e28a14 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 @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import json import os from pathlib import Path import re @@ -13,7 +14,6 @@ from . import __version__ 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]) @@ -28,13 +28,7 @@ def _is_newer(latest: str, current: str) -> bool: 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. - """ + """Supervisor-controlled updater with persistent, user-visible progress.""" def __init__(self, config, client, logger, install_dir: Path, db=None): self.config = config @@ -46,6 +40,7 @@ class AgentUpdater: self.busy = False self.lock = threading.RLock() self.latest_manifest: dict = {} + self.progress_file = self.install_dir / "data" / "update_progress.json" def _record(self, event_type: str, to_version: str = "", status: str = "", **kwargs) -> None: if self.db is not None: @@ -54,12 +49,50 @@ class AgentUpdater: except Exception: self.logger.exception("Could not record updater history") + def _set_progress( + self, + operation: str, + status: str, + phase: str, + percent: int, + message: str, + *, + target_version: str = "", + ) -> None: + payload = { + "operation": operation, + "status": status, + "phase": phase, + "percent": max(0, min(100, int(percent))), + "message": message, + "target_version": target_version, + "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "worker_pid": os.getpid(), + } + try: + self.progress_file.parent.mkdir(parents=True, exist_ok=True) + tmp = self.progress_file.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(self.progress_file) + except Exception: + self.logger.exception("Could not persist updater progress") + + def _progress(self) -> dict: + try: + if self.progress_file.exists(): + value = json.loads(self.progress_file.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except Exception: + pass + return {} + def check_for_update(self, force: bool = False) -> dict: with self.lock: now = time.time() if not force and now - self.last_check < self.config.update_check_interval_seconds and self.latest_manifest: return self.status() self.last_check = now + self._set_progress("check", "checking", "checking", 10, "Checking ERP for Local Agent updates.") try: manifest = self.client.update_manifest() latest = str(manifest.get("latest_version") or "").strip() @@ -71,9 +104,14 @@ class AgentUpdater: self.db.set_meta("update_available", "1" if available else "0") self.db.set_meta("last_update_check_utc", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())) self._record("check", latest, "available" if available else "current", sha256=expected) + if available: + self._set_progress("check", "available", "complete", 100, f"ERP Local Agent {latest} is available.", target_version=latest) + else: + self._set_progress("check", "current", "complete", 100, "ERP Local Agent is up to date.", target_version=latest or __version__) return self.status() except Exception as exc: self._record("check", "", "failed", error_message=str(exc)) + self._set_progress("check", "error", "error", 100, f"Update check failed: {exc}") self.logger.exception("ERP Local Agent update check failed: %s", exc) raise @@ -87,14 +125,10 @@ class AgentUpdater: 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() @@ -105,50 +139,85 @@ class AgentUpdater: self.logger.exception("ERP Local Agent automatic update failed: %s", exc) return False + def _download_body(self) -> dict: + if not self.latest_manifest: + 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 not _is_newer(latest, __version__): + self._set_progress("download", "current", "complete", 100, "No newer update is available.", target_version=latest or __version__) + return self.status() + + self._set_progress("download", "downloading", "downloading", 15, f"Downloading ERP Local Agent {latest}.", target_version=latest) + package = self.client.download_update_package() + self._set_progress("download", "downloading", "verifying", 65, "Download received. Verifying package integrity.", target_version=latest) + actual = hashlib.sha256(package).hexdigest().lower() + if len(expected) != 64 or actual != expected: + raise RuntimeError("ERP Local Agent update package SHA256 verification failed.") + + updates = self.install_dir / "updates" + updates.mkdir(parents=True, exist_ok=True) + safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update" + package_path = updates / f"ERP_Local_Agent_{safe}.zip" + package_path.write_bytes(package) + self._set_progress("download", "downloading", "staging", 82, "Package verified. Preparing update files.", target_version=latest) + staged = updates / f"staged_{safe}" + shutil.rmtree(staged, ignore_errors=True) + staged.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(package_path, "r") as archive: + 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) + self.db.set_meta("staged_update_path", str(staged)) + self._set_progress("download", "downloaded", "complete", 100, f"ERP Local Agent {latest} downloaded and verified.", target_version=latest) + return self.status() + def download_latest(self) -> dict: with self.lock: if self.busy: raise RuntimeError("Another update operation is already in progress.") self.busy = True - try: - if not self.latest_manifest: - 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 not _is_newer(latest, __version__): - return self.status() - package = self.client.download_update_package() - actual = hashlib.sha256(package).hexdigest().lower() - if len(expected) != 64 or actual != expected: - raise RuntimeError("ERP Local Agent update package SHA256 verification failed.") - updates = self.install_dir / "updates" - updates.mkdir(parents=True, exist_ok=True) - safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update" - package_path = updates / f"ERP_Local_Agent_{safe}.zip" - package_path.write_bytes(package) - staged = updates / f"staged_{safe}" - shutil.rmtree(staged, ignore_errors=True) - staged.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(package_path, "r") as archive: - 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) - self.db.set_meta("staged_update_path", str(staged)) - return self.status() - except Exception as exc: - latest = str(self.latest_manifest.get("latest_version") or "") - self._record("download", latest, "failed", error_message=str(exc)) - raise - finally: + try: + return self._download_body() + except Exception as exc: + latest = str(self.latest_manifest.get("latest_version") or "") + self._record("download", latest, "failed", error_message=str(exc)) + self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest) + raise + finally: + with self.lock: self.busy = False + def start_download(self) -> dict: + """Start download in the background so the dashboard can display live progress.""" + with self.lock: + if self.busy: + raise RuntimeError("Another update operation is already in progress.") + self.busy = True + latest = str(self.latest_manifest.get("latest_version") or "").strip() + self._set_progress("download", "downloading", "starting", 5, "Starting update download.", target_version=latest) + + def runner(): + try: + self._download_body() + except Exception as exc: + latest_inner = str(self.latest_manifest.get("latest_version") or "") + self._record("download", latest_inner, "failed", error_message=str(exc)) + self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest_inner) + self.logger.exception("ERP Local Agent update download failed: %s", exc) + finally: + with self.lock: + self.busy = False + + threading.Thread(target=runner, name="erp-local-agent-update-download", daemon=True).start() + return self.status() + def install_downloaded(self) -> dict: - """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 "") @@ -159,17 +228,18 @@ class AgentUpdater: 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}" if not (staged / "erp_local_agent" / "__init__.py").exists(): raise RuntimeError("The downloaded update is not staged correctly. Download it again.") - supervisor_lock = self.install_dir / "data" / "supervisor.lock" - if not supervisor_lock.exists(): + # The owner file is diagnostic, while the supervisor's Global mutex is authoritative. + # We require a recently written owner file only to avoid handing an update to no supervisor. + owner_file = self.install_dir / "data" / "supervisor_owner.json" + if not owner_file.exists(): raise RuntimeError( - "ERP Local Agent Supervisor is not running. Start the Local Agent from the " - "desktop shortcut or restart the 'ERP Local Agent' Scheduled Task." + "ERP Local Agent Supervisor is not running. Start the Local Agent from the desktop shortcut " + "or restart the 'ERP Local Agent' Scheduled Task." ) request_path = self.install_dir / "updates" / "supervisor_request.json" @@ -182,21 +252,14 @@ class AgentUpdater: "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") tmp.replace(request_path) - + self._set_progress("install", "queued", "queued", 5, f"ERP Local Agent {latest} update queued for safe installation.", target_version=latest) self._record("install_requested", latest, "pending", package_path=str(staged)) - self.logger.warning( - "ERP Local Agent update %s handed to persistent supervisor for controlled restart.", - latest, - ) + self.logger.warning("ERP Local Agent update %s handed to persistent supervisor for controlled restart.", latest) return { "ok": True, - "message": ( - f"ERP Local Agent {latest} installation has been handed to the desktop supervisor. " - "The worker will restart automatically and the desktop dashboard will reconnect." - ), + "message": f"ERP Local Agent {latest} installation is queued. Keep this dashboard open to watch progress.", "target_version": latest, } @@ -215,11 +278,9 @@ class AgentUpdater: state_path = self.install_dir / "data" / "supervisor_state.json" try: if state_path.exists(): - import json supervisor_state = json.loads(state_path.read_text(encoding="utf-8")) or {} except Exception: supervisor_state = {} - return { "current_version": __version__, "latest_version": latest, @@ -231,4 +292,5 @@ class AgentUpdater: "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, + "progress": self._progress(), } diff --git a/app/modules/documents/local_agent_runtime/install_task_scheduler.bat b/app/modules/documents/local_agent_runtime/install_task_scheduler.bat index 0bf2beb..af9cd2b 100644 --- a/app/modules/documents/local_agent_runtime/install_task_scheduler.bat +++ b/app/modules/documents/local_agent_runtime/install_task_scheduler.bat @@ -1,4 +1,4 @@ -@echo off +@echo off setlocal cd /d "%~dp0" if not exist ".env" ( echo ERROR: .env file is missing. Download the configured ERP Local Agent package from ERP. & pause & exit /b 1 ) @@ -13,7 +13,7 @@ set "AGENT_DIR=%~dp0" set "AGENT_PYTHONW=%~dp0.venv\Scripts\pythonw.exe" set "SUPERVISOR=%~dp0ERPAgentSupervisor.pyw" -powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$env:AGENT_DIR.TrimEnd('\')+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHONW; $s=$env:SUPERVISOR; $a=New-ScheduledTaskAction -Execute $p -Argument ('\"'+$s+'\" --background') -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'" +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHONW; $s=$env:SUPERVISOR; $arg='"' + $s + '" --background'; $a=New-ScheduledTaskAction -Execute $p -Argument $arg -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'" if errorlevel 1 goto :failed echo ERP Local Agent Supervisor installed and started successfully.