feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user