aps-agent/server/state/checkpoints.py

121 lines
7.0 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", "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 # 返回单例