Repair Local Agent supervisor and add update progress UI
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.22.11"
|
||||
__version__ = "1.22.12"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -63,7 +63,7 @@ class AgentDashboard:
|
||||
if self.path == "/api/update/check":
|
||||
return self._json({"ok": True, "update": dashboard.updater.check_for_update(force=True)})
|
||||
if self.path == "/api/update/download":
|
||||
return self._json({"ok": True, "update": dashboard.updater.download_latest()})
|
||||
return self._json({"ok": True, "update": dashboard.updater.start_download()})
|
||||
if self.path == "/api/update/install":
|
||||
state = dashboard.updater.status()
|
||||
if not state.get("downloaded"):
|
||||
@@ -136,7 +136,8 @@ class AgentDashboard:
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ERP Local Agent</title>
|
||||
<style>
|
||||
body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;margin:0}.wrap{max-width:1100px;margin:28px auto;padding:0 18px}h1{margin:0 0 4px}.muted{color:#6b7280}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:22px 0}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;box-shadow:0 1px 2px rgba(0,0,0,.04)}.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b7280}.value{font-size:22px;font-weight:650;margin-top:8px}.ok{color:#087f5b}.bad{color:#c92a2a}.warn{color:#b26a00}button{border:0;border-radius:8px;padding:10px 14px;margin:4px 6px 4px 0;font-weight:600;cursor:pointer;background:#111827;color:#fff}button.secondary{background:#e5e7eb;color:#111827}button:disabled{opacity:.5;cursor:not-allowed}.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;margin:14px 0}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #eee;vertical-align:top}#message{margin-top:10px;padding:10px;border-radius:8px;display:none}.msgok{display:block!important;background:#e6fcf5;color:#087f5b}.msgbad{display:block!important;background:#fff5f5;color:#c92a2a}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}</style></head>
|
||||
body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;margin:0}.wrap{max-width:1100px;margin:28px auto;padding:0 18px}h1{margin:0 0 4px}.muted{color:#6b7280}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:22px 0}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;box-shadow:0 1px 2px rgba(0,0,0,.04)}.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b7280}.value{font-size:22px;font-weight:650;margin-top:8px}.ok{color:#087f5b}.bad{color:#c92a2a}.warn{color:#b26a00}button{border:0;border-radius:8px;padding:10px 14px;margin:4px 6px 4px 0;font-weight:600;cursor:pointer;background:#111827;color:#fff}button.secondary{background:#e5e7eb;color:#111827}button:disabled{opacity:.5;cursor:not-allowed}.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;margin:14px 0}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #eee;vertical-align:top}#message{margin-top:10px;padding:10px;border-radius:8px;display:none}.msgok{display:block!important;background:#e6fcf5;color:#087f5b}.msgbad{display:block!important;background:#fff5f5;color:#c92a2a}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}.progressbox{margin-top:14px;padding:14px;border:1px solid #dbe3ea;border-radius:10px;background:#f8fafc}.progresshead{display:flex;justify-content:space-between;gap:12px;align-items:center}.progressbar{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-top:10px}.progressfill{height:100%;width:0%;background:#111827;transition:width .45s ease}.progressmsg{margin-top:8px;font-size:13px;color:#4b5563}.pulse{animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.65}50%{opacity:1}}
|
||||
</style></head>
|
||||
<body><div class="wrap"><h1>ERP Local Agent</h1><div class="muted">Local dashboard — available only on this computer</div>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">ERP Connection</div><div id="connection" class="value">Loading…</div><div id="heartbeat" class="muted"></div></div>
|
||||
@@ -144,24 +145,32 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg
|
||||
<div class="card"><div class="label">TallyPrime</div><div id="tally" class="value">—</div><div id="companies" class="muted"></div></div>
|
||||
<div class="card"><div class="label">Storage Free</div><div id="storage" class="value">—</div><div id="storageRoot" class="muted"></div></div>
|
||||
</div>
|
||||
<div class="section"><h2>Updates</h2><p class="muted">The agent checks for new versions automatically. Installation occurs only when a local user clicks Install; the persistent supervisor then restarts the worker safely.</p>
|
||||
<button onclick="action('/api/update/check')">Check for Update</button><button class="secondary" id="download" onclick="action('/api/update/download')">Download Update</button><button id="install" onclick="installUpdate()">Install & Restart Safely</button><div id="message"></div></div>
|
||||
<div class="section"><h2>Updates</h2><p class="muted">Check, download and apply Local Agent updates here. The dashboard stays open and shows progress while the worker restarts.</p>
|
||||
<button id="check" onclick="checkUpdate()">Check for Update</button><button class="secondary" id="download" onclick="downloadUpdate()">Download Update</button><button id="install" onclick="installUpdate()">Install & Restart Safely</button><div id="message"></div>
|
||||
<div id="progressBox" class="progressbox" style="display:none"><div class="progresshead"><strong id="progressTitle">Update</strong><span id="progressPct">0%</span></div><div class="progressbar"><div id="progressFill" class="progressfill"></div></div><div id="progressMsg" class="progressmsg">Waiting…</div></div></div>
|
||||
<div class="section"><h2>Tally</h2><button onclick="action('/api/tally/check')">Test Tally Connection</button><p class="muted">Tally is checked locally at <code>127.0.0.1:9000</code>. Port 9000 is not exposed to the internet.</p></div>
|
||||
<div class="section"><h2>Recent History</h2><div id="history" class="muted">Loading…</div></div>
|
||||
</div>
|
||||
<script>
|
||||
let installWatching=false,installTarget='',offlineTicks=0,lastServerPercent=0;
|
||||
function gb(n){return (n/1073741824).toFixed(1)+' GB'}
|
||||
async function refresh(){try{const r=await fetch('/api/status',{cache:'no-store'});const s=await r.json();
|
||||
function setProgress(title,pct,text,active=true){let b=document.getElementById('progressBox');b.style.display='block';document.getElementById('progressTitle').textContent=title;let p=Math.max(0,Math.min(100,Number(pct)||0));lastServerPercent=Math.max(lastServerPercent,p);document.getElementById('progressPct').textContent=Math.round(p)+'%';document.getElementById('progressFill').style.width=p+'%';document.getElementById('progressMsg').textContent=text||'';document.getElementById('progressFill').className='progressfill'+(active&&p<100?' pulse':'')}
|
||||
function applyProgress(u){let p=(u&&u.progress)||{};if(!p.operation)return;let title=p.operation==='check'?'Checking for Updates':p.operation==='download'?'Downloading Update':'Applying Update';setProgress(title,p.percent||0,p.message||p.phase||'',!['updated','downloaded','current','available','error'].includes(p.status));if(p.operation==='install'&&p.target_version){installTarget=p.target_version}}
|
||||
async function refresh(){try{const r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw new Error('status unavailable');const s=await r.json();offlineTicks=0;
|
||||
let c=s.agent.connection_state||'unknown';let ce=document.getElementById('connection');ce.textContent=c.charAt(0).toUpperCase()+c.slice(1);ce.className='value '+(c==='connected'?'ok':'bad');
|
||||
document.getElementById('heartbeat').textContent=s.agent.last_heartbeat_utc?'Last heartbeat: '+s.agent.last_heartbeat_utc:'No heartbeat recorded';
|
||||
document.getElementById('version').textContent=s.agent.version;document.getElementById('latest').textContent='Latest: '+s.update.latest_version+(s.update.update_available?' • update available':' • current');
|
||||
let te=document.getElementById('tally');let known=s.tally.connected!==null;te.textContent=!known?'Not checked':(s.tally.connected?'Connected':'Offline');te.className='value '+(!known?'warn':(s.tally.connected?'ok':'bad'));document.getElementById('companies').textContent=!known?'Use Test Tally Connection':(s.tally.connected?(s.tally.company_count+' loaded compan'+(s.tally.company_count===1?'y':'ies')):(s.tally.error||''));
|
||||
document.getElementById('storage').textContent=gb(s.storage.free_bytes);document.getElementById('storageRoot').textContent=s.storage.root;
|
||||
document.getElementById('download').disabled=!s.update.update_available;document.getElementById('install').disabled=!s.update.downloaded;await refreshHistory();}catch(e){console.log(e)}}
|
||||
async function refreshHistory(){const r=await fetch('/api/history',{cache:'no-store'});const h=await r.json();let rows=h.events.slice(0,12).map(x=>'<tr><td>'+x.occurred_at_utc+'</td><td>'+x.level+'</td><td>'+x.event_type+'</td><td>'+escapeHtml(x.message)+'</td></tr>').join('');document.getElementById('history').innerHTML='<table><thead><tr><th>Time UTC</th><th>Level</th><th>Event</th><th>Message</th></tr></thead><tbody>'+rows+'</tbody></table>'}
|
||||
document.getElementById('check').disabled=!!s.update.busy;document.getElementById('download').disabled=!s.update.update_available||!!s.update.busy;document.getElementById('install').disabled=!s.update.downloaded||!!s.update.busy;applyProgress(s.update);
|
||||
if(installWatching&&installTarget&&s.agent.version===installTarget){setProgress('Update Complete',100,'ERP Local Agent '+installTarget+' is running. Updated dashboard loaded.',false);msg('Update completed successfully.',true);installWatching=false;setTimeout(()=>location.reload(),1200)}
|
||||
await refreshHistory();}catch(e){if(installWatching){offlineTicks++;let estimated=Math.min(92,Math.max(lastServerPercent,35+offlineTicks*3));setProgress('Applying Update',estimated,'Local Agent is restarting. Waiting for the updated dashboard to come back online…',true)}else{console.log(e)}}}
|
||||
async function refreshHistory(){try{const r=await fetch('/api/history',{cache:'no-store'});const h=await r.json();let rows=h.events.slice(0,12).map(x=>'<tr><td>'+x.occurred_at_utc+'</td><td>'+x.level+'</td><td>'+x.event_type+'</td><td>'+escapeHtml(x.message)+'</td></tr>').join('');document.getElementById('history').innerHTML='<table><thead><tr><th>Time UTC</th><th>Level</th><th>Event</th><th>Message</th></tr></thead><tbody>'+rows+'</tbody></table>'}catch(e){}}
|
||||
function escapeHtml(v){return String(v||'').replace(/[&<>'"]/g,m=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[m]))}
|
||||
async function checkUpdate(){setProgress('Checking for Updates',10,'Checking ERP for the latest Local Agent version…',true);msg('Checking for updates…',true);try{const r=await fetch('/api/update/check',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Update check failed');applyProgress(j.update);msg(j.update.update_available?'Update '+j.update.latest_version+' is available.':'Local Agent is up to date.',true);await refresh()}catch(e){setProgress('Update Check',100,e.message,false);msg(e.message,false)}}
|
||||
async function downloadUpdate(){setProgress('Downloading Update',5,'Starting download…',true);msg('Downloading update…',true);try{const r=await fetch('/api/update/download',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Download failed');msg('Download started. You can keep this dashboard open.',true);await refresh()}catch(e){setProgress('Download Update',100,e.message,false);msg(e.message,false)}}
|
||||
async function action(url){msg('Working…',true);try{const r=await fetch(url,{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Operation failed');msg('Completed successfully.',true);await refresh()}catch(e){msg(e.message,false)}}
|
||||
async function installUpdate(){if(!confirm('Install the downloaded update now? The desktop supervisor will restart only the Local Agent worker and will keep supervising the restart.'))return;msg('Starting update installation…',true);try{const r=await fetch('/api/update/install',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Install failed');msg(j.message||'Update handed to supervisor. The desktop dashboard will reconnect automatically after restart.',true)}catch(e){msg(e.message,false)}}
|
||||
async function installUpdate(){if(!confirm('Install the downloaded update now? The Local Agent worker will restart safely and this dashboard will show progress until the updated dashboard returns.'))return;let latest=document.getElementById('latest').textContent.replace('Latest: ','').split(' •')[0];installTarget=latest;installWatching=true;offlineTicks=0;lastServerPercent=5;setProgress('Applying Update',5,'Queuing update with the Local Agent supervisor…',true);msg('Starting safe update installation…',true);try{const r=await fetch('/api/update/install',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Install failed');if(j.target_version)installTarget=j.target_version;msg(j.message||'Update queued. Keep this dashboard open.',true);setProgress('Applying Update',8,'Update queued. Waiting for supervisor…',true)}catch(e){installWatching=false;setProgress('Apply Update',100,e.message,false);msg(e.message,false)}}
|
||||
function msg(t,ok){let e=document.getElementById('message');e.textContent=t;e.className=ok?'msgok':'msgbad'}
|
||||
refresh();setInterval(refresh,5000);
|
||||
refresh();setInterval(refresh,1000);
|
||||
</script></body></html>'''
|
||||
|
||||
@@ -5,7 +5,6 @@ from ctypes import wintypes
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
@@ -69,39 +68,11 @@ def sync_desktop_helpers(root: Path, logger=None) -> bool:
|
||||
return changed
|
||||
|
||||
|
||||
def _run_powershell(script: str) -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def cleanup_legacy_duplicates(root: Path, *, keep_supervisor_pid: int, close_dashboards: bool) -> None:
|
||||
"""Remove duplicates left behind by pre-1.22.3 launchers on this installation only."""
|
||||
if os.name != "nt":
|
||||
return
|
||||
root_text = str(root.resolve()).replace("'", "''")
|
||||
dashboard_clause = ""
|
||||
if close_dashboards:
|
||||
dashboard_clause = " -or ($cmd -match 'ERPAgentDashboard\\.pyw')"
|
||||
script = rf"""
|
||||
$root='{root_text}'
|
||||
$keepSupervisor={int(keep_supervisor_pid or 0)}
|
||||
$selfPid={os.getpid()}
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {{
|
||||
$cmd = [string]$_.CommandLine
|
||||
if (-not $cmd -or $cmd -notlike ('*'+$root+'*')) {{ return }}
|
||||
$kill = $false
|
||||
if (($cmd -match 'ERPAgentSupervisor\.pyw') -and ($_.ProcessId -ne $keepSupervisor)) {{ $kill = $true }}
|
||||
if (($cmd -match 'erp_local_agent\.main') -and ($_.ProcessId -ne $selfPid)) {{ $kill = $true }}
|
||||
if (($cmd -match 'ERPAgentDashboard\.pyw') -and {str(bool(close_dashboards)).lower()}) {{ $kill = $true }}
|
||||
if ($kill) {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
|
||||
}}
|
||||
"""
|
||||
_run_powershell(script)
|
||||
"""Compatibility no-op for pre-1.22.12 callers.
|
||||
|
||||
Process-command-line cleanup is intentionally disabled. On Windows launcher/interpreter
|
||||
pairs can share the same command line and do not represent duplicate logical workers.
|
||||
Supervisor and worker singleton ownership is enforced only by Global named mutexes.
|
||||
"""
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -13,7 +14,6 @@ from . import __version__
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, ...]:
|
||||
"""Return a stable numeric tuple for ERP Local Agent release versions."""
|
||||
parts = [int(piece) for piece in re.findall(r"\d+", str(value or ""))]
|
||||
return tuple(parts or [0])
|
||||
|
||||
@@ -28,13 +28,7 @@ def _is_newer(latest: str, current: str) -> bool:
|
||||
|
||||
|
||||
class AgentUpdater:
|
||||
"""Supervisor-controlled ERP Local Agent updater.
|
||||
|
||||
The worker checks the ERP manifest in the background. When automatic installation
|
||||
is enabled it downloads the hash-verified package, stages it, and hands installation
|
||||
to the persistent desktop supervisor. Manual dashboard download/install actions are
|
||||
preserved and use the same code paths.
|
||||
"""
|
||||
"""Supervisor-controlled updater with persistent, user-visible progress."""
|
||||
|
||||
def __init__(self, config, client, logger, install_dir: Path, db=None):
|
||||
self.config = config
|
||||
@@ -46,6 +40,7 @@ class AgentUpdater:
|
||||
self.busy = False
|
||||
self.lock = threading.RLock()
|
||||
self.latest_manifest: dict = {}
|
||||
self.progress_file = self.install_dir / "data" / "update_progress.json"
|
||||
|
||||
def _record(self, event_type: str, to_version: str = "", status: str = "", **kwargs) -> None:
|
||||
if self.db is not None:
|
||||
@@ -54,12 +49,50 @@ class AgentUpdater:
|
||||
except Exception:
|
||||
self.logger.exception("Could not record updater history")
|
||||
|
||||
def _set_progress(
|
||||
self,
|
||||
operation: str,
|
||||
status: str,
|
||||
phase: str,
|
||||
percent: int,
|
||||
message: str,
|
||||
*,
|
||||
target_version: str = "",
|
||||
) -> None:
|
||||
payload = {
|
||||
"operation": operation,
|
||||
"status": status,
|
||||
"phase": phase,
|
||||
"percent": max(0, min(100, int(percent))),
|
||||
"message": message,
|
||||
"target_version": target_version,
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"worker_pid": os.getpid(),
|
||||
}
|
||||
try:
|
||||
self.progress_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.progress_file.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(self.progress_file)
|
||||
except Exception:
|
||||
self.logger.exception("Could not persist updater progress")
|
||||
|
||||
def _progress(self) -> dict:
|
||||
try:
|
||||
if self.progress_file.exists():
|
||||
value = json.loads(self.progress_file.read_text(encoding="utf-8"))
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def check_for_update(self, force: bool = False) -> dict:
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
if not force and now - self.last_check < self.config.update_check_interval_seconds and self.latest_manifest:
|
||||
return self.status()
|
||||
self.last_check = now
|
||||
self._set_progress("check", "checking", "checking", 10, "Checking ERP for Local Agent updates.")
|
||||
try:
|
||||
manifest = self.client.update_manifest()
|
||||
latest = str(manifest.get("latest_version") or "").strip()
|
||||
@@ -71,9 +104,14 @@ class AgentUpdater:
|
||||
self.db.set_meta("update_available", "1" if available else "0")
|
||||
self.db.set_meta("last_update_check_utc", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
||||
self._record("check", latest, "available" if available else "current", sha256=expected)
|
||||
if available:
|
||||
self._set_progress("check", "available", "complete", 100, f"ERP Local Agent {latest} is available.", target_version=latest)
|
||||
else:
|
||||
self._set_progress("check", "current", "complete", 100, "ERP Local Agent is up to date.", target_version=latest or __version__)
|
||||
return self.status()
|
||||
except Exception as exc:
|
||||
self._record("check", "", "failed", error_message=str(exc))
|
||||
self._set_progress("check", "error", "error", 100, f"Update check failed: {exc}")
|
||||
self.logger.exception("ERP Local Agent update check failed: %s", exc)
|
||||
raise
|
||||
|
||||
@@ -87,14 +125,10 @@ class AgentUpdater:
|
||||
return False
|
||||
if not self.config.auto_install_updates:
|
||||
return True
|
||||
|
||||
latest = str(state.get("latest_version") or "").strip()
|
||||
request_path = self.install_dir / "updates" / "supervisor_request.json"
|
||||
if request_path.exists():
|
||||
# The persistent supervisor will consume the pending request. Avoid
|
||||
# repeatedly downloading the same package while restart is underway.
|
||||
return True
|
||||
|
||||
staged_version = str(state.get("staged_version") or "").strip()
|
||||
if not state.get("downloaded") or staged_version != latest:
|
||||
state = self.download_latest()
|
||||
@@ -105,50 +139,85 @@ class AgentUpdater:
|
||||
self.logger.exception("ERP Local Agent automatic update failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _download_body(self) -> dict:
|
||||
if not self.latest_manifest:
|
||||
self.check_for_update(force=True)
|
||||
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
||||
expected = str(self.latest_manifest.get("sha256") or "").lower().strip()
|
||||
if not latest or not _is_newer(latest, __version__):
|
||||
self._set_progress("download", "current", "complete", 100, "No newer update is available.", target_version=latest or __version__)
|
||||
return self.status()
|
||||
|
||||
self._set_progress("download", "downloading", "downloading", 15, f"Downloading ERP Local Agent {latest}.", target_version=latest)
|
||||
package = self.client.download_update_package()
|
||||
self._set_progress("download", "downloading", "verifying", 65, "Download received. Verifying package integrity.", target_version=latest)
|
||||
actual = hashlib.sha256(package).hexdigest().lower()
|
||||
if len(expected) != 64 or actual != expected:
|
||||
raise RuntimeError("ERP Local Agent update package SHA256 verification failed.")
|
||||
|
||||
updates = self.install_dir / "updates"
|
||||
updates.mkdir(parents=True, exist_ok=True)
|
||||
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
|
||||
package_path = updates / f"ERP_Local_Agent_{safe}.zip"
|
||||
package_path.write_bytes(package)
|
||||
self._set_progress("download", "downloading", "staging", 82, "Package verified. Preparing update files.", target_version=latest)
|
||||
staged = updates / f"staged_{safe}"
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
archive.extractall(staged)
|
||||
if not (staged / "erp_local_agent" / "__init__.py").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is incomplete.")
|
||||
if not (staged / "requirements.txt").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is missing requirements.txt.")
|
||||
self._record("download", latest, "downloaded", sha256=actual, package_path=str(package_path))
|
||||
if self.db is not None:
|
||||
self.db.set_meta("staged_update_version", latest)
|
||||
self.db.set_meta("staged_update_path", str(staged))
|
||||
self._set_progress("download", "downloaded", "complete", 100, f"ERP Local Agent {latest} downloaded and verified.", target_version=latest)
|
||||
return self.status()
|
||||
|
||||
def download_latest(self) -> dict:
|
||||
with self.lock:
|
||||
if self.busy:
|
||||
raise RuntimeError("Another update operation is already in progress.")
|
||||
self.busy = True
|
||||
try:
|
||||
if not self.latest_manifest:
|
||||
self.check_for_update(force=True)
|
||||
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
||||
expected = str(self.latest_manifest.get("sha256") or "").lower().strip()
|
||||
if not latest or not _is_newer(latest, __version__):
|
||||
return self.status()
|
||||
package = self.client.download_update_package()
|
||||
actual = hashlib.sha256(package).hexdigest().lower()
|
||||
if len(expected) != 64 or actual != expected:
|
||||
raise RuntimeError("ERP Local Agent update package SHA256 verification failed.")
|
||||
updates = self.install_dir / "updates"
|
||||
updates.mkdir(parents=True, exist_ok=True)
|
||||
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
|
||||
package_path = updates / f"ERP_Local_Agent_{safe}.zip"
|
||||
package_path.write_bytes(package)
|
||||
staged = updates / f"staged_{safe}"
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
archive.extractall(staged)
|
||||
if not (staged / "erp_local_agent" / "__init__.py").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is incomplete.")
|
||||
if not (staged / "requirements.txt").exists():
|
||||
raise RuntimeError("ERP Local Agent update package is missing requirements.txt.")
|
||||
self._record("download", latest, "downloaded", sha256=actual, package_path=str(package_path))
|
||||
if self.db is not None:
|
||||
self.db.set_meta("staged_update_version", latest)
|
||||
self.db.set_meta("staged_update_path", str(staged))
|
||||
return self.status()
|
||||
except Exception as exc:
|
||||
latest = str(self.latest_manifest.get("latest_version") or "")
|
||||
self._record("download", latest, "failed", error_message=str(exc))
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
return self._download_body()
|
||||
except Exception as exc:
|
||||
latest = str(self.latest_manifest.get("latest_version") or "")
|
||||
self._record("download", latest, "failed", error_message=str(exc))
|
||||
self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest)
|
||||
raise
|
||||
finally:
|
||||
with self.lock:
|
||||
self.busy = False
|
||||
|
||||
def start_download(self) -> dict:
|
||||
"""Start download in the background so the dashboard can display live progress."""
|
||||
with self.lock:
|
||||
if self.busy:
|
||||
raise RuntimeError("Another update operation is already in progress.")
|
||||
self.busy = True
|
||||
latest = str(self.latest_manifest.get("latest_version") or "").strip()
|
||||
self._set_progress("download", "downloading", "starting", 5, "Starting update download.", target_version=latest)
|
||||
|
||||
def runner():
|
||||
try:
|
||||
self._download_body()
|
||||
except Exception as exc:
|
||||
latest_inner = str(self.latest_manifest.get("latest_version") or "")
|
||||
self._record("download", latest_inner, "failed", error_message=str(exc))
|
||||
self._set_progress("download", "error", "error", 100, f"Download failed: {exc}", target_version=latest_inner)
|
||||
self.logger.exception("ERP Local Agent update download failed: %s", exc)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.busy = False
|
||||
|
||||
threading.Thread(target=runner, name="erp-local-agent-update-download", daemon=True).start()
|
||||
return self.status()
|
||||
|
||||
def install_downloaded(self) -> dict:
|
||||
"""Ask the persistent desktop supervisor to install the staged worker."""
|
||||
with self.lock:
|
||||
latest = str(
|
||||
(self.db.get_meta("staged_update_version") if self.db else "")
|
||||
@@ -159,17 +228,18 @@ class AgentUpdater:
|
||||
raise RuntimeError("No downloaded ERP Local Agent update is available to install.")
|
||||
if not _is_newer(latest, __version__):
|
||||
return self.status()
|
||||
|
||||
safe = "".join(ch for ch in latest if ch.isalnum() or ch in ".-_") or "update"
|
||||
staged = self.install_dir / "updates" / f"staged_{safe}"
|
||||
if not (staged / "erp_local_agent" / "__init__.py").exists():
|
||||
raise RuntimeError("The downloaded update is not staged correctly. Download it again.")
|
||||
|
||||
supervisor_lock = self.install_dir / "data" / "supervisor.lock"
|
||||
if not supervisor_lock.exists():
|
||||
# The owner file is diagnostic, while the supervisor's Global mutex is authoritative.
|
||||
# We require a recently written owner file only to avoid handing an update to no supervisor.
|
||||
owner_file = self.install_dir / "data" / "supervisor_owner.json"
|
||||
if not owner_file.exists():
|
||||
raise RuntimeError(
|
||||
"ERP Local Agent Supervisor is not running. Start the Local Agent from the "
|
||||
"desktop shortcut or restart the 'ERP Local Agent' Scheduled Task."
|
||||
"ERP Local Agent Supervisor is not running. Start the Local Agent from the desktop shortcut "
|
||||
"or restart the 'ERP Local Agent' Scheduled Task."
|
||||
)
|
||||
|
||||
request_path = self.install_dir / "updates" / "supervisor_request.json"
|
||||
@@ -182,21 +252,14 @@ class AgentUpdater:
|
||||
"requested_by_pid": os.getpid(),
|
||||
"automatic": bool(self.config.auto_install_updates),
|
||||
}
|
||||
import json
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(request_path)
|
||||
|
||||
self._set_progress("install", "queued", "queued", 5, f"ERP Local Agent {latest} update queued for safe installation.", target_version=latest)
|
||||
self._record("install_requested", latest, "pending", package_path=str(staged))
|
||||
self.logger.warning(
|
||||
"ERP Local Agent update %s handed to persistent supervisor for controlled restart.",
|
||||
latest,
|
||||
)
|
||||
self.logger.warning("ERP Local Agent update %s handed to persistent supervisor for controlled restart.", latest)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"ERP Local Agent {latest} installation has been handed to the desktop supervisor. "
|
||||
"The worker will restart automatically and the desktop dashboard will reconnect."
|
||||
),
|
||||
"message": f"ERP Local Agent {latest} installation is queued. Keep this dashboard open to watch progress.",
|
||||
"target_version": latest,
|
||||
}
|
||||
|
||||
@@ -215,11 +278,9 @@ class AgentUpdater:
|
||||
state_path = self.install_dir / "data" / "supervisor_state.json"
|
||||
try:
|
||||
if state_path.exists():
|
||||
import json
|
||||
supervisor_state = json.loads(state_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
supervisor_state = {}
|
||||
|
||||
return {
|
||||
"current_version": __version__,
|
||||
"latest_version": latest,
|
||||
@@ -231,4 +292,5 @@ class AgentUpdater:
|
||||
"automatic_install_enabled": bool(self.config.auto_update and self.config.auto_install_updates),
|
||||
"manual_update_only": bool(getattr(self.config, "manual_update_only", False)),
|
||||
"supervisor": supervisor_state,
|
||||
"progress": self._progress(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user