将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。 同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
1375 lines
53 KiB
Python
1375 lines
53 KiB
Python
#!/usr/bin/env python3
|
||
"""reset 前一致性备份任意作品的升格派生状态,并支持纯离线复验。
|
||
|
||
备份命令只在一个 ``REPEATABLE READ READ ONLY`` 事务中读取数据库,不写数据库,
|
||
也不调用任何模型。七个状态域先按主键稳定排序,再写入独立 JSONL;每个文件和清单均先写
|
||
同目录临时文件,再用原子 rename 发布。数据库连接关闭后,脚本会从磁盘重新读取全部工件,
|
||
独立重算行数、主键摘要、内容摘要与文件摘要;失败时删除本次新建目录并非零退出。
|
||
|
||
``verify`` 子命令只读本地文件,不加载数据库连接配置,适合在离线环境复验已有备份。
|
||
"""
|
||
|
||
import base64
|
||
import datetime as dt
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import pathlib
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal
|
||
from typing import Any, Callable, Mapping, Sequence
|
||
|
||
import click
|
||
|
||
# 维护命令复用作品抽取 Skill 的同书锁和执行身份,不复制第二套互斥规则。
|
||
_HERE = pathlib.Path(__file__).resolve().parent
|
||
_EXTRACTION_SCRIPTS = _HERE.parents[1] / "extract-work-knowledge" / "scripts"
|
||
sys.path.insert(0, str(_HERE))
|
||
sys.path.insert(0, str(_EXTRACTION_SCRIPTS))
|
||
sys.path.insert(0, str(_HERE.parents[1] / "deconstruct-book" / "scripts"))
|
||
|
||
from upgrade_work_lock import UpgradeWorkLockUnavailable, upgrade_work_lock
|
||
|
||
|
||
SOURCE_TYPE = "upgrade_book"
|
||
PLANNED_MODEL = "MiniMax-M3"
|
||
PLANNED_SEMANTIC_DEDUP = True
|
||
BACKUP_ROOT = pathlib.Path("/private/tmp")
|
||
MANIFEST_FILE = "manifest.json"
|
||
MANIFEST_SHA_FILE = "manifest.sha256"
|
||
UPGRADE_SCHEMA_KEYS = (
|
||
"character", "location", "item", "faction", "power_system", "event",
|
||
"character_relation",
|
||
)
|
||
|
||
|
||
class BackupError(RuntimeError):
|
||
"""备份输入、数据库快照或工件写入不满足合同。"""
|
||
|
||
|
||
class VerificationError(BackupError):
|
||
"""磁盘备份的结构或摘要复验失败。"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DomainSpec:
|
||
"""单个备份域的表、主键、文件名和边界查询。"""
|
||
|
||
table: str
|
||
primary_keys: tuple[str, ...]
|
||
file_name: str
|
||
sql: str
|
||
params: tuple[str, ...]
|
||
|
||
|
||
# 七域查询都显式携带租户和作品边界。drafts 不过滤 deleted;windows 按需求只导出 active 行;
|
||
# audits/embeddings 不信任自身缺失的 work_id,而是通过同租户、同作品、同来源 draft 子查询定位。
|
||
DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||
"drafts": DomainSpec(
|
||
table="muse_knowledge_draft",
|
||
primary_keys=("id",),
|
||
file_name="drafts.jsonl",
|
||
sql="""SELECT d.*
|
||
FROM muse_knowledge_draft d
|
||
WHERE d.tenant_id = %s AND d.work_id = %s AND d.source_type = %s
|
||
ORDER BY d.id""",
|
||
params=("tenant", "work", "source"),
|
||
),
|
||
"windows": DomainSpec(
|
||
table="example_upgrade_window",
|
||
primary_keys=("id",),
|
||
file_name="windows.jsonl",
|
||
sql="""SELECT w.*
|
||
FROM example_upgrade_window w
|
||
WHERE w.tenant_id = %s AND w.work_id = %s AND w.deleted = FALSE
|
||
ORDER BY w.id""",
|
||
params=("tenant", "work"),
|
||
),
|
||
"aliases": DomainSpec(
|
||
table="example_upgrade_alias",
|
||
primary_keys=("id",),
|
||
file_name="aliases.jsonl",
|
||
sql="""SELECT a.*
|
||
FROM example_upgrade_alias a
|
||
WHERE a.tenant_id = %s AND a.work_id = %s
|
||
ORDER BY a.id""",
|
||
params=("tenant", "work"),
|
||
),
|
||
"presence": DomainSpec(
|
||
table="example_upgrade_presence",
|
||
primary_keys=("id",),
|
||
file_name="presence.jsonl",
|
||
sql="""SELECT p.*
|
||
FROM example_upgrade_presence p
|
||
WHERE p.tenant_id = %s AND p.work_id = %s
|
||
ORDER BY p.id""",
|
||
params=("tenant", "work"),
|
||
),
|
||
"card_state": DomainSpec(
|
||
table="example_upgrade_card_state",
|
||
primary_keys=("draft_id",),
|
||
file_name="card_state.jsonl",
|
||
sql="""SELECT s.*
|
||
FROM example_upgrade_card_state s
|
||
WHERE s.tenant_id = %s AND s.work_id = %s
|
||
ORDER BY s.draft_id""",
|
||
params=("tenant", "work"),
|
||
),
|
||
"audits": DomainSpec(
|
||
table="example_upgrade_audit",
|
||
primary_keys=("id",),
|
||
file_name="audits.jsonl",
|
||
sql="""SELECT a.*
|
||
FROM example_upgrade_audit a
|
||
WHERE a.tenant_id = %s AND a.draft_id IN (
|
||
SELECT d.id
|
||
FROM muse_knowledge_draft d
|
||
WHERE d.tenant_id = %s AND d.work_id = %s AND d.source_type = %s
|
||
)
|
||
ORDER BY a.id""",
|
||
params=("tenant", "tenant", "work", "source"),
|
||
),
|
||
"embeddings": DomainSpec(
|
||
table="example_knowledge_embedding",
|
||
primary_keys=("id",),
|
||
file_name="embeddings.jsonl",
|
||
# vector 显式转 text,避免不同 pgvector/psycopg 适配器返回不同 Python 类型。
|
||
sql="""SELECT e.id, e.draft_id, e.entity_id, e.content_hash, e.embed_text,
|
||
e.model, e.dimensions, e.embedding::text AS embedding,
|
||
e.creator, e.create_time, e.updater, e.update_time,
|
||
e.deleted, e.tenant_id
|
||
FROM example_knowledge_embedding e
|
||
WHERE e.tenant_id = %s AND e.draft_id IN (
|
||
SELECT d.id
|
||
FROM muse_knowledge_draft d
|
||
WHERE d.tenant_id = %s AND d.work_id = %s AND d.source_type = %s
|
||
)
|
||
ORDER BY e.id""",
|
||
params=("tenant", "tenant", "work", "source"),
|
||
),
|
||
}
|
||
|
||
|
||
def canonicalize(value: Any) -> Any:
|
||
"""把数据库常见类型转成无歧义、可重复 JSON 序列化的值。"""
|
||
|
||
if value is None or isinstance(value, (str, bool, int)):
|
||
return value
|
||
if isinstance(value, Decimal):
|
||
# Decimal 使用字符串保留尾零和完整精度,避免二进制浮点改变摘要。
|
||
return str(value)
|
||
if isinstance(value, float):
|
||
if not math.isfinite(value):
|
||
raise ValueError("JSON 不允许 NaN 或 Infinity")
|
||
return value
|
||
if isinstance(value, dt.datetime):
|
||
if value.tzinfo is not None:
|
||
utc_value = value.astimezone(dt.timezone.utc)
|
||
return utc_value.isoformat().replace("+00:00", "Z")
|
||
return value.isoformat()
|
||
if isinstance(value, (dt.date, dt.time)):
|
||
return value.isoformat()
|
||
if isinstance(value, uuid.UUID):
|
||
return str(value)
|
||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||
return {"$base64": base64.b64encode(bytes(value)).decode("ascii")}
|
||
if isinstance(value, Mapping):
|
||
return {str(key): canonicalize(item) for key, item in value.items()}
|
||
if isinstance(value, (list, tuple)):
|
||
return [canonicalize(item) for item in value]
|
||
# 某些 vector 适配器返回 ndarray;先转普通列表,再递归处理 Decimal 等元素。
|
||
to_list = getattr(value, "tolist", None)
|
||
if callable(to_list):
|
||
return canonicalize(to_list())
|
||
# PostgreSQL 扩展类型若没有专用适配器,字符串输出是其稳定文本表示。
|
||
return str(value)
|
||
|
||
|
||
def canonical_json(value: Any) -> str:
|
||
"""生成键排序、无多余空格、禁止非标准浮点值的规范 JSON。"""
|
||
|
||
return json.dumps(
|
||
canonicalize(value),
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
)
|
||
|
||
|
||
def _sha256(data: bytes) -> str:
|
||
"""返回字节内容的小写 SHA-256。"""
|
||
|
||
return hashlib.sha256(data).hexdigest()
|
||
|
||
|
||
def _primary_key_values(spec: DomainSpec, rows: Sequence[Mapping[str, Any]]) -> list[list[Any]]:
|
||
"""提取稳定排序后的主键元组,缺字段时立即失败。"""
|
||
|
||
values = []
|
||
for row in rows:
|
||
try:
|
||
values.append([canonicalize(row[key]) for key in spec.primary_keys])
|
||
except KeyError as error:
|
||
raise VerificationError(f"{spec.file_name} 缺少主键字段:{error.args[0]}") from error
|
||
return values
|
||
|
||
|
||
def sort_rows(domain: str, rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||
"""按域主键稳定排序并规范化整行,消除数据库返回顺序和类型差异。"""
|
||
|
||
if domain not in DOMAIN_SPECS:
|
||
raise ValueError(f"未知备份域:{domain}")
|
||
spec = DOMAIN_SPECS[domain]
|
||
normalized = []
|
||
for row in rows:
|
||
if not isinstance(row, Mapping):
|
||
raise BackupError(
|
||
f"{domain} 查询返回 {type(row).__name__},连接必须使用 psycopg.rows.dict_row"
|
||
)
|
||
normalized.append(canonicalize(dict(row)))
|
||
|
||
def sort_key(row: Mapping[str, Any]) -> tuple[Any, ...]:
|
||
try:
|
||
return tuple(row[key] for key in spec.primary_keys)
|
||
except KeyError as error:
|
||
raise VerificationError(f"{domain} 缺少主键字段:{error.args[0]}") from error
|
||
|
||
return sorted(normalized, key=sort_key)
|
||
|
||
|
||
def _content_sha(rows: Sequence[Mapping[str, Any]]) -> str:
|
||
"""对规范 JSON 数组计算内容摘要,避免 JSONL 行连接的边界歧义。"""
|
||
|
||
return _sha256(canonical_json(list(rows)).encode("utf-8"))
|
||
|
||
|
||
def _primary_key_sha(spec: DomainSpec, rows: Sequence[Mapping[str, Any]]) -> str:
|
||
"""对稳定主键元组数组计算摘要。"""
|
||
|
||
return _sha256(canonical_json(_primary_key_values(spec, rows)).encode("utf-8"))
|
||
|
||
|
||
def _jsonl_bytes(rows: Sequence[Mapping[str, Any]]) -> bytes:
|
||
"""生成 UTF-8 JSONL;空域对应空文件,非空文件以换行结尾。"""
|
||
|
||
if not rows:
|
||
return b""
|
||
return ("\n".join(canonical_json(row) for row in rows) + "\n").encode("utf-8")
|
||
|
||
|
||
def _window_boundary_sha(rows: Sequence[Mapping[str, Any]]) -> str:
|
||
"""只摘要 active 窗的业务序号与章边界,不让状态和时间字段污染边界身份。"""
|
||
|
||
boundaries = [
|
||
{
|
||
"windowNo": row.get("window_no"),
|
||
"fromChapter": row.get("from_chapter"),
|
||
"toChapter": row.get("to_chapter"),
|
||
}
|
||
for row in rows
|
||
]
|
||
boundaries.sort(key=lambda row: (row["fromChapter"], row["toChapter"], row["windowNo"]))
|
||
return _sha256(canonical_json(boundaries).encode("utf-8"))
|
||
|
||
|
||
def _is_under_backup_root(path: pathlib.Path) -> bool:
|
||
"""使用解析后的路径判断,防止 ``..`` 或符号链接绕过目录边界。"""
|
||
|
||
try:
|
||
path.relative_to(BACKUP_ROOT.resolve())
|
||
return path != BACKUP_ROOT.resolve()
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
def validate_new_output_dir(output_dir: pathlib.Path) -> pathlib.Path:
|
||
"""校验目标是 /private/tmp 下尚不存在的新目录,并返回绝对解析路径。"""
|
||
|
||
expanded = pathlib.Path(output_dir).expanduser().absolute()
|
||
try:
|
||
expanded.relative_to(BACKUP_ROOT)
|
||
except ValueError as error:
|
||
raise ValueError("输出目录必须显式位于 /private/tmp 下") from error
|
||
resolved = expanded.resolve(strict=False)
|
||
if not _is_under_backup_root(resolved):
|
||
raise ValueError("输出目录必须是 /private/tmp 下的子目录")
|
||
if resolved.exists() or resolved.is_symlink():
|
||
raise FileExistsError(f"输出目录已存在,禁止覆盖:{resolved}")
|
||
return resolved
|
||
|
||
|
||
def _validate_existing_backup_dir(backup_dir: pathlib.Path) -> pathlib.Path:
|
||
"""校验离线复验目标仍位于允许的备份根目录。"""
|
||
|
||
resolved = pathlib.Path(backup_dir).expanduser().resolve(strict=True)
|
||
if not _is_under_backup_root(resolved) or not resolved.is_dir():
|
||
raise VerificationError("备份目录必须是 /private/tmp 下的现有目录")
|
||
return resolved
|
||
|
||
|
||
def _atomic_write(path: pathlib.Path, data: bytes) -> None:
|
||
"""同目录写临时文件、落盘后原子替换,任何异常不遗留半文件。"""
|
||
|
||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||
try:
|
||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||
with os.fdopen(descriptor, "wb") as stream:
|
||
stream.write(data)
|
||
stream.flush()
|
||
os.fsync(stream.fileno())
|
||
os.replace(temporary, path)
|
||
finally:
|
||
temporary.unlink(missing_ok=True)
|
||
|
||
|
||
def _git_commit() -> str:
|
||
"""读取脚本所在仓库当前提交;不把工作区 diff 或远端信息写入备份。"""
|
||
|
||
repository = pathlib.Path(__file__).resolve().parents[4]
|
||
result = subprocess.run(
|
||
["git", "rev-parse", "HEAD"],
|
||
cwd=repository,
|
||
check=True,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
return result.stdout.strip()
|
||
|
||
|
||
def _utc_created_at() -> str:
|
||
"""生成固定 UTC 语义的 ISO-8601 时间。"""
|
||
|
||
return dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
def _query_params(names: Sequence[str], tenant: int, work_id: int) -> tuple[Any, ...]:
|
||
"""按域声明组装参数,避免 SQL 与边界参数错位。"""
|
||
|
||
values = {"tenant": tenant, "work": work_id, "source": SOURCE_TYPE}
|
||
return tuple(values[name] for name in names)
|
||
|
||
|
||
def _require_mapping(row: Any, context: str) -> Mapping[str, Any]:
|
||
"""数据库读取统一要求 dict_row,防止 tuple 被位置式误解。"""
|
||
|
||
if not isinstance(row, Mapping):
|
||
raise BackupError(f"{context} 未返回 dict_row")
|
||
return row
|
||
|
||
|
||
def _file_sha(path: pathlib.Path) -> str:
|
||
"""计算本地代码或 skill 文件摘要。"""
|
||
|
||
return _sha256(path.read_bytes())
|
||
|
||
|
||
def _repository_root() -> pathlib.Path:
|
||
"""返回 agent-example Git 根目录。"""
|
||
|
||
return pathlib.Path(__file__).resolve().parents[4]
|
||
|
||
|
||
def capture_code_identity(git_commit: str | None = None) -> dict[str, Any]:
|
||
"""绑定本次实际执行文件;未提交代码用 fileSha 表达,不借 HEAD 冒充。"""
|
||
|
||
here = pathlib.Path(__file__).resolve().parent
|
||
extraction_scripts = here.parents[1] / "extract-work-knowledge" / "scripts"
|
||
root = _repository_root()
|
||
# 契约 key 是稳定逻辑名(拆分前后字节不变,存量备份永久可离线复验);value 只是拆分后的真实定位路径。
|
||
files = {
|
||
"backup_upgrade_work.py": pathlib.Path(__file__).resolve(),
|
||
# reset 拆到独立 Skill 后路径改变;逻辑名 "reset_upgrade_work.py" 保持稳定。
|
||
"reset_upgrade_work.py": here.parents[1] / "reset-work-extraction" / "scripts" / "reset_upgrade_work.py",
|
||
# 升格执行器拆分后改名 upgrade.py;逻辑名 "parse_upgrade.py" 保持稳定。
|
||
"parse_upgrade.py": extraction_scripts / "upgrade.py",
|
||
# parse_llm 留 parse-book(字节不动);路径单向跨 skill 定位,逻辑名 "parse_llm.py" 不变。
|
||
"parse_llm.py": here.parents[1] / "deconstruct-book" / "scripts" / "parse_llm.py",
|
||
# 嵌入与模型调用的实现已装成共享运行时包;逻辑名不变,指向包内实现而非 Skill 的薄 CLI。
|
||
"embed_drafts.py": here.parents[3] / "muse-embed" / "src" / "muse_embed.py",
|
||
"upgrade_work_lock.py": extraction_scripts / "upgrade_work_lock.py",
|
||
"llm.py": here.parents[3] / "muse-llm" / "src" / "muse_llm.py",
|
||
# 逻辑名保留 "parse-book/SKILL.md"(稳定契约键);随 __file__ 实际指向 upgrade/SKILL.md。
|
||
"parse-book/SKILL.md": extraction_scripts.parent / "SKILL.md",
|
||
}
|
||
commit = git_commit or _git_commit()
|
||
file_shas = {name: _file_sha(path) for name, path in files.items()}
|
||
head_matches = {}
|
||
for name, path in files.items():
|
||
relative = path.relative_to(root).as_posix()
|
||
result = subprocess.run(
|
||
["git", "show", f"{commit}:{relative}"],
|
||
cwd=root,
|
||
capture_output=True,
|
||
)
|
||
head_matches[name] = result.returncode == 0 and _sha256(result.stdout) == file_shas[name]
|
||
return {
|
||
"gitCommit": commit,
|
||
"gitHeadCoversCodeFiles": all(head_matches.values()),
|
||
"codeFiles": file_shas,
|
||
"headMatches": head_matches,
|
||
}
|
||
|
||
|
||
def _database_identity(conn: Any) -> dict[str, Any]:
|
||
"""读取不含用户名和密码的数据库身份。"""
|
||
|
||
row = conn.execute(
|
||
"""SELECT current_database() AS database,
|
||
inet_server_addr()::text AS server_addr,
|
||
inet_server_port() AS server_port,
|
||
current_setting('server_version_num') AS server_version"""
|
||
).fetchone()
|
||
row = _require_mapping(row, "database identity")
|
||
return {
|
||
"database": row["database"],
|
||
"serverAddr": row["server_addr"],
|
||
"serverPort": row["server_port"],
|
||
"serverVersion": row["server_version"],
|
||
}
|
||
|
||
|
||
def _canonical_content_rows(conn: Any, work_id: int, tenant: int) -> list[dict[str, Any]]:
|
||
"""按章节和 block 业务顺序读取 Canonical 正文。"""
|
||
|
||
rows = conn.execute(
|
||
"""SELECT c.id AS chapter_id, c.order_no AS chapter_order, c.title AS chapter_title,
|
||
c.revision AS chapter_revision,
|
||
b.id AS block_id, b.order_no AS block_order, b.block_type,
|
||
b.title AS block_title, b.content_doc, b.content_text,
|
||
b.revision AS block_revision
|
||
FROM muse_content_chapter c
|
||
LEFT JOIN muse_content_block b
|
||
ON b.tenant_id = c.tenant_id AND b.work_id = c.work_id
|
||
AND b.chapter_id = c.id AND b.deleted = FALSE
|
||
WHERE c.tenant_id = %s AND c.work_id = %s AND c.deleted = FALSE
|
||
ORDER BY c.order_no, c.id, b.order_no NULLS FIRST, b.id NULLS FIRST""",
|
||
(tenant, work_id),
|
||
).fetchall()
|
||
return [canonicalize(dict(_require_mapping(row, "Canonical content"))) for row in rows]
|
||
|
||
|
||
def _canonical_content_identity(normalized: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||
"""摘要已按业务顺序读取的 Canonical 行。"""
|
||
|
||
chapter_ids = {row["chapter_id"] for row in normalized}
|
||
block_ids = {row["block_id"] for row in normalized if row.get("block_id") is not None}
|
||
return {
|
||
"chapterCount": len(chapter_ids),
|
||
"blockCount": len(block_ids),
|
||
"sha": _sha256(canonical_json(normalized).encode("utf-8")),
|
||
}
|
||
|
||
|
||
def _nonempty_block(row: Mapping[str, Any]) -> bool:
|
||
"""block 有 ID,且文本或结构化文档至少一项非空。"""
|
||
|
||
if row.get("block_id") is None:
|
||
return False
|
||
text = row.get("content_text")
|
||
document = row.get("content_doc")
|
||
return bool(isinstance(text, str) and text.strip()) or document not in (None, {}, [], "")
|
||
|
||
|
||
def validate_expected_scope(
|
||
canonical_rows: Sequence[Mapping[str, Any]],
|
||
windows: Sequence[Mapping[str, Any]],
|
||
*,
|
||
expected_chapters: int,
|
||
expected_windows: int,
|
||
) -> None:
|
||
"""机械验证章节/block 与窗口号、章域完整连续。"""
|
||
|
||
if expected_chapters <= 0 or expected_windows <= 0:
|
||
raise BackupError("expected-chapters/expected-windows 必须为正整数")
|
||
chapter_orders: dict[Any, int] = {}
|
||
chapter_has_content: dict[Any, bool] = {}
|
||
for row in canonical_rows:
|
||
row = _require_mapping(row, "expected Canonical row")
|
||
chapter_id = row.get("chapter_id")
|
||
order = row.get("chapter_order")
|
||
chapter_orders[chapter_id] = order
|
||
chapter_has_content[chapter_id] = chapter_has_content.get(chapter_id, False) or _nonempty_block(row)
|
||
if len(chapter_orders) != expected_chapters:
|
||
raise BackupError(
|
||
f"Canonical 章节数 {len(chapter_orders)} != expected {expected_chapters}"
|
||
)
|
||
ordered_chapters = sorted(chapter_orders.values())
|
||
if ordered_chapters != list(range(ordered_chapters[0], ordered_chapters[-1] + 1)):
|
||
raise BackupError("Canonical 章节 order_no 不连续")
|
||
missing_blocks = sorted(
|
||
chapter_orders[chapter_id]
|
||
for chapter_id, has_content in chapter_has_content.items() if not has_content
|
||
)
|
||
if missing_blocks:
|
||
raise BackupError(f"章节缺少非空 block:{missing_blocks[:20]}")
|
||
|
||
if len(windows) != expected_windows:
|
||
raise BackupError(f"active 窗数 {len(windows)} != expected {expected_windows}")
|
||
ordered_windows = sorted(windows, key=lambda row: row["window_no"])
|
||
window_numbers = [row["window_no"] for row in ordered_windows]
|
||
if window_numbers != list(range(1, expected_windows + 1)):
|
||
raise BackupError("active window_no 必须从 1 连续到 expected-windows")
|
||
cursor = ordered_chapters[0]
|
||
for row in ordered_windows:
|
||
start, end = row["from_chapter"], row["to_chapter"]
|
||
if start != cursor or end < start:
|
||
raise BackupError("active 窗章域存在头部、中部缝隙或非法倒序")
|
||
cursor = end + 1
|
||
if cursor - 1 != ordered_chapters[-1]:
|
||
raise BackupError("active 窗章域未覆盖 Canonical 尾章")
|
||
|
||
|
||
def _active_contract_identity(conn: Any, tenant: int) -> dict[str, Any]:
|
||
"""摘要升格七型当前激活字段合同和版本。"""
|
||
|
||
rows = conn.execute(
|
||
"""SELECT s.schema_key, s.active_version_id, v.field_contract_snapshot
|
||
FROM muse_meta_schema s
|
||
JOIN muse_meta_schema_version v ON v.id = s.active_version_id
|
||
WHERE s.tenant_id = %s AND s.schema_key = ANY(%s)
|
||
ORDER BY s.schema_key""",
|
||
(tenant, list(UPGRADE_SCHEMA_KEYS)),
|
||
).fetchall()
|
||
normalized = [
|
||
canonicalize(dict(_require_mapping(row, "active field contracts"))) for row in rows
|
||
]
|
||
keys = [row["schema_key"] for row in normalized]
|
||
if keys != sorted(UPGRADE_SCHEMA_KEYS):
|
||
raise BackupError(f"升格 active 字段合同不完整:{keys}")
|
||
return {
|
||
"count": len(normalized),
|
||
"schemaKeys": keys,
|
||
"sha": _sha256(canonical_json(normalized).encode("utf-8")),
|
||
}
|
||
|
||
|
||
def _load_active_windows(conn: Any, work_id: int, tenant: int) -> list[dict[str, Any]]:
|
||
"""恢复前重算输入时读取 active 窗边界。"""
|
||
|
||
spec = DOMAIN_SPECS["windows"]
|
||
rows = conn.execute(spec.sql, _query_params(spec.params, tenant, work_id)).fetchall()
|
||
return sort_rows("windows", rows)
|
||
|
||
|
||
def capture_input_snapshot(
|
||
conn: Any,
|
||
work_id: int,
|
||
tenant: int,
|
||
code_identity: Mapping[str, Any],
|
||
expected_chapters: int,
|
||
expected_windows: int,
|
||
windows: Sequence[Mapping[str, Any]] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""在当前只读快照中绑定 DB、Canonical、窗、合同、代码和计划参数。"""
|
||
|
||
active_windows = list(windows) if windows is not None else _load_active_windows(
|
||
conn, work_id, tenant
|
||
)
|
||
canonical_rows = _canonical_content_rows(conn, work_id, tenant)
|
||
validate_expected_scope(
|
||
canonical_rows,
|
||
active_windows,
|
||
expected_chapters=expected_chapters,
|
||
expected_windows=expected_windows,
|
||
)
|
||
return {
|
||
"databaseIdentity": _database_identity(conn),
|
||
"canonicalContent": _canonical_content_identity(canonical_rows),
|
||
"windowBoundary": {
|
||
"count": len(active_windows),
|
||
"sha": _window_boundary_sha(active_windows),
|
||
},
|
||
"activeFieldContracts": _active_contract_identity(conn, tenant),
|
||
"codeFiles": dict(code_identity["codeFiles"]),
|
||
"expectedChapters": expected_chapters,
|
||
"expectedWindows": expected_windows,
|
||
"plan": {"model": PLANNED_MODEL, "semanticDedup": PLANNED_SEMANTIC_DEDUP},
|
||
"sourceType": SOURCE_TYPE,
|
||
"tenant": tenant,
|
||
"work": work_id,
|
||
}
|
||
|
||
|
||
def _read_snapshot(conn: Any, work_id: int, tenant: int) -> tuple[str, dict[str, list[dict[str, Any]]]]:
|
||
"""在调用方已开启的只读事务内读取作品标题和七域完整快照。"""
|
||
|
||
work = conn.execute(
|
||
"""SELECT title FROM muse_content_work
|
||
WHERE tenant_id = %s AND id = %s""",
|
||
(tenant, work_id),
|
||
).fetchone()
|
||
if not work:
|
||
raise BackupError(f"作品不存在或不属于 tenant={tenant}:work={work_id}")
|
||
title = _require_mapping(work, "work title")["title"]
|
||
domains = {}
|
||
for name, spec in DOMAIN_SPECS.items():
|
||
rows = conn.execute(spec.sql, _query_params(spec.params, tenant, work_id)).fetchall()
|
||
domains[name] = sort_rows(name, rows)
|
||
return str(title), domains
|
||
|
||
|
||
def _build_manifest(
|
||
backup_id: str,
|
||
work_id: int,
|
||
tenant: int,
|
||
title: str,
|
||
git_commit: str,
|
||
input_summary: Mapping[str, Any],
|
||
git_head_covers_code: bool,
|
||
domains: Mapping[str, Sequence[Mapping[str, Any]]],
|
||
files: Mapping[str, bytes],
|
||
) -> dict[str, Any]:
|
||
"""构造不含 DSN/密码的自描述清单。"""
|
||
|
||
domain_manifest = {}
|
||
for name, spec in DOMAIN_SPECS.items():
|
||
rows = domains[name]
|
||
file_data = files[name]
|
||
domain_manifest[name] = {
|
||
"file": spec.file_name,
|
||
"rowCount": len(rows),
|
||
"primaryKeySha": _primary_key_sha(spec, rows),
|
||
"contentSha": _content_sha(rows),
|
||
"fileSha": _sha256(file_data),
|
||
}
|
||
input_sha = _sha256(canonical_json(input_summary).encode("utf-8"))
|
||
confirmation = {
|
||
"backup_id": backup_id,
|
||
"codeFiles": input_summary["codeFiles"],
|
||
"inputSha": input_sha,
|
||
"tenant": tenant,
|
||
"work": work_id,
|
||
}
|
||
return {
|
||
"schemaVersion": 1,
|
||
"backup_id": backup_id,
|
||
"work": work_id,
|
||
"tenant": tenant,
|
||
"title": title,
|
||
"createdAt": _utc_created_at(),
|
||
"gitCommit": git_commit,
|
||
"gitHeadCoversCodeFiles": git_head_covers_code,
|
||
"windowBoundarySha": input_summary["windowBoundary"]["sha"],
|
||
"input": dict(input_summary),
|
||
"inputSha": input_sha,
|
||
"confirmationSha": _sha256(canonical_json(confirmation).encode("utf-8")),
|
||
"domains": domain_manifest,
|
||
}
|
||
|
||
|
||
def _write_artifacts(
|
||
output_dir: pathlib.Path,
|
||
manifest: Mapping[str, Any],
|
||
files: Mapping[str, bytes],
|
||
) -> None:
|
||
"""按七域、清单、清单摘要的顺序原子发布所有工件。"""
|
||
|
||
for name, spec in DOMAIN_SPECS.items():
|
||
_atomic_write(output_dir / spec.file_name, files[name])
|
||
manifest_data = (canonical_json(manifest) + "\n").encode("utf-8")
|
||
_atomic_write(output_dir / MANIFEST_FILE, manifest_data)
|
||
manifest_sha = f"{_sha256(manifest_data)} {MANIFEST_FILE}\n".encode("ascii")
|
||
_atomic_write(output_dir / MANIFEST_SHA_FILE, manifest_sha)
|
||
|
||
|
||
def _read_jsonl(path: pathlib.Path) -> tuple[bytes, list[dict[str, Any]]]:
|
||
"""从磁盘读取并严格解析 JSONL,不接受空行或非对象行。"""
|
||
|
||
data = path.read_bytes()
|
||
rows = []
|
||
for line_number, line in enumerate(data.splitlines(), start=1):
|
||
if not line.strip():
|
||
raise VerificationError(f"{path.name} 第 {line_number} 行为空")
|
||
try:
|
||
row = json.loads(line)
|
||
except json.JSONDecodeError as error:
|
||
raise VerificationError(f"{path.name} 第 {line_number} 行不是合法 JSON") from error
|
||
if not isinstance(row, dict):
|
||
raise VerificationError(f"{path.name} 第 {line_number} 行不是 JSON 对象")
|
||
rows.append(row)
|
||
return data, rows
|
||
|
||
|
||
def verify_backup(backup_dir: pathlib.Path) -> dict[str, Any]:
|
||
"""只依赖磁盘工件,重算清单及七域全部摘要,成功时返回清单。"""
|
||
|
||
root = _validate_existing_backup_dir(backup_dir)
|
||
manifest_path = root / MANIFEST_FILE
|
||
manifest_sha_path = root / MANIFEST_SHA_FILE
|
||
try:
|
||
manifest_data = manifest_path.read_bytes()
|
||
expected_manifest_sha = manifest_sha_path.read_text(encoding="ascii").split()[0]
|
||
except (FileNotFoundError, IndexError, UnicodeError) as error:
|
||
raise VerificationError("缺少或无法读取 manifest 工件") from error
|
||
if _sha256(manifest_data) != expected_manifest_sha:
|
||
raise VerificationError("manifest.json 文件摘要不一致")
|
||
try:
|
||
manifest = json.loads(manifest_data)
|
||
except json.JSONDecodeError as error:
|
||
raise VerificationError("manifest.json 不是合法 JSON") from error
|
||
if not isinstance(manifest, dict):
|
||
raise VerificationError("manifest.json 顶层必须是对象")
|
||
required = {
|
||
"backup_id", "work", "tenant", "title", "createdAt", "gitCommit",
|
||
"gitHeadCoversCodeFiles", "windowBoundarySha", "input", "inputSha",
|
||
"confirmationSha", "domains",
|
||
}
|
||
missing = sorted(required - set(manifest))
|
||
if missing:
|
||
raise VerificationError(f"manifest 缺少字段:{', '.join(missing)}")
|
||
try:
|
||
uuid.UUID(str(manifest["backup_id"]))
|
||
except ValueError as error:
|
||
raise VerificationError("backup_id 不是合法 UUID") from error
|
||
if set(manifest["domains"]) != set(DOMAIN_SPECS):
|
||
raise VerificationError("manifest 七域集合不完整或包含未知域")
|
||
input_sha = _sha256(canonical_json(manifest["input"]).encode("utf-8"))
|
||
if input_sha != manifest["inputSha"]:
|
||
raise VerificationError("inputSha 与输入摘要不一致")
|
||
input_summary = manifest["input"]
|
||
if not isinstance(input_summary, dict):
|
||
raise VerificationError("input 摘要必须是对象")
|
||
if input_summary.get("sourceType") != SOURCE_TYPE \
|
||
or input_summary.get("tenant") != manifest["tenant"] \
|
||
or input_summary.get("work") != manifest["work"]:
|
||
raise VerificationError("input 摘要与 work/tenant/sourceType 不一致")
|
||
required_input = {
|
||
"databaseIdentity", "canonicalContent", "windowBoundary",
|
||
"activeFieldContracts", "codeFiles", "expectedChapters", "expectedWindows", "plan",
|
||
}
|
||
if not required_input.issubset(input_summary):
|
||
raise VerificationError("input 摘要缺少数据库、Canonical、窗、合同、代码或计划身份")
|
||
if input_summary["plan"] != {
|
||
"model": PLANNED_MODEL, "semanticDedup": PLANNED_SEMANTIC_DEDUP,
|
||
}:
|
||
raise VerificationError("input 计划模型或 semantic-dedup 与恢复合同不一致")
|
||
if input_summary["canonicalContent"].get("chapterCount") != input_summary["expectedChapters"] \
|
||
or input_summary["windowBoundary"].get("count") != input_summary["expectedWindows"]:
|
||
raise VerificationError("input 实际章节/窗口数与 expected 值不一致")
|
||
required_code = {
|
||
"backup_upgrade_work.py", "parse_upgrade.py", "parse_llm.py", "embed_drafts.py",
|
||
"upgrade_work_lock.py", "llm.py", "parse-book/SKILL.md",
|
||
}
|
||
if not required_code.issubset(input_summary["codeFiles"]):
|
||
raise VerificationError("input codeFiles 未覆盖升格实际依赖")
|
||
confirmation = {
|
||
"backup_id": manifest["backup_id"],
|
||
"codeFiles": input_summary["codeFiles"],
|
||
"inputSha": manifest["inputSha"],
|
||
"tenant": manifest["tenant"],
|
||
"work": manifest["work"],
|
||
}
|
||
if _sha256(canonical_json(confirmation).encode("utf-8")) != manifest["confirmationSha"]:
|
||
raise VerificationError("confirmationSha 未绑定 backup/input/code fileSha")
|
||
|
||
disk_rows = {}
|
||
for name, spec in DOMAIN_SPECS.items():
|
||
expected = manifest["domains"].get(name)
|
||
if not isinstance(expected, dict) or expected.get("file") != spec.file_name:
|
||
raise VerificationError(f"{name} 的文件声明不合法")
|
||
try:
|
||
file_data, rows = _read_jsonl(root / spec.file_name)
|
||
except FileNotFoundError as error:
|
||
raise VerificationError(f"缺少域文件:{spec.file_name}") from error
|
||
ordered = sort_rows(name, rows)
|
||
if rows != ordered:
|
||
raise VerificationError(f"{spec.file_name} 未按主键稳定排序")
|
||
actual = {
|
||
"file": spec.file_name,
|
||
"rowCount": len(rows),
|
||
"primaryKeySha": _primary_key_sha(spec, rows),
|
||
"contentSha": _content_sha(rows),
|
||
"fileSha": _sha256(file_data),
|
||
}
|
||
if actual != expected:
|
||
raise VerificationError(f"{spec.file_name} 行数或摘要不一致")
|
||
disk_rows[name] = rows
|
||
boundary_sha = _window_boundary_sha(disk_rows["windows"])
|
||
if boundary_sha != manifest["windowBoundarySha"]:
|
||
raise VerificationError("windowBoundarySha 与 windows.jsonl 不一致")
|
||
if boundary_sha != input_summary["windowBoundary"]["sha"] \
|
||
or len(disk_rows["windows"]) != input_summary["windowBoundary"]["count"]:
|
||
raise VerificationError("input 窗边界摘要与 windows.jsonl 不一致")
|
||
return manifest
|
||
|
||
|
||
def _default_connect(dsn: str, **kwargs: Any) -> Any:
|
||
"""仅 backup 命令真正执行时才加载 psycopg,保持 verify 可纯离线运行。"""
|
||
|
||
import psycopg
|
||
from psycopg.rows import dict_row
|
||
|
||
return psycopg.connect(dsn, row_factory=dict_row, **kwargs)
|
||
|
||
|
||
def _load_db_config() -> tuple[str, int]:
|
||
"""连接串来自共享的 muse_db,租户口径仍随 parse-book;本脚本和清单均不复制密码。"""
|
||
|
||
from muse_db import DSN
|
||
from parse_llm import TENANT
|
||
|
||
return DSN, TENANT
|
||
|
||
|
||
def backup_work(
|
||
work_id: int,
|
||
output_dir: pathlib.Path | None = None,
|
||
*,
|
||
tenant: int,
|
||
dsn: str,
|
||
expected_chapters: int,
|
||
expected_windows: int,
|
||
connect: Callable[..., Any] = _default_connect,
|
||
git_commit: str | None = None,
|
||
backup_id: str | None = None,
|
||
code_identity: Mapping[str, Any] | None = None,
|
||
lock_context: Callable[..., Any] = upgrade_work_lock,
|
||
lock_connect: Callable[..., Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""持同书锁创建备份;锁失败时不创建业务连接或输出目录。"""
|
||
|
||
with lock_context(dsn, tenant, work_id, connect=lock_connect):
|
||
return _backup_work_locked(
|
||
work_id,
|
||
output_dir,
|
||
tenant=tenant,
|
||
dsn=dsn,
|
||
expected_chapters=expected_chapters,
|
||
expected_windows=expected_windows,
|
||
connect=connect,
|
||
git_commit=git_commit,
|
||
backup_id=backup_id,
|
||
code_identity=code_identity,
|
||
)
|
||
|
||
|
||
def _backup_work_locked(
|
||
work_id: int,
|
||
output_dir: pathlib.Path | None = None,
|
||
*,
|
||
tenant: int,
|
||
dsn: str,
|
||
expected_chapters: int,
|
||
expected_windows: int,
|
||
connect: Callable[..., Any] = _default_connect,
|
||
git_commit: str | None = None,
|
||
backup_id: str | None = None,
|
||
code_identity: Mapping[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""创建并立即离线复验备份;任何失败只删除本次新建目录。"""
|
||
|
||
identifier = backup_id or str(uuid.uuid4())
|
||
try:
|
||
uuid.UUID(identifier)
|
||
except ValueError as error:
|
||
raise BackupError("backup_id 必须是合法 UUID") from error
|
||
requested = output_dir or (BACKUP_ROOT / f"upgrade-work-{work_id}-{identifier}")
|
||
target = validate_new_output_dir(pathlib.Path(requested))
|
||
created = False
|
||
try:
|
||
target.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||
created = True
|
||
actual_code = dict(code_identity or capture_code_identity(git_commit))
|
||
# autocommit=True 让事务边界完全由下面唯一一组 BEGIN/COMMIT 控制,不产生隐式第二事务。
|
||
with connect(dsn, autocommit=True) as conn:
|
||
conn.execute("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||
try:
|
||
title, domains = _read_snapshot(conn, work_id, tenant)
|
||
input_summary = capture_input_snapshot(
|
||
conn,
|
||
work_id,
|
||
tenant,
|
||
actual_code,
|
||
expected_chapters,
|
||
expected_windows,
|
||
windows=domains["windows"],
|
||
)
|
||
conn.execute("COMMIT")
|
||
except Exception:
|
||
conn.execute("ROLLBACK")
|
||
raise
|
||
files = {name: _jsonl_bytes(rows) for name, rows in domains.items()}
|
||
manifest = _build_manifest(
|
||
backup_id=identifier,
|
||
work_id=work_id,
|
||
tenant=tenant,
|
||
title=title,
|
||
git_commit=actual_code["gitCommit"],
|
||
input_summary=input_summary,
|
||
git_head_covers_code=actual_code["gitHeadCoversCodeFiles"],
|
||
domains=domains,
|
||
files=files,
|
||
)
|
||
_write_artifacts(target, manifest, files)
|
||
# 必须退出连接上下文后再走只读磁盘复验,避免误用内存结果或仍存活的 DB 连接。
|
||
return verify_backup(target)
|
||
except Exception:
|
||
if created:
|
||
shutil.rmtree(target, ignore_errors=True)
|
||
raise
|
||
|
||
|
||
def read_backup_rows(backup_dir: pathlib.Path) -> dict[str, list[dict[str, Any]]]:
|
||
"""离线 verify 通过后,从七个固定文件加载恢复输入。"""
|
||
|
||
root = _validate_existing_backup_dir(backup_dir)
|
||
return {
|
||
name: _read_jsonl(root / spec.file_name)[1] for name, spec in DOMAIN_SPECS.items()
|
||
}
|
||
|
||
|
||
def assert_restore_input(
|
||
manifest: Mapping[str, Any],
|
||
current_input: Mapping[str, Any],
|
||
*,
|
||
work_id: int,
|
||
tenant: int,
|
||
) -> None:
|
||
"""恢复前拒绝 work、tenant、数据库身份或任一输入摘要漂移。"""
|
||
|
||
if manifest.get("work") != work_id or manifest.get("tenant") != tenant:
|
||
raise BackupError("恢复 work/tenant 与备份 manifest 不一致")
|
||
if current_input.get("databaseIdentity") != manifest["input"].get("databaseIdentity"):
|
||
raise BackupError("当前数据库 identity 与备份不一致")
|
||
current_sha = _sha256(canonical_json(current_input).encode("utf-8"))
|
||
if current_sha != manifest.get("inputSha"):
|
||
raise BackupError("Canonical、窗、字段合同、代码文件或计划参数发生 input 漂移")
|
||
|
||
|
||
def validate_execute_confirmation(
|
||
execute: bool,
|
||
supplied_backup_id: str | None,
|
||
manifest_backup_id: str,
|
||
supplied_confirmation_sha: str | None,
|
||
manifest_confirmation_sha: str,
|
||
) -> None:
|
||
"""rehearse/restore 都核对 confirmation;execute 再核对 backup_id。"""
|
||
|
||
if not supplied_confirmation_sha or supplied_confirmation_sha != manifest_confirmation_sha:
|
||
raise BackupError("必须提供与 manifest 精确匹配的 --confirmation-sha")
|
||
if not supplied_backup_id or supplied_backup_id != manifest_backup_id:
|
||
if execute:
|
||
raise BackupError("--execute 必须同时提供与 manifest 匹配的 --backup-id")
|
||
|
||
|
||
def _sql_identifier(name: str) -> str:
|
||
"""恢复 SQL 只允许固定的小写标识符,禁止从工件注入 SQL。"""
|
||
|
||
if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
|
||
raise BackupError(f"非法 SQL 标识符:{name}")
|
||
return name
|
||
|
||
|
||
JSON_COLUMNS = {
|
||
"muse_knowledge_draft": {
|
||
"proposed_changes", "draft_payload", "current_canonical_snapshot",
|
||
"risk_marker_snapshot",
|
||
},
|
||
}
|
||
|
||
|
||
def _upsert_row(conn: Any, spec: DomainSpec, row: Mapping[str, Any]) -> None:
|
||
"""按备份行全部列 exact upsert;JSON/vector 使用显式目标类型。"""
|
||
|
||
table = _sql_identifier(spec.table)
|
||
columns = [_sql_identifier(column) for column in row]
|
||
if not columns:
|
||
raise BackupError(f"{table} 恢复行为空")
|
||
placeholders = []
|
||
values = []
|
||
for column in columns:
|
||
value = row[column]
|
||
if column in JSON_COLUMNS.get(table, set()):
|
||
placeholders.append("CAST(%s AS jsonb)")
|
||
values.append(canonical_json(value) if value is not None else None)
|
||
elif table == "example_knowledge_embedding" and column == "embedding":
|
||
placeholders.append("CAST(%s AS vector)")
|
||
values.append(value)
|
||
else:
|
||
placeholders.append("%s")
|
||
values.append(value)
|
||
conflict = ", ".join(spec.primary_keys)
|
||
updates = [column for column in columns if column not in spec.primary_keys]
|
||
update_sql = ", ".join(f"{column} = EXCLUDED.{column}" for column in updates)
|
||
overriding = " OVERRIDING SYSTEM VALUE" if table in {
|
||
"muse_knowledge_draft", "example_knowledge_embedding"
|
||
} else ""
|
||
sql = (
|
||
f"INSERT INTO {table} ({', '.join(columns)}){overriding} "
|
||
f"VALUES ({', '.join(placeholders)}) ON CONFLICT ({conflict}) DO UPDATE SET {update_sql}"
|
||
)
|
||
conn.execute(sql, tuple(values))
|
||
|
||
|
||
def _restore_ids(rows: Sequence[Mapping[str, Any]], key: str) -> list[Any]:
|
||
"""提取恢复主键;空集合仍以空数组传给 ANY。"""
|
||
|
||
return [row[key] for row in rows]
|
||
|
||
|
||
def apply_restore_kernel(
|
||
conn: Any,
|
||
manifest: Mapping[str, Any],
|
||
rows: Mapping[str, Sequence[Mapping[str, Any]]],
|
||
) -> None:
|
||
"""rehearse/restore 共用七域恢复内核,顺序兼容未来外键收紧。"""
|
||
|
||
work_id = manifest["work"]
|
||
tenant = manifest["tenant"]
|
||
draft_ids = _restore_ids(rows["drafts"], "id")
|
||
window_ids = _restore_ids(rows["windows"], "id")
|
||
embedding_ids = _restore_ids(rows["embeddings"], "id")
|
||
|
||
# 额外 active 窗不是 reset 的合法副作用,必须在任何恢复写入前拒绝。
|
||
extra_window = conn.execute(
|
||
"""SELECT w.id FROM example_upgrade_window w
|
||
WHERE w.tenant_id = %s AND w.work_id = %s AND w.deleted = FALSE
|
||
AND NOT (w.id = ANY(%s)) LIMIT 1""",
|
||
(tenant, work_id, window_ids),
|
||
).fetchone()
|
||
if extra_window:
|
||
raise BackupError("当前存在备份之外的 active 窗,拒绝恢复")
|
||
|
||
# 先清引用域,再恢复父行;audit 通过同 work 的全部升格 draft 定位。
|
||
conn.execute(
|
||
"""DELETE FROM example_upgrade_audit a
|
||
WHERE a.tenant_id = %s AND a.draft_id IN (
|
||
SELECT d.id FROM muse_knowledge_draft d
|
||
WHERE d.tenant_id = %s AND d.work_id = %s AND d.source_type = %s
|
||
)""",
|
||
(tenant, tenant, work_id, SOURCE_TYPE),
|
||
)
|
||
for table in ("example_upgrade_card_state", "example_upgrade_alias", "example_upgrade_presence"):
|
||
conn.execute(
|
||
f"DELETE FROM {_sql_identifier(table)} WHERE tenant_id = %s AND work_id = %s",
|
||
(tenant, work_id),
|
||
)
|
||
|
||
# reset 后新增的同 work 升格卡不物删,只软删;备份 ID 随后 exact upsert 恢复原 deleted 值。
|
||
conn.execute(
|
||
"""UPDATE muse_knowledge_draft d SET deleted = TRUE
|
||
WHERE d.tenant_id = %s AND d.work_id = %s AND d.source_type = %s
|
||
AND NOT (d.id = ANY(%s))""",
|
||
(tenant, work_id, SOURCE_TYPE, draft_ids),
|
||
)
|
||
# 额外向量同样只软删,范围只覆盖当前 work 的升格 draft。
|
||
conn.execute(
|
||
"""UPDATE example_knowledge_embedding e SET deleted = TRUE
|
||
WHERE e.tenant_id = %s AND NOT (e.id = ANY(%s)) AND EXISTS (
|
||
SELECT 1 FROM muse_knowledge_draft d
|
||
WHERE d.id = e.draft_id AND d.tenant_id = %s
|
||
AND d.work_id = %s AND d.source_type = %s
|
||
)""",
|
||
(tenant, embedding_ids, tenant, work_id, SOURCE_TYPE),
|
||
)
|
||
|
||
for name in ("drafts", "windows", "aliases", "presence", "card_state", "audits", "embeddings"):
|
||
spec = DOMAIN_SPECS[name]
|
||
for row in rows[name]:
|
||
_upsert_row(conn, spec, row)
|
||
|
||
|
||
def verify_restored_domains(
|
||
conn: Any,
|
||
manifest: Mapping[str, Any],
|
||
expected: Mapping[str, Sequence[Mapping[str, Any]]],
|
||
) -> None:
|
||
"""事务内逐域读回;备份 ID exact,新增 draft/embedding 只能是软删态。"""
|
||
|
||
_title, actual = _read_snapshot(conn, manifest["work"], manifest["tenant"])
|
||
for name in ("windows", "aliases", "presence", "card_state", "audits"):
|
||
if actual[name] != list(expected[name]):
|
||
raise BackupError(f"恢复读回不一致:{name}")
|
||
for name in ("drafts", "embeddings"):
|
||
key = DOMAIN_SPECS[name].primary_keys[0]
|
||
expected_by_id = {row[key]: row for row in expected[name]}
|
||
actual_by_id = {row[key]: row for row in actual[name]}
|
||
for row_id, expected_row in expected_by_id.items():
|
||
if actual_by_id.get(row_id) != expected_row:
|
||
raise BackupError(f"恢复读回不一致:{name} id={row_id}")
|
||
extras = [row for row_id, row in actual_by_id.items() if row_id not in expected_by_id]
|
||
if any(not row.get("deleted") for row in extras):
|
||
raise BackupError(f"恢复后存在未软删的额外 {name}")
|
||
|
||
|
||
SEQUENCE_TABLES = (
|
||
"muse_knowledge_draft", "example_upgrade_window", "example_upgrade_alias",
|
||
"example_upgrade_presence", "example_upgrade_audit", "example_knowledge_embedding",
|
||
)
|
||
|
||
|
||
def advance_sequences_forward(conn: Any) -> None:
|
||
"""仅 execute 成功读回后向前推进 identity/serial,绝不 setval 回退。"""
|
||
|
||
for table in SEQUENCE_TABLES:
|
||
sequence_row = conn.execute(
|
||
"SELECT pg_get_serial_sequence(%s, 'id') AS sequence_name", (table,)
|
||
).fetchone()
|
||
sequence_row = _require_mapping(sequence_row, f"{table} sequence")
|
||
sequence_name = sequence_row.get("sequence_name")
|
||
if not sequence_name:
|
||
continue
|
||
schema, name = str(sequence_name).split(".", 1)
|
||
last_row = conn.execute(
|
||
"""SELECT last_value FROM pg_sequences
|
||
WHERE schemaname = %s AND sequencename = %s""",
|
||
(schema, name),
|
||
).fetchone()
|
||
max_row = conn.execute(
|
||
f"SELECT MAX(id) AS max_id FROM {_sql_identifier(table)}"
|
||
).fetchone()
|
||
current = _require_mapping(last_row, f"{table} sequence value").get("last_value") or 0
|
||
maximum = _require_mapping(max_row, f"{table} max id").get("max_id") or 0
|
||
if maximum > current:
|
||
conn.execute("SELECT setval(%s::regclass, %s, TRUE)", (sequence_name, maximum))
|
||
|
||
|
||
def restore_backup(
|
||
backup_dir: pathlib.Path,
|
||
*,
|
||
work_id: int,
|
||
tenant: int,
|
||
dsn: str,
|
||
connect: Callable[..., Any] = _default_connect,
|
||
execute: bool = False,
|
||
backup_id: str | None = None,
|
||
confirmation_sha: str | None = None,
|
||
code_identity: Mapping[str, Any] | None = None,
|
||
lock_context: Callable[..., Any] = upgrade_work_lock,
|
||
lock_connect: Callable[..., Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""持同书锁完成离线 verify、演练回滚或显式恢复提交。"""
|
||
|
||
with lock_context(dsn, tenant, work_id, connect=lock_connect):
|
||
return _restore_backup_locked(
|
||
backup_dir,
|
||
work_id=work_id,
|
||
tenant=tenant,
|
||
dsn=dsn,
|
||
connect=connect,
|
||
execute=execute,
|
||
backup_id=backup_id,
|
||
confirmation_sha=confirmation_sha,
|
||
code_identity=code_identity,
|
||
)
|
||
|
||
|
||
def _restore_backup_locked(
|
||
backup_dir: pathlib.Path,
|
||
*,
|
||
work_id: int,
|
||
tenant: int,
|
||
dsn: str,
|
||
connect: Callable[..., Any],
|
||
execute: bool,
|
||
backup_id: str | None,
|
||
confirmation_sha: str | None,
|
||
code_identity: Mapping[str, Any] | None,
|
||
) -> dict[str, Any]:
|
||
"""锁内先离线 verify,再在真实 public 表演练或恢复。"""
|
||
|
||
manifest = verify_backup(backup_dir)
|
||
rows = read_backup_rows(backup_dir)
|
||
validate_execute_confirmation(
|
||
execute,
|
||
backup_id,
|
||
manifest["backup_id"],
|
||
confirmation_sha,
|
||
manifest["confirmationSha"],
|
||
)
|
||
actual_code = dict(code_identity or capture_code_identity())
|
||
with connect(dsn, autocommit=True) as conn:
|
||
conn.execute("BEGIN ISOLATION LEVEL REPEATABLE READ")
|
||
conn.execute("SET LOCAL search_path TO public")
|
||
finished = False
|
||
try:
|
||
current_input = capture_input_snapshot(
|
||
conn,
|
||
work_id,
|
||
tenant,
|
||
actual_code,
|
||
manifest["input"]["expectedChapters"],
|
||
manifest["input"]["expectedWindows"],
|
||
)
|
||
assert_restore_input(manifest, current_input, work_id=work_id, tenant=tenant)
|
||
# updated_at trigger 会改写备份时间戳;事务局部 replica 模式保证 exact 恢复,结束即复原。
|
||
conn.execute("SET LOCAL session_replication_role TO replica")
|
||
apply_restore_kernel(conn, manifest, rows)
|
||
verify_restored_domains(conn, manifest, rows)
|
||
if execute:
|
||
advance_sequences_forward(conn)
|
||
conn.execute("COMMIT")
|
||
else:
|
||
# PostgreSQL sequence 非事务性;rehearse 不调用 sequence 内核,只回滚表数据。
|
||
conn.execute("ROLLBACK")
|
||
finished = True
|
||
finally:
|
||
if not finished:
|
||
conn.execute("ROLLBACK")
|
||
return manifest
|
||
|
||
|
||
@click.group()
|
||
def cli() -> None:
|
||
"""升格作品 reset 前备份与离线校验。"""
|
||
|
||
|
||
@cli.command("backup")
|
||
@click.option("--work-id", type=click.IntRange(min=1), required=True, help="待备份作品 ID")
|
||
@click.option("--expected-chapters", type=click.IntRange(min=1), required=True)
|
||
@click.option("--expected-windows", type=click.IntRange(min=1), required=True)
|
||
@click.option(
|
||
"--output-dir",
|
||
type=click.Path(path_type=pathlib.Path, file_okay=False, resolve_path=False),
|
||
help="/private/tmp 下尚不存在的目录;不填则按 backup_id 自动生成",
|
||
)
|
||
def backup_command(
|
||
work_id: int,
|
||
expected_chapters: int,
|
||
expected_windows: int,
|
||
output_dir: pathlib.Path | None,
|
||
) -> None:
|
||
"""在单个只读一致性事务中创建七域备份并立即复验。"""
|
||
|
||
try:
|
||
dsn, tenant = _load_db_config()
|
||
manifest = backup_work(
|
||
work_id,
|
||
output_dir,
|
||
tenant=tenant,
|
||
dsn=dsn,
|
||
expected_chapters=expected_chapters,
|
||
expected_windows=expected_windows,
|
||
)
|
||
except Exception as error:
|
||
raise click.ClickException(str(error)) from error
|
||
click.echo(
|
||
f"备份并离线复验通过:backup_id={manifest['backup_id']} "
|
||
f"work={manifest['work']} title=《{manifest['title']}》 "
|
||
f"inputSha={manifest['inputSha']} confirmationSha={manifest['confirmationSha']}"
|
||
)
|
||
click.echo("代码 fileSha:" + canonical_json(manifest["input"]["codeFiles"]))
|
||
final_dir = output_dir or BACKUP_ROOT / f"upgrade-work-{work_id}-{manifest['backup_id']}"
|
||
click.echo(f"目录:{final_dir}")
|
||
_propose_backup_lesson(work_id=work_id, manifest=manifest)
|
||
|
||
|
||
def _propose_backup_lesson(*, work_id: int, manifest: Mapping[str, Any]) -> dict:
|
||
"""备份并离线复验通过后登记 lesson。"""
|
||
evidence = _HERE.parents[1] / "record-run-evidence" / "scripts"
|
||
if str(evidence) not in sys.path:
|
||
sys.path.insert(0, str(evidence))
|
||
from lesson_registry import propose_lesson_dedup # noqa: WPS433
|
||
|
||
backup_id = str(manifest["backup_id"])
|
||
return propose_lesson_dedup(
|
||
kind="win",
|
||
title="作品抽取七域备份复验通过",
|
||
detail={
|
||
"skill": "backup-work-extraction",
|
||
"work_id": work_id,
|
||
"backup_id": backup_id,
|
||
"confirmation_sha": manifest.get("confirmationSha"),
|
||
},
|
||
work_id=work_id,
|
||
run_id=f"backup-{work_id}-{backup_id}",
|
||
creator="backup-work-extraction",
|
||
)
|
||
|
||
|
||
@cli.command("verify")
|
||
@click.option(
|
||
"--backup-dir",
|
||
type=click.Path(path_type=pathlib.Path, exists=True, file_okay=False, resolve_path=False),
|
||
required=True,
|
||
help="待离线复验的备份目录",
|
||
)
|
||
def verify_command(backup_dir: pathlib.Path) -> None:
|
||
"""不连接数据库、不调用模型,复验现有磁盘备份。"""
|
||
|
||
try:
|
||
manifest = verify_backup(backup_dir)
|
||
except Exception as error:
|
||
raise click.ClickException(str(error)) from error
|
||
counts = ", ".join(
|
||
f"{name}={manifest['domains'][name]['rowCount']}" for name in DOMAIN_SPECS
|
||
)
|
||
click.echo(f"离线复验通过:backup_id={manifest['backup_id']};{counts}")
|
||
|
||
|
||
@cli.command("rehearse")
|
||
@click.option("--work-id", type=click.IntRange(min=1), required=True, help="恢复作品 ID")
|
||
@click.option("--confirmation-sha", required=True, help="必须与 manifest 精确一致")
|
||
@click.option(
|
||
"--backup-dir",
|
||
type=click.Path(path_type=pathlib.Path, exists=True, file_okay=False),
|
||
required=True,
|
||
)
|
||
def rehearse_command(work_id: int, confirmation_sha: str, backup_dir: pathlib.Path) -> None:
|
||
"""在真实 public 表事务内恢复并读回,最终强制 ROLLBACK。"""
|
||
|
||
try:
|
||
dsn, tenant = _load_db_config()
|
||
manifest = restore_backup(
|
||
backup_dir,
|
||
work_id=work_id,
|
||
tenant=tenant,
|
||
dsn=dsn,
|
||
execute=False,
|
||
confirmation_sha=confirmation_sha,
|
||
)
|
||
except Exception as error:
|
||
raise click.ClickException(str(error)) from error
|
||
click.echo(
|
||
f"恢复演练读回通过并已回滚:backup_id={manifest['backup_id']} "
|
||
f"confirmationSha={manifest['confirmationSha']}"
|
||
)
|
||
|
||
|
||
@cli.command("restore")
|
||
@click.option("--work-id", type=click.IntRange(min=1), required=True, help="恢复作品 ID")
|
||
@click.option(
|
||
"--backup-dir",
|
||
type=click.Path(path_type=pathlib.Path, exists=True, file_okay=False),
|
||
required=True,
|
||
)
|
||
@click.option("--backup-id", help="必须与 manifest backup_id 精确一致")
|
||
@click.option("--confirmation-sha", required=True, help="必须与 manifest 精确一致")
|
||
@click.option("--execute", is_flag=True, help="显式提交恢复;缺少时拒绝执行")
|
||
def restore_command(
|
||
work_id: int,
|
||
backup_dir: pathlib.Path,
|
||
backup_id: str | None,
|
||
confirmation_sha: str,
|
||
execute: bool,
|
||
) -> None:
|
||
"""经双确认后提交恢复;不提供 --execute 时不会降级成隐式演练。"""
|
||
|
||
if not execute:
|
||
raise click.ClickException("restore 必须显式提供 --execute;演练请使用 rehearse")
|
||
try:
|
||
dsn, tenant = _load_db_config()
|
||
manifest = restore_backup(
|
||
backup_dir,
|
||
work_id=work_id,
|
||
tenant=tenant,
|
||
dsn=dsn,
|
||
execute=True,
|
||
backup_id=backup_id,
|
||
confirmation_sha=confirmation_sha,
|
||
)
|
||
except Exception as error:
|
||
raise click.ClickException(str(error)) from error
|
||
click.echo(f"恢复已提交:backup_id={manifest['backup_id']} work={manifest['work']}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
cli()
|