"""Typed external action adapters. Credentials are request-scoped, never persisted.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Literal, Protocol from uuid import UUID import httpx from pydantic import BaseModel, ConfigDict, Field, ValidationError from pydantic_settings import BaseSettings, SettingsConfigDict from app.auth.project_dependencies import ProjectContext from app.domain.schemas.operational_task import ( ActionReceipt, CreateWorkOrderPayload, HumanApprovalAction, IssueCommandPayload, OperationalTaskDetail, ) @dataclass(frozen=True) class ActionDispatchOutcome: status: Literal["submitted", "succeeded", "failed", "unsupported", "unknown"] detail: str receipt: ActionReceipt | None = None class OperationalActionDispatcher(Protocol): async def dispatch( self, *, context: ProjectContext, task: OperationalTaskDetail, action: HumanApprovalAction, credential: str | None, ) -> ActionDispatchOutcome: ... async def reconcile( self, *, context: ProjectContext, task: OperationalTaskDetail, action: HumanApprovalAction, credential: str | None, ) -> ActionDispatchOutcome: ... class OperationalActionSettings(BaseSettings): peripheral_url: str = "" command_gateway_url: str = "" timeout_seconds: float = Field(default=10, ge=1, le=60) model_config = SettingsConfigDict( env_prefix="OPERATIONAL_ACTION_", extra="ignore", env_file=Path(__file__).resolve().parents[3] / ".env", ) class SampleWorkOrderReceipt(BaseModel): model_config = ConfigDict(extra="ignore") work_order_id: UUID project_id: UUID created_at: datetime origin: Literal["synthetic"] status: Literal[ "draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled" ] class CommandGatewayReceipt(BaseModel): model_config = ConfigDict(extra="forbid") request_id: UUID project_id: UUID receipt_id: str = Field(min_length=1) status: Literal["accepted", "running", "completed", "failed"] submitted_at: datetime completed_at: datetime | None = None detail: str = Field(min_length=1) class HttpOperationalActionDispatcher: def __init__( self, settings: OperationalActionSettings | None = None, *, transport: httpx.AsyncBaseTransport | None = None, ): self.settings = settings or OperationalActionSettings() self.transport = transport async def dispatch( self, *, context: ProjectContext, task: OperationalTaskDetail, action: HumanApprovalAction, credential: str | None, ) -> ActionDispatchOutcome: base = (self.settings.peripheral_url if action.kind == "create_work_order" else self.settings.command_gateway_url) if not base: return ActionDispatchOutcome("unsupported", "尚未配置该动作的外部执行适配器") if not credential or not credential.lower().startswith("bearer "): return ActionDispatchOutcome("failed", "缺少当前用户的产品身份,未提交外部动作") if action.task_id != task.task_id or task.project_id != context.project_id: return ActionDispatchOutcome("failed", "动作与当前项目任务不匹配,未提交") if action.approval_status != "approved": return ActionDispatchOutcome("failed", "动作尚未通过人工确认,未提交") request_id = action.execution_request_id if request_id is None: return ActionDispatchOutcome("failed", "缺少持久化执行编号,未提交") headers = {"Authorization": credential, "X-Project-Id": str(context.project_id)} try: if action.kind == "create_work_order": payload = CreateWorkOrderPayload.model_validate(action.payload) body = { "request_id": str(request_id), "title": payload.title, "summary": payload.description, "status": "open", "priority": payload.priority, "assignee": payload.assignee, "scheduled_at": payload.scheduled_at.isoformat() if payload.scheduled_at else None, "location": payload.location, # An approved action creates a child execution order, not a # new inbound complaint/burst event for the sync loop. "work_order_type": "general", } path = "/api/sample/v1/work-orders" recovery_path = f"/api/sample/v1/requests/{request_id}" else: payload = IssueCommandPayload.model_validate(action.payload) if payload.expires_at <= datetime.now(timezone.utc): return ActionDispatchOutcome("failed", "命令确认窗口已过期,未提交") body = { "request_id": str(request_id), "project_id": str(context.project_id), "task_id": str(task.task_id), "action_id": str(action.action_id), "action_version": action.version, "approved_by": action.decided_by, "approved_at": action.decided_at.isoformat() if action.decided_at else None, "command": payload.model_dump(mode="json"), } path = "/api/v1/commands" recovery_path = f"/api/v1/commands/requests/{request_id}" except (ValidationError, TypeError, ValueError): return ActionDispatchOutcome("failed", "动作参数不符合执行契约,未提交") async with httpx.AsyncClient( base_url=base.rstrip("/"), headers=headers, timeout=self.settings.timeout_seconds, follow_redirects=False, trust_env=False, transport=self.transport, ) as client: try: response = await client.post(path, json=body) if response.status_code in {400, 401, 403, 404, 409, 422}: # Sample responses explicitly state whether any write occurred. # An untyped rejection is not proof that no effect took place. error = response.json() if isinstance(error, dict) and error.get("effect") == "none": return ActionDispatchOutcome("failed", "外部系统拒绝了动作,未生效") response.raise_for_status() return self._receipt(action.kind, response.json(), request_id, context.project_id) except (httpx.HTTPError, ValueError, KeyError, ValidationError): # A lost response can follow a successful write. Recover using the # same idempotency key, never manufacture success or blindly retry. try: recovered = await client.get(recovery_path) recovered.raise_for_status() value = recovered.json() if action.kind == "create_work_order": if not isinstance(value, dict) or value.get("request_id") != str(request_id): raise ValueError("request receipt mismatch") value = value["result"] return self._receipt(action.kind, value, request_id, context.project_id) except (httpx.HTTPError, ValueError, KeyError, ValidationError): return ActionDispatchOutcome("unknown", "外部执行结果待核实,请通过回执核对,勿重复下发") async def reconcile( self, *, context: ProjectContext, task: OperationalTaskDetail, action: HumanApprovalAction, credential: str | None, ) -> ActionDispatchOutcome: request_id = action.execution_request_id if not credential or request_id is None or action.task_id != task.task_id or task.project_id != context.project_id: return ActionDispatchOutcome("unknown", "无法核实当前动作的身份或执行编号") base = self.settings.peripheral_url if action.kind == "create_work_order" else self.settings.command_gateway_url if not base: return ActionDispatchOutcome("unknown", "回执查询服务尚未配置") path = (f"/api/sample/v1/requests/{request_id}" if action.kind == "create_work_order" else f"/api/v1/commands/requests/{request_id}") try: async with httpx.AsyncClient( base_url=base.rstrip("/"), timeout=self.settings.timeout_seconds, follow_redirects=False, trust_env=False, transport=self.transport, ) as client: response = await client.get(path, headers={ "Authorization": credential, "X-Project-Id": str(context.project_id), }) response.raise_for_status() value = response.json() if action.kind == "create_work_order": if not isinstance(value, dict) or value.get("request_id") != str(request_id): raise ValueError("request receipt mismatch") value = value["result"] return self._receipt(action.kind, value, request_id, context.project_id) except (httpx.HTTPError, ValueError, KeyError, ValidationError): return ActionDispatchOutcome("unknown", "尚未取得可核实的执行回执") @staticmethod def _receipt(kind: str, value: object, request_id: UUID, project_id: UUID) -> ActionDispatchOutcome: if kind == "create_work_order": order = SampleWorkOrderReceipt.model_validate(value) if order.project_id != project_id or order.created_at.tzinfo is None: raise ValueError("invalid external project or timestamp") receipt = ActionReceipt( adapter="peripheral_sample", receipt_id=str(order.work_order_id), submitted_at=order.created_at, completed_at=order.created_at, external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步", ) return ActionDispatchOutcome("succeeded", receipt.detail, receipt) result = CommandGatewayReceipt.model_validate(value) if result.project_id != project_id or result.request_id != request_id: raise ValueError("command receipt does not match submission") if result.submitted_at.tzinfo is None or (result.completed_at and result.completed_at.tzinfo is None): raise ValueError("command receipt timestamps must include timezone") if result.status == "completed" and result.completed_at is None: raise ValueError("command has no completion receipt") receipt = ActionReceipt( adapter="command_gateway", receipt_id=result.receipt_id, submitted_at=result.submitted_at, completed_at=result.completed_at, external_status=result.status, detail=result.detail, ) status = "succeeded" if result.status == "completed" else "failed" if result.status == "failed" else "submitted" return ActionDispatchOutcome(status, result.detail, receipt) def get_operational_action_dispatcher() -> OperationalActionDispatcher: return HttpOperationalActionDispatcher()