Repair Local Agent supervisor and add update progress UI
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import hashlib
|
||||
import os
|
||||
import atexit
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
@@ -16,8 +14,6 @@ from tkinter import Tk, messagebox
|
||||
|
||||
|
||||
INSTALL_ROOT = Path(__file__).resolve().parent
|
||||
DATA_DIR = INSTALL_ROOT / "data"
|
||||
SUPERVISOR_LOCK = DATA_DIR / "supervisor.lock"
|
||||
SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw"
|
||||
URL = "http://127.0.0.1:8788"
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
@@ -69,7 +65,7 @@ def _focus_existing_dashboard() -> None:
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||
if "ERP Local Agent Dashboard" in buf.value:
|
||||
user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
||||
user32.ShowWindow(hwnd, 9)
|
||||
user32.SetForegroundWindow(hwnd)
|
||||
return False
|
||||
return True
|
||||
@@ -93,42 +89,6 @@ def _popup(title: str, message: str, error: bool = False) -> None:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = ctypes.c_ulong()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _supervisor_running() -> bool:
|
||||
try:
|
||||
if not SUPERVISOR_LOCK.exists():
|
||||
return False
|
||||
return _pid_alive(int(SUPERVISOR_LOCK.read_text(encoding="utf-8").strip()))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pythonw() -> Path:
|
||||
pyw = INSTALL_ROOT / ".venv" / "Scripts" / "pythonw.exe"
|
||||
if pyw.exists():
|
||||
@@ -139,18 +99,30 @@ def _pythonw() -> Path:
|
||||
raise RuntimeError("ERP Local Agent Python environment was not found.")
|
||||
|
||||
|
||||
def _ready(timeout: float = 1.5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(URL + "/api/status", timeout=timeout) as response:
|
||||
return int(getattr(response, "status", 200)) < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_supervisor() -> None:
|
||||
if _supervisor_running():
|
||||
# If the dashboard is already healthy, never start another supervisor or worker.
|
||||
if _ready():
|
||||
return
|
||||
|
||||
# The Scheduled Task is the canonical owner/launcher. Starting it repeatedly
|
||||
# is safe because the task is configured with MultipleInstances=IgnoreNew,
|
||||
# and the supervisor itself also owns a machine-wide Global mutex.
|
||||
started_by_task = False
|
||||
if os.name == "nt":
|
||||
result = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
||||
"Start-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction Stop"],
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"Start-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction Stop",
|
||||
],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -160,8 +132,7 @@ def _ensure_supervisor() -> None:
|
||||
started_by_task = result.returncode == 0
|
||||
|
||||
if not started_by_task:
|
||||
# Recovery fallback for a workstation where the scheduled task was not
|
||||
# installed yet. The Global mutex still prevents a second supervisor.
|
||||
# Recovery fallback. The Supervisor Global mutex makes repeated launches harmless.
|
||||
subprocess.Popen(
|
||||
[str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
@@ -171,23 +142,8 @@ def _ensure_supervisor() -> None:
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
if _supervisor_running():
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("ERP Local Agent supervisor did not start.")
|
||||
|
||||
|
||||
def _ready(timeout: float = 1.5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(URL + "/api/status", timeout=timeout) as response:
|
||||
return int(getattr(response, "status", 200)) < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_ready(seconds: int = 75) -> bool:
|
||||
def _wait_ready(seconds: int = 90) -> bool:
|
||||
deadline = time.time() + seconds
|
||||
while time.time() < deadline:
|
||||
if _ready():
|
||||
@@ -202,6 +158,7 @@ def main() -> None:
|
||||
_focus_existing_dashboard()
|
||||
return
|
||||
atexit.register(_close_dashboard_mutex, dashboard_mutex)
|
||||
|
||||
try:
|
||||
_ensure_supervisor()
|
||||
except Exception as exc:
|
||||
@@ -211,8 +168,9 @@ def main() -> None:
|
||||
if not _wait_ready():
|
||||
_popup(
|
||||
"ERP Local Agent",
|
||||
"The Local Agent supervisor is running, but the dashboard did not become ready.\n\n"
|
||||
f"Check logs under:\n{INSTALL_ROOT / 'logs'}",
|
||||
"The Local Agent did not become ready.\n\n"
|
||||
"Check the 'ERP Local Agent' Scheduled Task and the logs under:\n"
|
||||
f"{INSTALL_ROOT / 'logs'}",
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
@@ -252,9 +210,9 @@ def main() -> None:
|
||||
pass
|
||||
outage_seen = False
|
||||
was_ready = current
|
||||
time.sleep(1.5)
|
||||
time.sleep(1.0)
|
||||
except Exception:
|
||||
time.sleep(2)
|
||||
time.sleep(1.5)
|
||||
|
||||
webview.start(monitor)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
@@ -23,14 +22,14 @@ LOCK_FILE = DATA_DIR / "supervisor.lock"
|
||||
STATE_FILE = DATA_DIR / "supervisor_state.json"
|
||||
OWNER_FILE = DATA_DIR / "supervisor_owner.json"
|
||||
REQUEST_FILE = UPDATES_DIR / "supervisor_request.json"
|
||||
UPDATE_PROGRESS_FILE = DATA_DIR / "update_progress.json"
|
||||
WORKER_LOG = LOG_DIR / "worker-supervisor.log"
|
||||
SUPERVISOR_LOG = LOG_DIR / "supervisor.log"
|
||||
DASHBOARD_URL = "http://127.0.0.1:8788"
|
||||
WORKER_MODULE = "erp_local_agent.main"
|
||||
RESTART_DELAY_SECONDS = 3
|
||||
HEALTH_TIMEOUT_SECONDS = 60
|
||||
HEALTH_TIMEOUT_SECONDS = 90
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
SINGLETON_WATCHDOG_SECONDS = 10
|
||||
|
||||
|
||||
def _supervisor_mutex_name() -> str:
|
||||
@@ -64,28 +63,6 @@ def _close_os_mutex(handle) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _kill_duplicate_supervisors() -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
root = str(INSTALL_ROOT).replace("'", "''")
|
||||
script = (
|
||||
"$root='" + root + "';$self=" + str(os.getpid()) + ";"
|
||||
"Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | "
|
||||
"Where-Object { $_.ProcessId -ne $self -and $_.CommandLine -and "
|
||||
"$_.CommandLine -match 'ERPAgentSupervisor\\.pyw' -and "
|
||||
"$_.CommandLine -like ('*'+$root+'*') } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_create_no_window(),
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _create_no_window() -> int:
|
||||
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
@@ -100,16 +77,6 @@ def _log(message: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
@@ -129,6 +96,20 @@ def _state(status: str, message: str, **extra) -> None:
|
||||
_log(f"{status}: {message}")
|
||||
|
||||
|
||||
def _update_progress(phase: str, percent: int, message: str, *, version: str = "", status: str = "installing") -> None:
|
||||
payload = {
|
||||
"operation": "install",
|
||||
"status": status,
|
||||
"phase": phase,
|
||||
"percent": max(0, min(100, int(percent))),
|
||||
"message": message,
|
||||
"target_version": version,
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"supervisor_pid": os.getpid(),
|
||||
}
|
||||
_write_json_atomic(UPDATE_PROGRESS_FILE, payload)
|
||||
|
||||
|
||||
def _dashboard_ready(timeout: float = 1.5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(DASHBOARD_URL + "/api/status", timeout=timeout) as response:
|
||||
@@ -149,9 +130,7 @@ def _python(console: bool = True) -> Path:
|
||||
|
||||
|
||||
def _terminate_process_tree(process: subprocess.Popen | None) -> None:
|
||||
if process is None:
|
||||
return
|
||||
if process.poll() is not None:
|
||||
if process is None or process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -167,91 +146,41 @@ def _terminate_process_tree(process: subprocess.Popen | None) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=15)
|
||||
process.wait(timeout=20)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _kill_unmanaged_workers() -> None:
|
||||
script = (
|
||||
"$root='" + str(INSTALL_ROOT).replace("'", "''") + "';"
|
||||
"Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | "
|
||||
"Where-Object { $_.CommandLine -and $_.CommandLine -match 'erp_local_agent\\.main' "
|
||||
"-and $_.CommandLine -like ('*'+$root+'*') } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_create_no_window(),
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_singleton_processes(*, keep_worker_pid: int | None = None) -> None:
|
||||
"""Kill stale/duplicate supervisors and workers for this install only.
|
||||
|
||||
The Global named mutex is the primary atomic guard. This watchdog is a
|
||||
recovery layer for legacy processes started before the hardened runtime.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return
|
||||
root = str(INSTALL_ROOT.resolve()).replace("'", "''")
|
||||
keep_worker = int(keep_worker_pid or 0)
|
||||
script = rf"""
|
||||
$root='{root}'
|
||||
$selfPid={os.getpid()}
|
||||
$keepWorker={keep_worker}
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{
|
||||
$cmd=[string]$_.CommandLine
|
||||
if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }}
|
||||
if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $selfPid)) {{
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
return
|
||||
}}
|
||||
if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $keepWorker)) {{
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_create_no_window(),
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class Supervisor:
|
||||
def __init__(self):
|
||||
self.worker: subprocess.Popen | None = None
|
||||
self.running = True
|
||||
self._os_mutex = None
|
||||
self._worker_log_handle = None
|
||||
|
||||
def acquire_lock(self) -> bool:
|
||||
# The named mutex is atomic at the Windows kernel level and prevents
|
||||
# the lock-file race that allowed multiple supervisors before 1.22.3.
|
||||
"""Acquire the only authoritative supervisor singleton guard.
|
||||
|
||||
The Global Windows mutex is atomic across SYSTEM and user sessions. PID/owner
|
||||
files are diagnostics only and must never veto a successfully acquired mutex.
|
||||
"""
|
||||
_log("startup: acquiring Global supervisor mutex")
|
||||
self._os_mutex = _acquire_os_mutex()
|
||||
if self._os_mutex is None:
|
||||
_log("startup: another supervisor already owns the Global mutex; exiting")
|
||||
return False
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if LOCK_FILE.exists():
|
||||
try:
|
||||
existing = int(LOCK_FILE.read_text(encoding="utf-8").strip() or "0")
|
||||
except Exception:
|
||||
existing = 0
|
||||
if existing and existing != os.getpid() and _pid_alive(existing):
|
||||
return False
|
||||
LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8")
|
||||
_write_json_atomic(OWNER_FILE, {
|
||||
"supervisor_pid": os.getpid(),
|
||||
"install_root": str(INSTALL_ROOT),
|
||||
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
})
|
||||
_write_json_atomic(
|
||||
OWNER_FILE,
|
||||
{
|
||||
"supervisor_pid": os.getpid(),
|
||||
"install_root": str(INSTALL_ROOT),
|
||||
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"singleton": "global_mutex",
|
||||
},
|
||||
)
|
||||
_log("startup: Global supervisor mutex acquired")
|
||||
return True
|
||||
|
||||
def release_lock(self) -> None:
|
||||
@@ -273,26 +202,32 @@ class Supervisor:
|
||||
def start_worker(self) -> None:
|
||||
if self.worker is not None and self.worker.poll() is None:
|
||||
return
|
||||
_kill_unmanaged_workers()
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
py = _python(console=True)
|
||||
handle = WORKER_LOG.open("a", encoding="utf-8")
|
||||
handle.write(f"\n--- worker start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
|
||||
handle.flush()
|
||||
self._worker_log_handle = WORKER_LOG.open("a", encoding="utf-8")
|
||||
self._worker_log_handle.write(f"\n--- worker start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
|
||||
self._worker_log_handle.flush()
|
||||
self.worker = subprocess.Popen(
|
||||
[str(py), "-m", WORKER_MODULE],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=handle,
|
||||
stderr=handle,
|
||||
stdout=self._worker_log_handle,
|
||||
stderr=self._worker_log_handle,
|
||||
creationflags=_create_no_window(),
|
||||
)
|
||||
_state("running", "ERP Local Agent worker started.", worker_pid=self.worker.pid)
|
||||
|
||||
def stop_worker(self) -> None:
|
||||
if self.worker is None:
|
||||
return
|
||||
_state("stopping", "Stopping ERP Local Agent worker.")
|
||||
_terminate_process_tree(self.worker)
|
||||
self.worker = None
|
||||
_kill_unmanaged_workers()
|
||||
if self._worker_log_handle is not None:
|
||||
try:
|
||||
self._worker_log_handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._worker_log_handle = None
|
||||
|
||||
@staticmethod
|
||||
def _read_request() -> dict | None:
|
||||
@@ -345,6 +280,8 @@ class Supervisor:
|
||||
"stop_task_scheduler.bat",
|
||||
"status_task_scheduler.bat",
|
||||
"uninstall_task_scheduler.bat",
|
||||
"ERPAgentSupervisor.pyw",
|
||||
"ERPAgentDashboard.pyw",
|
||||
):
|
||||
src = INSTALL_ROOT / name
|
||||
if src.exists():
|
||||
@@ -356,11 +293,9 @@ class Supervisor:
|
||||
target = INSTALL_ROOT / "erp_local_agent"
|
||||
new_target = INSTALL_ROOT / "erp_local_agent.__new__"
|
||||
old_target = INSTALL_ROOT / "erp_local_agent.__old__"
|
||||
|
||||
shutil.rmtree(new_target, ignore_errors=True)
|
||||
shutil.rmtree(old_target, ignore_errors=True)
|
||||
shutil.copytree(staged / "erp_local_agent", new_target)
|
||||
|
||||
if target.exists():
|
||||
target.rename(old_target)
|
||||
try:
|
||||
@@ -371,9 +306,6 @@ class Supervisor:
|
||||
raise
|
||||
shutil.rmtree(old_target, ignore_errors=True)
|
||||
|
||||
# Root helper files are safe to refresh while the stable supervisor is running.
|
||||
# Supervisor/dashboard launchers themselves are intentionally excluded here;
|
||||
# they are updated only by the explicit direct installer.
|
||||
for name in (
|
||||
"requirements.txt",
|
||||
"README_ERP_LOCAL_AGENT.txt",
|
||||
@@ -387,6 +319,8 @@ class Supervisor:
|
||||
"stop_task_scheduler.bat",
|
||||
"status_task_scheduler.bat",
|
||||
"uninstall_task_scheduler.bat",
|
||||
"ERPAgentSupervisor.pyw",
|
||||
"ERPAgentDashboard.pyw",
|
||||
):
|
||||
src = staged / name
|
||||
if src.exists():
|
||||
@@ -407,7 +341,6 @@ class Supervisor:
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("Dependency installation failed:\n" + result.stdout[-4000:])
|
||||
|
||||
code = (
|
||||
"import erp_local_agent;"
|
||||
"from erp_local_agent import accounting_store,commands,tally,updater;"
|
||||
@@ -443,16 +376,26 @@ class Supervisor:
|
||||
staged_text = str(request.get("staged_dir") or "").strip()
|
||||
if not version or not staged_text:
|
||||
raise RuntimeError("Supervisor update request is missing version/staged_dir.")
|
||||
|
||||
staged = Path(staged_text).resolve()
|
||||
self._validate_staged(staged)
|
||||
_state("installing", f"Installing ERP Local Agent {version}.", target_version=version)
|
||||
|
||||
self.stop_worker()
|
||||
backup = self._backup_runtime(version)
|
||||
_update_progress("preparing", 8, f"Preparing ERP Local Agent {version} update.", version=version)
|
||||
_state("installing", f"Installing ERP Local Agent {version}.", target_version=version, progress_pct=8)
|
||||
backup = None
|
||||
try:
|
||||
_update_progress("stopping", 18, "Stopping the current Local Agent worker safely.", version=version)
|
||||
self.stop_worker()
|
||||
|
||||
_update_progress("backup", 30, "Creating rollback backup.", version=version)
|
||||
backup = self._backup_runtime(version)
|
||||
|
||||
_update_progress("replacing", 48, "Replacing Local Agent runtime files.", version=version)
|
||||
self._replace_worker_runtime(staged)
|
||||
|
||||
_update_progress("dependencies", 66, "Verifying runtime dependencies and imports.", version=version)
|
||||
self._install_requirements_and_verify()
|
||||
|
||||
_update_progress("starting", 82, "Starting the updated Local Agent.", version=version)
|
||||
self.start_worker()
|
||||
|
||||
deadline = time.time() + HEALTH_TIMEOUT_SECONDS
|
||||
@@ -460,42 +403,36 @@ class Supervisor:
|
||||
if self.worker is not None and self.worker.poll() is not None:
|
||||
raise RuntimeError(f"Updated worker exited with code {self.worker.returncode}.")
|
||||
if _dashboard_ready():
|
||||
_update_progress("complete", 100, f"ERP Local Agent {version} updated successfully.", version=version, status="updated")
|
||||
_state(
|
||||
"updated",
|
||||
f"ERP Local Agent {version} installed and restarted successfully.",
|
||||
target_version=version,
|
||||
worker_pid=(self.worker.pid if self.worker else None),
|
||||
backup_path=str(backup),
|
||||
progress_pct=100,
|
||||
)
|
||||
return
|
||||
_update_progress("health_check", 90, "Waiting for the updated dashboard to become ready.", version=version)
|
||||
time.sleep(1)
|
||||
|
||||
raise RuntimeError("Updated worker did not become healthy within the restart timeout.")
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
_update_progress("rollback", 92, f"Update failed; restoring the previous runtime: {exc}", version=version, status="rollback")
|
||||
_state("rollback", f"Update {version} failed. Restoring previous worker.", target_version=version)
|
||||
self.stop_worker()
|
||||
self._restore_backup(backup)
|
||||
if backup is not None:
|
||||
self._restore_backup(backup)
|
||||
self.start_worker()
|
||||
_update_progress("error", 100, f"Update failed and the previous version was restored: {exc}", version=version, status="error")
|
||||
raise
|
||||
|
||||
def run(self) -> int:
|
||||
if not self.acquire_lock():
|
||||
return 0
|
||||
|
||||
try:
|
||||
_kill_duplicate_supervisors()
|
||||
_state("starting", "ERP Local Agent supervisor starting.")
|
||||
self.start_worker()
|
||||
last_singleton_watchdog = 0.0
|
||||
|
||||
while self.running:
|
||||
now = time.time()
|
||||
if now - last_singleton_watchdog >= SINGLETON_WATCHDOG_SECONDS:
|
||||
_enforce_singleton_processes(
|
||||
keep_worker_pid=(self.worker.pid if self.worker is not None and self.worker.poll() is None else None)
|
||||
)
|
||||
last_singleton_watchdog = now
|
||||
|
||||
request = self._read_request()
|
||||
if request and str(request.get("action") or "") == "install_update":
|
||||
self._clear_request()
|
||||
@@ -503,37 +440,34 @@ class Supervisor:
|
||||
self.apply_update(request)
|
||||
except Exception as exc:
|
||||
_state("error", f"Update failed and worker was restored/restarted: {exc}")
|
||||
|
||||
if self.worker is None or self.worker.poll() is not None:
|
||||
code = None if self.worker is None else self.worker.returncode
|
||||
_state("restarting", f"ERP Local Agent worker stopped (exit={code}); restarting.")
|
||||
time.sleep(RESTART_DELAY_SECONDS)
|
||||
self.start_worker()
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return 0
|
||||
except BaseException as exc:
|
||||
_log(f"fatal: {type(exc).__name__}: {exc}")
|
||||
try:
|
||||
_state("fatal", f"Supervisor stopped unexpectedly: {exc}")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
self.stop_worker()
|
||||
self.release_lock()
|
||||
_state("stopped", "ERP Local Agent supervisor stopped.")
|
||||
try:
|
||||
self.stop_worker()
|
||||
finally:
|
||||
self.release_lock()
|
||||
_state("stopped", "ERP Local Agent supervisor stopped.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="ERP Local Agent desktop/background supervisor")
|
||||
parser.add_argument("--background", action="store_true")
|
||||
parser.add_argument("--replace", action="store_true", help="Explicit controlled replacement of an older supervisor instance")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.replace and os.name == "nt":
|
||||
# Explicit restart/update only. Normal duplicate launches never disturb
|
||||
# the healthy owner; they simply fail the Global mutex and exit.
|
||||
_kill_duplicate_supervisors()
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.25)
|
||||
break
|
||||
|
||||
parser.add_argument("--replace", action="store_true", help="Compatibility flag; the Global mutex remains authoritative")
|
||||
parser.parse_args()
|
||||
_log(f"startup: supervisor process entered main pid={os.getpid()} root={INSTALL_ROOT}")
|
||||
supervisor = Supervisor()
|
||||
|
||||
def stop_handler(*_args):
|
||||
@@ -544,9 +478,14 @@ def main() -> int:
|
||||
signal.signal(signal.SIGINT, stop_handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return supervisor.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
_log(f"startup-fatal: {type(exc).__name__}: {exc}")
|
||||
raise
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.22.11"
|
||||
__version__ = "1.22.12"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -63,7 +63,7 @@ class AgentDashboard:
|
||||
if self.path == "/api/update/check":
|
||||
return self._json({"ok": True, "update": dashboard.updater.check_for_update(force=True)})
|
||||
if self.path == "/api/update/download":
|
||||
return self._json({"ok": True, "update": dashboard.updater.download_latest()})
|
||||
return self._json({"ok": True, "update": dashboard.updater.start_download()})
|
||||
if self.path == "/api/update/install":
|
||||
state = dashboard.updater.status()
|
||||
if not state.get("downloaded"):
|
||||
@@ -136,7 +136,8 @@ class AgentDashboard:
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ERP Local Agent</title>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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">Storage Free</div><div id="storage" class="value">—</div><div id="storageRoot" class="muted"></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>
|
||||
<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>
|
||||
<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 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>Recent History</h2><div id="history" class="muted">Loading…</div></div>
|
||||
</div>
|
||||
<script>
|
||||
let installWatching=false,installTarget='',offlineTicks=0,lastServerPercent=0;
|
||||
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');
|
||||
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');
|
||||
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('download').disabled=!s.update.update_available;document.getElementById('install').disabled=!s.update.downloaded;await refreshHistory();}catch(e){console.log(e)}}
|
||||
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>'}
|
||||
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);
|
||||
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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 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'}
|
||||
refresh();setInterval(refresh,5000);
|
||||
refresh();setInterval(refresh,1000);
|
||||
</script></body></html>'''
|
||||
|
||||
@@ -5,7 +5,6 @@ from ctypes import wintypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
@@ -69,39 +68,11 @@ def sync_desktop_helpers(root: Path, logger=None) -> bool:
|
||||
return changed
|
||||
|
||||
|
||||
def _run_powershell(script: str) -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def cleanup_legacy_duplicates(root: Path, *, keep_supervisor_pid: int, close_dashboards: bool) -> None:
|
||||
"""Remove duplicates left behind by pre-1.22.3 launchers on this installation only."""
|
||||
if os.name != "nt":
|
||||
return
|
||||
root_text = str(root.resolve()).replace("'", "''")
|
||||
dashboard_clause = ""
|
||||
if close_dashboards:
|
||||
dashboard_clause = " -or ($cmd -match 'ERPAgentDashboard\\.pyw')"
|
||||
script = rf"""
|
||||
$root='{root_text}'
|
||||
$keepSupervisor={int(keep_supervisor_pid or 0)}
|
||||
$selfPid={os.getpid()}
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{
|
||||
$cmd = [string]$_.CommandLine
|
||||
if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }}
|
||||
$kill = $false
|
||||
if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $keepSupervisor)) {{ $kill = $true }}
|
||||
if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $selfPid)) {{ $kill = $true }}
|
||||
if (($cmd -match 'ERPAgentDashboard\.pyw') -and {str(bool(close_dashboards)).lower()}) {{ $kill = $true }}
|
||||
if ($kill) {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
|
||||
}}
|
||||
"""
|
||||
_run_powershell(script)
|
||||
"""Compatibility no-op for pre-1.22.12 callers.
|
||||
|
||||
Process-command-line cleanup is intentionally disabled. On Windows launcher/interpreter
|
||||
pairs can share the same command line and do not represent duplicate logical workers.
|
||||
Supervisor and worker singleton ownership is enforced only by Global named mutexes.
|
||||
"""
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -13,7 +14,6 @@ from . import __version__
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, ...]:
|
||||
"""Return a stable numeric tuple for ERP Local Agent release versions."""
|
||||
parts = [int(piece) for piece in re.findall(r"\d+", str(value or ""))]
|
||||
return tuple(parts or [0])
|
||||
|
||||
@@ -28,13 +28,7 @@ def _is_newer(latest: str, current: str) -> bool:
|
||||
|
||||
|
||||
class AgentUpdater:
|
||||
"""Supervisor-controlled ERP Local Agent updater.
|
||||
|
||||
The worker checks the ERP manifest in the background. When automatic installation
|
||||
is enabled it downloads the hash-verified package, stages it, and hands installation
|
||||
to the persistent desktop supervisor. Manual dashboard download/install actions are
|
||||
preserved and use the same code paths.
|
||||
"""
|
||||
"""Supervisor-controlled updater with persistent, user-visible progress."""
|
||||
|
||||
def __init__(self, config, client, logger, install_dir: Path, db=None):
|
||||
self.config = config
|
||||
@@ -46,6 +40,7 @@ class AgentUpdater:
|
||||
self.busy = False
|
||||
self.lock = threading.RLock()
|
||||
self.latest_manifest: dict = {}
|
||||
self.progress_file = self.install_dir / "data" / "update_progress.json"
|
||||
|
||||
def _record(self, event_type: str, to_version: str = "", status: str = "", **kwargs) -> None:
|
||||
if self.db is not None:
|
||||
@@ -54,12 +49,50 @@ class AgentUpdater:
|
||||
except Exception:
|
||||
self.logger.exception("Could not record updater history")
|
||||
|
||||
def _set_progress(
|
||||
self,
|
||||
operation: str,
|
||||
status: str,
|
||||
phase: str,
|
||||
percent: int,
|
||||
message: str,
|
||||
*,
|
||||
target_version: str = "",
|
||||
) -> None:
|
||||
payload = {
|
||||
"operation": operation,
|
||||
"status": status,
|
||||
"phase": phase,
|
||||
"percent": max(0, min(100, int(percent))),
|
||||
"message": message,
|
||||
"target_version": target_version,
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"worker_pid": os.getpid(),
|
||||
}
|
||||
try:
|
||||
self.progress_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.progress_file.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(self.progress_file)
|
||||
except Exception:
|
||||
self.logger.exception("Could not persist updater progress")
|
||||
|
||||
def _progress(self) -> dict:
|
||||
try:
|
||||
if self.progress_file.exists():
|
||||
value = json.loads(self.progress_file.read_text(encoding="utf-8"))
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def check_for_update(self, force: bool = False) -> dict:
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
if not force and now - self.last_check < self.config.update_check_interval_seconds and self.latest_manifest:
|
||||
return self.status()
|
||||
self.last_check = now
|
||||
self._set_progress("check", "checking", "checking", 10, "Checking ERP for Local Agent updates.")
|
||||
try:
|
||||
manifest = self.client.update_manifest()
|
||||
latest = str(manifest.get("latest_version") or "").strip()
|
||||
@@ -71,9 +104,14 @@ class AgentUpdater:
|
||||
self.db.set_meta("update_available", "1" if available else "0")
|
||||
self.db.set_meta("last_update_check_utc", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
||||
self._record("check", latest, "available" if available else "current", sha256=expected)
|
||||
if available:
|
||||
self._set_progress("check", "available", "complete", 100, f"ERP Local Agent {latest} is available.", target_version=latest)
|
||||
else:
|
||||
self._set_progress("check", "current", "complete", 100, "ERP Local Agent is up to date.", target_version=latest or __version__)
|
||||
return self.status()
|
||||
except Exception as exc:
|
||||
self._record("check", "", "failed", error_message=str(exc))
|
||||
self._set_progress("check", "error", "error", 100, f"Update check failed: {exc}")
|
||||
self.logger.exception("ERP Local Agent update check failed: %s", exc)
|
||||
raise
|
||||
|
||||
@@ -87,14 +125,10 @@ class AgentUpdater:
|
||||
return False
|
||||
if not self.config.auto_install_updates:
|
||||
return True
|
||||
|
||||
latest = str(state.get("latest_version") or "").strip()
|
||||
request_path = self.install_dir / "updates" / "supervisor_request.json"
|
||||
if request_path.exists():
|
||||
# The persistent supervisor will consume the pending request. Avoid
|
||||
# repeatedly downloading the same package while restart is underway.
|
||||
return True
|
||||
|
||||
staged_version = str(state.get("staged_version") or "").strip()
|
||||
if not state.get("downloaded") or staged_version != latest:
|
||||
state = self.download_latest()
|
||||
@@ -105,50 +139,85 @@ class AgentUpdater:
|
||||
self.logger.exception("ERP Local Agent automatic update failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _download_body(self) -> dict:
|
||||
if not self.latest_manifest:
|
||||
self.check_for_update(force=True)
|
||||
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
||||
expected = str(self.latest_manifest.get("sha256") or "").lower().strip()
|
||||
if not latest or not _is_newer(latest, __version__):
|
||||
self._set_progress("download", "current", "complete", 100, "No newer update is available.", target_version=latest or __version__)
|
||||
return self.status()
|
||||
|
||||
self._set_progress("download", "downloading", "downloading", 15, f"Downloading ERP Local Agent {latest}.", target_version=latest)
|
||||
package = self.client.download_update_package()
|
||||
self._set_progress("download", "downloading", "verifying", 65, "Download received. Verifying package integrity.", target_version=latest)
|
||||
actual = hashlib.sha256(package).hexdigest().lower()
|
||||
if len(expected) != 64 or actual != expected:
|
||||
raise RuntimeError("ERP Local Agent update package SHA256 verification failed.")
|
||||
|
||||
updates = self.install_dir / "updates"
|
||||
updates.mkdir(parents=True, exist_ok=True)
|
||||
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
|
||||
package_path = updates / f"ERP_Local_Agent_{safe}.zip"
|
||||
package_path.write_bytes(package)
|
||||
self._set_progress("download", "downloading", "staging", 82, "Package verified. Preparing update files.", target_version=latest)
|
||||
staged = updates / f"staged_{safe}"
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
archive.extractall(staged)
|
||||
if not (staged / "erp_local_agent" / "__init__.py").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is incomplete.")
|
||||
if not (staged / "requirements.txt").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is missing requirements.txt.")
|
||||
self._record("download", latest, "downloaded", sha256=actual, package_path=str(package_path))
|
||||
if self.db is not None:
|
||||
self.db.set_meta("staged_update_version", latest)
|
||||
self.db.set_meta("staged_update_path", str(staged))
|
||||
self._set_progress("download", "downloaded", "complete", 100, f"ERP Local Agent {latest} downloaded and verified.", target_version=latest)
|
||||
return self.status()
|
||||
|
||||
def download_latest(self) -> dict:
|
||||
with self.lock:
|
||||
if self.busy:
|
||||
raise RuntimeError("Another update operation is already in progress.")
|
||||
self.busy = True
|
||||
try:
|
||||
if not self.latest_manifest:
|
||||
self.check_for_update(force=True)
|
||||
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
||||
expected = str(self.latest_manifest.get("sha256") or "").lower().strip()
|
||||
if not latest or not _is_newer(latest, __version__):
|
||||
return self.status()
|
||||
package = self.client.download_update_package()
|
||||
actual = hashlib.sha256(package).hexdigest().lower()
|
||||
if len(expected) != 64 or actual != expected:
|
||||
raise RuntimeError("ERP Local Agent update package SHA256 verification failed.")
|
||||
updates = self.install_dir / "updates"
|
||||
updates.mkdir(parents=True, exist_ok=True)
|
||||
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
|
||||
package_path = updates / f"ERP_Local_Agent_{safe}.zip"
|
||||
package_path.write_bytes(package)
|
||||
staged = updates / f"staged_{safe}"
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
archive.extractall(staged)
|
||||
if not (staged / "erp_local_agent" / "__init__.py").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is incomplete.")
|
||||
if not (staged / "requirements.txt").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is missing requirements.txt.")
|
||||
self._record("download", latest, "downloaded", sha256=actual, package_path=str(package_path))
|
||||
if self.db is not None:
|
||||
self.db.set_meta("staged_update_version", latest)
|
||||
self.db.set_meta("staged_update_path", str(staged))
|
||||
return self.status()
|
||||
except Exception as exc:
|
||||
latest = str(self.latest_manifest.get("latest_version") or "")
|
||||
self._record("download", latest, "failed", error_message=str(exc))
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
return self._download_body()
|
||||
except Exception as exc:
|
||||
latest = str(self.latest_manifest.get("latest_version") or "")
|
||||
self._record("download", latest, "failed", error_message=str(exc))
|
||||
self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest)
|
||||
raise
|
||||
finally:
|
||||
with self.lock:
|
||||
self.busy = False
|
||||
|
||||
def start_download(self) -> dict:
|
||||
"""Start download in the background so the dashboard can display live progress."""
|
||||
with self.lock:
|
||||
if self.busy:
|
||||
raise RuntimeError("Another update operation is already in progress.")
|
||||
self.busy = True
|
||||
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
||||
self._set_progress("download", "downloading", "starting", 5, "Starting update download.", target_version=latest)
|
||||
|
||||
def runner():
|
||||
try:
|
||||
self._download_body()
|
||||
except Exception as exc:
|
||||
latest_inner = str(self.latest_manifest.get("latest_version") or "")
|
||||
self._record("download", latest_inner, "failed", error_message=str(exc))
|
||||
self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest_inner)
|
||||
self.logger.exception("ERP Local Agent update download failed: %s", exc)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.busy = False
|
||||
|
||||
threading.Thread(target=runner, name="erp-local-agent-update-download", daemon=True).start()
|
||||
return self.status()
|
||||
|
||||
def install_downloaded(self) -> dict:
|
||||
"""Ask the persistent desktop supervisor to install the staged worker."""
|
||||
with self.lock:
|
||||
latest = str(
|
||||
(self.db.get_meta("staged_update_version") if self.db else "")
|
||||
@@ -159,17 +228,18 @@ class AgentUpdater:
|
||||
raise RuntimeError("No downloaded ERP Local Agent update is available to install.")
|
||||
if not _is_newer(latest, __version__):
|
||||
return self.status()
|
||||
|
||||
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
|
||||
staged = self.install_dir / "updates" / f"staged_{safe}"
|
||||
if not (staged / "erp_local_agent" / "__init__.py").exists():
|
||||
raise RuntimeError("The downloaded update is not staged correctly. Download it again.")
|
||||
|
||||
supervisor_lock = self.install_dir / "data" / "supervisor.lock"
|
||||
if not supervisor_lock.exists():
|
||||
# The owner file is diagnostic, while the supervisor's Global mutex is authoritative.
|
||||
# We require a recently written owner file only to avoid handing an update to no supervisor.
|
||||
owner_file = self.install_dir / "data" / "supervisor_owner.json"
|
||||
if not owner_file.exists():
|
||||
raise RuntimeError(
|
||||
"ERP Local Agent Supervisor is not running. Start the Local Agent from the "
|
||||
"desktop shortcut or restart the 'ERP Local Agent' Scheduled Task."
|
||||
"ERP Local Agent Supervisor is not running. Start the Local Agent from the desktop shortcut "
|
||||
"or restart the 'ERP Local Agent' Scheduled Task."
|
||||
)
|
||||
|
||||
request_path = self.install_dir / "updates" / "supervisor_request.json"
|
||||
@@ -182,21 +252,14 @@ class AgentUpdater:
|
||||
"requested_by_pid": os.getpid(),
|
||||
"automatic": bool(self.config.auto_install_updates),
|
||||
}
|
||||
import json
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(request_path)
|
||||
|
||||
self._set_progress("install", "queued", "queued", 5, f"ERP Local Agent {latest} update queued for safe installation.", target_version=latest)
|
||||
self._record("install_requested", latest, "pending", package_path=str(staged))
|
||||
self.logger.warning(
|
||||
"ERP Local Agent update %s handed to persistent supervisor for controlled restart.",
|
||||
latest,
|
||||
)
|
||||
self.logger.warning("ERP Local Agent update %s handed to persistent supervisor for controlled restart.", latest)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"ERP Local Agent {latest} installation has been handed to the desktop supervisor. "
|
||||
"The worker will restart automatically and the desktop dashboard will reconnect."
|
||||
),
|
||||
"message": f"ERP Local Agent {latest} installation is queued. Keep this dashboard open to watch progress.",
|
||||
"target_version": latest,
|
||||
}
|
||||
|
||||
@@ -215,11 +278,9 @@ class AgentUpdater:
|
||||
state_path = self.install_dir / "data" / "supervisor_state.json"
|
||||
try:
|
||||
if state_path.exists():
|
||||
import json
|
||||
supervisor_state = json.loads(state_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
supervisor_state = {}
|
||||
|
||||
return {
|
||||
"current_version": __version__,
|
||||
"latest_version": latest,
|
||||
@@ -231,4 +292,5 @@ class AgentUpdater:
|
||||
"automatic_install_enabled": bool(self.config.auto_update and self.config.auto_install_updates),
|
||||
"manual_update_only": bool(getattr(self.config, "manual_update_only", False)),
|
||||
"supervisor": supervisor_state,
|
||||
"progress": self._progress(),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@echo off
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
if not exist ".env" ( echo ERROR: .env file is missing. Download the configured ERP Local Agent package from ERP. & pause & exit /b 1 )
|
||||
@@ -13,7 +13,7 @@ set "AGENT_DIR=%~dp0"
|
||||
set "AGENT_PYTHONW=%~dp0.venv\Scripts\pythonw.exe"
|
||||
set "SUPERVISOR=%~dp0ERPAgentSupervisor.pyw"
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$env:AGENT_DIR.TrimEnd('\')+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHONW; $s=$env:SUPERVISOR; $a=New-ScheduledTaskAction -Execute $p -Argument ('\"'+$s+'\" --background') -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHONW; $s=$env:SUPERVISOR; $arg='"' + $s + '" --background'; $a=New-ScheduledTaskAction -Execute $p -Argument $arg -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'"
|
||||
if errorlevel 1 goto :failed
|
||||
|
||||
echo ERP Local Agent Supervisor installed and started successfully.
|
||||
|
||||
Reference in New Issue
Block a user