Add persistent desktop supervisor for ERP Local Agent
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from tkinter import Tk, messagebox
|
||||
|
||||
|
||||
INSTALL_ROOT = Path(__file__).resolve().parent
|
||||
DATA_DIR = INSTALL_ROOT / "data"
|
||||
SUPERVISOR_LOCK = DATA_DIR / "supervisor.lock"
|
||||
SUPERVISOR_SCRIPT = INSTALL_ROOT / "ERPAgentSupervisor.pyw"
|
||||
URL = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def _create_no_window() -> int:
|
||||
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def _popup(title: str, message: str, error: bool = False) -> None:
|
||||
root = Tk()
|
||||
root.withdraw()
|
||||
if error:
|
||||
messagebox.showerror(title, message)
|
||||
else:
|
||||
messagebox.showinfo(title, message)
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _supervisor_running() -> bool:
|
||||
try:
|
||||
if not SUPERVISOR_LOCK.exists():
|
||||
return False
|
||||
return _pid_alive(int(SUPERVISOR_LOCK.read_text(encoding="utf-8").strip()))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pythonw() -> Path:
|
||||
pyw = INSTALL_ROOT / ".venv" / "Scripts" / "pythonw.exe"
|
||||
if pyw.exists():
|
||||
return pyw
|
||||
py = INSTALL_ROOT / ".venv" / "Scripts" / "python.exe"
|
||||
if py.exists():
|
||||
return py
|
||||
raise RuntimeError("ERP Local Agent Python environment was not found.")
|
||||
|
||||
|
||||
def _ensure_supervisor() -> None:
|
||||
if _supervisor_running():
|
||||
return
|
||||
subprocess.Popen(
|
||||
[str(_pythonw()), str(SUPERVISOR_SCRIPT), "--background"],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_create_no_window(),
|
||||
close_fds=True,
|
||||
)
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
if _supervisor_running():
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("ERP Local Agent supervisor did not start.")
|
||||
|
||||
|
||||
def _ready(timeout: float = 1.5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(URL + "/api/status", timeout=timeout) as response:
|
||||
return int(getattr(response, "status", 200)) < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_ready(seconds: int = 75) -> bool:
|
||||
deadline = time.time() + seconds
|
||||
while time.time() < deadline:
|
||||
if _ready():
|
||||
return True
|
||||
time.sleep(0.75)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
_ensure_supervisor()
|
||||
except Exception as exc:
|
||||
_popup("ERP Local Agent", str(exc), error=True)
|
||||
return
|
||||
|
||||
if not _wait_ready():
|
||||
_popup(
|
||||
"ERP Local Agent",
|
||||
"The Local Agent supervisor is running, but the dashboard did not become ready.\n\n"
|
||||
f"Check logs under:\n{INSTALL_ROOT / 'logs'}",
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
import webview
|
||||
except Exception as exc:
|
||||
_popup(
|
||||
"ERP Local Agent Dashboard",
|
||||
"Desktop dashboard dependency could not be loaded.\n\n"
|
||||
f"{exc}\n\nRun the Local Agent updater/installer again.",
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
window = webview.create_window(
|
||||
"ERP Local Agent Dashboard",
|
||||
URL,
|
||||
width=1180,
|
||||
height=820,
|
||||
min_size=(980, 650),
|
||||
confirm_close=False,
|
||||
)
|
||||
|
||||
def monitor():
|
||||
was_ready = True
|
||||
outage_seen = False
|
||||
while True:
|
||||
try:
|
||||
current = _ready()
|
||||
if was_ready and not current:
|
||||
outage_seen = True
|
||||
if outage_seen and current:
|
||||
try:
|
||||
window.load_url(URL)
|
||||
except Exception:
|
||||
pass
|
||||
outage_seen = False
|
||||
was_ready = current
|
||||
time.sleep(1.5)
|
||||
except Exception:
|
||||
time.sleep(2)
|
||||
|
||||
webview.start(monitor)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,418 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
INSTALL_ROOT = Path(__file__).resolve().parent
|
||||
DATA_DIR = INSTALL_ROOT / "data"
|
||||
LOG_DIR = INSTALL_ROOT / "logs"
|
||||
UPDATES_DIR = INSTALL_ROOT / "updates"
|
||||
LOCK_FILE = DATA_DIR / "supervisor.lock"
|
||||
STATE_FILE = DATA_DIR / "supervisor_state.json"
|
||||
REQUEST_FILE = UPDATES_DIR / "supervisor_request.json"
|
||||
WORKER_LOG = LOG_DIR / "worker-supervisor.log"
|
||||
SUPERVISOR_LOG = LOG_DIR / "supervisor.log"
|
||||
DASHBOARD_URL = "http://127.0.0.1:8788"
|
||||
WORKER_MODULE = "erp_local_agent.main"
|
||||
RESTART_DELAY_SECONDS = 3
|
||||
HEALTH_TIMEOUT_SECONDS = 60
|
||||
|
||||
|
||||
def _create_no_window() -> int:
|
||||
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n"
|
||||
try:
|
||||
with SUPERVISOR_LOG.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _state(status: str, message: str, **extra) -> None:
|
||||
payload = {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"supervisor_pid": os.getpid(),
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
payload.update(extra)
|
||||
_write_json_atomic(STATE_FILE, payload)
|
||||
_log(f"{status}: {message}")
|
||||
|
||||
|
||||
def _dashboard_ready(timeout: float = 1.5) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(DASHBOARD_URL + "/api/status", timeout=timeout) as response:
|
||||
return int(getattr(response, "status", 200)) < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _python(console: bool = True) -> Path:
|
||||
name = "python.exe" if console else "pythonw.exe"
|
||||
candidate = INSTALL_ROOT / ".venv" / "Scripts" / name
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
fallback = INSTALL_ROOT / ".venv" / "Scripts" / "python.exe"
|
||||
if fallback.exists():
|
||||
return fallback
|
||||
raise RuntimeError(f"ERP Local Agent Python environment not found under {INSTALL_ROOT / '.venv'}")
|
||||
|
||||
|
||||
def _terminate_process_tree(process: subprocess.Popen | None) -> None:
|
||||
if process is None:
|
||||
return
|
||||
if process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_create_no_window(),
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _kill_unmanaged_workers() -> None:
|
||||
script = (
|
||||
"$root='" + str(INSTALL_ROOT).replace("'", "''") + "';"
|
||||
"Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | "
|
||||
"Where-Object { $_.CommandLine -and $_.CommandLine -match 'erp_local_agent\\.main' "
|
||||
"-and $_.CommandLine -like ('*'+$root+'*') } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=_create_no_window(),
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class Supervisor:
|
||||
def __init__(self):
|
||||
self.worker: subprocess.Popen | None = None
|
||||
self.running = True
|
||||
|
||||
def acquire_lock(self) -> bool:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if LOCK_FILE.exists():
|
||||
try:
|
||||
existing = int(LOCK_FILE.read_text(encoding="utf-8").strip() or "0")
|
||||
except Exception:
|
||||
existing = 0
|
||||
if existing and existing != os.getpid() and _pid_alive(existing):
|
||||
return False
|
||||
LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8")
|
||||
return True
|
||||
|
||||
def release_lock(self) -> None:
|
||||
try:
|
||||
if LOCK_FILE.exists() and LOCK_FILE.read_text(encoding="utf-8").strip() == str(os.getpid()):
|
||||
LOCK_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start_worker(self) -> None:
|
||||
if self.worker is not None and self.worker.poll() is None:
|
||||
return
|
||||
_kill_unmanaged_workers()
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
py = _python(console=True)
|
||||
handle = WORKER_LOG.open("a", encoding="utf-8")
|
||||
handle.write(f"\n--- worker start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
|
||||
handle.flush()
|
||||
self.worker = subprocess.Popen(
|
||||
[str(py), "-m", WORKER_MODULE],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=handle,
|
||||
stderr=handle,
|
||||
creationflags=_create_no_window(),
|
||||
)
|
||||
_state("running", "ERP Local Agent worker started.", worker_pid=self.worker.pid)
|
||||
|
||||
def stop_worker(self) -> None:
|
||||
_state("stopping", "Stopping ERP Local Agent worker.")
|
||||
_terminate_process_tree(self.worker)
|
||||
self.worker = None
|
||||
_kill_unmanaged_workers()
|
||||
|
||||
@staticmethod
|
||||
def _read_request() -> dict | None:
|
||||
if not REQUEST_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(REQUEST_FILE.read_text(encoding="utf-8"))
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception as exc:
|
||||
_state("error", f"Invalid supervisor update request: {exc}")
|
||||
try:
|
||||
REQUEST_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _clear_request() -> None:
|
||||
try:
|
||||
REQUEST_FILE.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _validate_staged(staged: Path) -> None:
|
||||
if not staged.exists() or not staged.is_dir():
|
||||
raise RuntimeError(f"Staged update directory not found: {staged}")
|
||||
if not (staged / "erp_local_agent" / "__init__.py").is_file():
|
||||
raise RuntimeError("Staged update does not contain erp_local_agent/__init__.py.")
|
||||
if not (staged / "requirements.txt").is_file():
|
||||
raise RuntimeError("Staged update does not contain requirements.txt.")
|
||||
|
||||
@staticmethod
|
||||
def _backup_runtime(version: str) -> Path:
|
||||
backup = UPDATES_DIR / f"supervisor_backup_{time.strftime('%Y%m%d_%H%M%S')}_{version}"
|
||||
backup.mkdir(parents=True, exist_ok=True)
|
||||
agent = INSTALL_ROOT / "erp_local_agent"
|
||||
if agent.exists():
|
||||
shutil.copytree(agent, backup / "erp_local_agent")
|
||||
for name in (
|
||||
"requirements.txt",
|
||||
"README_ERP_LOCAL_AGENT.txt",
|
||||
"run_agent.bat",
|
||||
"run_once.bat",
|
||||
"check_config.py",
|
||||
"check_config.bat",
|
||||
"open_dashboard.bat",
|
||||
"install_task_scheduler.bat",
|
||||
"start_task_scheduler.bat",
|
||||
"stop_task_scheduler.bat",
|
||||
"status_task_scheduler.bat",
|
||||
"uninstall_task_scheduler.bat",
|
||||
):
|
||||
src = INSTALL_ROOT / name
|
||||
if src.exists():
|
||||
shutil.copy2(src, backup / name)
|
||||
return backup
|
||||
|
||||
@staticmethod
|
||||
def _replace_worker_runtime(staged: Path) -> None:
|
||||
target = INSTALL_ROOT / "erp_local_agent"
|
||||
new_target = INSTALL_ROOT / "erp_local_agent.__new__"
|
||||
old_target = INSTALL_ROOT / "erp_local_agent.__old__"
|
||||
|
||||
shutil.rmtree(new_target, ignore_errors=True)
|
||||
shutil.rmtree(old_target, ignore_errors=True)
|
||||
shutil.copytree(staged / "erp_local_agent", new_target)
|
||||
|
||||
if target.exists():
|
||||
target.rename(old_target)
|
||||
try:
|
||||
new_target.rename(target)
|
||||
except Exception:
|
||||
if old_target.exists() and not target.exists():
|
||||
old_target.rename(target)
|
||||
raise
|
||||
shutil.rmtree(old_target, ignore_errors=True)
|
||||
|
||||
# Root helper files are safe to refresh while the stable supervisor is running.
|
||||
# Supervisor/dashboard launchers themselves are intentionally excluded here;
|
||||
# they are updated only by the explicit direct installer.
|
||||
for name in (
|
||||
"requirements.txt",
|
||||
"README_ERP_LOCAL_AGENT.txt",
|
||||
"run_agent.bat",
|
||||
"run_once.bat",
|
||||
"check_config.py",
|
||||
"check_config.bat",
|
||||
"open_dashboard.bat",
|
||||
"install_task_scheduler.bat",
|
||||
"start_task_scheduler.bat",
|
||||
"stop_task_scheduler.bat",
|
||||
"status_task_scheduler.bat",
|
||||
"uninstall_task_scheduler.bat",
|
||||
):
|
||||
src = staged / name
|
||||
if src.exists():
|
||||
shutil.copy2(src, INSTALL_ROOT / name)
|
||||
|
||||
@staticmethod
|
||||
def _install_requirements_and_verify() -> None:
|
||||
py = _python(console=True)
|
||||
req = INSTALL_ROOT / "requirements.txt"
|
||||
result = subprocess.run(
|
||||
[str(py), "-m", "pip", "install", "-r", str(req)],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
creationflags=_create_no_window(),
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("Dependency installation failed:\n" + result.stdout[-4000:])
|
||||
|
||||
code = (
|
||||
"import erp_local_agent;"
|
||||
"from erp_local_agent import accounting_store,commands,tally,updater;"
|
||||
"print(erp_local_agent.__version__)"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[str(py), "-c", code],
|
||||
cwd=str(INSTALL_ROOT),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
creationflags=_create_no_window(),
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("Updated worker import verification failed:\n" + result.stdout[-4000:])
|
||||
|
||||
@staticmethod
|
||||
def _restore_backup(backup: Path) -> None:
|
||||
target = INSTALL_ROOT / "erp_local_agent"
|
||||
backup_agent = backup / "erp_local_agent"
|
||||
if backup_agent.exists():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
shutil.copytree(backup_agent, target)
|
||||
for item in backup.iterdir():
|
||||
if item.name == "erp_local_agent":
|
||||
continue
|
||||
if item.is_file():
|
||||
shutil.copy2(item, INSTALL_ROOT / item.name)
|
||||
|
||||
def apply_update(self, request: dict) -> None:
|
||||
version = str(request.get("version") or "").strip()
|
||||
staged_text = str(request.get("staged_dir") or "").strip()
|
||||
if not version or not staged_text:
|
||||
raise RuntimeError("Supervisor update request is missing version/staged_dir.")
|
||||
|
||||
staged = Path(staged_text).resolve()
|
||||
self._validate_staged(staged)
|
||||
_state("installing", f"Installing ERP Local Agent {version}.", target_version=version)
|
||||
|
||||
self.stop_worker()
|
||||
backup = self._backup_runtime(version)
|
||||
try:
|
||||
self._replace_worker_runtime(staged)
|
||||
self._install_requirements_and_verify()
|
||||
self.start_worker()
|
||||
|
||||
deadline = time.time() + HEALTH_TIMEOUT_SECONDS
|
||||
while time.time() < deadline:
|
||||
if self.worker is not None and self.worker.poll() is not None:
|
||||
raise RuntimeError(f"Updated worker exited with code {self.worker.returncode}.")
|
||||
if _dashboard_ready():
|
||||
_state(
|
||||
"updated",
|
||||
f"ERP Local Agent {version} installed and restarted successfully.",
|
||||
target_version=version,
|
||||
worker_pid=(self.worker.pid if self.worker else None),
|
||||
backup_path=str(backup),
|
||||
)
|
||||
return
|
||||
time.sleep(1)
|
||||
|
||||
raise RuntimeError("Updated worker did not become healthy within the restart timeout.")
|
||||
except Exception:
|
||||
_state("rollback", f"Update {version} failed. Restoring previous worker.", target_version=version)
|
||||
self.stop_worker()
|
||||
self._restore_backup(backup)
|
||||
self.start_worker()
|
||||
raise
|
||||
|
||||
def run(self) -> int:
|
||||
if not self.acquire_lock():
|
||||
return 0
|
||||
|
||||
try:
|
||||
_state("starting", "ERP Local Agent supervisor starting.")
|
||||
self.start_worker()
|
||||
|
||||
while self.running:
|
||||
request = self._read_request()
|
||||
if request and str(request.get("action") or "") == "install_update":
|
||||
self._clear_request()
|
||||
try:
|
||||
self.apply_update(request)
|
||||
except Exception as exc:
|
||||
_state("error", f"Update failed and worker was restored/restarted: {exc}")
|
||||
|
||||
if self.worker is None or self.worker.poll() is not None:
|
||||
code = None if self.worker is None else self.worker.returncode
|
||||
_state("restarting", f"ERP Local Agent worker stopped (exit={code}); restarting.")
|
||||
time.sleep(RESTART_DELAY_SECONDS)
|
||||
self.start_worker()
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return 0
|
||||
finally:
|
||||
self.stop_worker()
|
||||
self.release_lock()
|
||||
_state("stopped", "ERP Local Agent supervisor stopped.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="ERP Local Agent desktop/background supervisor")
|
||||
parser.add_argument("--background", action="store_true")
|
||||
parser.parse_args()
|
||||
|
||||
supervisor = Supervisor()
|
||||
|
||||
def stop_handler(*_args):
|
||||
supervisor.running = False
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, stop_handler)
|
||||
signal.signal(signal.SIGINT, stop_handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return supervisor.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,26 @@
|
||||
Option Explicit
|
||||
|
||||
Dim fso, shell, baseDir, launcher, pyw, py, cmd
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
Set shell = CreateObject("WScript.Shell")
|
||||
|
||||
baseDir = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||
launcher = baseDir & "\ERPAgentDashboard.pyw"
|
||||
pyw = baseDir & "\.venv\Scripts\pythonw.exe"
|
||||
py = baseDir & "\.venv\Scripts\python.exe"
|
||||
|
||||
If Not fso.FileExists(launcher) Then
|
||||
MsgBox "ERPAgentDashboard.pyw was not found in " & baseDir, 16, "ERP Local Agent"
|
||||
WScript.Quit
|
||||
End If
|
||||
|
||||
If fso.FileExists(pyw) Then
|
||||
cmd = """" & pyw & """ """ & launcher & """"
|
||||
ElseIf fso.FileExists(py) Then
|
||||
cmd = """" & py & """ """ & launcher & """"
|
||||
Else
|
||||
MsgBox "ERP Local Agent Python environment was not found.", 16, "ERP Local Agent"
|
||||
WScript.Quit
|
||||
End If
|
||||
|
||||
shell.Run cmd, 0, False
|
||||
@@ -1,4 +1,4 @@
|
||||
ERP Local Agent 1.8.2
|
||||
ERP Local Agent 1.9.0
|
||||
|
||||
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
|
||||
|
||||
@@ -52,3 +52,17 @@ Accounting engagement-folder policy 1.8.2:
|
||||
- Depreciation/approval/write-back use the selected depreciation financial year.
|
||||
- Existing .act databases from original flat, v1.8.0 Local-Agent-data, and v1.8.1 Accounting\Clients locations are migrated safely by SQLite backup and retained as recovery copies.
|
||||
- The Accounting folder is the stable base for later Reports, Exports, Workpapers and Backups; this release does not change report-generation behavior.
|
||||
|
||||
|
||||
Desktop Supervisor 1.9.0
|
||||
------------------------
|
||||
- Scheduled Task now starts ERPAgentSupervisor.pyw instead of starting erp_local_agent.main directly.
|
||||
- Supervisor owns the worker lifecycle and automatically restarts the worker if it exits.
|
||||
- Dashboard update installation no longer makes the worker launch a detached PowerShell installer and os._exit().
|
||||
- Downloaded/staged updates are handed to the supervisor through updates\supervisor_request.json.
|
||||
- Supervisor stops the worker, backs it up, atomically replaces the worker package, installs requirements, verifies imports, restarts it, waits for dashboard health, and rolls back if startup fails.
|
||||
- Stable supervisor/dashboard launchers are deliberately not self-replaced during normal worker updates.
|
||||
- Desktop shortcut opens "Open ERP Local Agent Dashboard.vbs", which uses pythonw.exe + ERPAgentDashboard.pyw + pywebview.
|
||||
- Closing the desktop dashboard does NOT stop the supervisor or worker.
|
||||
- The dashboard automatically reconnects/reloads when the worker goes down and returns during an update.
|
||||
- Existing browser access at http://127.0.0.1:8788 remains available.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.8.2"
|
||||
__version__ = "1.9.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -144,8 +144,8 @@ 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, but it will never install an update without a local user clicking Install.</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 Downloaded Update</button><div id="message"></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>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>
|
||||
@@ -161,7 +161,7 @@ document.getElementById('download').disabled=!s.update.update_available;document
|
||||
async function refreshHistory(){const r=await fetch('/api/history',{cache:'no-store'});const h=await r.json();let rows=h.events.slice(0,12).map(x=>'<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>'}
|
||||
function escapeHtml(v){return String(v||'').replace(/[&<>'"]/g,m=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[m]))}
|
||||
async function action(url){msg('Working…',true);try{const r=await fetch(url,{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Operation failed');msg('Completed successfully.',true);await refresh()}catch(e){msg(e.message,false)}}
|
||||
async function installUpdate(){if(!confirm('Install the downloaded update now? The ERP Local Agent will restart.'))return;msg('Starting update installation…',true);try{const r=await fetch('/api/update/install',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Install failed');msg(j.message||'Update started. Refresh this page after the agent restarts.',true)}catch(e){msg(e.message,false)}}
|
||||
async function installUpdate(){if(!confirm('Install the downloaded update now? The desktop supervisor will restart only the Local Agent worker and will keep supervising the restart.'))return;msg('Starting update installation…',true);try{const r=await fetch('/api/update/install',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Install failed');msg(j.message||'Update handed to supervisor. The desktop dashboard will reconnect automatically after restart.',true)}catch(e){msg(e.message,false)}}
|
||||
function msg(t,ok){let e=document.getElementById('message');e.textContent=t;e.className=ok?'msgok':'msgbad'}
|
||||
refresh();setInterval(refresh,5000);
|
||||
</script></body></html>'''
|
||||
|
||||
@@ -109,28 +109,59 @@ class AgentUpdater:
|
||||
finally:
|
||||
self.busy = False
|
||||
|
||||
def install_downloaded(self) -> None:
|
||||
def install_downloaded(self) -> dict:
|
||||
"""Ask the persistent desktop supervisor to install the staged worker.
|
||||
|
||||
The worker does not terminate itself. This lets the HTTP request finish cleanly,
|
||||
then the supervisor owns stop/replace/restart/rollback.
|
||||
"""
|
||||
with self.lock:
|
||||
latest = str((self.db.get_meta("staged_update_version") if self.db else "") or self.latest_manifest.get("latest_version") or "").strip()
|
||||
latest = str(
|
||||
(self.db.get_meta("staged_update_version") if self.db else "")
|
||||
or self.latest_manifest.get("latest_version")
|
||||
or ""
|
||||
).strip()
|
||||
if not latest:
|
||||
raise RuntimeError("No downloaded ERP Local Agent update is available to install.")
|
||||
|
||||
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
|
||||
staged = self.install_dir / "updates" / f"staged_{safe}"
|
||||
if not (staged / "erp_local_agent" / "__init__.py").exists():
|
||||
raise RuntimeError("The downloaded update is not staged correctly. Download it again.")
|
||||
script_path = self.install_dir / "updates" / f"apply_{safe}.ps1"
|
||||
script_path.write_text(self._powershell_update_script(staged, latest), encoding="utf-8")
|
||||
|
||||
supervisor_lock = self.install_dir / "data" / "supervisor.lock"
|
||||
if not supervisor_lock.exists():
|
||||
raise RuntimeError(
|
||||
"ERP Local Agent Supervisor is not running. Start the Local Agent from the "
|
||||
"desktop shortcut or restart the 'ERP Local Agent' Scheduled Task."
|
||||
)
|
||||
|
||||
request_path = self.install_dir / "updates" / "supervisor_request.json"
|
||||
tmp = request_path.with_suffix(".json.tmp")
|
||||
payload = {
|
||||
"action": "install_update",
|
||||
"version": latest,
|
||||
"staged_dir": str(staged.resolve()),
|
||||
"requested_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"requested_by_pid": os.getpid(),
|
||||
}
|
||||
import json
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(request_path)
|
||||
|
||||
self._record("install_requested", latest, "pending", package_path=str(staged))
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
process = subprocess.Popen(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path), "-ParentPid", str(os.getpid())],
|
||||
cwd=str(self.install_dir), creationflags=flags, close_fds=True,
|
||||
self.logger.warning(
|
||||
"ERP Local Agent update %s handed to persistent supervisor for controlled restart.",
|
||||
latest,
|
||||
)
|
||||
if process.pid <= 0:
|
||||
raise RuntimeError("Could not launch the ERP Local Agent update installer.")
|
||||
self.logger.warning("ERP Local Agent update %s installation requested by local user; restarting.", latest)
|
||||
time.sleep(0.3)
|
||||
os._exit(0)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"ERP Local Agent {latest} installation has been handed to the desktop supervisor. "
|
||||
"The worker will restart automatically and the desktop dashboard will reconnect."
|
||||
),
|
||||
"target_version": latest,
|
||||
}
|
||||
|
||||
def status(self) -> dict:
|
||||
latest = str(self.latest_manifest.get("latest_version") or (self.db.get_meta("latest_agent_version") if self.db else "") or __version__).strip()
|
||||
@@ -139,6 +170,15 @@ class AgentUpdater:
|
||||
if staged_version:
|
||||
safe = "".join(ch for ch in staged_version if ch.isalnum() or ch in ".-_") or "update"
|
||||
staged_ok = (self.install_dir / "updates" / f"staged_{safe}" / "erp_local_agent" / "__init__.py").exists()
|
||||
supervisor_state = {}
|
||||
state_path = self.install_dir / "data" / "supervisor_state.json"
|
||||
try:
|
||||
if state_path.exists():
|
||||
import json
|
||||
supervisor_state = json.loads(state_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
supervisor_state = {}
|
||||
|
||||
return {
|
||||
"current_version": __version__,
|
||||
"latest_version": latest,
|
||||
@@ -148,44 +188,5 @@ class AgentUpdater:
|
||||
"busy": bool(self.busy),
|
||||
"automatic_check_enabled": bool(self.config.auto_update),
|
||||
"automatic_install_enabled": False,
|
||||
"supervisor": supervisor_state,
|
||||
}
|
||||
|
||||
def _powershell_update_script(self, staged: Path, latest: str) -> str:
|
||||
install = str(self.install_dir).replace("'", "''")
|
||||
stage = str(staged.resolve()).replace("'", "''")
|
||||
latest_safe = str(latest).replace("'", "''")
|
||||
lines = [
|
||||
"param([int]$ParentPid)",
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
"$InstallDir = '" + install + "'",
|
||||
"$StagedDir = '" + stage + "'",
|
||||
"$TargetVersion = '" + latest_safe + "'",
|
||||
"$TaskName = 'ERP Local Agent'",
|
||||
"$BackupDir = Join-Path $InstallDir ('updates\\backup_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))",
|
||||
"try {",
|
||||
" if ($ParentPid -gt 0) { try { Wait-Process -Id $ParentPid -Timeout 120 -ErrorAction SilentlyContinue } catch {} }",
|
||||
" try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}",
|
||||
" Start-Sleep -Seconds 2",
|
||||
" Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'erp_local_agent\\.main' -and $_.CommandLine -like ('*' + $InstallDir + '*') } | ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} }",
|
||||
" Start-Sleep -Seconds 2",
|
||||
" New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null",
|
||||
" if (Test-Path (Join-Path $InstallDir 'erp_local_agent')) { Copy-Item (Join-Path $InstallDir 'erp_local_agent') -Destination $BackupDir -Recurse -Force }",
|
||||
" Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction Stop",
|
||||
" Copy-Item (Join-Path $StagedDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force",
|
||||
" foreach ($Item in @('requirements.txt','run_agent.bat','run_once.bat','check_config.py','check_config.bat','open_dashboard.bat','start_task_scheduler.bat','stop_task_scheduler.bat','status_task_scheduler.bat','install_task_scheduler.bat','uninstall_task_scheduler.bat')) { $Source=Join-Path $StagedDir $Item; if (Test-Path $Source) { Copy-Item $Source -Destination (Join-Path $InstallDir $Item) -Force } }",
|
||||
" $Python = Join-Path $InstallDir '.venv\\Scripts\\python.exe'",
|
||||
" if (-not (Test-Path $Python)) { throw 'ERP Local Agent virtual environment is missing.' }",
|
||||
" & $Python -m pip install -r (Join-Path $InstallDir 'requirements.txt')",
|
||||
" if ($LASTEXITCODE -ne 0) { throw 'Dependency update failed.' }",
|
||||
" Push-Location $InstallDir",
|
||||
" try { $InstalledVersion = & $Python -c \"import erp_local_agent; print(getattr(erp_local_agent,'__version__',''))\" } finally { Pop-Location }",
|
||||
" if (($InstalledVersion | Out-String).Trim() -ne $TargetVersion) { throw ('Installed version verification failed. Expected ' + $TargetVersion + ', got ' + (($InstalledVersion | Out-String).Trim())) }",
|
||||
" Start-ScheduledTask -TaskName $TaskName",
|
||||
"} catch {",
|
||||
" try { if (Test-Path (Join-Path $BackupDir 'erp_local_agent')) { Remove-Item (Join-Path $InstallDir 'erp_local_agent') -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item (Join-Path $BackupDir 'erp_local_agent') -Destination $InstallDir -Recurse -Force }; Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {}",
|
||||
" New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') | Out-Null",
|
||||
" Add-Content -Path (Join-Path $InstallDir 'logs\\update_error.log') -Value ((Get-Date).ToString('s') + ' ' + $_.Exception.Message)",
|
||||
" exit 1",
|
||||
"}",
|
||||
]
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
@echo off
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
if not exist ".env" ( echo ERROR: .env file is missing. Download the configured ERP Local Agent package from ERP. & pause & exit /b 1 )
|
||||
if not exist ".venv\Scripts\python.exe" ( py -3 -m venv .venv & if errorlevel 1 ( echo ERROR: Unable to create Python virtual environment. & pause & exit /b 1 ) )
|
||||
|
||||
".venv\Scripts\python.exe" -m pip install --upgrade pip
|
||||
if errorlevel 1 goto :failed
|
||||
".venv\Scripts\python.exe" -m pip install -r requirements.txt
|
||||
if errorlevel 1 goto :failed
|
||||
|
||||
set "AGENT_DIR=%~dp0"
|
||||
set "AGENT_PYTHON=%~dp0.venv\Scripts\python.exe"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHON; $a=New-ScheduledTaskAction -Execute $p -Argument '-m erp_local_agent.main' -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $s -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'"
|
||||
set "AGENT_PYTHONW=%~dp0.venv\Scripts\pythonw.exe"
|
||||
set "SUPERVISOR=%~dp0ERPAgentSupervisor.pyw"
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; try { Stop-ScheduledTask -TaskName 'AuditFirmStorageAgent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$env:AGENT_DIR.TrimEnd('\')+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }; $d=$env:AGENT_DIR.TrimEnd('\'); $p=$env:AGENT_PYTHONW; $s=$env:SUPERVISOR; $a=New-ScheduledTaskAction -Execute $p -Argument ('\"'+$s+'\" --background') -WorkingDirectory $d; $t=New-ScheduledTaskTrigger -AtStartup; $pr=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $settings=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName 'ERP Local Agent' -Action $a -Trigger $t -Principal $pr -Settings $settings -Force | Out-Null; Start-ScheduledTask -TaskName 'ERP Local Agent'"
|
||||
if errorlevel 1 goto :failed
|
||||
echo ERP Local Agent installed and started successfully.
|
||||
echo Future compatible releases will update automatically from ERP.
|
||||
|
||||
echo ERP Local Agent Supervisor installed and started successfully.
|
||||
echo Use the desktop shortcut "ERP Local Agent Dashboard" to open the local dashboard.
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:failed
|
||||
echo ERROR: ERP Local Agent installation failed. Run this file as Administrator.
|
||||
pause
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
@echo off
|
||||
start "" "http://127.0.0.1:8788"
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
wscript.exe "%~dp0Open ERP Local Agent Dashboard.vbs"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
requests==2.32.3
|
||||
python-dotenv==1.0.1
|
||||
websockets==12.0
|
||||
pywebview==5.3.2
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
@echo off
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
".venv\Scripts\python.exe" -m erp_local_agent.main
|
||||
if exist ".venv\Scripts\pythonw.exe" (
|
||||
".venv\Scripts\pythonw.exe" "ERPAgentSupervisor.pyw" --background
|
||||
) else (
|
||||
echo ERROR: Local Agent virtual environment not found.
|
||||
pause
|
||||
)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Stop-ScheduledTask -TaskName 'ERP Local Agent'"
|
||||
@echo off
|
||||
setlocal
|
||||
set "AGENT_DIR=%~dp0"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue; $root=$env:AGENT_DIR.TrimEnd('\'); Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$root+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName 'AuditFirmStorageAgent' -Confirm:$false -ErrorAction SilentlyContinue"
|
||||
@echo off
|
||||
setlocal
|
||||
set "AGENT_DIR=%~dp0"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$root=$env:AGENT_DIR.TrimEnd('\'); try { Stop-ScheduledTask -TaskName 'ERP Local Agent' -ErrorAction SilentlyContinue } catch {}; try { Unregister-ScheduledTask -TaskName 'ERP Local Agent' -Confirm:$false -ErrorAction SilentlyContinue } catch {}; Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and (($_.CommandLine -match 'ERPAgentSupervisor\.pyw') -or ($_.CommandLine -match 'erp_local_agent\.main')) -and $_.CommandLine -like ('*'+$root+'*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
pause
|
||||
|
||||
Reference in New Issue
Block a user