feat(workbench): persist operational workflows and events

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent baff7dfe9e
commit 9335bdc1ea
66 changed files with 16580 additions and 238 deletions
@@ -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()