Repair Local Agent supervisor and add update progress UI

This commit is contained in:
A R R R Associates
2026-09-04 13:33:34 +05:30
parent 4deef57364
commit 7bc97be1b1
8 changed files with 282 additions and 343 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.11" ERP_LOCAL_AGENT_VERSION = "1.22.12"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _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" f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n"
"Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\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" "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")) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8"))
return buffer.getvalue() return buffer.getvalue()
@@ -1,14 +1,12 @@
from __future__ import annotations from __future__ import annotations
import atexit
import ctypes import ctypes
from ctypes import wintypes from ctypes import wintypes
import hashlib import hashlib
import os import os
import atexit
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys
import threading
import time import time
import urllib.request import urllib.request
@@ -16,8 +14,6 @@ from tkinter import Tk, messagebox
INSTALL_ROOT = Path(__file__).resolve().parent INSTALL_ROOT = Path(__file__).resolve().parent
DATA_DIR = INSTALL_ROOT / "data"
SUPERVISOR_LOCK = DATA_DIR / "supervisor.lock"
SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw" SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw"
URL = "http://127.0.0.1:8788" URL = "http://127.0.0.1:8788"
ERROR_ALREADY_EXISTS = 183 ERROR_ALREADY_EXISTS = 183
@@ -69,7 +65,7 @@ def _focus_existing_dashboard() -> None:
buf = ctypes.create_unicode_buffer(length + 1) buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1) user32.GetWindowTextW(hwnd, buf, length + 1)
if "ERP Local Agent Dashboard" in buf.value: if "ERP Local Agent Dashboard" in buf.value:
user32.ShowWindow(hwnd, 9) # SW_RESTORE user32.ShowWindow(hwnd, 9)
user32.SetForegroundWindow(hwnd) user32.SetForegroundWindow(hwnd)
return False return False
return True return True
@@ -93,42 +89,6 @@ def _popup(title: str, message: str, error: bool = False) -> None:
root.destroy() 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: def _pythonw() -> Path:
pyw = INSTALL_ROOT / ".venv" / "Scripts" / "pythonw.exe" pyw = INSTALL_ROOT / ".venv" / "Scripts" / "pythonw.exe"
if pyw.exists(): if pyw.exists():
@@ -139,18 +99,30 @@ def _pythonw() -> Path:
raise RuntimeError("ERP Local Agent Python environment was not found.") 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: def _ensure_supervisor() -> None:
if _supervisor_running(): # If the dashboard is already healthy, never start another supervisor or worker.
if _ready():
return 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 started_by_task = False
if os.name == "nt": if os.name == "nt":
result = subprocess.run( 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), cwd=str(INSTALL_ROOT),
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -160,8 +132,7 @@ def _ensure_supervisor() -> None:
started_by_task = result.returncode == 0 started_by_task = result.returncode == 0
if not started_by_task: if not started_by_task:
# Recovery fallback for a workstation where the scheduled task was not # Recovery fallback. The Supervisor Global mutex makes repeated launches harmless.
# installed yet. The Global mutex still prevents a second supervisor.
subprocess.Popen( subprocess.Popen(
[str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"], [str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"],
cwd=str(INSTALL_ROOT), cwd=str(INSTALL_ROOT),
@@ -171,23 +142,8 @@ def _ensure_supervisor() -> None:
close_fds=True, 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 _wait_ready(seconds: int = 90) -> bool:
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 deadline = time.time() + seconds
while time.time() < deadline: while time.time() < deadline:
if _ready(): if _ready():
@@ -202,6 +158,7 @@ def main() -> None:
_focus_existing_dashboard() _focus_existing_dashboard()
return return
atexit.register(_close_dashboard_mutex, dashboard_mutex) atexit.register(_close_dashboard_mutex, dashboard_mutex)
try: try:
_ensure_supervisor() _ensure_supervisor()
except Exception as exc: except Exception as exc:
@@ -211,8 +168,9 @@ def main() -> None:
if not _wait_ready(): if not _wait_ready():
_popup( _popup(
"ERP Local Agent", "ERP Local Agent",
"The Local Agent supervisor is running, but the dashboard did not become ready.\n\n" "The Local Agent did not become ready.\n\n"
f"Check logs under:\n{INSTALL_ROOT / 'logs'}", "Check the 'ERP Local Agent' Scheduled Task and the logs under:\n"
f"{INSTALL_ROOT / 'logs'}",
error=True, error=True,
) )
return return
@@ -252,9 +210,9 @@ def main() -> None:
pass pass
outage_seen = False outage_seen = False
was_ready = current was_ready = current
time.sleep(1.5) time.sleep(1.0)
except Exception: except Exception:
time.sleep(2) time.sleep(1.5)
webview.start(monitor) webview.start(monitor)
@@ -10,7 +10,6 @@ from pathlib import Path
import shutil import shutil
import signal import signal
import subprocess import subprocess
import sys
import time import time
import urllib.request import urllib.request
@@ -23,14 +22,14 @@ LOCK_FILE = DATA_DIR / "supervisor.lock"
STATE_FILE = DATA_DIR / "supervisor_state.json" STATE_FILE = DATA_DIR / "supervisor_state.json"
OWNER_FILE = DATA_DIR / "supervisor_owner.json" OWNER_FILE = DATA_DIR / "supervisor_owner.json"
REQUEST_FILE = UPDATES_DIR / "supervisor_request.json" REQUEST_FILE = UPDATES_DIR / "supervisor_request.json"
UPDATE_PROGRESS_FILE = DATA_DIR / "update_progress.json"
WORKER_LOG = LOG_DIR / "worker-supervisor.log" WORKER_LOG = LOG_DIR / "worker-supervisor.log"
SUPERVISOR_LOG = LOG_DIR / "supervisor.log" SUPERVISOR_LOG = LOG_DIR / "supervisor.log"
DASHBOARD_URL = "http://127.0.0.1:8788" DASHBOARD_URL = "http://127.0.0.1:8788"
WORKER_MODULE = "erp_local_agent.main" WORKER_MODULE = "erp_local_agent.main"
RESTART_DELAY_SECONDS = 3 RESTART_DELAY_SECONDS = 3
HEALTH_TIMEOUT_SECONDS = 60 HEALTH_TIMEOUT_SECONDS = 90
ERROR_ALREADY_EXISTS = 183 ERROR_ALREADY_EXISTS = 183
SINGLETON_WATCHDOG_SECONDS = 10
def _supervisor_mutex_name() -> str: def _supervisor_mutex_name() -> str:
@@ -64,28 +63,6 @@ def _close_os_mutex(handle) -> None:
pass 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: def _create_no_window() -> int:
return getattr(subprocess, "CREATE_NO_WINDOW", 0) return getattr(subprocess, "CREATE_NO_WINDOW", 0)
@@ -100,16 +77,6 @@ def _log(message: str) -> None:
pass 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: def _write_json_atomic(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp") tmp = path.with_suffix(path.suffix + ".tmp")
@@ -129,6 +96,20 @@ def _state(status: str, message: str, **extra) -> None:
_log(f"{status}: {message}") _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: def _dashboard_ready(timeout: float = 1.5) -> bool:
try: try:
with urllib.request.urlopen(DASHBOARD_URL + "/api/status", timeout=timeout) as response: 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: def _terminate_process_tree(process: subprocess.Popen | None) -> None:
if process is None: if process is None or process.poll() is not None:
return
if process.poll() is not None:
return return
try: try:
subprocess.run( subprocess.run(
@@ -167,91 +146,41 @@ def _terminate_process_tree(process: subprocess.Popen | None) -> None:
except Exception: except Exception:
pass pass
try: try:
process.wait(timeout=15) process.wait(timeout=20)
except Exception: except Exception:
pass 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: class Supervisor:
def __init__(self): def __init__(self):
self.worker: subprocess.Popen | None = None self.worker: subprocess.Popen | None = None
self.running = True self.running = True
self._os_mutex = None self._os_mutex = None
self._worker_log_handle = None
def acquire_lock(self) -> bool: def acquire_lock(self) -> bool:
# The named mutex is atomic at the Windows kernel level and prevents """Acquire the only authoritative supervisor singleton guard.
# the lock-file race that allowed multiple supervisors before 1.22.3.
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() self._os_mutex = _acquire_os_mutex()
if self._os_mutex is None: if self._os_mutex is None:
_log("startup: another supervisor already owns the Global mutex; exiting")
return False return False
DATA_DIR.mkdir(parents=True, exist_ok=True) 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") LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8")
_write_json_atomic(OWNER_FILE, { _write_json_atomic(
"supervisor_pid": os.getpid(), OWNER_FILE,
"install_root": str(INSTALL_ROOT), {
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "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 return True
def release_lock(self) -> None: def release_lock(self) -> None:
@@ -273,26 +202,32 @@ class Supervisor:
def start_worker(self) -> None: def start_worker(self) -> None:
if self.worker is not None and self.worker.poll() is None: if self.worker is not None and self.worker.poll() is None:
return return
_kill_unmanaged_workers()
LOG_DIR.mkdir(parents=True, exist_ok=True) LOG_DIR.mkdir(parents=True, exist_ok=True)
py = _python(console=True) py = _python(console=True)
handle = WORKER_LOG.open("a", encoding="utf-8") self._worker_log_handle = WORKER_LOG.open("a", encoding="utf-8")
handle.write(f"\n--- worker start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n") self._worker_log_handle.write(f"\n--- worker start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
handle.flush() self._worker_log_handle.flush()
self.worker = subprocess.Popen( self.worker = subprocess.Popen(
[str(py), "-m", WORKER_MODULE], [str(py), "-m", WORKER_MODULE],
cwd=str(INSTALL_ROOT), cwd=str(INSTALL_ROOT),
stdout=handle, stdout=self._worker_log_handle,
stderr=handle, stderr=self._worker_log_handle,
creationflags=_create_no_window(), creationflags=_create_no_window(),
) )
_state("running", "ERP Local Agent worker started.", worker_pid=self.worker.pid) _state("running", "ERP Local Agent worker started.", worker_pid=self.worker.pid)
def stop_worker(self) -> None: def stop_worker(self) -> None:
if self.worker is None:
return
_state("stopping", "Stopping ERP Local Agent worker.") _state("stopping", "Stopping ERP Local Agent worker.")
_terminate_process_tree(self.worker) _terminate_process_tree(self.worker)
self.worker = None 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 @staticmethod
def _read_request() -> dict | None: def _read_request() -> dict | None:
@@ -345,6 +280,8 @@ class Supervisor:
"stop_task_scheduler.bat", "stop_task_scheduler.bat",
"status_task_scheduler.bat", "status_task_scheduler.bat",
"uninstall_task_scheduler.bat", "uninstall_task_scheduler.bat",
"ERPAgentSupervisor.pyw",
"ERPAgentDashboard.pyw",
): ):
src = INSTALL_ROOT / name src = INSTALL_ROOT / name
if src.exists(): if src.exists():
@@ -356,11 +293,9 @@ class Supervisor:
target = INSTALL_ROOT / "erp_local_agent" target = INSTALL_ROOT / "erp_local_agent"
new_target = INSTALL_ROOT / "erp_local_agent.__new__" new_target = INSTALL_ROOT / "erp_local_agent.__new__"
old_target = INSTALL_ROOT / "erp_local_agent.__old__" old_target = INSTALL_ROOT / "erp_local_agent.__old__"
shutil.rmtree(new_target, ignore_errors=True) shutil.rmtree(new_target, ignore_errors=True)
shutil.rmtree(old_target, ignore_errors=True) shutil.rmtree(old_target, ignore_errors=True)
shutil.copytree(staged / "erp_local_agent", new_target) shutil.copytree(staged / "erp_local_agent", new_target)
if target.exists(): if target.exists():
target.rename(old_target) target.rename(old_target)
try: try:
@@ -371,9 +306,6 @@ class Supervisor:
raise raise
shutil.rmtree(old_target, ignore_errors=True) 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 ( for name in (
"requirements.txt", "requirements.txt",
"README_ERP_LOCAL_AGENT.txt", "README_ERP_LOCAL_AGENT.txt",
@@ -387,6 +319,8 @@ class Supervisor:
"stop_task_scheduler.bat", "stop_task_scheduler.bat",
"status_task_scheduler.bat", "status_task_scheduler.bat",
"uninstall_task_scheduler.bat", "uninstall_task_scheduler.bat",
"ERPAgentSupervisor.pyw",
"ERPAgentDashboard.pyw",
): ):
src = staged / name src = staged / name
if src.exists(): if src.exists():
@@ -407,7 +341,6 @@ class Supervisor:
) )
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError("Dependency installation failed:\n" + result.stdout[-4000:]) raise RuntimeError("Dependency installation failed:\n" + result.stdout[-4000:])
code = ( code = (
"import erp_local_agent;" "import erp_local_agent;"
"from erp_local_agent import accounting_store,commands,tally,updater;" "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() staged_text = str(request.get("staged_dir") or "").strip()
if not version or not staged_text: if not version or not staged_text:
raise RuntimeError("Supervisor update request is missing version/staged_dir.") raise RuntimeError("Supervisor update request is missing version/staged_dir.")
staged = Path(staged_text).resolve() staged = Path(staged_text).resolve()
self._validate_staged(staged) self._validate_staged(staged)
_state("installing", f"Installing ERP Local Agent {version}.", target_version=version)
self.stop_worker() _update_progress("preparing", 8, f"Preparing ERP Local Agent {version} update.", version=version)
backup = self._backup_runtime(version) _state("installing", f"Installing ERP Local Agent {version}.", target_version=version, progress_pct=8)
backup = None
try: 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) self._replace_worker_runtime(staged)
_update_progress("dependencies", 66, "Verifying runtime dependencies and imports.", version=version)
self._install_requirements_and_verify() self._install_requirements_and_verify()
_update_progress("starting", 82, "Starting the updated Local Agent.", version=version)
self.start_worker() self.start_worker()
deadline = time.time() + HEALTH_TIMEOUT_SECONDS 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: if self.worker is not None and self.worker.poll() is not None:
raise RuntimeError(f"Updated worker exited with code {self.worker.returncode}.") raise RuntimeError(f"Updated worker exited with code {self.worker.returncode}.")
if _dashboard_ready(): if _dashboard_ready():
_update_progress("complete", 100, f"ERP Local Agent {version} updated successfully.", version=version, status="updated")
_state( _state(
"updated", "updated",
f"ERP Local Agent {version} installed and restarted successfully.", f"ERP Local Agent {version} installed and restarted successfully.",
target_version=version, target_version=version,
worker_pid=(self.worker.pid if self.worker else None), worker_pid=(self.worker.pid if self.worker else None),
backup_path=str(backup), backup_path=str(backup),
progress_pct=100,
) )
return return
_update_progress("health_check", 90, "Waiting for the updated dashboard to become ready.", version=version)
time.sleep(1) time.sleep(1)
raise RuntimeError("Updated worker did not become healthy within the restart timeout.") 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) _state("rollback", f"Update {version} failed. Restoring previous worker.", target_version=version)
self.stop_worker() self.stop_worker()
self._restore_backup(backup) if backup is not None:
self._restore_backup(backup)
self.start_worker() self.start_worker()
_update_progress("error", 100, f"Update failed and the previous version was restored: {exc}", version=version, status="error")
raise raise
def run(self) -> int: def run(self) -> int:
if not self.acquire_lock(): if not self.acquire_lock():
return 0 return 0
try: try:
_kill_duplicate_supervisors()
_state("starting", "ERP Local Agent supervisor starting.") _state("starting", "ERP Local Agent supervisor starting.")
self.start_worker() self.start_worker()
last_singleton_watchdog = 0.0
while self.running: 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() request = self._read_request()
if request and str(request.get("action") or "") == "install_update": if request and str(request.get("action") or "") == "install_update":
self._clear_request() self._clear_request()
@@ -503,37 +440,34 @@ class Supervisor:
self.apply_update(request) self.apply_update(request)
except Exception as exc: except Exception as exc:
_state("error", f"Update failed and worker was restored/restarted: {exc}") _state("error", f"Update failed and worker was restored/restarted: {exc}")
if self.worker is None or self.worker.poll() is not None: if self.worker is None or self.worker.poll() is not None:
code = None if self.worker is None else self.worker.returncode code = None if self.worker is None else self.worker.returncode
_state("restarting", f"ERP Local Agent worker stopped (exit={code}); restarting.") _state("restarting", f"ERP Local Agent worker stopped (exit={code}); restarting.")
time.sleep(RESTART_DELAY_SECONDS) time.sleep(RESTART_DELAY_SECONDS)
self.start_worker() self.start_worker()
time.sleep(1) time.sleep(1)
return 0 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: finally:
self.stop_worker() try:
self.release_lock() self.stop_worker()
_state("stopped", "ERP Local Agent supervisor stopped.") finally:
self.release_lock()
_state("stopped", "ERP Local Agent supervisor stopped.")
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="ERP Local Agent desktop/background supervisor") parser = argparse.ArgumentParser(description="ERP Local Agent desktop/background supervisor")
parser.add_argument("--background", action="store_true") parser.add_argument("--background", action="store_true")
parser.add_argument("--replace", action="store_true", help="Explicit controlled replacement of an older supervisor instance") parser.add_argument("--replace", action="store_true", help="Compatibility flag; the Global mutex remains authoritative")
args = parser.parse_args() parser.parse_args()
_log(f"startup: supervisor process entered main pid={os.getpid()} root={INSTALL_ROOT}")
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
supervisor = Supervisor() supervisor = Supervisor()
def stop_handler(*_args): def stop_handler(*_args):
@@ -544,9 +478,14 @@ def main() -> int:
signal.signal(signal.SIGINT, stop_handler) signal.signal(signal.SIGINT, stop_handler)
except Exception: except Exception:
pass pass
return supervisor.run() return supervisor.run()
if __name__ == "__main__": 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
@@ -1,2 +1,2 @@
__version__ = "1.22.11" __version__ = "1.22.12"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -63,7 +63,7 @@ class AgentDashboard:
if self.path == "/api/update/check": if self.path == "/api/update/check":
return self._json({"ok": True, "update": dashboard.updater.check_for_update(force=True)}) return self._json({"ok": True, "update": dashboard.updater.check_for_update(force=True)})
if self.path == "/api/update/download": 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": if self.path == "/api/update/install":
state = dashboard.updater.status() state = dashboard.updater.status()
if not state.get("downloaded"): if not state.get("downloaded"):
@@ -136,7 +136,8 @@ class AgentDashboard:
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ERP Local Agent</title> <title>ERP Local Agent</title>
<style> <style>
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}</style></head> 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}}
</style></head>
<body><div class="wrap"><h1>ERP Local Agent</h1><div class="muted">Local dashboard — available only on this computer</div> <body><div class="wrap"><h1>ERP Local Agent</h1><div class="muted">Local dashboard — available only on this computer</div>
<div class="grid"> <div class="grid">
<div class="card"><div class="label">ERP Connection</div><div id="connection" class="value">Loading…</div><div id="heartbeat" class="muted"></div></div> <div class="card"><div class="label">ERP Connection</div><div id="connection" class="value">Loading…</div><div id="heartbeat" class="muted"></div></div>
@@ -144,24 +145,32 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg
<div class="card"><div class="label">TallyPrime</div><div id="tally" class="value">—</div><div id="companies" class="muted"></div></div> <div class="card"><div class="label">TallyPrime</div><div id="tally" class="value">—</div><div id="companies" class="muted"></div></div>
<div class="card"><div class="label">Storage Free</div><div id="storage" class="value">—</div><div id="storageRoot" class="muted"></div></div> <div class="card"><div class="label">Storage Free</div><div id="storage" class="value">—</div><div id="storageRoot" class="muted"></div></div>
</div> </div>
<div class="section"><h2>Updates</h2><p class="muted">The agent checks for new versions automatically. Installation occurs only when a local user clicks Install; the persistent supervisor then restarts the worker safely.</p> <div class="section"><h2>Updates</h2><p class="muted">Check, download and apply Local Agent updates here. The dashboard stays open and shows progress while the worker restarts.</p>
<button onclick="action('/api/update/check')">Check for Update</button><button class="secondary" id="download" onclick="action('/api/update/download')">Download Update</button><button id="install" onclick="installUpdate()">Install & Restart Safely</button><div id="message"></div></div> <button id="check" onclick="checkUpdate()">Check for Update</button><button class="secondary" id="download" onclick="downloadUpdate()">Download Update</button><button id="install" onclick="installUpdate()">Install & Restart Safely</button><div id="message"></div>
<div id="progressBox" class="progressbox" style="display:none"><div class="progresshead"><strong id="progressTitle">Update</strong><span id="progressPct">0%</span></div><div class="progressbar"><div id="progressFill" class="progressfill"></div></div><div id="progressMsg" class="progressmsg">Waiting…</div></div></div>
<div class="section"><h2>Tally</h2><button onclick="action('/api/tally/check')">Test Tally Connection</button><p class="muted">Tally is checked locally at <code>127.0.0.1:9000</code>. Port 9000 is not exposed to the internet.</p></div> <div class="section"><h2>Tally</h2><button onclick="action('/api/tally/check')">Test Tally Connection</button><p class="muted">Tally is checked locally at <code>127.0.0.1:9000</code>. Port 9000 is not exposed to the internet.</p></div>
<div class="section"><h2>Recent History</h2><div id="history" class="muted">Loading…</div></div> <div class="section"><h2>Recent History</h2><div id="history" class="muted">Loading…</div></div>
</div> </div>
<script> <script>
let installWatching=false,installTarget='',offlineTicks=0,lastServerPercent=0;
function gb(n){return (n/1073741824).toFixed(1)+' GB'} function gb(n){return (n/1073741824).toFixed(1)+' GB'}
async function refresh(){try{const r=await fetch('/api/status',{cache:'no-store'});const s=await r.json(); function setProgress(title,pct,text,active=true){let b=document.getElementById('progressBox');b.style.display='block';document.getElementById('progressTitle').textContent=title;let p=Math.max(0,Math.min(100,Number(pct)||0));lastServerPercent=Math.max(lastServerPercent,p);document.getElementById('progressPct').textContent=Math.round(p)+'%';document.getElementById('progressFill').style.width=p+'%';document.getElementById('progressMsg').textContent=text||'';document.getElementById('progressFill').className='progressfill'+(active&&p<100?' pulse':'')}
function applyProgress(u){let p=(u&&u.progress)||{};if(!p.operation)return;let title=p.operation==='check'?'Checking for Updates':p.operation==='download'?'Downloading Update':'Applying Update';setProgress(title,p.percent||0,p.message||p.phase||'',!['updated','downloaded','current','available','error'].includes(p.status));if(p.operation==='install'&&p.target_version){installTarget=p.target_version}}
async function refresh(){try{const r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw new Error('status unavailable');const s=await r.json();offlineTicks=0;
let c=s.agent.connection_state||'unknown';let ce=document.getElementById('connection');ce.textContent=c.charAt(0).toUpperCase()+c.slice(1);ce.className='value '+(c==='connected'?'ok':'bad'); let c=s.agent.connection_state||'unknown';let ce=document.getElementById('connection');ce.textContent=c.charAt(0).toUpperCase()+c.slice(1);ce.className='value '+(c==='connected'?'ok':'bad');
document.getElementById('heartbeat').textContent=s.agent.last_heartbeat_utc?'Last heartbeat: '+s.agent.last_heartbeat_utc:'No heartbeat recorded'; document.getElementById('heartbeat').textContent=s.agent.last_heartbeat_utc?'Last heartbeat: '+s.agent.last_heartbeat_utc:'No heartbeat recorded';
document.getElementById('version').textContent=s.agent.version;document.getElementById('latest').textContent='Latest: '+s.update.latest_version+(s.update.update_available?' • update available':' • current'); document.getElementById('version').textContent=s.agent.version;document.getElementById('latest').textContent='Latest: '+s.update.latest_version+(s.update.update_available?' • update available':' • current');
let te=document.getElementById('tally');let known=s.tally.connected!==null;te.textContent=!known?'Not checked':(s.tally.connected?'Connected':'Offline');te.className='value '+(!known?'warn':(s.tally.connected?'ok':'bad'));document.getElementById('companies').textContent=!known?'Use Test Tally Connection':(s.tally.connected?(s.tally.company_count+' loaded compan'+(s.tally.company_count===1?'y':'ies')):(s.tally.error||'')); let te=document.getElementById('tally');let known=s.tally.connected!==null;te.textContent=!known?'Not checked':(s.tally.connected?'Connected':'Offline');te.className='value '+(!known?'warn':(s.tally.connected?'ok':'bad'));document.getElementById('companies').textContent=!known?'Use Test Tally Connection':(s.tally.connected?(s.tally.company_count+' loaded compan'+(s.tally.company_count===1?'y':'ies')):(s.tally.error||''));
document.getElementById('storage').textContent=gb(s.storage.free_bytes);document.getElementById('storageRoot').textContent=s.storage.root; document.getElementById('storage').textContent=gb(s.storage.free_bytes);document.getElementById('storageRoot').textContent=s.storage.root;
document.getElementById('download').disabled=!s.update.update_available;document.getElementById('install').disabled=!s.update.downloaded;await refreshHistory();}catch(e){console.log(e)}} document.getElementById('check').disabled=!!s.update.busy;document.getElementById('download').disabled=!s.update.update_available||!!s.update.busy;document.getElementById('install').disabled=!s.update.downloaded||!!s.update.busy;applyProgress(s.update);
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=>'<tr><td>'+x.occurred_at_utc+'</td><td>'+x.level+'</td><td>'+x.event_type+'</td><td>'+escapeHtml(x.message)+'</td></tr>').join('');document.getElementById('history').innerHTML='<table><thead><tr><th>Time UTC</th><th>Level</th><th>Event</th><th>Message</th></tr></thead><tbody>'+rows+'</tbody></table>'} if(installWatching&&installTarget&&s.agent.version===installTarget){setProgress('Update Complete',100,'ERP Local Agent '+installTarget+' is running. Updated dashboard loaded.',false);msg('Update completed successfully.',true);installWatching=false;setTimeout(()=>location.reload(),1200)}
await refreshHistory();}catch(e){if(installWatching){offlineTicks++;let estimated=Math.min(92,Math.max(lastServerPercent,35+offlineTicks*3));setProgress('Applying Update',estimated,'Local Agent is restarting. Waiting for the updated dashboard to come back online…',true)}else{console.log(e)}}}
async function refreshHistory(){try{const r=await fetch('/api/history',{cache:'no-store'});const h=await r.json();let rows=h.events.slice(0,12).map(x=>'<tr><td>'+x.occurred_at_utc+'</td><td>'+x.level+'</td><td>'+x.event_type+'</td><td>'+escapeHtml(x.message)+'</td></tr>').join('');document.getElementById('history').innerHTML='<table><thead><tr><th>Time UTC</th><th>Level</th><th>Event</th><th>Message</th></tr></thead><tbody>'+rows+'</tbody></table>'}catch(e){}}
function escapeHtml(v){return String(v||'').replace(/[&<>'"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[m]))} function escapeHtml(v){return String(v||'').replace(/[&<>'"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[m]))}
async function checkUpdate(){setProgress('Checking for Updates',10,'Checking ERP for the latest Local Agent version…',true);msg('Checking for updates…',true);try{const r=await fetch('/api/update/check',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Update check failed');applyProgress(j.update);msg(j.update.update_available?'Update '+j.update.latest_version+' is available.':'Local Agent is up to date.',true);await refresh()}catch(e){setProgress('Update Check',100,e.message,false);msg(e.message,false)}}
async function downloadUpdate(){setProgress('Downloading Update',5,'Starting download…',true);msg('Downloading update…',true);try{const r=await fetch('/api/update/download',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Download failed');msg('Download started. You can keep this dashboard open.',true);await refresh()}catch(e){setProgress('Download Update',100,e.message,false);msg(e.message,false)}}
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 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 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)}} async function installUpdate(){if(!confirm('Install the downloaded update now? The Local Agent worker will restart safely and this dashboard will show progress until the updated dashboard returns.'))return;let latest=document.getElementById('latest').textContent.replace('Latest: ','').split(' •')[0];installTarget=latest;installWatching=true;offlineTicks=0;lastServerPercent=5;setProgress('Applying Update',5,'Queuing update with the Local Agent supervisor…',true);msg('Starting safe 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');if(j.target_version)installTarget=j.target_version;msg(j.message||'Update queued. Keep this dashboard open.',true);setProgress('Applying Update',8,'Update queued. Waiting for supervisor…',true)}catch(e){installWatching=false;setProgress('Apply Update',100,e.message,false);msg(e.message,false)}}
function msg(t,ok){let e=document.getElementById('message');e.textContent=t;e.className=ok?'msgok':'msgbad'} function msg(t,ok){let e=document.getElementById('message');e.textContent=t;e.className=ok?'msgok':'msgbad'}
refresh();setInterval(refresh,5000); refresh();setInterval(refresh,1000);
</script></body></html>''' </script></body></html>'''
@@ -5,7 +5,6 @@ from ctypes import wintypes
import hashlib import hashlib
import os import os
from pathlib import Path from pathlib import Path
import subprocess
ERROR_ALREADY_EXISTS = 183 ERROR_ALREADY_EXISTS = 183
@@ -69,39 +68,11 @@ def sync_desktop_helpers(root: Path, logger=None) -> bool:
return changed 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: 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.""" """Compatibility no-op for pre-1.22.12 callers.
if os.name != "nt":
return Process-command-line cleanup is intentionally disabled. On Windows launcher/interpreter
root_text = str(root.resolve()).replace("'", "''") pairs can share the same command line and do not represent duplicate logical workers.
dashboard_clause = "" Supervisor and worker singleton ownership is enforced only by Global named mutexes.
if close_dashboards: """
dashboard_clause = " -or ($cmd -match 'ERPAgentDashboard\\.pyw')" return
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)
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import os import os
from pathlib import Path from pathlib import Path
import re import re
@@ -13,7 +14,6 @@ from . import __version__
def _version_tuple(value: str) -> tuple[int, ...]: 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 ""))] parts = [int(piece) for piece in re.findall(r"\d+", str(value or ""))]
return tuple(parts or [0]) return tuple(parts or [0])
@@ -28,13 +28,7 @@ def _is_newer(latest: str, current: str) -> bool:
class AgentUpdater: class AgentUpdater:
"""Supervisor-controlled ERP Local Agent updater. """Supervisor-controlled updater with persistent, user-visible progress."""
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): def __init__(self, config, client, logger, install_dir: Path, db=None):
self.config = config self.config = config
@@ -46,6 +40,7 @@ class AgentUpdater:
self.busy = False self.busy = False
self.lock = threading.RLock() self.lock = threading.RLock()
self.latest_manifest: dict = {} 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: def _record(self, event_type: str, to_version: str = "", status: str = "", **kwargs) -> None:
if self.db is not None: if self.db is not None:
@@ -54,12 +49,50 @@ class AgentUpdater:
except Exception: except Exception:
self.logger.exception("Could not record updater history") 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: def check_for_update(self, force: bool = False) -> dict:
with self.lock: with self.lock:
now = time.time() now = time.time()
if not force and now - self.last_check < self.config.update_check_interval_seconds and self.latest_manifest: if not force and now - self.last_check < self.config.update_check_interval_seconds and self.latest_manifest:
return self.status() return self.status()
self.last_check = now self.last_check = now
self._set_progress("check", "checking", "checking", 10, "Checking ERP for Local Agent updates.")
try: try:
manifest = self.client.update_manifest() manifest = self.client.update_manifest()
latest = str(manifest.get("latest_version") or "").strip() 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("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.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) 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() return self.status()
except Exception as exc: except Exception as exc:
self._record("check", "", "failed", error_message=str(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) self.logger.exception("ERP Local Agent update check failed: %s", exc)
raise raise
@@ -87,14 +125,10 @@ class AgentUpdater:
return False return False
if not self.config.auto_install_updates: if not self.config.auto_install_updates:
return True return True
latest = str(state.get("latest_version") or "").strip() latest = str(state.get("latest_version") or "").strip()
request_path = self.install_dir / "updates" / "supervisor_request.json" request_path = self.install_dir / "updates" / "supervisor_request.json"
if request_path.exists(): if request_path.exists():
# The persistent supervisor will consume the pending request. Avoid
# repeatedly downloading the same package while restart is underway.
return True return True
staged_version = str(state.get("staged_version") or "").strip() staged_version = str(state.get("staged_version") or "").strip()
if not state.get("downloaded") or staged_version != latest: if not state.get("downloaded") or staged_version != latest:
state = self.download_latest() state = self.download_latest()
@@ -105,50 +139,85 @@ class AgentUpdater:
self.logger.exception("ERP Local Agent automatic update failed: %s", exc) self.logger.exception("ERP Local Agent automatic update failed: %s", exc)
return False 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: def download_latest(self) -> dict:
with self.lock: with self.lock:
if self.busy: if self.busy:
raise RuntimeError("Another update operation is already in progress.") raise RuntimeError("Another update operation is already in progress.")
self.busy = True self.busy = True
try: try:
if not self.latest_manifest: return self._download_body()
self.check_for_update(force=True) except Exception as exc:
latest = str(self.latest_manifest.get("latest_version") or "").strip() latest = str(self.latest_manifest.get("latest_version") or "")
expected = str(self.latest_manifest.get("sha256") or "").lower().strip() self._record("download", latest, "failed", error_message=str(exc))
if not latest or not _is_newer(latest, __version__): self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest)
return self.status() raise
package = self.client.download_update_package() finally:
actual = hashlib.sha256(package).hexdigest().lower() with self.lock:
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:
self.busy = False 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: def install_downloaded(self) -> dict:
"""Ask the persistent desktop supervisor to install the staged worker."""
with self.lock: with self.lock:
latest = str( latest = str(
(self.db.get_meta("staged_update_version") if self.db else "") (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.") raise RuntimeError("No downloaded ERP Local Agent update is available to install.")
if not _is_newer(latest, __version__): if not _is_newer(latest, __version__):
return self.status() return self.status()
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update" safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
staged = self.install_dir / "updates" / f"staged_{safe}" staged = self.install_dir / "updates" / f"staged_{safe}"
if not (staged / "erp_local_agent" / "__init__.py").exists(): if not (staged / "erp_local_agent" / "__init__.py").exists():
raise RuntimeError("The downloaded update is not staged correctly. Download it again.") raise RuntimeError("The downloaded update is not staged correctly. Download it again.")
supervisor_lock = self.install_dir / "data" / "supervisor.lock" # The owner file is diagnostic, while the supervisor's Global mutex is authoritative.
if not supervisor_lock.exists(): # 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( raise RuntimeError(
"ERP Local Agent Supervisor is not running. Start the Local Agent from the " "ERP Local Agent Supervisor is not running. Start the Local Agent from the desktop shortcut "
"desktop shortcut or restart the 'ERP Local Agent' Scheduled Task." "or restart the 'ERP Local Agent' Scheduled Task."
) )
request_path = self.install_dir / "updates" / "supervisor_request.json" request_path = self.install_dir / "updates" / "supervisor_request.json"
@@ -182,21 +252,14 @@ class AgentUpdater:
"requested_by_pid": os.getpid(), "requested_by_pid": os.getpid(),
"automatic": bool(self.config.auto_install_updates), "automatic": bool(self.config.auto_install_updates),
} }
import json
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(request_path) 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._record("install_requested", latest, "pending", package_path=str(staged))
self.logger.warning( self.logger.warning("ERP Local Agent update %s handed to persistent supervisor for controlled restart.", latest)
"ERP Local Agent update %s handed to persistent supervisor for controlled restart.",
latest,
)
return { return {
"ok": True, "ok": True,
"message": ( "message": f"ERP Local Agent {latest} installation is queued. Keep this dashboard open to watch progress.",
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, "target_version": latest,
} }
@@ -215,11 +278,9 @@ class AgentUpdater:
state_path = self.install_dir / "data" / "supervisor_state.json" state_path = self.install_dir / "data" / "supervisor_state.json"
try: try:
if state_path.exists(): if state_path.exists():
import json
supervisor_state = json.loads(state_path.read_text(encoding="utf-8")) or {} supervisor_state = json.loads(state_path.read_text(encoding="utf-8")) or {}
except Exception: except Exception:
supervisor_state = {} supervisor_state = {}
return { return {
"current_version": __version__, "current_version": __version__,
"latest_version": latest, "latest_version": latest,
@@ -231,4 +292,5 @@ class AgentUpdater:
"automatic_install_enabled": bool(self.config.auto_update and self.config.auto_install_updates), "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)), "manual_update_only": bool(getattr(self.config, "manual_update_only", False)),
"supervisor": supervisor_state, "supervisor": supervisor_state,
"progress": self._progress(),
} }
@@ -1,4 +1,4 @@
@echo off @echo off
setlocal setlocal
cd /d "%~dp0" 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 ".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 "AGENT_PYTHONW=%~dp0.venv\Scripts\pythonw.exe"
set "SUPERVISOR=%~dp0ERPAgentSupervisor.pyw" 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 if errorlevel 1 goto :failed
echo ERP Local Agent Supervisor installed and started successfully. echo ERP Local Agent Supervisor installed and started successfully.