aps-agent/server/state/checkpoints.py

239 lines
13 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 成对快照存储(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"
2026-07-21 11:05:57 +08:00
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]:
2026-07-21 11:05:57 +08:00
"""创建一个成对快照(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;若受保护快照数量
超过容量上限,允许仓暂时超限。
2026-07-21 11:05:57 +08:00
Returns: 快照元信息(不含 world 大字段)
"""
protected = set(protected_pair_ids or ())
2026-07-21 11:05:57 +08:00
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"]
2026-07-21 11:05:57 +08:00
if world["scheduleVersions"] else None),
"world": copy.deepcopy(world), # 世界侧完整快照(深拷贝隔离)
}
protected.add(pair["pairId"]) # create must never evict its own result
2026-07-21 11:05:57 +08:00
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 # 全部受保护时保留超限状态
2026-07-21 11:05:57 +08:00
self._write() # 落盘
return self.meta(pair)
2026-07-21 11:05:57 +08:00
# ---------------- 查询 ----------------
@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
2026-07-21 11:05:57 +08:00
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()
2026-07-21 11:05:57 +08:00
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)