Stabilize Local Agent and workstation management v1.26.23

This commit is contained in:
A R R R Associates
2026-09-30 21:35:40 +05:30
parent 76e13aa379
commit 56098aaeb9
4 changed files with 71 additions and 12 deletions
+22 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.26.22" ERP_LOCAL_AGENT_VERSION = "1.26.23"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -39,9 +39,30 @@ def _write_zip_bytes(dst: zipfile.ZipFile, name: str, payload: bytes) -> None:
dst.writestr(_zip_info(name), payload) dst.writestr(_zip_info(name), payload)
def _validate_runtime_source() -> None:
init_path = RUNTIME_ROOT / "erp_local_agent" / "__init__.py"
dashboard_path = RUNTIME_ROOT / "erp_local_agent" / "dashboard.py"
if not init_path.is_file() or not dashboard_path.is_file():
raise RuntimeError("ERP Local Agent runtime is incomplete.")
namespace: dict[str, object] = {}
exec(compile(init_path.read_text(encoding="utf-8"), str(init_path), "exec"), namespace)
runtime_version = str(namespace.get("__version__") or "").strip()
agent_name = str(namespace.get("AGENT_NAME") or "").strip()
if runtime_version != ERP_LOCAL_AGENT_VERSION:
raise RuntimeError(
f"ERP Local Agent package version mismatch: package={ERP_LOCAL_AGENT_VERSION}, runtime={runtime_version or 'missing'}."
)
if agent_name != ERP_LOCAL_AGENT_NAME:
raise RuntimeError("ERP Local Agent runtime is missing the canonical AGENT_NAME constant.")
dashboard_text = dashboard_path.read_text(encoding="utf-8")
if "from . import AGENT_NAME, __version__" not in dashboard_text:
raise RuntimeError("ERP Local Agent dashboard/runtime identity contract is inconsistent.")
def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes: def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes:
if not RUNTIME_ROOT.exists(): if not RUNTIME_ROOT.exists():
raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}") raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}")
_validate_runtime_source()
buffer = io.BytesIO() buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as dst: with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as dst:
for path in sorted(RUNTIME_ROOT.rglob("*"), key=lambda item: item.as_posix()): for path in sorted(RUNTIME_ROOT.rglob("*"), key=lambda item: item.as_posix()):
@@ -252,14 +252,50 @@ class Supervisor:
pass pass
@staticmethod @staticmethod
def _validate_staged(staged: Path) -> None: def _validate_staged(staged: Path, expected_version: str = "") -> None:
if not staged.exists() or not staged.is_dir(): if not staged.exists() or not staged.is_dir():
raise RuntimeError(f"Staged update directory not found: {staged}") raise RuntimeError(f"Staged update directory not found: {staged}")
if not (staged / "erp_local_agent" / "__init__.py").is_file(): package_dir = staged / "erp_local_agent"
init_path = package_dir / "__init__.py"
dashboard_path = package_dir / "dashboard.py"
main_path = package_dir / "main.py"
if not init_path.is_file():
raise RuntimeError("Staged update does not contain erp_local_agent/__init__.py.") raise RuntimeError("Staged update does not contain erp_local_agent/__init__.py.")
if not dashboard_path.is_file() or not main_path.is_file():
raise RuntimeError("Staged update is missing dashboard.py or main.py.")
if not (staged / "requirements.txt").is_file(): if not (staged / "requirements.txt").is_file():
raise RuntimeError("Staged update does not contain requirements.txt.") raise RuntimeError("Staged update does not contain requirements.txt.")
# Validate the candidate in-place BEFORE the working worker is stopped.
# This specifically protects the dashboard identity contract that caused
# older staged builds to fail on: from . import AGENT_NAME, __version__.
py = _python(console=True)
code = (
"import sys;"
f"sys.path.insert(0, {str(staged)!r});"
"import erp_local_agent;"
"assert getattr(erp_local_agent, 'AGENT_NAME', '') == 'ERP Local Agent', 'AGENT_NAME missing/invalid';"
"assert getattr(erp_local_agent, '__version__', ''), '__version__ missing';"
"from erp_local_agent import dashboard,main,commands,updater;"
"print(erp_local_agent.__version__)"
)
result = subprocess.run(
[str(py), "-c", code],
cwd=str(staged),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
creationflags=_create_no_window(),
timeout=90,
)
if result.returncode != 0:
raise RuntimeError("Staged update preflight import validation failed:\n" + result.stdout[-4000:])
staged_version = (result.stdout or "").strip().splitlines()[-1].strip()
if expected_version and staged_version != expected_version:
raise RuntimeError(
f"Staged update version mismatch: requested={expected_version}, package={staged_version or 'missing'}."
)
@staticmethod @staticmethod
def _backup_runtime(version: str) -> Path: def _backup_runtime(version: str) -> Path:
backup = UPDATES_DIR / f"supervisor_backup_{time.strftime('%Y%m%d_%H%M%S')}_{version}" backup = UPDATES_DIR / f"supervisor_backup_{time.strftime('%Y%m%d_%H%M%S')}_{version}"
@@ -345,7 +381,8 @@ class Supervisor:
raise RuntimeError("Dependency installation failed:\n" + result.stdout[-4000:]) raise RuntimeError("Dependency installation failed:\n" + result.stdout[-4000:])
code = ( code = (
"import erp_local_agent;" "import erp_local_agent;"
"from erp_local_agent import accounting_store,commands,tally,updater;" "assert getattr(erp_local_agent, 'AGENT_NAME', '') == 'ERP Local Agent', 'AGENT_NAME missing/invalid';"
"from erp_local_agent import accounting_store,commands,tally,updater,dashboard,main;"
"print(erp_local_agent.__version__)" "print(erp_local_agent.__version__)"
) )
result = subprocess.run( result = subprocess.run(
@@ -379,7 +416,7 @@ class Supervisor:
if not version or not staged_text: if not version or not staged_text:
raise RuntimeError("Supervisor update request is missing version/staged_dir.") raise RuntimeError("Supervisor update request is missing version/staged_dir.")
staged = Path(staged_text).resolve() staged = Path(staged_text).resolve()
self._validate_staged(staged) self._validate_staged(staged, expected_version=version)
_update_progress("preparing", 8, f"Preparing ERP Local Agent {version} update.", version=version) _update_progress("preparing", 8, f"Preparing ERP Local Agent {version} update.", version=version)
_state("installing", f"Installing ERP Local Agent {version}.", target_version=version, progress_pct=8) _state("installing", f"Installing ERP Local Agent {version}.", target_version=version, progress_pct=8)
@@ -1 +1,2 @@
__version__ = "1.26.22" __version__ = "1.26.23"
AGENT_NAME = "ERP Local Agent"
@@ -3,8 +3,8 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-slate-900">ERP Local Agent</h1> <h1 class="text-2xl font-bold text-slate-900">Office Server & Workstation Agents</h1>
<p class="text-sm text-slate-500">Branch-local ERP runtime. Existing storage remains unchanged; future Tally/GST/local tools will use the same agent.</p> <p class="text-sm text-slate-500">Office Server/Storage Agent manages branch storage; Workstation Agents provide per-PC Tally, GST and desktop integration. Existing storage and workstation features remain unchanged.</p>
<p class="text-xs text-slate-400 mt-1">Scope: {{ storage_scope_title or '-' }}</p> <p class="text-xs text-slate-400 mt-1">Scope: {{ storage_scope_title or '-' }}</p>
<p class="text-xs text-emerald-700 mt-1">ERP Local Agent release: {{ erp_local_agent_version or '-' }} · automatic updates enabled</p> <p class="text-xs text-emerald-700 mt-1">ERP Local Agent release: {{ erp_local_agent_version or '-' }} · automatic updates enabled</p>
</div> </div>
@@ -128,7 +128,7 @@
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm"> <div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b flex items-center justify-between"> <div class="p-4 border-b flex items-center justify-between">
<div> <div>
<h2 class="font-bold text-slate-900">Configured ERP Local Agents</h2> <h2 class="font-bold text-slate-900">Office Server / Storage Agents</h2>
<p class="text-xs text-slate-500">Partners/Branch Managers see only their managed branch. One branch should have one active node and one fixed storage root. Existing node secrets are not shown again.</p> <p class="text-xs text-slate-500">Partners/Branch Managers see only their managed branch. One branch should have one active node and one fixed storage root. Existing node secrets are not shown again.</p>
</div> </div>
</div> </div>
@@ -152,7 +152,7 @@
<form method="post" action="/documents/storage-nodes/{{ node.id }}/download-new-workstation"> <form method="post" action="/documents/storage-nodes/{{ node.id }}/download-new-workstation">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}"> <input type="hidden" name="storage_root_path" value="{{ node.storage_root_path or 'D:\AuditFirmStorage' }}">
<button class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white">+ New Workstation</button> <button class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white">Install New Workstation</button>
</form> </form>
<div class="max-w-48 text-right text-[11px] text-slate-500">Installs on another office PC without changing credentials used by existing workstations.</div> <div class="max-w-48 text-right text-[11px] text-slate-500">Installs on another office PC without changing credentials used by existing workstations.</div>
<form method="post" action="/documents/storage-nodes/{{ node.id }}/download-agent-package"> <form method="post" action="/documents/storage-nodes/{{ node.id }}/download-agent-package">
@@ -178,8 +178,8 @@
<div class="rounded-2xl border bg-white overflow-hidden shadow-sm"> <div class="rounded-2xl border bg-white overflow-hidden shadow-sm">
<div class="p-4 border-b"> <div class="p-4 border-b">
<h2 class="font-bold text-slate-900">Registered Workstations</h2> <h2 class="font-bold text-slate-900">Workstation Agents</h2>
<p class="text-xs text-slate-500 mt-1">Each PC keeps a persistent agent identity. Use <strong>+ New Workstation</strong> on the branch node above for another office PC; it receives its own workstation credential and existing agents keep working.</p> <p class="text-xs text-slate-500 mt-1">Each PC keeps a persistent agent identity. Use <strong>Install New Workstation</strong> on the branch node above for another office PC; it receives its own workstation credential and existing agents keep working.</p>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">