aps-agent/server/aps_domain/saga.py

945 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# Saga/补偿编排框架(moduleId: domain-saga, 可重用 ✔)
# round-39 方向 P:跨内部世界状态与外部系统的 Saga/补偿
# ① 编排单元:step(name/action/幂等键 idem_key/超时 timeout_sec/重试 max_retries/补偿 compensation_action)
# ② 恢复队列:saga 记录随世界状态持久化(宽容加载),resume/replay 未完成 saga
# ③ 补偿注册表:动作→补偿动作映射;失败自动执行补偿链;补偿也失败 → MANUAL_TAKEOVER
# ④ 超时/重试:每步超时上限 + 重试次数(指数退避简化),超限走补偿
# ⑤ 审计:每次 step 状态迁移写 saga.step.*(含 idemKey/status/compensation)
# ⑥ 第一个 Saga 实例:WMS 缺料→重排→MES 下发→回执 闭环(server/aps_domain/wms_events.py)
# ============================================================
from __future__ import annotations
import copy
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Callable
from server.timeutil import fmt_dt
World = dict[str, Any]
# ------------------------------------------------------------
# 状态机
# ------------------------------------------------------------
SAGA_STATUSES = ("PENDING", "RUNNING", "WAITING_HUMAN", "SUCCEEDED", "FAILED",
"COMPENSATING", "COMPENSATED", "MANUAL_TAKEOVER")
STEP_STATUSES = ("PENDING", "RUNNING", "WAITING_HUMAN", "SUCCEEDED", "FAILED",
"COMPENSATED", "SKIPPED")
_TERMINAL = ("SUCCEEDED", "COMPENSATED", "MANUAL_TAKEOVER")
_RECOVERABLE = ("PENDING", "RUNNING", "WAITING_HUMAN", "FAILED")
class SagaError(Exception):
"""Saga 编排基础异常。"""
class SagaStepTimeout(SagaError):
"""步骤超时(超过 timeout_sec 或动作主动抛出)。"""
class SagaStepFailed(SagaError):
"""步骤业务失败(动作可主动抛出,等价于抛普通异常)。"""
class SagaCompensationError(SagaError):
"""补偿动作失败(触发 MANUAL_TAKEOVER)。"""
class SagaNotFound(KeyError):
"""saga 记录不存在。"""
def _now() -> str:
return fmt_dt(datetime.now())
def _jsonable(value: Any) -> Any:
"""把步骤结果递归转成可 JSON 持久化形态(pydantic BaseModel → dict,其余兜底 str)。"""
if hasattr(value, "model_dump"):
return _jsonable(value.model_dump())
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if value is None or isinstance(value, (str, int, float, bool)):
return value
return str(value)
# ------------------------------------------------------------
# ① 编排单元
# ------------------------------------------------------------
@dataclass
class SagaStep:
"""单个 saga 步骤:动作 + 幂等键 + 超时 + 重试 + 补偿动作。"""
name: str
action: str
idem_key: str = ""
timeout_sec: float = 30.0
max_retries: int = 2
compensation_action: str | None = None
gate: bool = False
@classmethod
def from_mapping(cls, raw: dict[str, Any]) -> SagaStep:
d = dict(raw or {})
keys = {"name", "action", "idem_key", "timeout_sec", "max_retries",
"compensation_action", "gate", "idemKey", "timeoutSec",
"maxRetries", "compensationAction"}
clean: dict[str, Any] = {}
for k, v in d.items():
if k not in keys:
continue
clean[k] = v
if "idemKey" in clean and "idem_key" not in clean:
clean["idem_key"] = clean.pop("idemKey")
if "timeoutSec" in clean and "timeout_sec" not in clean:
clean["timeout_sec"] = clean.pop("timeoutSec")
if "maxRetries" in clean and "max_retries" not in clean:
clean["max_retries"] = clean.pop("maxRetries")
if "compensationAction" in clean and "compensation_action" not in clean:
clean["compensation_action"] = clean.pop("compensationAction")
return cls(**clean)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"action": self.action,
"idemKey": self.idem_key,
"timeoutSec": float(self.timeout_sec),
"maxRetries": int(self.max_retries),
"compensationAction": self.compensation_action,
"gate": bool(self.gate),
"status": "PENDING",
"attempts": 0,
"timeouts": 0,
"startedAt": None,
"finishedAt": None,
"result": None,
"error": None,
"compensation": None,
}
# ------------------------------------------------------------
# Saga 编排器
# ------------------------------------------------------------
class SagaCoordinator:
"""Saga 编排器:步骤执行 / 重试 / 超时 / 补偿链 / 人工接管 / 持久化恢复。
- saga 记录持久化在 store.data["sagas"](随世界状态落盘,宽容加载);
- 幂等键登记在 store.data["sagaIdem"](跨 saga 去重 / 恢复时安全重放);
- 补偿注册表 registry: {动作名: {"fn": callable(ctx)->dict, "compensation": 补偿动作名}};
- 补偿动作也是注册表条目:{补偿动作名: {"fn": callable(ctx)->dict}}。
"""
def __init__(self, store, *, actor: str = "saga",
registry: dict[str, dict[str, Any]] | None = None,
clock: Callable[[], float] | None = None,
sleep_fn: Callable[[float], None] | None = None,
backoff_base: float = 0.0, backoff_cap: float = 60.0,
second_approver: Any = None):
self.store = store
self.actor = actor
self.registry = registry if registry is not None else default_registry()
self.clock = clock or time.monotonic
self.sleep_fn = sleep_fn or time.sleep
self.backoff_base = backoff_base
self.backoff_cap = backoff_cap
self.second_approver = second_approver
self._ensure_tables()
# ---------------- 持久化(随世界状态,宽容加载) ----------------
def _ensure_tables(self) -> None:
world = self.store.data
sagas = world.setdefault("sagas", [])
world["sagas"] = [s for s in sagas if isinstance(s, dict)]
idem = world.setdefault("sagaIdem", {})
if not isinstance(idem, dict):
world["sagaIdem"] = {}
def _save(self) -> None:
self.store.save()
def _find(self, saga_id: str) -> dict[str, Any]:
for s in self.store.data.get("sagas", []):
if s.get("id") == saga_id:
return s
raise SagaNotFound(saga_id)
# ---------------- 创建 ----------------
def create_saga(self, name: str, steps: list[SagaStep | dict[str, Any]],
context: dict[str, Any] | None = None, *,
dedupe_key: str | None = None) -> dict[str, Any]:
"""创建并持久化一条 saga;dedupe_key 相同(如 WMS 事件 eventId)时复用已有记录(幂等)。"""
if dedupe_key:
existing = next((s for s in self.store.data.get("sagas", [])
if s.get("name") == name and s.get("dedupeKey") == dedupe_key), None)
if existing is not None:
return existing
saga_id = f"saga-{uuid.uuid4().hex[:10]}"
step_records: list[dict[str, Any]] = []
for raw in steps:
step_records.append(raw.to_dict() if isinstance(raw, SagaStep)
else SagaStep.from_mapping(dict(raw)).to_dict())
record: dict[str, Any] = {
"id": saga_id,
"name": name,
"status": "PENDING",
"createdAt": _now(),
"updatedAt": _now(),
"context": _jsonable(copy.deepcopy(context or {})),
"dedupeKey": dedupe_key,
"steps": step_records,
"manualTakeover": False,
"takeoverReason": None,
"compensationReason": None,
"auditRefs": [f"saga:{saga_id}"],
}
self.store.data.setdefault("sagas", []).append(record)
self._audit_saga(record, "saga.created", result="PENDING")
self._save()
return record
# ---------------- 执行 ----------------
def run(self, saga_id: str, *, auto_approve: bool = True) -> dict[str, Any]:
"""执行/续跑一条 saga:按序执行未完成步骤;失败自动进入补偿链。
- auto_approve=True:gate 步骤由编排器代行人工确认(测试/自动化模式);
- auto_approve=False:gate 步骤停在 WAITING_HUMAN(中间态可见,人工门禁)。
"""
saga = self._find(saga_id)
if saga["status"] in ("SUCCEEDED", "COMPENSATED"):
return saga
if saga["status"] == "MANUAL_TAKEOVER" or saga.get("manualTakeover"):
return saga # 人工接管中:需显式 retry 恢复自动化
saga["status"] = "RUNNING"
saga["updatedAt"] = _now()
self._audit_saga(saga, "saga.run", result="RUNNING")
for step in saga["steps"]:
if step["status"] in ("SUCCEEDED", "COMPENSATED", "SKIPPED"):
continue
if step.get("gate") and not auto_approve:
step["status"] = "WAITING_HUMAN"
saga["status"] = "WAITING_HUMAN"
saga["updatedAt"] = _now()
self._audit_step(saga, step, "saga.step.waiting_human", result="WAITING_HUMAN")
self._save()
return saga
outcome = self._execute_step(saga, step, auto_approve=auto_approve)
if outcome == "FAILED":
self._compensate(saga, reason=f"step:{step['name']}")
return saga
saga["status"] = "SUCCEEDED"
saga["updatedAt"] = _now()
self._audit_saga(saga, "saga.succeeded", result="SUCCEEDED")
self._save()
return saga
def retry(self, saga_id: str, *, auto_approve: bool = True) -> dict[str, Any]:
"""显式重试:回滚失败步的已发生副作用(写前快照),重置重试预算后重跑。
人工接管(MANUAL_TAKEOVER)后调用 retry 可恢复自动化。
"""
saga = self._find(saga_id)
if saga["status"] in ("SUCCEEDED", "COMPENSATED"):
return saga
ctx = self._ctx(saga, None)
for step in saga["steps"]:
if step["status"] == "FAILED":
if step.get("checkpointId"):
try:
_restore_from_checkpoint({**ctx, "step": step})
except SagaCompensationError:
pass # 快照缺失时保留现场,交由 run 决定
step["status"] = "PENDING"
step["error"] = None
step["attempts"] = 0
step["timeouts"] = 0
step["result"] = None
step["finishedAt"] = None
elif step["status"] == "WAITING_HUMAN":
step["status"] = "PENDING"
saga["manualTakeover"] = False
saga["takeoverReason"] = None
saga["compensationReason"] = None
saga["status"] = "PENDING"
saga["updatedAt"] = _now()
self._audit_saga(saga, "saga.retry", result="RUNNING")
self._save()
return self.run(saga_id, auto_approve=auto_approve)
def compensate(self, saga_id: str, *, reason: str = "manual") -> dict[str, Any]:
"""手动补偿(P2 人工动作):对已成功(或失败但留有写前快照)的步骤逆序补偿。"""
saga = self._find(saga_id)
if saga["status"] == "COMPENSATED":
return saga
self._compensate(saga, reason=reason)
return saga
def takeover(self, saga_id: str, *, reason: str = "manual") -> dict[str, Any]:
"""人工接管:停止自动执行,标记 MANUAL_TAKEOVER(可经 retry 恢复自动化)。"""
saga = self._find(saga_id)
if saga["status"] in _TERMINAL:
return saga
saga["manualTakeover"] = True
saga["takeoverReason"] = reason
saga["status"] = "MANUAL_TAKEOVER"
saga["updatedAt"] = _now()
self._audit_saga(saga, "saga.manual_takeover", result="MANUAL_TAKEOVER",
extra={"reason": reason})
self._save()
return saga
def resume_all(self, *, auto_approve: bool = True) -> list[dict[str, Any]]:
"""恢复队列:重放所有未完成(非终态、非人工接管)的 saga。"""
out: list[dict[str, Any]] = []
for saga in list(self.store.data.get("sagas", [])):
if saga["status"] in _RECOVERABLE and not saga.get("manualTakeover"):
out.append(self.run(saga["id"], auto_approve=auto_approve))
return out
def list(self, limit: int | None = None) -> list[dict[str, Any]]:
sagas = list(reversed(self.store.data.get("sagas", [])))
return sagas if not limit else sagas[: int(limit)]
def get(self, saga_id: str) -> dict[str, Any] | None:
try:
return self._find(saga_id)
except SagaNotFound:
return None
# ---------------- 步骤执行 ----------------
def _execute_step(self, saga: dict[str, Any], step: dict[str, Any], *,
auto_approve: bool) -> str:
act = self.registry.get(step["action"])
if act is None:
step["status"] = "FAILED"
step["error"] = f"未注册动作 {step['action']}"
step["finishedAt"] = _now()
self._audit_step(saga, step, "saga.step.failed", result="FAILED")
self._save()
return "FAILED"
# 幂等重放:同 idem_key 已被(其他/先前)saga 成功执行 → 复用记录,不重复外部效果
idem_key = step.get("idemKey") or ""
if idem_key and step["status"] != "FAILED":
prior = self.store.data.setdefault("sagaIdem", {}).get(idem_key)
if prior and prior.get("step") == step["name"]:
step["result"] = _jsonable(copy.deepcopy(prior.get("result")))
step["status"] = "SUCCEEDED"
step["finishedAt"] = _now()
self._audit_step(saga, step, "saga.step.idempotent_replayed", result="SUCCEEDED")
self._save()
return "OK"
# 补偿性步骤执行前:自动建写前快照(回滚锚点)
if step.get("compensationAction") and not step.get("checkpointId"):
step["checkpointId"] = self._snapshot(saga, step)
max_attempts = int(step.get("maxRetries") or 0) + 1
for attempt in range(max_attempts):
step["status"] = "RUNNING"
step["attempts"] = int(step.get("attempts") or 0) + 1
step["startedAt"] = _now()
step["error"] = None
started = self.clock()
try:
result = act["fn"](self._ctx(saga, step))
elapsed = self.clock() - started
if float(step.get("timeoutSec") or 0) > 0 and elapsed > float(step["timeoutSec"]):
raise SagaStepTimeout(
f"步骤超时 {elapsed:.1f}s > {step['timeoutSec']}s")
step["result"] = _jsonable(result if isinstance(result, dict) else {"value": result})
step["status"] = "SUCCEEDED"
step["finishedAt"] = _now()
if idem_key:
self.store.data.setdefault("sagaIdem", {})[idem_key] = {
"sagaId": saga["id"], "step": step["name"],
"result": step["result"], "at": _now()}
self._audit_step(saga, step, "saga.step.succeeded", result="SUCCEEDED")
self._save()
return "OK"
except SagaStepTimeout as exc:
step["timeouts"] = int(step.get("timeouts") or 0) + 1
step["error"] = f"timeout: {exc}"
self._audit_step(saga, step, "saga.step.timeout", result="TIMEOUT")
except Exception as exc:
step["error"] = f"{type(exc).__name__}: {exc}"
self._audit_step(saga, step, "saga.step.failed", result="FAILED")
step["finishedAt"] = _now()
if attempt < max_attempts - 1:
delay = min(float(self.backoff_base) * (2 ** attempt), float(self.backoff_cap))
self._audit_step(saga, step, "saga.step.retry", result="RETRY",
extra={"delaySec": delay, "attempt": step["attempts"]})
self._save()
if delay > 0:
self.sleep_fn(delay)
continue
break
step["status"] = "FAILED"
step["finishedAt"] = _now()
self._audit_step(saga, step, "saga.step.failed", result="FAILED")
self._save()
return "FAILED"
# ---------------- 补偿链 ----------------
def _compensate(self, saga: dict[str, Any], *, reason: str) -> None:
saga["status"] = "COMPENSATING"
saga["compensationReason"] = reason
saga["updatedAt"] = _now()
self._audit_saga(saga, "saga.compensating", result="COMPENSATING",
extra={"reason": reason})
failed = False
for step in reversed(saga["steps"]):
if not self._compensatable(step):
continue
comp_action = step["compensationAction"]
comp = self.registry.get(comp_action)
if comp is None:
step["compensation"] = {"status": "FAILED",
"error": f"未注册补偿动作 {comp_action}", "at": _now()}
failed = True
self._audit_step(saga, step, "saga.step.compensation_failed", result="FAILED",
extra={"compensation": comp_action})
self._save()
break
try:
res = comp["fn"](self._ctx(saga, step))
step["compensation"] = {
"status": "SUCCEEDED",
"action": comp_action,
"result": _jsonable(res if isinstance(res, dict) else {"value": res}),
"at": _now(),
}
if step.get("idemKey"):
self.store.data.setdefault("sagaIdem", {}).pop(step["idemKey"], None)
self._audit_step(saga, step, "saga.step.compensated", result="SUCCEEDED",
extra={"compensation": comp_action})
self._save()
except Exception as exc:
step["compensation"] = {
"status": "FAILED", "action": comp_action,
"error": f"{type(exc).__name__}: {exc}", "at": _now(),
}
failed = True
self._audit_step(saga, step, "saga.step.compensation_failed", result="FAILED",
extra={"compensation": comp_action})
self._save()
break
if failed:
saga["status"] = "MANUAL_TAKEOVER"
saga["manualTakeover"] = True
saga["takeoverReason"] = f"compensation-failed: {reason}"
else:
saga["status"] = "COMPENSATED"
saga["updatedAt"] = _now()
self._audit_saga(saga, "saga.manual_takeover" if failed else "saga.compensated",
result=saga["status"],
extra={"reason": reason, "compensationReason": saga["compensationReason"]})
self._save()
@staticmethod
def _compensatable(step: dict[str, Any]) -> bool:
if not step.get("compensationAction"):
return False
if (step.get("compensation") or {}).get("status") == "SUCCEEDED":
return False # 已补偿
return step["status"] == "SUCCEEDED" or bool(step.get("checkpointId"))
# ---------------- 基础设施 ----------------
def _ctx(self, saga: dict[str, Any], step: dict[str, Any] | None) -> dict[str, Any]:
return {
"store": self.store,
"saga": saga,
"step": step,
"steps": {s["name"]: s for s in saga["steps"]},
"actor": self.actor,
"context": saga.get("context") or {},
"coordinator": self,
}
def _snapshot(self, saga: dict[str, Any], step: dict[str, Any]) -> str:
cps = self._checkpoint_store()
pair = cps.create(self.store.data,
label=f"saga:{saga['id']}:{step['name']} 前",
reason="auto:saga.pre-step",
conversation_note=f"Saga {saga['name']} 步骤 {step['name']} 执行前基线")
return str(pair["pairId"])
def _checkpoint_store(self):
cps = getattr(self.store, "checkpoints", None)
if cps is None:
from server.state.checkpoints import get_checkpoints
cps = get_checkpoints()
return cps
def _audit_saga(self, saga: dict[str, Any], action: str, *, result: str = "SUCCESS",
extra: dict[str, Any] | None = None) -> None:
from server.agent_core.audit import write_audit
write_audit(self.store.data, self.store.next_id, actor=self.actor, category="SAGA",
action=action, target={"type": "SAGA", "id": saga["id"], "name": saga["name"]},
power="P1",
rationale={"sagaId": saga["id"], "name": saga["name"],
"status": saga["status"], **(extra or {})},
result=result,
evidence_refs=list(saga.get("auditRefs") or [f"saga:{saga['id']}"]))
def _audit_step(self, saga: dict[str, Any], step: dict[str, Any], action: str, *,
result: str = "SUCCESS", extra: dict[str, Any] | None = None) -> None:
from server.agent_core.audit import write_audit
refs = list(saga.get("auditRefs") or [f"saga:{saga['id']}"])
step_ref = f"saga.step:{saga['id']}:{step['name']}"
if step_ref not in refs:
refs.append(step_ref)
saga["auditRefs"] = refs
write_audit(self.store.data, self.store.next_id, actor=self.actor, category="SAGA",
action=action,
target={"type": "SAGA_STEP", "sagaId": saga["id"], "step": step["name"]},
power="P1",
rationale={"sagaId": saga["id"], "step": step["name"], "action": step["action"],
"idemKey": step.get("idemKey"), "status": step["status"],
"attempts": step.get("attempts"),
"compensation": step.get("compensationAction"),
**(extra or {})},
result=result, evidence_refs=refs)
# ------------------------------------------------------------
# ⑥ WMS 缺料闭环 Saga 动作(动作注册表默认值)
# ------------------------------------------------------------
def _act_wms_consume_event(ctx: dict[str, Any]) -> dict[str, Any]:
"""动作:WMS 事件消费(eventId 幂等)。"""
from server.aps_domain import wms_events
event = ctx["context"].get("event") or {}
r = wms_events.consume_event(ctx["store"], event, actor=ctx["actor"])
if r.get("duplicate"):
return {**r, "replayed": True}
return r
def _act_wms_evaluate_impact(ctx: dict[str, Any]) -> dict[str, Any]:
"""动作:缺料影响半径(沙盒只读差分)。"""
from server.aps_domain import wms_events
event = ctx["context"].get("event") or {}
return wms_events.evaluate_shortage_impact(ctx["store"].data, event)
def _act_wms_stage_solution(ctx: dict[str, Any]) -> dict[str, Any]:
"""动作:生成 Explore 方案卡(P2 门禁卡;确认点在下一 gate 步骤)。"""
from server.aps_domain import wms_events
store = ctx["store"]
event = ctx["context"].get("event") or {}
session_id = (ctx["context"].get("sessionId")) or "wms"
return wms_events.stage_shortage_solution(store, event, session_id=session_id,
actor=ctx["actor"])
def _act_flex_reschedule(ctx: dict[str, Any]) -> dict[str, Any]:
"""执行一次经确认的 L4 重排,并只返回本次确认实际创建的精确版本。"""
from server.aps_domain.workflow import execute_confirmed
store = ctx["store"]
stage_res = (ctx["steps"].get("stage_solution") or {}).get("result") or {}
confirm_id = str(stage_res.get("confirmId") or "")
if not confirm_id:
raise SagaStepFailed("缺少方案卡 confirmId(stage_solution 未成功)")
before_versions = list(store.data.get("flexScheduleVersions") or [])
before_ids = {str(row.get("id")) for row in before_versions}
msg = execute_confirmed(store, confirm_id, approve=True, actor=ctx["actor"])
after_versions = list(store.data.get("flexScheduleVersions") or [])
new_versions = [row for row in after_versions if str(row.get("id")) not in before_ids]
if len(after_versions) <= len(before_versions) or len(new_versions) != 1:
raise SagaStepFailed(
"本次重排确认未创建唯一的新排产版本,拒绝沿用历史 DRAFT 版本:"
f"confirmId={confirm_id}; message={msg}"
)
version = new_versions[0]
version_id = version.get("id")
event_id = str((ctx.get("context", {}).get("event") or {}).get("eventId") or "")
approval = next(
(
event
for event in reversed(store.data.get("auditEvents") or [])
if event.get("action") == "flex.reschedule.approve"
and str((event.get("rationale") or {}).get("confirmId") or "") == confirm_id
and str((event.get("target") or {}).get("id")) == str(version_id)
),
None,
)
expected_event_ref = f"wms-event:{event_id}" if event_id else ""
if approval is None or (
expected_event_ref
and expected_event_ref not in (approval.get("evidenceRefs") or [])
):
raise SagaStepFailed(
"新排产版本缺少与当前确认卡/WMS 事件一致的审计证据,拒绝进入发布门禁:"
f"confirmId={confirm_id}; versionId={version_id}; eventId={event_id or '-'}"
)
return {
"message": msg,
"versionId": version_id,
"versionNo": version.get("versionNo"),
"confirmId": confirm_id,
"eventId": event_id or None,
"approvalAuditId": approval.get("id"),
}
def _act_schedule_publish_stage(ctx: dict[str, Any]) -> dict[str, Any]:
"""Stage an exact flex-version P2 publication card after rescheduling."""
from server.aps_domain.workflow import stage_schedule_publish
store = ctx["store"]
reschedule_result = (ctx["steps"].get("reschedule") or {}).get("result") or {}
version_id = reschedule_result.get("versionId")
if not version_id:
raise SagaStepFailed("缺少重排版本 versionId(reschedule 未成功)")
session_id = ctx["context"].get("sessionId") or "wms"
reply = stage_schedule_publish(
store,
session_id=session_id,
actor=ctx["actor"],
track="flex",
version_id=int(version_id),
)
block = next(
(
item
for item in reply.blocks or []
if getattr(item, "type", None) == "confirm-card"
),
None,
)
confirm_id = str((getattr(block, "props", None) or {}).get("confirmId") or "")
if not confirm_id:
raise SagaStepFailed(f"柔性版本发布暂存失败: {reply.text}")
return {
"staged": True,
"confirmId": confirm_id,
"versionId": int(version_id),
"message": reply.text,
}
def _act_schedule_publish(ctx: dict[str, Any]) -> dict[str, Any]:
"""Approve the independent P2 publication gate without touching MES."""
from server.aps_domain.workflow import execute_confirmed
store = ctx["store"]
stage_result = (ctx["steps"].get("stage_publish") or {}).get("result") or {}
confirm_id = str(stage_result.get("confirmId") or "")
version_id = stage_result.get("versionId")
if not confirm_id or not version_id:
raise SagaStepFailed("缺少发布确认卡或目标版本(stage_publish 未成功)")
message = execute_confirmed(
store,
confirm_id,
approve=True,
actor=ctx["actor"],
)
version = next(
(
row
for row in store.data.get("flexScheduleVersions") or []
if row.get("id") == int(version_id)
),
None,
)
if version is None or str(version.get("status") or "").upper() != "PUBLISHED":
raise SagaStepFailed(f"柔性版本发布失败: {message}")
return {
"published": True,
"confirmId": confirm_id,
"versionId": int(version_id),
"message": message,
}
def _act_mes_dispatch_stage(ctx: dict[str, Any]) -> dict[str, Any]:
"""Stage P3 MES dispatch only for the exact published reschedule version."""
from server.aps_domain import wms_events
store = ctx["store"]
event = ctx["context"].get("event") or {}
event_id = str(event.get("eventId") or "")
session_id = ctx["context"].get("sessionId") or "wms"
reschedule_result = (ctx["steps"].get("reschedule") or {}).get("result") or {}
expected_version_id = reschedule_result.get("versionId")
versions = store.data.get("flexScheduleVersions") or []
latest = versions[-1] if versions else None
if (
latest is None
or latest.get("id") != expected_version_id
or str(latest.get("status") or "").upper() != "PUBLISHED"
):
raise SagaStepFailed("MES 下发暂存失败: 重排目标版本不是当前已发布执行基准")
result = wms_events.stage_dispatch(
store,
event_id,
session_id=session_id,
actor=ctx["actor"],
)
if not result.get("staged"):
raise SagaStepFailed(f"MES 下发暂存失败: {result.get('message')}")
staged_version_id = (result.get("validation") or {}).get("versionId")
if int(staged_version_id or 0) != int(expected_version_id or 0):
raise SagaStepFailed("MES dispatch staging failed: confirmation card is not bound to the rescheduled version")
return result
def _act_mes_dispatch(ctx: dict[str, Any]) -> dict[str, Any]:
"""动作(gate):P3 双人确认 → MES 下发 → 回执镜像写回审计。
失败时保留部分结果(已创建外部工单/执行凭据)供补偿链撤销。
"""
from server.aps_domain import wms_events
store = ctx["store"]
event = ctx["context"].get("event") or {}
event_id = str(event.get("eventId") or "")
stage_res = (ctx["steps"].get("stage_dispatch") or {}).get("result") or {}
block = stage_res.get("block") or {}
confirm_id = str((block.get("props") or {}).get("confirmId")
or stage_res.get("confirmId") or "")
if not confirm_id:
raise SagaStepFailed("缺少 MES 下发确认卡 confirmId(stage_dispatch 未成功)")
step = ctx["step"]
prior = (step.get("result") or {}).get("executionGrant") if isinstance(step.get("result"), dict) else None
grant = prior or _p3_double_approve(ctx["coordinator"], confirm_id)
reschedule_res = (ctx["steps"].get("reschedule") or {}).get("result") or {}
version_id = reschedule_res.get("versionId")
validation = stage_res.get("validation") or {}
dispatch_evidence = [f"schedule-version:{int(version_id or 0)}"]
if validation.get("evidenceRef"):
dispatch_evidence.append(str(validation["evidenceRef"]))
cps = ctx["coordinator"]._checkpoint_store()
try:
res = wms_events.execute_dispatch_receipt(
store, event_id=event_id, confirm_id=confirm_id, execution_grant=grant,
version_id=int(version_id or 0),
before_snapshot=str(step.get("checkpointId") or ""),
checkpoint_store=cps, evidence_refs=dispatch_evidence,
actor=ctx["actor"])
except Exception as exc:
# 保留部分执行信息(已创建的外部工单/凭据)供补偿链撤销
step["result"] = _jsonable({
"partial": True, "error": str(exc), "executionGrant": grant,
"created": _external_wo_ids(store, version_id),
})
ctx["coordinator"]._save()
raise
return {**res, "executionGrant": grant}
def _p3_double_approve(coordinator: SagaCoordinator, confirm_id: str) -> str:
"""P3 双人职责分离确认:当前身份第一重 + approver-2 第二重 → executionGrant。"""
from server.agent_core import harness
from server.auth.context import IdentityContext, bind_identity, reset_identity
first = harness.take_confirmation(confirm_id, approve=True)
if first is None:
raise SagaStepFailed("MES P3 确认卡不可用(可能已处理)")
if first.get("needsSecondConfirm"):
approver = coordinator.second_approver or IdentityContext(
2002, "approver-2", "Approver 2", "platform", roles=("planner",))
token = bind_identity(approver)
try:
second = harness.take_confirmation(confirm_id, approve=True)
finally:
reset_identity(token)
if second is None:
raise SagaStepFailed("MES P3 二次确认失败")
grant = second.get("executionGrant")
else:
grant = first.get("executionGrant")
if not grant:
raise SagaStepFailed("MES P3 未获得执行凭据")
return str(grant)
def _external_wo_ids(store, version_id) -> list[str]:
"""从 mesLinks 反查某版本已下发的外部工单 ID(补偿撤销用)。"""
out: list[str] = []
for link in store.data.get("mesLinks", []):
if link.get("kind") != "dispatch":
continue
if version_id is not None and link.get("versionId") != version_id:
continue
if link.get("externalWoId") and link["externalWoId"] not in out:
out.append(link["externalWoId"])
return out
# ---------------- 补偿动作 ----------------
def _restore_from_checkpoint(ctx: dict[str, Any]) -> dict[str, Any]:
"""按步骤写前快照恢复世界(保留审计链与 saga 日志,回滚业务表到步骤前)。"""
store = ctx["store"]
step = ctx["step"]
pair_id = step.get("checkpointId")
if not pair_id:
raise SagaCompensationError(f"步骤 {step['name']} 缺少写前快照,无法回滚")
cps = ctx["coordinator"]._checkpoint_store()
pair = cps.get(pair_id)
if pair is None:
raise SagaCompensationError(f"写前快照 {pair_id} 不存在,无法回滚")
world = copy.deepcopy(pair["world"])
current = store.data
for key in ("auditEvents", "sagas", "sagaIdem", "mesCancellations", "wmsReceiptVoids"):
if key in current:
world[key] = current[key]
_replace_world(store, world)
return {"pairId": pair_id, "label": pair.get("label")}
def _replace_world(store, world: dict[str, Any]) -> None:
if hasattr(store, "restore"):
store.restore(world)
else:
store.data.clear()
store.data.update(world)
def _comp_flex_reschedule_rollback(ctx: dict[str, Any]) -> dict[str, Any]:
"""补偿:重排失败 → 回滚到步骤执行前版本(写前快照恢复)。"""
restored = _restore_from_checkpoint(ctx)
return {"worldRestore": restored, "message": "重排已回滚到步骤执行前版本"}
def _comp_mes_cancel_dispatch(ctx: dict[str, Any]) -> dict[str, Any]:
"""补偿:MES 下发失败 → 撤销外部工单 + 回执 VOIDED + 世界回滚到下发前。"""
from server.aps_domain import wms_events
from server.aps_domain.mes import cancel_dispatch
store = ctx["store"]
step = ctx["step"]
result = step.get("result") or {}
external_ids = list(result.get("created") or [])
version_id = (ctx["steps"].get("reschedule") or {}).get("result", {}).get("versionId")
if not external_ids:
external_ids = _external_wo_ids(store, version_id)
cancel_res = cancel_dispatch(store, external_wo_ids=external_ids,
idem_key=step.get("idemKey"), actor=ctx["actor"],
reason="saga-compensation")
rollback: dict[str, Any] = {}
try:
rollback = wms_events.rollback_receipt(
store, event_id=(ctx["context"].get("event") or {}).get("eventId"),
actor=ctx["actor"], reason="saga-compensation")
except Exception as exc:
rollback = {"error": f"{type(exc).__name__}: {exc}"}
restored = _restore_from_checkpoint(ctx)
return {"cancel": cancel_res, "receiptRollback": rollback, "worldRestore": restored}
# ------------------------------------------------------------
# 默认动作注册表(动作 → 补偿动作)
# ------------------------------------------------------------
def default_registry() -> dict[str, dict[str, Any]]:
return {
"wms.consume_event": {"fn": _act_wms_consume_event, "compensation": None},
"wms.evaluate_impact": {"fn": _act_wms_evaluate_impact, "compensation": None},
"wms.stage_solution": {"fn": _act_wms_stage_solution, "compensation": None},
"flex.reschedule": {
"fn": _act_flex_reschedule,
"compensation": "flex.reschedule.rollback",
},
"flex.reschedule.rollback": {"fn": _comp_flex_reschedule_rollback},
"schedule.publish.stage": {"fn": _act_schedule_publish_stage, "compensation": None},
"schedule.publish": {"fn": _act_schedule_publish, "compensation": None},
"mes.dispatch.stage": {"fn": _act_mes_dispatch_stage, "compensation": None},
"mes.dispatch": {"fn": _act_mes_dispatch, "compensation": "mes.cancel_dispatch"},
"mes.cancel_dispatch": {"fn": _comp_mes_cancel_dispatch},
}
# ------------------------------------------------------------
# 便捷入口
# ------------------------------------------------------------
def get_coordinator(store, *, actor: str = "saga",
registry: dict[str, dict[str, Any]] | None = None,
**kwargs) -> SagaCoordinator:
"""按世界状态构造(或复用)Saga 编排器。"""
return SagaCoordinator(store, actor=actor, registry=registry, **kwargs)
def build_wms_shortage_saga(coordinator: SagaCoordinator, event: dict[str, Any], *,
session_id: str = "wms", actor: str = "saga",
timeout_sec: float = 30.0, max_retries: int = 2) -> dict[str, Any]:
"""Build the governed WMS shortage closure Saga.
consume -> impact -> solution card -> P2 reschedule -> P2 publish ->
P3 MES dispatch -> receipt. Publication and dispatch remain separate gates.
"""
event_id = str(event.get("eventId") or "")
if not event_id:
raise ValueError("WMS 缺料 Saga 需要事件 eventId")
steps = [
SagaStep(
"consume_event",
"wms.consume_event",
idem_key=f"wms-event:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
),
SagaStep(
"evaluate_impact",
"wms.evaluate_impact",
idem_key=f"impact:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
),
SagaStep(
"stage_solution",
"wms.stage_solution",
idem_key=f"stage:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
),
SagaStep(
"reschedule",
"flex.reschedule",
idem_key=f"reschedule:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
compensation_action="flex.reschedule.rollback",
gate=True,
),
SagaStep(
"stage_publish",
"schedule.publish.stage",
idem_key=f"publish-stage:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
),
SagaStep(
"publish",
"schedule.publish",
idem_key=f"publish:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
gate=True,
),
SagaStep(
"stage_dispatch",
"mes.dispatch.stage",
idem_key=f"dispatch-stage:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
),
SagaStep(
"dispatch",
"mes.dispatch",
idem_key=f"dispatch:{event_id}",
timeout_sec=timeout_sec,
max_retries=max_retries,
compensation_action="mes.cancel_dispatch",
gate=True,
),
]
return coordinator.create_saga(
"wms-shortage-closure",
steps,
context={"event": event, "sessionId": session_id},
dedupe_key=event_id,
)