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,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"