feat: extend operational investigation workflows
This commit is contained in:
@@ -124,6 +124,20 @@ class OperationalWorker:
|
||||
async def tick(self) -> int:
|
||||
headers = await self.credentials.headers(self.client)
|
||||
server = self.settings.server_url.rstrip("/")
|
||||
agent = self.settings.agent_url.rstrip("/")
|
||||
target_agent_instance_id: str | None = None
|
||||
|
||||
async def target_instance_id() -> str:
|
||||
nonlocal target_agent_instance_id
|
||||
if target_agent_instance_id is None:
|
||||
response = await self.client.get(f"{agent}/health")
|
||||
response.raise_for_status()
|
||||
value = response.json().get("agent_instance_id")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("Agent health response has no instance identity")
|
||||
target_agent_instance_id = value.strip()
|
||||
return target_agent_instance_id
|
||||
|
||||
scope = {"task_type": "event_investigation"}
|
||||
if self.settings.task_created_from:
|
||||
scope["task_created_from"] = self.settings.task_created_from
|
||||
@@ -136,10 +150,13 @@ class OperationalWorker:
|
||||
for status_value in ("claimed", "running"):
|
||||
response = await self.client.get(
|
||||
f"{server}/api/v1/operational-task-runs", headers=headers,
|
||||
params={"status": status_value, "limit": self.settings.max_concurrency, **scope},
|
||||
params={"status": status_value, "limit": 100, **scope},
|
||||
)
|
||||
response.raise_for_status()
|
||||
for item in response.json()["items"]:
|
||||
owner = item.get("agent_instance_id")
|
||||
if not isinstance(owner, str) or owner != await target_instance_id():
|
||||
continue
|
||||
run_id = str(item["run_id"])
|
||||
active_run_ids.add(run_id)
|
||||
session_id = item.get("agent_session_id")
|
||||
@@ -152,7 +169,7 @@ class OperationalWorker:
|
||||
# Refresh the Agent vault with the worker's existing OAuth provider.
|
||||
# Background runs never depend on an interactive browser refresh.
|
||||
renewed = await self.client.post(
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/session/{session_id}/credentials",
|
||||
f"{agent}/api/v1/session/{session_id}/credentials",
|
||||
headers=headers, json={},
|
||||
)
|
||||
if renewed.status_code not in (202, 404, 409):
|
||||
@@ -167,6 +184,7 @@ class OperationalWorker:
|
||||
)
|
||||
response.raise_for_status()
|
||||
queued = response.json()["items"]
|
||||
dispatch_instance_id = await target_instance_id() if queued else None
|
||||
|
||||
async def dispatch(run: dict) -> bool:
|
||||
if run.get("status") != "queued":
|
||||
@@ -176,18 +194,21 @@ class OperationalWorker:
|
||||
request_id = str(UUID(run["execution_request_id"]))
|
||||
try:
|
||||
result = await self.client.post(
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/task-executions",
|
||||
f"{agent}/api/v1/task-executions",
|
||||
headers=headers,
|
||||
json={
|
||||
"task_id": task_id,
|
||||
"run_id": run_id,
|
||||
"execution_request_id": request_id,
|
||||
"agent_instance_id": dispatch_instance_id,
|
||||
},
|
||||
)
|
||||
if result.is_success:
|
||||
payload = result.json()
|
||||
if payload.get("task_id") != task_id or payload.get("run_id") != run_id:
|
||||
raise ValueError("Agent returned a different task run")
|
||||
if payload.get("agent_instance_id") != dispatch_instance_id:
|
||||
raise ValueError("Agent returned a different instance identity")
|
||||
session_id = payload.get("session_id")
|
||||
if isinstance(session_id, str) and session_id:
|
||||
self._agent_sessions[run_id] = session_id
|
||||
@@ -221,7 +242,12 @@ class OperationalWorker:
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/task-messages/deliver",
|
||||
headers=headers, json={"message_id": message_id},
|
||||
)
|
||||
if delivered.status_code in {400, 403, 404, 410, 422}:
|
||||
if delivered.status_code == 404:
|
||||
# A different Agent instance may own the target session.
|
||||
# Keep the durable message pending instead of turning a
|
||||
# routing miss into a terminal delivery failure.
|
||||
logger.info("Agent message target is not local; delivery remains pending: %s", message_id)
|
||||
elif delivered.status_code in {400, 403, 410, 422}:
|
||||
failed = await self.client.post(
|
||||
f"{self.settings.server_url.rstrip('/')}/api/v1/agent-messages/{message_id}/delivery",
|
||||
headers=headers, json={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synchronize the explicitly synthetic peripheral work orders into task projections."""
|
||||
"""Synchronize peripheral work orders as project-owned system data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -15,7 +15,6 @@ import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from samples.peripheral.seed_identity import SEED_ACTOR_ID
|
||||
from app.domain.schemas.workbench_events import WorkbenchEvent
|
||||
|
||||
try:
|
||||
@@ -42,23 +41,15 @@ def peripheral_order_id(external_id: str | None) -> str | None:
|
||||
|
||||
|
||||
def should_project_work_order(order: dict, linked_order_ids: set[str]) -> bool:
|
||||
"""Exclude retired seed checks while preserving user and workflow orders."""
|
||||
work_order_id = order["work_order_id"]
|
||||
work_order_type = order.get("work_order_type", "general")
|
||||
if work_order_type in EVENT_WORK_ORDER_TYPES or work_order_id in linked_order_ids:
|
||||
return True
|
||||
return not (
|
||||
work_order_type == "general"
|
||||
and order.get("origin") == "synthetic"
|
||||
and order.get("created_by") == str(SEED_ACTOR_ID)
|
||||
)
|
||||
"""Project every active work order returned by the peripheral system."""
|
||||
return True
|
||||
|
||||
|
||||
def work_order_snapshot(order: dict) -> dict:
|
||||
"""Preserve the flat order and its recorded activity without inventing stages."""
|
||||
required = (
|
||||
"work_order_id", "revision", "title", "summary", "status", "priority",
|
||||
"origin", "updated_at",
|
||||
"updated_at",
|
||||
)
|
||||
snapshot = {key: order[key] for key in required}
|
||||
snapshot.update({key: order.get(key) for key in (
|
||||
@@ -67,6 +58,7 @@ def work_order_snapshot(order: dict) -> dict:
|
||||
snapshot["feedback_requirements"] = order.get("feedback_requirements") or []
|
||||
snapshot["activity"] = order.get("activity") or []
|
||||
snapshot["work_order_type"] = order.get("work_order_type", "general")
|
||||
snapshot["origin"] = "system_data"
|
||||
return snapshot
|
||||
|
||||
|
||||
@@ -148,8 +140,9 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
work_order_ids, deleted_ids,
|
||||
):
|
||||
for order in page["items"]:
|
||||
if order["project_id"] != settings.project_id or order["origin"] != "synthetic":
|
||||
if order["project_id"] != settings.project_id or order["origin"] != "system_data":
|
||||
raise ValueError("peripheral work order scope or origin mismatch")
|
||||
source_label = "外围工单"
|
||||
seen_order_ids.add(order["work_order_id"])
|
||||
if not should_project_work_order(order, linked_order_ids):
|
||||
continue
|
||||
@@ -165,12 +158,13 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
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']}",
|
||||
"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 '位置待确认'}"},
|
||||
"detail": f"外围事件上报 · {order.get('location') or '位置待确认'}"},
|
||||
})
|
||||
event.raise_for_status()
|
||||
event_tasks[order["work_order_id"]] = event.json()
|
||||
@@ -179,12 +173,12 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
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']}",
|
||||
"title": order["title"], "summary": f"{source_label}:{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": "外围样板工单同步,进度以外部系统反馈为准"},
|
||||
"detail": "外围工单同步,进度以外部系统反馈为准"},
|
||||
"scheduled_for": order["scheduled_at"],
|
||||
})
|
||||
result.raise_for_status()
|
||||
@@ -225,9 +219,9 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
|
||||
"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": evidence_summary,
|
||||
"source_label": "外围样板合成工单", "source_ref": external_id,
|
||||
"detail": f"{source_label} {order['work_order_id']}:{order['status']}",
|
||||
"evidence": {"evidence_id": event_id, "kind": "external_feedback", "summary": evidence_summary,
|
||||
"source_label": source_label, "source_ref": external_id,
|
||||
"observed_at": order["updated_at"]},
|
||||
}
|
||||
# A child order's creation is already represented by its dispatch
|
||||
|
||||
Reference in New Issue
Block a user