Add staff visibility workstation enrollment registration vault and category task views

This commit is contained in:
A R R R Associates
2026-09-02 14:05:20 +05:30
parent 1fe1597d6f
commit 97777e13a1
22 changed files with 538 additions and 103 deletions
@@ -1,2 +1,2 @@
__version__ = "1.21.1"
__version__ = "1.22.0"
AGENT_NAME = "ERP Local Agent"
@@ -1,96 +1,114 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Iterable
from typing import Any
import requests
from .config import AgentConfig
class ERPClient:
def __init__(self, config: AgentConfig):
AUTH_FILE = "workstation_auth.json"
def __init__(self, config: AgentConfig, *, workstation: dict[str, Any] | None = None, root: Path | None = None):
self.config = config
self.workstation = workstation or {}
self.root = root or Path.cwd()
self.session = requests.Session()
self.session.headers.update(config.headers)
self.node_secret = self._resolve_node_secret()
self.session.headers.update({
"X-Node-Code": self.config.node_code,
"X-Node-Secret": self.node_secret,
"User-Agent": self.config.headers.get("User-Agent", "ERPLocalAgent"),
})
instance_id = str(self.workstation.get("agent_instance_id") or self.config.agent_instance_id or "").strip()
if instance_id:
self.session.headers["X-Agent-Instance-ID"] = instance_id
def _url(self, path: str) -> str:
return f"{self.config.erp_base_url}{path}"
def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]:
response = self.session.post(
self._url("/documents/storage-agent/heartbeat"),
json=payload,
timeout=self.config.request_timeout_seconds,
def _auth_path(self) -> Path:
path = self.root / "data" / self.AUTH_FILE
path.parent.mkdir(parents=True, exist_ok=True)
return path
def _cached_workstation_secret(self) -> str:
path = self._auth_path()
try:
payload = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
except Exception:
return ""
if str(payload.get("node_code") or "") != self.config.node_code:
return ""
expected_instance = str(self.workstation.get("agent_instance_id") or "")
if expected_instance and str(payload.get("agent_instance_id") or "") != expected_instance:
return ""
return str(payload.get("workstation_secret") or "").strip()
def _resolve_node_secret(self) -> str:
if self.config.node_secret:
return self.config.node_secret
cached = self._cached_workstation_secret()
if cached:
return cached
token = (self.config.enrollment_token or "").strip()
if not token:
raise RuntimeError("Local Agent has neither a node secret nor a workstation enrollment token.")
response = requests.post(
self._url("/documents/storage-agent/enroll-workstation"),
json={"enrollment_token": token, "workstation": self.workstation},
timeout=max(30, self.config.request_timeout_seconds),
headers={"User-Agent": self.config.headers.get("User-Agent", "ERPLocalAgent")},
)
response.raise_for_status()
return response.json() if response.content else {"status": "ok"}
data = response.json()
secret = str(data.get("workstation_secret") or "").strip()
if not secret:
raise RuntimeError(data.get("error") or "ERP did not return a workstation credential.")
path = self._auth_path()
payload = {
"node_code": self.config.node_code,
"agent_instance_id": str(self.workstation.get("agent_instance_id") or ""),
"workstation_secret": secret,
}
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(path)
return secret
def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]:
response = self.session.post(self._url("/documents/storage-agent/heartbeat"), json=payload, timeout=self.config.request_timeout_seconds)
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
def pending_storage_jobs(self) -> list[dict[str, Any]]:
response = self.session.get(
self._url("/documents/storage-agent/jobs/pending"),
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
return data.get("jobs", [])
response = self.session.get(self._url("/documents/storage-agent/jobs/pending"), timeout=self.config.request_timeout_seconds)
response.raise_for_status(); data = response.json(); return data if isinstance(data, list) else data.get("jobs", [])
def download_storage_job(self, job_id: int | str):
response = self.session.get(
self._url(f"/documents/storage-agent/jobs/{job_id}/download"),
stream=True,
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
return response
response = self.session.get(self._url(f"/documents/storage-agent/jobs/{job_id}/download"), stream=True, timeout=self.config.request_timeout_seconds)
response.raise_for_status(); return response
def acknowledge_storage_job(self, job_id: int | str, payload: dict[str, Any]) -> dict[str, Any]:
response = self.session.post(
self._url(f"/documents/storage-agent/jobs/{job_id}/ack"),
json=payload,
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
return response.json() if response.content else {"status": "ok"}
response = self.session.post(self._url(f"/documents/storage-agent/jobs/{job_id}/ack"), json=payload, timeout=self.config.request_timeout_seconds)
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
def pending_download_requests(self) -> list[dict[str, Any]]:
response = self.session.get(
self._url("/documents/storage-agent/download-requests/pending"),
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
return data.get("requests", [])
response = self.session.get(self._url("/documents/storage-agent/download-requests/pending"), timeout=self.config.request_timeout_seconds)
response.raise_for_status(); data = response.json(); return data if isinstance(data, list) else data.get("requests", [])
def upload_download_request_file(self, request_id: int | str, file_path: Path, extra: dict[str, Any]) -> dict[str, Any]:
with file_path.open("rb") as handle:
files = {"file": (file_path.name, handle, "application/octet-stream")}
data = {key: str(value) for key, value in extra.items() if value is not None}
response = self.session.post(
self._url(f"/documents/storage-agent/download-requests/{request_id}/upload"),
files=files,
data=data,
timeout=max(self.config.request_timeout_seconds, 300),
)
response.raise_for_status()
return response.json() if response.content else {"status": "ok"}
response = self.session.post(self._url(f"/documents/storage-agent/download-requests/{request_id}/upload"), files=files, data=data, timeout=max(self.config.request_timeout_seconds, 300))
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
def update_manifest(self) -> dict[str, Any]:
response = self.session.get(
self._url("/documents/erp-local-agent/update-manifest"),
timeout=self.config.request_timeout_seconds,
)
response.raise_for_status()
return response.json()
response = self.session.get(self._url("/documents/erp-local-agent/update-manifest"), timeout=self.config.request_timeout_seconds)
response.raise_for_status(); return response.json()
def download_update_package(self) -> bytes:
response = self.session.get(
self._url("/documents/erp-local-agent/update-package"),
timeout=max(self.config.request_timeout_seconds, 300),
)
response.raise_for_status()
return response.content
response = self.session.get(self._url("/documents/erp-local-agent/update-package"), timeout=max(self.config.request_timeout_seconds, 300))
response.raise_for_status(); return response.content
@@ -16,6 +16,8 @@ class AgentConfig:
node_code: str
node_secret: str
storage_root: Path
enrollment_token: str | None = None
agent_instance_id: str | None = None
tenant_id: str | None = None
branch_id: str | None = None
poll_interval_seconds: int = 30
@@ -34,11 +36,14 @@ class AgentConfig:
@property
def headers(self) -> dict[str, str]:
return {
headers = {
"X-Node-Code": self.node_code,
"X-Node-Secret": self.node_secret,
"User-Agent": f"ERPLocalAgent/{__version__}",
}
if self.agent_instance_id:
headers["X-Agent-Instance-ID"] = self.agent_instance_id
return headers
@property
def tunnel_url(self) -> str:
@@ -49,7 +54,10 @@ class AgentConfig:
ws_base = "ws://" + base[len("http://"):]
else:
ws_base = base
query = urlencode({"node_code": self.node_code, "node_secret": self.node_secret})
query_data = {"node_code": self.node_code, "node_secret": self.node_secret}
if self.agent_instance_id:
query_data["agent_instance_id"] = self.agent_instance_id
query = urlencode(query_data)
return f"{ws_base}/documents/storage-agent/tunnel?{query}"
@@ -79,6 +87,7 @@ def load_config(env_file: str | None = None) -> AgentConfig:
erp_base_url = os.getenv("ERP_BASE_URL", "").rstrip("/")
node_code = os.getenv("NODE_CODE", "").strip()
node_secret = os.getenv("NODE_SECRET", "").strip()
enrollment_token = os.getenv("ENROLLMENT_TOKEN", "").strip() or None
storage_root_raw = os.getenv("STORAGE_ROOT", "").strip()
tenant_id = os.getenv("TENANT_ID", os.getenv("AUDIT_FIRM_ID", "")).strip() or None
branch_id = os.getenv("BRANCH_ID", "").strip() or None
@@ -88,8 +97,8 @@ def load_config(env_file: str | None = None) -> AgentConfig:
missing.append("ERP_BASE_URL")
if not node_code:
missing.append("NODE_CODE")
if not node_secret:
missing.append("NODE_SECRET")
if not node_secret and not enrollment_token:
missing.append("NODE_SECRET or ENROLLMENT_TOKEN")
if not storage_root_raw:
missing.append("STORAGE_ROOT")
if missing:
@@ -103,6 +112,7 @@ def load_config(env_file: str | None = None) -> AgentConfig:
node_code=node_code,
node_secret=node_secret,
storage_root=storage_root,
enrollment_token=enrollment_token,
tenant_id=tenant_id,
branch_id=branch_id,
poll_interval_seconds=_get_int("POLL_INTERVAL_SECONDS", 30),
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
from pathlib import Path
import threading
import time
@@ -42,7 +43,12 @@ def main() -> int:
db.set_meta("machine_name", workstation["machine_name"])
db.set_meta("machine_fingerprint", workstation["machine_fingerprint"])
db.record_event("INFO", "agent_started", f"ERP Local Agent {__version__} started")
client = ERPClient(config)
client = ERPClient(config, workstation=workstation, root=root)
config = replace(
config,
node_secret=client.node_secret,
agent_instance_id=workstation["agent_instance_id"],
)
agent = StorageAgent(config, client, db, logger)
updater = AgentUpdater(config, client, logger, root, db=db)
dashboard = AgentDashboard(config, db, updater, logger, root)