# ============================================================ # 成对快照存储(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 = "") -> dict[str, Any]: """创建一个成对快照(P1:只新增快照,不改世界)。 Args: world: 当前世界状态(深拷贝入档) label: 人类可读名(中文可命名,§4.5) reason: 触发原因(auto:publish / manual / auto:reset…) conversation_note: 对话侧摘要(M2 简化形态) Returns: 快照元信息(不含 world 大字段) """ with self._lock: # 串行化写 pair = { # 成对快照体 "pairId": uuid.uuid4().hex[:10], # 快照 ID(@引用可用) "label": label, # 命名 "reason": reason, # 触发原因(审计维度) "createdAt": fmt_dt(datetime.now()), # 时间戳 "conversationNote": conversation_note, # 对话侧摘要(M2 简化) "versionNo": (world["scheduleVersions"][-1]["versionNo"] # 锚定的最新版本号(导轨展示) if world["scheduleVersions"] else None), "world": copy.deepcopy(world), # 世界侧完整快照(深拷贝隔离) } self.pairs.append(pair) # 追加入仓 if len(self.pairs) > _MAX_PAIRS: # 超容量 → 淘汰最旧 self.pairs = self.pairs[-_MAX_PAIRS:] 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 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() # 落盘 # ---------------- 按租户/项目隔离的实例 ---------------- _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