Add persistent desktop supervisor for ERP Local Agent

This commit is contained in:
A R R R Associates
2026-08-20 20:01:59 +05:30
parent f7ebd32597
commit de8b32b06d
14 changed files with 707 additions and 72 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.8.2"
ERP_LOCAL_AGENT_VERSION = "1.9.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[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