Add Phase 1 distributed lean workstation agent foundation

This commit is contained in:
A R R R Associates
2026-08-22 11:37:25 +05:30
parent 798a2532c1
commit c8b7dcb805
9 changed files with 269 additions and 7 deletions
@@ -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