aps-agent/server/state/checkpoints.py

239 lines
13 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.

# ============================================================
# 成对快照存储(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)