diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index e0d8bdf..7dee8a4 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.26.22" +ERP_LOCAL_AGENT_VERSION = "1.26.23" 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) @@ -39,9 +39,30 @@ def _write_zip_bytes(dst: zipfile.ZipFile, name: str, payload: bytes) -> None: 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: if not RUNTIME_ROOT.exists(): raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}") + _validate_runtime_source() buffer = io.BytesIO() 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()): diff --git a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw index 5a1c8b4..57ebb66 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentSupervisor.pyw @@ -252,14 +252,50 @@ class Supervisor: pass @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(): 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.") + 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(): 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 def _backup_runtime(version: str) -> Path: 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:]) code = ( "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__)" ) result = subprocess.run( @@ -379,7 +416,7 @@ class Supervisor: 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) + self._validate_staged(staged, expected_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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index a5bcd3d..3349620 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1 +1,2 @@ -__version__ = "1.26.22" +__version__ = "1.26.23" +AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/templates/documents/storage_nodes.html b/app/modules/documents/templates/documents/storage_nodes.html index 2d05440..752349a 100644 --- a/app/modules/documents/templates/documents/storage_nodes.html +++ b/app/modules/documents/templates/documents/storage_nodes.html @@ -3,8 +3,8 @@
Branch-local ERP runtime. Existing storage remains unchanged; future Tally/GST/local tools will use the same agent.
+Office Server/Storage Agent manages branch storage; Workstation Agents provide per-PC Tally, GST and desktop integration. Existing storage and workstation features remain unchanged.
Scope: {{ storage_scope_title or '-' }}
ERP Local Agent release: {{ erp_local_agent_version or '-' }} ยท automatic updates enabled
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.