Add Phase 1 distributed lean workstation agent foundation
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.9.5"
|
||||
__version__ = "1.10.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -15,6 +15,7 @@ from .logger import setup_logger
|
||||
from .sync import StorageAgent
|
||||
from .tunnel import StorageAgentTunnel
|
||||
from .updater import AgentUpdater
|
||||
from .workstation_identity import load_or_create
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -36,6 +37,10 @@ def main() -> int:
|
||||
db.set_meta("agent_version", __version__)
|
||||
db.set_meta("node_code", config.node_code)
|
||||
db.set_meta("erp_base_url", config.erp_base_url)
|
||||
workstation = load_or_create(root)
|
||||
db.set_meta("agent_instance_id", workstation["agent_instance_id"])
|
||||
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)
|
||||
agent = StorageAgent(config, client, db, logger)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import websockets
|
||||
|
||||
from .commands import AgentCommandProcessor
|
||||
from . import __version__
|
||||
from .tally import TallyLiveConnector
|
||||
from .workstation_identity import load_or_create
|
||||
from .sync import StorageAgent
|
||||
|
||||
|
||||
@@ -17,6 +22,10 @@ class StorageAgentTunnel:
|
||||
self.logger = agent.logger
|
||||
self.db = agent.db
|
||||
self.command_processor = AgentCommandProcessor(self.config, self.logger)
|
||||
self.identity = load_or_create(Path.cwd())
|
||||
self.tally = TallyLiveConnector()
|
||||
self._last_tally_status_at = 0.0
|
||||
self._cached_tally_status = {"connected": False, "company_count": 0, "companies": []}
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
self.logger.info("Starting tunnel mode for node=%s", self.config.node_code)
|
||||
@@ -48,7 +57,7 @@ class StorageAgentTunnel:
|
||||
) as websocket:
|
||||
self.db.record_connection("connected", endpoint)
|
||||
self.db.record_event("INFO", "tunnel_connected", f"WebSocket connected to {endpoint}")
|
||||
await websocket.send(self._json_text({"type": "ready", "agent_time_utc": self._now()}))
|
||||
await websocket.send(self._json_text({"type": "ready", "agent_time_utc": self._now(), "workstation": self._workstation_status(force_tally=True)}))
|
||||
self.agent._maybe_heartbeat(force=True)
|
||||
async for raw in websocket:
|
||||
message = self._parse_json(raw)
|
||||
@@ -76,13 +85,43 @@ class StorageAgentTunnel:
|
||||
"jobs_seen": len(jobs),
|
||||
"requests_seen": len(requests_),
|
||||
"commands_seen": len(commands),
|
||||
"capabilities": ["storage", "tally", "accounting_act", "tally_master_sync", "tally_transaction_sync", "local_dashboard", "manual_updates"],
|
||||
"capabilities": self._capabilities(),
|
||||
"workstation": self._workstation_status(),
|
||||
"agent_time_utc": self._now(),
|
||||
}))
|
||||
continue
|
||||
if msg_type == "error":
|
||||
self.logger.error("Tunnel server error: %s", message.get("error"))
|
||||
|
||||
def _capabilities(self) -> list[str]:
|
||||
return [
|
||||
"storage", "local_dashboard", "manual_updates", "accounting_act",
|
||||
"tally.status", "tally.company_identity", "tally.read_groups",
|
||||
"tally.read_ledgers", "tally.read_stock_items", "tally.read_vouchers",
|
||||
"tally.read_trial_balance", "tally.master_sync", "tally.transaction_sync",
|
||||
"tally.write_journal", "accounting.it_depreciation",
|
||||
]
|
||||
|
||||
def _workstation_status(self, force_tally: bool = False) -> dict[str, Any]:
|
||||
now = time.monotonic()
|
||||
if force_tally or now - self._last_tally_status_at >= 30:
|
||||
try:
|
||||
self._cached_tally_status = self.tally.status()
|
||||
except Exception as exc:
|
||||
self._cached_tally_status = {"connected": False, "company_count": 0, "companies": [], "error": str(exc)}
|
||||
self._last_tally_status_at = now
|
||||
tally = self._cached_tally_status or {}
|
||||
return {
|
||||
**self.identity,
|
||||
"agent_version": __version__,
|
||||
"capabilities": self._capabilities(),
|
||||
"tally": {
|
||||
"connected": bool(tally.get("connected")),
|
||||
"company_count": int(tally.get("company_count") or 0),
|
||||
"companies": list(tally.get("companies") or []),
|
||||
},
|
||||
}
|
||||
|
||||
def _now(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
IDENTITY_FILE = "workstation_identity.json"
|
||||
|
||||
def _windows_machine_guid() -> str:
|
||||
if os.name != "nt":
|
||||
return ""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["reg", "query", r"HKLM\SOFTWARE\Microsoft\Cryptography", "/v", "MachineGuid"],
|
||||
capture_output=True, text=True, timeout=5, check=False,
|
||||
)
|
||||
for line in completed.stdout.splitlines():
|
||||
if "MachineGuid" in line:
|
||||
return line.split()[-1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _fingerprint(machine_name: str) -> str:
|
||||
material = "|".join([_windows_machine_guid(), machine_name, platform.system(), platform.machine()])
|
||||
return hashlib.sha256(material.encode("utf-8", errors="ignore")).hexdigest()
|
||||
|
||||
def load_or_create(root: Path) -> dict:
|
||||
data_dir = root / "data"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = data_dir / IDENTITY_FILE
|
||||
machine_name = socket.gethostname().strip() or platform.node().strip() or "UNKNOWN-PC"
|
||||
existing = {}
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
except Exception:
|
||||
existing = {}
|
||||
instance_id = str(existing.get("agent_instance_id") or "").strip() or uuid4().hex
|
||||
payload = {
|
||||
"agent_instance_id": instance_id,
|
||||
"machine_fingerprint": _fingerprint(machine_name),
|
||||
"machine_name": machine_name,
|
||||
"platform_name": f"{platform.system()} {platform.release()} {platform.machine()}".strip(),
|
||||
}
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
return payload
|
||||
Reference in New Issue
Block a user