121 lines
7.0 KiB
Python
121 lines
7.0 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", "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() # 落盘
|
||
|
||
|
||
# ---------------- 进程级单例 ----------------
|
||
_ckpt: CheckpointStore | None = None # 单例槽
|
||
|
||
|
||
def get_checkpoints() -> CheckpointStore:
|
||
"""获取全局 CheckpointStore 单例(懒加载)。"""
|
||
global _ckpt # 引用模块级槽
|
||
if _ckpt is None: # 首次调用
|
||
_ckpt = CheckpointStore() # 创建实例
|
||
return _ckpt # 返回单例
|