Stabilize Local Agent and workstation management v1.26.23
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user