Add Phase 2 durable workstation agent job routing
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user