Repair Local Agent supervisor and add update progress UI
This commit is contained in:
@@ -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(
|
||||||
|
OWNER_FILE,
|
||||||
|
{
|
||||||
"supervisor_pid": os.getpid(),
|
"supervisor_pid": os.getpid(),
|
||||||
"install_root": str(INSTALL_ROOT),
|
"install_root": str(INSTALL_ROOT),
|
||||||
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"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()
|
||||||
|
if backup is not None:
|
||||||
self._restore_backup(backup)
|
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,18 +440,24 @@ 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:
|
||||||
|
try:
|
||||||
self.stop_worker()
|
self.stop_worker()
|
||||||
|
finally:
|
||||||
self.release_lock()
|
self.release_lock()
|
||||||
_state("stopped", "ERP Local Agent supervisor stopped.")
|
_state("stopped", "ERP Local Agent supervisor stopped.")
|
||||||
|
|
||||||
@@ -522,18 +465,9 @@ class Supervisor:
|
|||||||
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__":
|
||||||
|
try:
|
||||||
raise SystemExit(main())
|
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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[m]))}
|
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 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":
|
|
||||||
|
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
|
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)
|
|
||||||
|
|||||||
@@ -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,27 +139,28 @@ 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_latest(self) -> dict:
|
def _download_body(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:
|
if not self.latest_manifest:
|
||||||
self.check_for_update(force=True)
|
self.check_for_update(force=True)
|
||||||
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
||||||
expected = str(self.latest_manifest.get("sha256") or "").lower().strip()
|
expected = str(self.latest_manifest.get("sha256") or "").lower().strip()
|
||||||
if not latest or not _is_newer(latest, __version__):
|
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()
|
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()
|
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()
|
actual = hashlib.sha256(package).hexdigest().lower()
|
||||||
if len(expected) != 64 or actual != expected:
|
if len(expected) != 64 or actual != expected:
|
||||||
raise RuntimeError("ERP Local Agent update package SHA256 verification failed.")
|
raise RuntimeError("ERP Local Agent update package SHA256 verification failed.")
|
||||||
|
|
||||||
updates = self.install_dir / "updates"
|
updates = self.install_dir / "updates"
|
||||||
updates.mkdir(parents=True, exist_ok=True)
|
updates.mkdir(parents=True, exist_ok=True)
|
||||||
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"
|
||||||
package_path = updates / f"ERP_Local_Agent_{safe}.zip"
|
package_path = updates / f"ERP_Local_Agent_{safe}.zip"
|
||||||
package_path.write_bytes(package)
|
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}"
|
staged = updates / f"staged_{safe}"
|
||||||
shutil.rmtree(staged, ignore_errors=True)
|
shutil.rmtree(staged, ignore_errors=True)
|
||||||
staged.mkdir(parents=True, exist_ok=True)
|
staged.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -139,16 +174,50 @@ class AgentUpdater:
|
|||||||
if self.db is not None:
|
if self.db is not None:
|
||||||
self.db.set_meta("staged_update_version", latest)
|
self.db.set_meta("staged_update_version", latest)
|
||||||
self.db.set_meta("staged_update_path", str(staged))
|
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()
|
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:
|
||||||
|
return self._download_body()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
latest = str(self.latest_manifest.get("latest_version") or "")
|
latest = str(self.latest_manifest.get("latest_version") or "")
|
||||||
self._record("download", latest, "failed", error_message=str(exc))
|
self._record("download", latest, "failed", error_message=str(exc))
|
||||||
|
self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
with self.lock:
|
||||||
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user