239 lines
13 KiB
Python
239 lines
13 KiB
Python
# ============================================================
|
||
# 成对快照存储(moduleId: state-checkpoints, 可重生 ✅, 黄金测试 tests/golden/test_m2_state.py)
|
||
# plan.md §4.2/§4.3:对话状态与世界状态成对快照,作为回滚锚点。
|
||
# M2 简化(诚实声明):对话侧暂存"触发上下文摘要"(conversationNote),
|
||
# 完整对话快照待会话树持久化后成对存储(§4 已知缺口 5)。
|
||
# 触发策略(§4.3):P2 写操作前自动·强制;用户口令手动;容量上限自动淘汰最旧。
|
||
# ============================================================
|
||
from __future__ import annotations # 前向类型引用
|
||
|
||
import copy # 世界状态深拷贝
|
||
import json # 序列化
|
||
import os # 路径与原子替换
|
||
import tempfile # 原子写临时文件
|
||
import threading # 互斥锁
|
||
import uuid # pairId 生成
|
||
from datetime import datetime # 时间戳
|
||
from typing import Any # 类型标注
|
||
|
||
from server.timeutil import fmt_dt # 时间格式化
|
||
|
||
# 快照容量上限:超出后淘汰最旧(防止 JSON 文件无限膨胀)
|
||
_MAX_PAIRS = 20
|
||
|
||
|
||
class CheckpointStore:
|
||
"""成对快照仓:创建/列出/取回/恢复(单文件 JSON + 原子写,接口可整体换 DB 实现)。"""
|
||
|
||
def __init__(self, path: str | None = None) -> None:
|
||
"""初始化:确定存储路径并加载既有快照。权力等级 P0(构造只读)。"""
|
||
# 存储路径:默认与世界状态同目录
|
||
self.path = path or os.environ.get("APS_CHECKPOINT_PATH")
|
||
if not self.path:
|
||
try:
|
||
from server.aps_home import default_checkpoint_path
|
||
self.path = default_checkpoint_path()
|
||
except Exception:
|
||
self.path = "server/data/checkpoints.json"
|
||
|
||
self._lock = threading.Lock() # 并发保护
|
||
self.pairs: list[dict[str, Any]] = self._load() # 快照列表(时间升序)
|
||
|
||
# ---------------- 加载与落盘 ----------------
|
||
def _load(self) -> list[dict[str, Any]]:
|
||
"""从磁盘加载快照列表;缺失/损坏返回空列表(快照丢失不致命)。"""
|
||
try:
|
||
with open(self.path, "r", encoding="utf-8") as f: # 读取文件
|
||
return json.load(f).get("pairs", []) # 取 pairs 数组
|
||
except (FileNotFoundError, json.JSONDecodeError): # 缺失或损坏
|
||
return [] # 空仓起步
|
||
|
||
def _write(self) -> None:
|
||
"""原子写盘(同 WorldStore:tmp + os.replace)。"""
|
||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) # 确保目录
|
||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp") # 同目录临时文件
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as f: # 写临时文件
|
||
json.dump({"pairs": self.pairs}, f, ensure_ascii=False) # 序列化(快照体量大,不缩进)
|
||
os.replace(tmp, self.path) # 原子替换
|
||
except BaseException: # 失败清理
|
||
if os.path.exists(tmp):
|
||
os.unlink(tmp)
|
||
raise # 上抛
|
||
|
||
# ---------------- 创建快照(§4.3 触发策略的执行端) ----------------
|
||
def create(
|
||
self,
|
||
world: dict[str, Any],
|
||
*,
|
||
label: str,
|
||
reason: str,
|
||
conversation_note: str = "",
|
||
conversation_side: dict[str, Any] | None = None,
|
||
protected_pair_ids: set[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""创建一个成对快照(P1:只新增快照,不改世界)。
|
||
|
||
Args:
|
||
world: 当前世界状态(深拷贝入档)
|
||
label: 人类可读名(中文可命名,§4.5)
|
||
reason: 触发原因(auto:publish / manual / auto:reset…)
|
||
conversation_note: 对话侧摘要(M2 简化形态)
|
||
conversation_side: 对话侧完整快照(project workspace + messages + Plan 等;
|
||
未提供时仅存摘要,见矩阵 52 行成对快照验收)
|
||
protected_pair_ids: 容量淘汰时必须保留的快照 ID;若受保护快照数量
|
||
超过容量上限,允许仓暂时超限。
|
||
Returns: 快照元信息(不含 world 大字段)
|
||
"""
|
||
protected = set(protected_pair_ids or ())
|
||
with self._lock: # 串行化写
|
||
pair = { # 成对快照体
|
||
"pairId": uuid.uuid4().hex[:10], # 快照 ID(@引用可用)
|
||
"label": label, # 命名
|
||
"reason": reason, # 触发原因(审计维度)
|
||
"createdAt": fmt_dt(datetime.now()), # 时间戳
|
||
"conversationNote": conversation_note, # 对话侧摘要(M2 简化)
|
||
"conversationSide": (copy.deepcopy(conversation_side)
|
||
if conversation_side is not None else None),
|
||
"versionNo": (world["scheduleVersions"][-1]["versionNo"]
|
||
if world["scheduleVersions"] else None),
|
||
"world": copy.deepcopy(world), # 世界侧完整快照(深拷贝隔离)
|
||
}
|
||
protected.add(pair["pairId"]) # create must never evict its own result
|
||
self.pairs.append(pair) # 追加入仓
|
||
overflow = len(self.pairs) - _MAX_PAIRS
|
||
if overflow > 0:
|
||
retained: list[dict[str, Any]] = []
|
||
for candidate in self.pairs: # 从最旧的非保护快照开始淘汰
|
||
if overflow > 0 and candidate["pairId"] not in protected:
|
||
overflow -= 1
|
||
continue
|
||
retained.append(candidate)
|
||
self.pairs = retained # 全部受保护时保留超限状态
|
||
self._write() # 落盘
|
||
return self.meta(pair)
|
||
|
||
# ---------------- 查询 ----------------
|
||
@staticmethod
|
||
def meta(pair: dict[str, Any]) -> dict[str, Any]:
|
||
"""快照元信息投影(剥离大字段 world,供导轨/列表用)。"""
|
||
return {k: pair[k] for k in ("pairId", "label", "reason", "createdAt", "conversationNote", "versionNo")}
|
||
|
||
def list_meta(self) -> list[dict[str, Any]]:
|
||
"""全部快照的元信息(时间升序,时间线导轨数据源)。"""
|
||
return [self.meta(p) for p in self.pairs] # 逐个投影
|
||
|
||
def get(self, pair_id: str) -> dict[str, Any] | None:
|
||
"""按 ID 取完整快照(回滚用)。"""
|
||
return next((p for p in self.pairs if p["pairId"] == pair_id), None) # 线性查找(容量≤20)
|
||
|
||
|
||
def delete(self, pair_id: str) -> bool:
|
||
"""原子删除指定快照;不存在返回 False,供 staging 失败时补偿。"""
|
||
with self._lock:
|
||
index = next(
|
||
(i for i, pair in enumerate(self.pairs) if pair["pairId"] == pair_id),
|
||
None,
|
||
)
|
||
if index is None:
|
||
return False
|
||
removed = self.pairs.pop(index)
|
||
try:
|
||
self._write()
|
||
except BaseException:
|
||
self.pairs.insert(index, removed)
|
||
raise
|
||
return True
|
||
|
||
def latest(self) -> dict[str, Any] | None:
|
||
"""最近一个快照("回滚到上一个检查点"的默认目标)。"""
|
||
return self.pairs[-1] if self.pairs else None # 末尾即最新
|
||
|
||
def clear(self) -> None:
|
||
"""清空快照仓(数据重置时随世界一起清,防止跨世界恢复)。"""
|
||
with self._lock: # 串行化
|
||
self.pairs = [] # 清空
|
||
self._write() # 落盘
|
||
|
||
|
||
# ---------------- 世界侧投影(矩阵 54 行:逐字段 diff 数据源) ----------------
|
||
def world_projection(self, pair_id: str) -> dict[str, Any]:
|
||
"""检查点世界侧轻量投影(P0 只读;矩阵 54 行验收数据源)。
|
||
|
||
在仓内 world 的深拷贝上计算视图(绝不改动仓内数据),返回:
|
||
summary 世界摘要(hasVersion/versionNo/status/woCount/conflictCount/
|
||
totalTardiness/avgUtilization + 柔性/固定/池计数)
|
||
gantt 固定轨工单投影(订单/工序/开始/结束/设备/模具/交期/状态 + 超期/冲突标记)
|
||
flexGantt 柔性轨工单投影(版本/排序/工单,含模具/超期/冲突/瓶颈)
|
||
flexCapacity 产能池投影(设备数/利用率/告警/瓶颈日产能)
|
||
缺失 pair 显式抛 KeyError(NotFound 语义,由网关转 404)。
|
||
"""
|
||
pair = self.get(pair_id) # 定位快照
|
||
if pair is None: # 缺失显式失败
|
||
raise KeyError(pair_id)
|
||
world = copy.deepcopy(pair["world"]) # 深拷贝隔离:投影绝不改动仓内数据
|
||
from server.aps_domain.views import gantt_view, world_summary # 世界视图(延迟导入防环)
|
||
from server.aps_domain.flex import capacity_analysis, flex_gantt_view
|
||
summary = world_summary(world) # KPI 摘要
|
||
gantt = gantt_view(world) # 固定轨工单投影
|
||
flex = flex_gantt_view(world) # 柔性轨工单投影
|
||
cap = capacity_analysis(world) # 产能池投影
|
||
# 固定轨工单富化:交期(销售订单)/ 状态(工单)/ 模具(固定轨无模具 → null)
|
||
wo_by_id = {w["id"]: w for w in world.get("workOrders", [])}
|
||
po_by_id = {p["id"]: p for p in world.get("productionOrders", [])}
|
||
so_by_id = {s["id"]: s for s in world.get("salesOrders", [])}
|
||
for row in gantt.get("workOrders", []):
|
||
wo = wo_by_id.get(row.get("id")) or {}
|
||
po = po_by_id.get(wo.get("productionOrderId")) or {}
|
||
so = so_by_id.get(po.get("salesOrderId")) or {}
|
||
row["deliveryDate"] = so.get("deliveryDate") or po.get("plannedEndDate") or None # 交期
|
||
row["status"] = wo.get("status") or None # 状态
|
||
row["mold"] = None # 固定轨无模具概念
|
||
# 柔性工单富化:交期(柔性订单)
|
||
flex_by_no = {o.get("orderNo"): o for o in world.get("flexOrders", [])}
|
||
for row in flex.get("workOrders", []):
|
||
row["dueDate"] = (flex_by_no.get(row.get("orderNo")) or {}).get("dueDate") or None
|
||
# 摘要补足柔性/固定/池计数(前端 diff 视图 WorldStats 的数据源)
|
||
flex_wos = flex.get("workOrders") or []
|
||
summary["flexVersionNo"] = flex.get("versionNo")
|
||
summary["flexSortMode"] = flex.get("sortMode")
|
||
summary["flexWoCount"] = len(flex_wos)
|
||
summary["flexOverdue"] = sum(1 for w in flex_wos if w.get("overdue"))
|
||
summary["flexConflict"] = sum(1 for w in flex_wos if w.get("conflict"))
|
||
summary["fixedWoCount"] = len(gantt.get("workOrders") or [])
|
||
pools = cap.get("pools") or []
|
||
summary["poolCount"] = len(pools)
|
||
bottleneck = cap.get("bottleneckPool")
|
||
summary["bottleneckDaily"] = bottleneck.get("dailyCapacity") if bottleneck else None
|
||
return {"summary": summary, "gantt": gantt, "flexGantt": flex, "flexCapacity": cap}
|
||
|
||
# ---------------- 按租户/项目隔离的实例 ----------------
|
||
_checkpoints: dict[tuple[str, str], CheckpointStore] = {}
|
||
_checkpoints_lock = threading.Lock()
|
||
|
||
|
||
def get_checkpoints() -> CheckpointStore:
|
||
"""Return checkpoints for the current tenant and project world."""
|
||
from server.auth.context import get_identity
|
||
from server.state.store import get_store
|
||
|
||
identity = get_identity()
|
||
world = get_store()
|
||
key = (identity.tenant_uuid, world.world_key)
|
||
with _checkpoints_lock:
|
||
store = _checkpoints.get(key)
|
||
if store is None:
|
||
path = os.path.join(os.path.dirname(world.path) or ".", "checkpoints.json")
|
||
store = CheckpointStore(path)
|
||
_checkpoints[key] = store
|
||
return store
|
||
|
||
|
||
def world_projection(pair_id: str, store: CheckpointStore | None = None) -> dict[str, Any]:
|
||
"""检查点世界侧投影(矩阵 54 行;网关端点数据源,store 默认当前租户/项目仓)。
|
||
|
||
缺失 pair 显式抛 KeyError(NotFound 语义,由网关转 404)。
|
||
"""
|
||
ck = store if store is not None else get_checkpoints()
|
||
return ck.world_projection(pair_id)
|