From de8b32b06df438b31e3b7bf291f1bc0518f85058 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 20 Aug 2026 20:01:59 +0530 Subject: [PATCH] Add persistent desktop supervisor for ERP Local Agent --- app/modules/documents/agent_package.py | 2 +- .../local_agent_runtime/ERPAgentDashboard.pyw | 159 +++++++ .../ERPAgentSupervisor.pyw | 418 ++++++++++++++++++ .../Open ERP Local Agent Dashboard.vbs | 26 ++ .../README_ERP_LOCAL_AGENT.txt | 16 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/dashboard.py | 6 +- .../erp_local_agent/updater.py | 107 ++--- .../install_task_scheduler.bat | 16 +- .../local_agent_runtime/open_dashboard.bat | 5 +- .../local_agent_runtime/requirements.txt | 1 + .../local_agent_runtime/run_agent.bat | 9 +- .../stop_task_scheduler.bat | 6 +- .../uninstall_task_scheduler.bat | 6 +- 14 files changed, 707 insertions(+), 72 deletions(-) create mode 100644 app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw create mode 100644 app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw create mode 100644 app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 46a1a38..8120df3 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.8.2" +ERP_LOCAL_AGENT_VERSION = "1.9.0" 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) diff --git a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw new file mode 100644 index 0000000..3b79c8f --- /dev/null +++ b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import threading +import time +import urllib.request + +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" + + +def _create_no_window() -> int: + return getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def _popup(title: str, message: str, error: bool = False) -> None: + root = Tk() + root.withdraw() + if error: + messagebox.showerror(title, message) + else: + messagebox.showinfo(title, message) + root.destroy() + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + 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(): + return pyw + py = INSTALL_ROOT / ".venv" / "Scripts" / "python.exe" + if py.exists(): + return py + raise RuntimeError("ERP Local Agent Python environment was not found.") + + +def _ensure_supervisor() -> None: + if _supervisor_running(): + return + subprocess.Popen( + [str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"], + cwd=str(INSTALL_ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=_create_no_window(), + 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: + deadline = time.time() + seconds + while time.time() < deadline: + if _ready(): + return True + time.sleep(0.75) + return False + + +def main() -> None: + try: + _ensure_supervisor() + except Exception as exc: + _popup("ERP Local Agent", str(exc), error=True) + return + + 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'}", + error=True, + ) + return + + try: + import webview + except Exception as exc: + _popup( + "ERP Local Agent Dashboard", + "Desktop dashboard dependency could not be loaded.\n\n" + f"{exc}\n\nRun the Local Agent updater/installer again.", + error=True, + ) + return + + window = webview.create_window( + "ERP Local Agent Dashboard", + URL, + width=1180, + height=820, + min_size=(980, 650), + confirm_close=False, + ) + + def monitor(): + was_ready = True + outage_seen = False + while True: + try: + current = _ready() + if was_ready and not current: + outage_seen = True + if outage_seen and current: + try: + window.load_url(URL) + except Exception: + pass + outage_seen = False + was_ready = current + time.sleep(1.5) + except Exception: + time.sleep(2) + + webview.start(monitor) + + +if __name__ == "__main__": + main() diff --git a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw new file mode 100644 index 0000000..6800539 --- /dev/null +++ b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw @@ -0,0 +1,418 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time +import urllib.request + + +INSTALL_ROOT = Path(__file__).resolve().parent +DATA_DIR = INSTALL_ROOT / "data" +LOG_DIR = INSTALL_ROOT / "logs" +UPDATES_DIR = INSTALL_ROOT / "updates" +LOCK_FILE = DATA_DIR / "supervisor.lock" +STATE_FILE = DATA_DIR / "supervisor_state.json" +REQUEST_FILE = UPDATES_DIR / "supervisor_request.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 + + +def _create_no_window() -> int: + return getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def _log(message: str) -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n" + try: + with SUPERVISOR_LOG.open("a", encoding="utf-8") as handle: + handle.write(line) + except Exception: + 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") + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(path) + + +def _state(status: str, message: str, **extra) -> None: + payload = { + "status": status, + "message": message, + "supervisor_pid": os.getpid(), + "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + payload.update(extra) + _write_json_atomic(STATE_FILE, payload) + _log(f"{status}: {message}") + + +def _dashboard_ready(timeout: float = 1.5) -> bool: + try: + with urllib.request.urlopen(DASHBOARD_URL + "/api/status", timeout=timeout) as response: + return int(getattr(response, "status", 200)) < 500 + except Exception: + return False + + +def _python(console: bool = True) -> Path: + name = "python.exe" if console else "pythonw.exe" + candidate = INSTALL_ROOT / ".venv" / "Scripts" / name + if candidate.exists(): + return candidate + fallback = INSTALL_ROOT / ".venv" / "Scripts" / "python.exe" + if fallback.exists(): + return fallback + raise RuntimeError(f"ERP Local Agent Python environment not found under {INSTALL_ROOT / '.venv'}") + + +def _terminate_process_tree(process: subprocess.Popen | None) -> None: + if process is None: + return + if process.poll() is not None: + return + try: + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=_create_no_window(), + check=False, + ) + except Exception: + try: + process.terminate() + except Exception: + pass + try: + process.wait(timeout=15) + 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, + ) + + +class Supervisor: + def __init__(self): + self.worker: subprocess.Popen | None = None + self.running = True + + def acquire_lock(self) -> bool: + 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") + return True + + def release_lock(self) -> None: + try: + if LOCK_FILE.exists() and LOCK_FILE.read_text(encoding="utf-8").strip() == str(os.getpid()): + LOCK_FILE.unlink() + except Exception: + pass + + 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 = subprocess.Popen( + [str(py), "-m", WORKER_MODULE], + cwd=str(INSTALL_ROOT), + stdout=handle, + stderr=handle, + creationflags=_create_no_window(), + ) + _state("running", "ERP Local Agent worker started.", worker_pid=self.worker.pid) + + def stop_worker(self) -> None: + _state("stopping", "Stopping ERP Local Agent worker.") + _terminate_process_tree(self.worker) + self.worker = None + _kill_unmanaged_workers() + + @staticmethod + def _read_request() -> dict | None: + if not REQUEST_FILE.exists(): + return None + try: + payload = json.loads(REQUEST_FILE.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else None + except Exception as exc: + _state("error", f"Invalid supervisor update request: {exc}") + try: + REQUEST_FILE.unlink() + except Exception: + pass + return None + + @staticmethod + def _clear_request() -> None: + try: + REQUEST_FILE.unlink() + except FileNotFoundError: + pass + + @staticmethod + def _validate_staged(staged: Path) -> None: + if not staged.exists() or not staged.is_dir(): + raise RuntimeError(f"Staged update directory not found: {staged}") + if not (staged / "erp_local_agent" / "__init__.py").is_file(): + raise RuntimeError("Staged update does not contain erp_local_agent/__init__.py.") + if not (staged / "requirements.txt").is_file(): + raise RuntimeError("Staged update does not contain requirements.txt.") + + @staticmethod + def _backup_runtime(version: str) -> Path: + backup = UPDATES_DIR / f"supervisor_backup_{time.strftime('%Y%m%d_%H%M%S')}_{version}" + backup.mkdir(parents=True, exist_ok=True) + agent = INSTALL_ROOT / "erp_local_agent" + if agent.exists(): + shutil.copytree(agent, backup / "erp_local_agent") + for name in ( + "requirements.txt", + "README_ERP_LOCAL_AGENT.txt", + "run_agent.bat", + "run_once.bat", + "check_config.py", + "check_config.bat", + "open_dashboard.bat", + "install_task_scheduler.bat", + "start_task_scheduler.bat", + "stop_task_scheduler.bat", + "status_task_scheduler.bat", + "uninstall_task_scheduler.bat", + ): + src = INSTALL_ROOT / name + if src.exists(): + shutil.copy2(src, backup / name) + return backup + + @staticmethod + def _replace_worker_runtime(staged: Path) -> None: + 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: + new_target.rename(target) + except Exception: + if old_target.exists() and not target.exists(): + old_target.rename(target) + 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", + "run_agent.bat", + "run_once.bat", + "check_config.py", + "check_config.bat", + "open_dashboard.bat", + "install_task_scheduler.bat", + "start_task_scheduler.bat", + "stop_task_scheduler.bat", + "status_task_scheduler.bat", + "uninstall_task_scheduler.bat", + ): + src = staged / name + if src.exists(): + shutil.copy2(src, INSTALL_ROOT / name) + + @staticmethod + def _install_requirements_and_verify() -> None: + py = _python(console=True) + req = INSTALL_ROOT / "requirements.txt" + result = subprocess.run( + [str(py), "-m", "pip", "install", "-r", str(req)], + cwd=str(INSTALL_ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + creationflags=_create_no_window(), + timeout=300, + ) + 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;" + "print(erp_local_agent.__version__)" + ) + result = subprocess.run( + [str(py), "-c", code], + cwd=str(INSTALL_ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + creationflags=_create_no_window(), + timeout=60, + ) + if result.returncode != 0: + raise RuntimeError("Updated worker import verification failed:\n" + result.stdout[-4000:]) + + @staticmethod + def _restore_backup(backup: Path) -> None: + target = INSTALL_ROOT / "erp_local_agent" + backup_agent = backup / "erp_local_agent" + if backup_agent.exists(): + shutil.rmtree(target, ignore_errors=True) + shutil.copytree(backup_agent, target) + for item in backup.iterdir(): + if item.name == "erp_local_agent": + continue + if item.is_file(): + shutil.copy2(item, INSTALL_ROOT / item.name) + + def apply_update(self, request: dict) -> None: + version = str(request.get("version") or "").strip() + 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) + try: + self._replace_worker_runtime(staged) + self._install_requirements_and_verify() + self.start_worker() + + deadline = time.time() + HEALTH_TIMEOUT_SECONDS + while time.time() < deadline: + 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(): + _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), + ) + return + time.sleep(1) + + raise RuntimeError("Updated worker did not become healthy within the restart timeout.") + except Exception: + _state("rollback", f"Update {version} failed. Restoring previous worker.", target_version=version) + self.stop_worker() + self._restore_backup(backup) + self.start_worker() + raise + + def run(self) -> int: + if not self.acquire_lock(): + return 0 + + try: + _state("starting", "ERP Local Agent supervisor starting.") + self.start_worker() + + while self.running: + request = self._read_request() + if request and str(request.get("action") or "") == "install_update": + self._clear_request() + try: + 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 + finally: + self.stop_worker() + 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.parse_args() + + supervisor = Supervisor() + + def stop_handler(*_args): + supervisor.running = False + + try: + signal.signal(signal.SIGTERM, stop_handler) + signal.signal(signal.SIGINT, stop_handler) + except Exception: + pass + + return supervisor.run() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs b/app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs new file mode 100644 index 0000000..743ddde --- /dev/null +++ b/app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs @@ -0,0 +1,26 @@ +Option Explicit + +Dim fso, shell, baseDir, launcher, pyw, py, cmd +Set fso = CreateObject("Scripting.FileSystemObject") +Set shell = CreateObject("WScript.Shell") + +baseDir = fso.GetParentFolderName(WScript.ScriptFullName) +launcher = baseDir & "\ERPAgentDashboard.pyw" +pyw = baseDir & "\.venv\Scripts\pythonw.exe" +py = baseDir & "\.venv\Scripts\python.exe" + +If Not fso.FileExists(launcher) Then + MsgBox "ERPAgentDashboard.pyw was not found in " & baseDir, 16, "ERP Local Agent" + WScript.Quit +End If + +If fso.FileExists(pyw) Then + cmd = """" & pyw & """ """ & launcher & """" +ElseIf fso.FileExists(py) Then + cmd = """" & py & """ """ & launcher & """" +Else + MsgBox "ERP Local Agent Python environment was not found.", 16, "ERP Local Agent" + WScript.Quit +End If + +shell.Run cmd, 0, False diff --git a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt index aa57549..795dc55 100644 --- a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt +++ b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt @@ -1,4 +1,4 @@ -ERP Local Agent 1.8.2 +ERP Local Agent 1.9.0 Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved. @@ -52,3 +52,17 @@ Accounting engagement-folder policy 1.8.2: - Depreciation/approval/write-back use the selected depreciation financial year. - Existing .act databases from original flat, v1.8.0 Local-Agent-data, and v1.8.1 Accounting\Clients locations are migrated safely by SQLite backup and retained as recovery copies. - The Accounting folder is the stable base for later Reports, Exports, Workpapers and Backups; this release does not change report-generation behavior. + + +Desktop Supervisor 1.9.0 +------------------------ +- Scheduled Task now starts ERPAgentSupervisor.pyw instead of starting erp_local_agent.main directly. +- Supervisor owns the worker lifecycle and automatically restarts the worker if it exits. +- Dashboard update installation no longer makes the worker launch a detached PowerShell installer and os._exit(). +- Downloaded/staged updates are handed to the supervisor through updates\supervisor_request.json. +- Supervisor stops the worker, backs it up, atomically replaces the worker package, installs requirements, verifies imports, restarts it, waits for dashboard health, and rolls back if startup fails. +- Stable supervisor/dashboard launchers are deliberately not self-replaced during normal worker updates. +- Desktop shortcut opens "Open ERP Local Agent Dashboard.vbs", which uses pythonw.exe + ERPAgentDashboard.pyw + pywebview. +- Closing the desktop dashboard does NOT stop the supervisor or worker. +- The dashboard automatically reconnects/reloads when the worker goes down and returns during an update. +- Existing browser access at http://127.0.0.1:8788 remains available. 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 2bf6808..d93f970 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.8.2" +__version__ = "1.9.0" 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 9bf2ec7..bef88e0 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 @@ -144,8 +144,8 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg
TallyPrime
—
Storage Free
—
-

Updates

The agent checks for new versions automatically, but it will never install an update without a local user clicking Install.

-
+

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.

+

Tally

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

Recent History

Loading…
@@ -161,7 +161,7 @@ document.getElementById('download').disabled=!s.update.update_available;document async function refreshHistory(){const r=await fetch('/api/history',{cache:'no-store'});const h=await r.json();let rows=h.events.slice(0,12).map(x=>''+x.occurred_at_utc+''+x.level+''+x.event_type+''+escapeHtml(x.message)+'').join('');document.getElementById('history').innerHTML=''+rows+'
Time UTCLevelEventMessage
'} function escapeHtml(v){return String(v||'').replace(/[&<>'"]/g,m=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[m]))} async function action(url){msg('Working…',true);try{const r=await fetch(url,{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Operation failed');msg('Completed successfully.',true);await refresh()}catch(e){msg(e.message,false)}} -async function installUpdate(){if(!confirm('Install the downloaded update now? The ERP Local Agent will restart.'))return;msg('Starting update installation…',true);try{const r=await fetch('/api/update/install',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Install failed');msg(j.message||'Update started. Refresh this page after the agent restarts.',true)}catch(e){msg(e.message,false)}} +async function installUpdate(){if(!confirm('Install the downloaded update now? The desktop supervisor will restart only the Local Agent worker and will keep supervising the restart.'))return;msg('Starting update installation…',true);try{const r=await fetch('/api/update/install',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Install failed');msg(j.message||'Update handed to supervisor. The desktop dashboard will reconnect automatically after restart.',true)}catch(e){msg(e.message,false)}} function msg(t,ok){let e=document.getElementById('message');e.textContent=t;e.className=ok?'msgok':'msgbad'} refresh();setInterval(refresh,5000); ''' 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 9ef2728..68081e9 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 @@ -109,28 +109,59 @@ class AgentUpdater: finally: self.busy = False - def install_downloaded(self) -> None: + 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. + """ with self.lock: - latest = str((self.db.get_meta("staged_update_version") if self.db else "") or self.latest_manifest.get("latest_version") or "").strip() + latest = str( + (self.db.get_meta("staged_update_version") if self.db else "") + or self.latest_manifest.get("latest_version") + or "" + ).strip() if not latest: raise RuntimeError("No downloaded ERP Local Agent update is available to install.") + 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.") - script_path = self.install_dir / "updates" / f"apply_{safe}.ps1" - script_path.write_text(self._powershell_update_script(staged, latest), encoding="utf-8") + + supervisor_lock = self.install_dir / "data" / "supervisor.lock" + if not supervisor_lock.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." + ) + + request_path = self.install_dir / "updates" / "supervisor_request.json" + tmp = request_path.with_suffix(".json.tmp") + payload = { + "action": "install_update", + "version": latest, + "staged_dir": str(staged.resolve()), + "requested_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "requested_by_pid": os.getpid(), + } + import json + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(request_path) + self._record("install_requested", latest, "pending", package_path=str(staged)) - flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) - process = subprocess.Popen( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path), "-ParentPid", str(os.getpid())], - cwd=str(self.install_dir), creationflags=flags, close_fds=True, + self.logger.warning( + "ERP Local Agent update %s handed to persistent supervisor for controlled restart.", + latest, ) - if process.pid <= 0: - raise RuntimeError("Could not launch the ERP Local Agent update installer.") - self.logger.warning("ERP Local Agent update %s installation requested by local user; restarting.", latest) - time.sleep(0.3) - os._exit(0) + 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." + ), + "target_version": latest, + } 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() @@ -139,6 +170,15 @@ class AgentUpdater: if staged_version: safe = "".join(ch for ch in staged_version if ch.isalnum() or ch in ".-_") or "update" staged_ok = (self.install_dir / "updates" / f"staged_{safe}" / "erp_local_agent" / "__init__.py").exists() + supervisor_state = {} + 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, @@ -148,44 +188,5 @@ class AgentUpdater: "busy": bool(self.busy), "automatic_check_enabled": bool(self.config.auto_update), "automatic_install_enabled": False, + "supervisor": supervisor_state, } - - def _powershell_update_script(self, staged: Path, latest: str) -> str: - install = str(self.install_dir).replace("'", "''") - stage = str(staged.resolve()).replace("'", "''") - latest_safe = str(latest).replace("'", "''") - lines = [ - "param([int]$ParentPid)", - '$ErrorActionPreference = "Stop"', - "$InstallDir = '" + install + "'", - "$StagedDir = '" + stage + "'", - "$TargetVersion = '" + latest_safe + "'", - "$TaskName = 'ERP Local Agent'", - "$BackupDir = Join-Path $InstallDir ('updates\\backup_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))", - "try {", - " if ($ParentPid -gt 0) { try { Wait-Process -Id $ParentPid -Timeout 120 -ErrorAction SilentlyContinue } catch {} }", - " try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}", - " Start-Sleep -Seconds 2", - " Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'erp_local_agent\\.main' -and $_.CommandLine -like ('*' + $InstallDir + '*') } | ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} }", - " Start-Sleep -Seconds 2", - " New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null", - " if (Test-Path (Join-Path $InstallDir 'erp_local_agent')) { Copy-Item (Join-Path $InstallDir 'erp_local_agent') -Destination $BackupDir -Recurse -Force }", - " Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction Stop", - " Copy-Item (Join-Path $StagedDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force", - " foreach ($Item in @('requirements.txt','run_agent.bat','run_once.bat','check_config.py','check_config.bat','open_dashboard.bat','start_task_scheduler.bat','stop_task_scheduler.bat','status_task_scheduler.bat','install_task_scheduler.bat','uninstall_task_scheduler.bat')) { $Source=Join-Path $StagedDir $Item; if (Test-Path $Source) { Copy-Item $Source -Destination (Join-Path $InstallDir $Item) -Force } }", - " $Python = Join-Path $InstallDir '.venv\\Scripts\\python.exe'", - " if (-not (Test-Path $Python)) { throw 'ERP Local Agent virtual environment is missing.' }", - " & $Python -m pip install -r (Join-Path $InstallDir 'requirements.txt')", - " if ($LASTEXITCODE -ne 0) { throw 'Dependency update failed.' }", - " Push-Location $InstallDir", - " try { $InstalledVersion = & $Python -c \"import erp_local_agent; print(getattr(erp_local_agent,'__version__',''))\" } finally { Pop-Location }", - " if (($InstalledVersion | Out-String).Trim() -ne $TargetVersion) { throw ('Installed version verification failed. Expected ' + $TargetVersion + ', got ' + (($InstalledVersion | Out-String).Trim())) }", - " Start-ScheduledTask -TaskName $TaskName", - "} catch {", - " try { if (Test-Path (Join-Path $BackupDir 'erp_local_agent')) { Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item (Join-Path $BackupDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force }; Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}", - " New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') | Out-Null", - " Add-Content -Path (Join-Path $InstallDir 'logs\\update_error.log') -Value ((Get-Date).ToString('s') + ' ' + $_.Exception.Message)", - " exit 1", - "}", - ] - return "\r\n".join(lines) + "\r\n" 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 dd589e6..67e3aff 100644 --- a/app/modules/documents/local_agent_runtime/install_task_scheduler.bat +++ b/app/modules/documents/local_agent_runtime/install_task_scheduler.bat @@ -1,20 +1,26 @@ -@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 ) if not exist ".venv\Scripts\python.exe" ( py -3 -m venv .venv & if errorlevel 1 ( echo ERROR: Unable to create Python virtual environment. & pause & exit /b 1 ) ) + ".venv\Scripts\python.exe" -m pip install --upgrade pip if errorlevel 1 goto :failed ".venv\Scripts\python.exe" -m pip install -r requirements.txt if errorlevel 1 goto :failed + set "AGENT_DIR=%~dp0" -set "AGENT_PYTHON=%~dp0.venv\Scripts\python.exe" -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 {}; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHON; $a=New-ScheduledTaskAction -Execute $p -Argument '-m erp_local_agent.main' -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $s -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'" +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; 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 installed and started successfully. -echo Future compatible releases will update automatically from ERP. + +echo ERP Local Agent Supervisor installed and started successfully. +echo Use the desktop shortcut "ERP Local Agent Dashboard" to open the local dashboard. pause exit /b 0 + :failed echo ERROR: ERP Local Agent installation failed. Run this file as Administrator. pause diff --git a/app/modules/documents/local_agent_runtime/open_dashboard.bat b/app/modules/documents/local_agent_runtime/open_dashboard.bat index ff05dfd..72fecf7 100644 --- a/app/modules/documents/local_agent_runtime/open_dashboard.bat +++ b/app/modules/documents/local_agent_runtime/open_dashboard.bat @@ -1,2 +1,3 @@ -@echo off -start "" "http://127.0.0.1:8788" +@echo off +cd /d "%~dp0" +wscript.exe "%~dp0Open ERP Local Agent Dashboard.vbs" diff --git a/app/modules/documents/local_agent_runtime/requirements.txt b/app/modules/documents/local_agent_runtime/requirements.txt index 2ce79e4..e1d6097 100644 --- a/app/modules/documents/local_agent_runtime/requirements.txt +++ b/app/modules/documents/local_agent_runtime/requirements.txt @@ -1,3 +1,4 @@ requests==2.32.3 python-dotenv==1.0.1 websockets==12.0 +pywebview==5.3.2 diff --git a/app/modules/documents/local_agent_runtime/run_agent.bat b/app/modules/documents/local_agent_runtime/run_agent.bat index a60871d..10d022d 100644 --- a/app/modules/documents/local_agent_runtime/run_agent.bat +++ b/app/modules/documents/local_agent_runtime/run_agent.bat @@ -1,3 +1,8 @@ -@echo off +@echo off cd /d "%~dp0" -".venv\Scripts\python.exe" -m erp_local_agent.main +if exist ".venv\Scripts\pythonw.exe" ( + ".venv\Scripts\pythonw.exe" "ERPAgentSupervisor.pyw" --background +) else ( + echo ERROR: Local Agent virtual environment not found. + pause +) diff --git a/app/modules/documents/local_agent_runtime/stop_task_scheduler.bat b/app/modules/documents/local_agent_runtime/stop_task_scheduler.bat index 8d132aa..8fb31ec 100644 --- a/app/modules/documents/local_agent_runtime/stop_task_scheduler.bat +++ b/app/modules/documents/local_agent_runtime/stop_task_scheduler.bat @@ -1,2 +1,4 @@ -@echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Stop-ScheduledTask -TaskName 'ERP Local Agent'" +@echo off +setlocal +set "AGENT_DIR=%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue; $root=$env:AGENT_DIR.TrimEnd('\'); Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$root+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }" diff --git a/app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat b/app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat index 7aa43b8..0ece2ed 100644 --- a/app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat +++ b/app/modules/documents/local_agent_runtime/uninstall_task_scheduler.bat @@ -1,3 +1,5 @@ -@echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue" +@echo off +setlocal +set "AGENT_DIR=%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$root=$env:AGENT_DIR.TrimEnd('\'); 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 ('*'+$root+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }" pause