Add Phase 2 durable workstation agent job routing

This commit is contained in:
A R R R Associates
2026-08-22 11:50:20 +05:30
parent c8b7dcb805
commit f7bf1437ef
9 changed files with 657 additions and 10 deletions
+71 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from uuid import uuid4
import json
import asyncio
import logging
@@ -83,6 +84,7 @@ from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.core.tenancy.year_control import is_row_financial_year_locked
from app.modules.documents.agent_package import ERP_LOCAL_AGENT_VERSION, build_agent_env, build_preconfigured_agent_zip, build_agent_update_zip
from app.modules.documents.models import BranchStorageNode, ERPWorkstationAgent
from app.modules.documents.agent_jobs import claim_jobs_for_workstation, complete_agent_job, list_recent_agent_jobs, enqueue_agent_job
from app.modules.accounting.agent_bridge import list_pending_agent_commands, record_agent_command_result
from app.modules.services.task_documents import (
@@ -1319,6 +1321,7 @@ def storage_nodes(request: Request):
jobs = list_storage_jobs(db, tenant_id=tenant_filter, branch_id=branch_filter, limit=10)
requests = list_download_requests(db, tenant_id=tenant_filter, branch_id=branch_filter, limit=10)
workstations = _visible_workstation_agents(db, tenant_filter, branch_filter)
agent_jobs = list_recent_agent_jobs(db, tenant_id=tenant_filter, branch_id=branch_filter, limit=20)
return _render(
request,
"modules/documents/templates/documents/storage_nodes.html",
@@ -1331,6 +1334,7 @@ def storage_nodes(request: Request):
recent_jobs=jobs,
recent_download_requests=requests,
workstation_agents=workstations,
recent_agent_jobs=agent_jobs,
generated_secret=None,
storage_scope_title=_storage_scope_title(user, scope),
erp_local_agent_version=ERP_LOCAL_AGENT_VERSION,
@@ -1341,6 +1345,43 @@ def storage_nodes(request: Request):
db.close()
@router.post("/storage-workstations/{workstation_id}/test-route")
def test_workstation_job_route(request: Request, workstation_id: int, csrf_token: str = Form(...)):
"""Queue a harmless workstation-specific Tally status job for Phase 2 routing verification."""
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "documents.upload")
if response:
return response
scope = build_document_scope(request, db, user)
if not _can_manage_branch_storage(scope):
return _redirect_denied()
workstation = db.get(ERPWorkstationAgent, int(workstation_id))
if not workstation:
return _redirect_denied()
tenant_filter = _storage_tenant_filter(user, scope)
branch_filter = _storage_branch_filter(user, scope)
if tenant_filter is not None and int(workstation.tenant_id) != int(tenant_filter):
return _redirect_denied()
if branch_filter is not None and int(workstation.branch_id or 0) != int(branch_filter):
return _redirect_denied()
enqueue_agent_job(
db,
workstation_agent_id=workstation.id,
action="tally_status",
payload={},
idempotency_key=f"route-test:{workstation.id}:{uuid4().hex}",
created_by_user_id=int(user.id),
priority=10,
max_attempts=3,
)
db.commit()
return RedirectResponse(url="/documents/storage-nodes", status_code=303)
finally:
db.close()
@router.get("/branch-storage-dashboard")
def partner_branch_storage_dashboard(request: Request):
"""Partner/Branch dashboard card for local storage setup and status.
@@ -1737,15 +1778,28 @@ def _permanent_download_requests_payload(items):
]
def _storage_agent_sync_payload(db, node):
def _storage_agent_sync_payload(db, node, workstation_instance_id: str | None = None):
# Retry cleanup in tunnel mode as well as normal heartbeat mode.
retry_verified_vps_cleanup_for_node(db, node, limit=25)
jobs = _normal_storage_jobs_payload(list_pending_storage_jobs(db, node))
jobs += _permanent_storage_jobs_payload(list_pending_permanent_storage_jobs(db, node))
requests_ = _normal_download_requests_payload(list_pending_download_requests(db, node))
requests_ += _permanent_download_requests_payload(list_pending_permanent_download_requests(db, node))
# Legacy node-level commands are retained unchanged for backward compatibility.
commands = list_pending_agent_commands(node.node_code, limit=10)
return {"ok": True, "jobs": jobs, "download_requests": requests_, "requests": requests_, "commands": commands}
agent_jobs = []
if workstation_instance_id:
agent_jobs = claim_jobs_for_workstation(
db, storage_node_id=node.id, agent_instance_id=workstation_instance_id, limit=10
)
return {
"ok": True,
"jobs": jobs,
"download_requests": requests_,
"requests": requests_,
"commands": commands,
"agent_jobs": agent_jobs,
}
@router.get("/storage-agent/jobs/pending")
@@ -1875,6 +1929,7 @@ async def storage_agent_tunnel(websocket: WebSocket):
db.close()
last_push = 0.0
connection_workstation_instance_id = ""
while True:
try:
try:
@@ -1891,10 +1946,21 @@ async def storage_agent_tunnel(websocket: WebSocket):
return
if message and isinstance(message.get("workstation"), dict):
client_ip = websocket.client.host if websocket.client else None
_record_workstation_status(db, node, message.get("workstation"), client_ip)
workstation_payload = message.get("workstation")
_record_workstation_status(db, node, workstation_payload, client_ip)
advertised_instance_id = str(workstation_payload.get("agent_instance_id") or "").strip()
if advertised_instance_id:
connection_workstation_instance_id = advertised_instance_id
if message and message.get("type") == "command_result":
record_agent_command_result(node.node_code, message)
if message and message.get("type") in {"heartbeat", "agent_status", "command_result"}:
if message and message.get("type") == "agent_job_result":
complete_agent_job(
db,
storage_node_id=node.id,
agent_instance_id=connection_workstation_instance_id,
message=message,
)
if message and message.get("type") in {"heartbeat", "agent_status", "command_result", "agent_job_result"}:
try:
node.storage_mode = "tunnel"
except Exception:
@@ -1902,7 +1968,7 @@ async def storage_agent_tunnel(websocket: WebSocket):
now = datetime.now(timezone.utc).timestamp()
force = bool(message and message.get("type") in {"ready", "sync_now", "agent_status"})
if force or now - last_push >= 5:
payload = _storage_agent_sync_payload(db, node)
payload = _storage_agent_sync_payload(db, node, connection_workstation_instance_id)
payload.update({"type": "sync", "node_code": node.node_code})
await websocket.send_json(payload)
last_push = now