zizi 091b66a9bb 重构: 收敛 Agent/Skill 运行时与创作质量闭环
将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。

同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
2026-08-22 02:12:32 +08:00

1375 lines
53 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.

#!/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()