feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synchronize the explicitly synthetic peripheral work orders into task projections."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||
except ModuleNotFoundError:
|
||||
from run_operational_worker import CredentialProvider, WorkerSettings
|
||||
|
||||
|
||||
logger = logging.getLogger("peripheral-task-sync")
|
||||
STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress",
|
||||
"awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled"}
|
||||
|
||||
|
||||
def work_order_snapshot(order: dict) -> dict:
|
||||
"""Preserve external process facts without deriving stages from total status."""
|
||||
required = (
|
||||
"work_order_id", "revision", "title", "summary", "status", "priority",
|
||||
"origin", "updated_at",
|
||||
)
|
||||
snapshot = {key: order[key] for key in required}
|
||||
snapshot.update({key: order.get(key) for key in (
|
||||
"assignee", "scheduled_at", "location", "scenario_code", "subtasks",
|
||||
)})
|
||||
snapshot["work_order_type"] = order.get("work_order_type", "general")
|
||||
return snapshot
|
||||
|
||||
|
||||
async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, peripheral_url: str,
|
||||
credentials: CredentialProvider) -> int:
|
||||
headers = await credentials.headers(client)
|
||||
tasks_url = f"{settings.server_url.rstrip('/')}/api/v1/operational-tasks"
|
||||
existing, event_tasks, investigations, offset = {}, {}, [], 0
|
||||
while True:
|
||||
response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset})
|
||||
response.raise_for_status()
|
||||
page = response.json()
|
||||
for task in page["items"]:
|
||||
if task["task_type"] == "external_work_order":
|
||||
existing[task["source"].get("external_id")] = task
|
||||
elif task["task_type"] == "event_investigation":
|
||||
event_tasks[task.get("source", {}).get("external_id")] = task
|
||||
if task["status"] in {"queued", "waiting_human", "running"}:
|
||||
investigations.append(task)
|
||||
if page.get("next_offset") is None:
|
||||
break
|
||||
offset = page["next_offset"]
|
||||
|
||||
linked: dict[str, list[str]] = {}
|
||||
for task in investigations:
|
||||
response = await client.get(f"{tasks_url}/{task['task_id']}", headers=headers)
|
||||
response.raise_for_status()
|
||||
for action in response.json().get("actions", []):
|
||||
receipt = action.get("receipt")
|
||||
if action["kind"] == "create_work_order" and receipt and receipt["adapter"] == "peripheral_sample":
|
||||
linked.setdefault(receipt["receipt_id"], []).append(task["task_id"])
|
||||
|
||||
cursor, synchronized = None, 0
|
||||
while True:
|
||||
params = {"limit": 100}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
response = await client.get(f"{peripheral_url.rstrip('/')}/api/sample/v1/work-orders",
|
||||
headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
page = response.json()
|
||||
for order in page["items"]:
|
||||
if order["project_id"] != settings.project_id or order["origin"] != "synthetic":
|
||||
raise ValueError("peripheral work order scope or origin mismatch")
|
||||
external_id = f"peripheral_sample:{order['work_order_id']}"
|
||||
# Complaint intake is an event even if no browser was open when the
|
||||
# external system created it. Use the same source key as the UI.
|
||||
event_kind = order.get("work_order_type")
|
||||
if (
|
||||
event_kind in {"water_quality_complaint", "burst_report"}
|
||||
and order["work_order_id"] not in event_tasks
|
||||
and order["work_order_id"] not in linked
|
||||
):
|
||||
event_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation"))
|
||||
event = await client.post(tasks_url, headers=headers, json={
|
||||
"request_id": event_request_id, "task_type": "event_investigation", "workflow": event_kind,
|
||||
"title": "水质投诉调查" if event_kind == "water_quality_complaint" else "爆管事件调查", "summary": f"合成外围事件:{order['summary']}",
|
||||
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
|
||||
"source": {"kind": "external_system", "external_id": order["work_order_id"],
|
||||
"external_revision": order["revision"], "event_kind": event_kind},
|
||||
"trigger": {"kind": "event", "occurred_at": order.get("scheduled_at") or order["created_at"],
|
||||
"detail": f"外围样板事件上报 · {order.get('location') or '位置待确认'}"},
|
||||
})
|
||||
event.raise_for_status()
|
||||
event_tasks[order["work_order_id"]] = event.json()
|
||||
task = existing.get(external_id)
|
||||
if task is None:
|
||||
request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:create"))
|
||||
result = await client.post(tasks_url, headers=headers, json={
|
||||
"request_id": request_id, "task_type": "external_work_order", "workflow": "external-work-order",
|
||||
"title": order["title"], "summary": f"合成外围工单:{order['summary']}",
|
||||
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
|
||||
"source": {"kind": "external_system", "external_id": external_id,
|
||||
"event_kind": order.get("work_order_type", "general")},
|
||||
"trigger": {"kind": "external_sync", "occurred_at": order["created_at"],
|
||||
"detail": "外围样板工单同步,进度以外部系统反馈为准"},
|
||||
"scheduled_for": order["scheduled_at"],
|
||||
})
|
||||
result.raise_for_status()
|
||||
task = result.json()
|
||||
existing[external_id] = task
|
||||
event_id = f"{external_id}:{order['revision']}"
|
||||
feedback = {
|
||||
"request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id,
|
||||
"source_revision": order["revision"], "status": STATUS[order["status"]],
|
||||
"occurred_at": order["updated_at"],
|
||||
"detail": f"合成外围工单 {order['work_order_id']}:{order['status']}",
|
||||
"evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": order["summary"],
|
||||
"source_label": "外围样板合成工单", "source_ref": external_id,
|
||||
"observed_at": order["updated_at"]},
|
||||
}
|
||||
targets = set(linked.get(order["work_order_id"], []))
|
||||
event_task = event_tasks.get(order["work_order_id"])
|
||||
if event_task and order["revision"] > (event_task["source"].get("external_revision") or 0):
|
||||
targets.add(event_task["task_id"])
|
||||
if (task["source"].get("external_revision") or 0) < order["revision"]:
|
||||
targets.add(task["task_id"])
|
||||
for target in targets:
|
||||
payload = dict(feedback)
|
||||
if target == task["task_id"]:
|
||||
payload["work_order_snapshot"] = work_order_snapshot(order)
|
||||
result = await client.post(f"{tasks_url}/{target}/external-feedback", headers=headers, json=payload)
|
||||
result.raise_for_status()
|
||||
synchronized += 1
|
||||
cursor = page.get("next_cursor")
|
||||
if not cursor:
|
||||
return synchronized
|
||||
|
||||
|
||||
async def run(once: bool):
|
||||
settings = WorkerSettings.from_environment()
|
||||
peripheral_url = os.environ["OPERATIONAL_ACTION_PERIPHERAL_URL"]
|
||||
credentials = CredentialProvider(settings)
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||
while True:
|
||||
try:
|
||||
count = await sync_once(client, settings, peripheral_url, credentials)
|
||||
logger.info("Peripheral progress synchronization completed: %d revisions", count)
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
logger.error("Peripheral synchronization failed; next tick will reconcile persisted revisions")
|
||||
if once:
|
||||
raise SystemExit(1) from None
|
||||
if once:
|
||||
return
|
||||
await asyncio.sleep(settings.interval_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
try:
|
||||
asyncio.run(run(args.once))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Reference in New Issue
Block a user