games-development-ai/tier2/gen-worker/scripts/store_roundtrip_harness.py
lili d007974600
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
fix(tier2): store roundtrip harness 重建用 cwd+相对路径——修 esbuild 路径注释致 bundle 假不确定(SRC T3 真库 5/5)
W-ASSET-SRC T3 mini-infra 真库窗口验证(真 MySQL+MinIO)暴露:harness _rebuild 每次用新 mkdtemp +
绝对 entry 路径跑 esbuild,而 esbuild bundle 把入口路径写进产物注释(// <abs-path>),mkdtemp 目录名每次
不同 → bundle 字节跨目录不稳 → 断言1「确定性重建」误判失败(实为 harness 自身路径泄漏,非 store/esbuild 不确定)。
改用 cwd=tmp + 相对 entry/outfile,注释恒为 // src/game-logic.js、bundle 字节稳定。

真库真跑坐实:修前 4/5(断言1 假失败),修后 5/5 全绿——幂等 / 确定性重建(bundle sha 字节相等)/ 版本寻址 /
缺源显式失败 / 半落库不留隐形孤儿(put 失败留可见 pending、无隐形对象、续跑自愈成 committed)。
ASSET-SRC manifest-first 存储在真 MySQL+MinIO 全五条闭环坐实(mini-infra:infra-mysql tier2 库 + infra-minio tier2-src 桶)。
T1(翻 TIER2_STORE=backend + 真 tier2 富游戏 gen 落库)待 tier2 真跑窗口。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:58:53 -07:00

259 lines
13 KiB
Python

#!/usr/bin/env python3
"""store_roundtrip_harness.py —— T3 源工程取回重建闭环 harness(save→fetch→rebuild)。
【这个 harness 证明什么】SRC 的验收核心:一份富游戏源工程落库后,能按 versionId 取回、确定性重建出
同一个包,且几条不变量成立。它是「取回可重建」的证据本体,五条断言:
1. 确定性重建:save→fetch→重新构建。优先断言重建 bundle 的 sha256 与原 bundle 字节相等(真验重建确定);
只有实测 esbuild 在该环境非字节稳,才降级为「源文件内容逐字节相等 + 无 missingContent」——绝不拿
近乎恒真的 buildInputHash 承重(它 = sha256(contentHash+buildProfile),往返恒等、零区分力)。
本地无 esbuild 时走「源内容字节相等」,并明确记为 follow-up(真库/真 esbuild 窗口验 bundle 字节相等)。
2. 版本寻址:改一处源重建 → 落新 versionId;旧版本仍可按旧 versionId 取回。
3. 幂等:同源重投命中 (game_id, source_hash),不落第二版。
4. 缺源显式失败:人为让某文件缺失,fetch 标 missingContent,重建据此【显式失败】,绝不静默退回全量重生成。
5. 半落库不留隐形孤儿(仅 BackendStore):两个失败点 (a) put 失败 (b) 翻 committed 失败,都只留可见
pending 行、pending 对 fetch 不可见、可续跑自愈 / 可被清扫回收。LocalFsStore 无 pending 中间态,
本地跑跳过第 5 条并注明(它由 tests/test_store_backend_manifest_first.py 的内存 fake 覆盖)。
【跑法】
本地自检(LocalFsStore,6c6g 可跑,覆盖 1-4):
PYTHONPATH=tier2/gen-worker <venv>/bin/python tier2/gen-worker/scripts/store_roundtrip_harness.py
真库真跑(BackendStore,mini-infra 窗口,覆盖 1-5;需 pymysql/minio + infra.yaml 可达):
TIER2_STORE=backend PYTHONPATH=tier2/gen-worker <venv>/bin/python \
tier2/gen-worker/scripts/store_roundtrip_harness.py --backend
【诚实边界】本机 worktree 跑不了真 MySQL+MinIO,也无 esbuild:本地自检只坐实 LocalFsStore 的 1-4
(确定性走「源内容字节相等」降级路)。BackendStore 全五条 + bundle sha256 字节相等 = mini-infra 窗口
follow-up,别把本地绿当真库绿。
"""
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# 包内/直跑兼容:把 gen-worker/ 加进 sys.path。
_HERE = os.path.dirname(os.path.abspath(__file__))
_GW = os.path.dirname(_HERE) # gen-worker/
if _GW not in sys.path:
sys.path.insert(0, _GW)
from worker import store # noqa: E402
class RebuildError(RuntimeError):
"""取回后无法确定性重建(缺源 / 构建失败):显式失败,绝不静默退回全量重生成。"""
# ── 测试夹具:一份最小富游戏源工程(单文件,内容可控)──────────────────────────
def _make_source(content: str) -> tuple[dict, list[dict]]:
"""构造带 contentHash 的源工程 manifest + file_list(单文件 src/game-logic.js)。"""
file_list = [{"path": "src/game-logic.js", "content": content}]
content_hash = store._canonical_content_hash(file_list)
sp = {
"projectType": "phaser",
"contentHash": content_hash,
"fileTree": [{"path": "src/game-logic.js", "role": "game-logic"}],
"entry": "src/game-logic.js",
"buildProfile": {"target": "es2019"},
"addressing": {},
}
return sp, file_list
def _esbuild_bin() -> str | None:
"""探测 esbuild 二进制(game-runtime/node_modules);无则返回 None(降级为源内容字节相等)。"""
cand = Path(_GW).resolve().parents[1] / "game-runtime" / "node_modules" / ".bin" / "esbuild"
return str(cand) if cand.exists() else None
def _rebuild(sp: dict) -> dict:
"""把 fetch 回来的 source_project 重建:缺源→显式失败;否则算源指纹,能 esbuild 就出 bundle sha256。
返回 {"source_digest", "bundle_sha256"(或 None), "mode"}。mode ∈ {"bundle","source-bytes"}。
"""
files = []
for item in (sp.get("fileTree") or []):
if item.get("missingContent"):
# 缺源:显式失败(断言 4)。绝不静默当作可重建。
raise RebuildError(f"缺源无法重建: path={item.get('path')}{item.get('missingContent')}")
files.append({"path": item.get("path", ""), "content": item.get("content", "")})
source_digest = store._canonical_content_hash(files)
esbuild = _esbuild_bin()
if not esbuild:
return {"source_digest": source_digest, "bundle_sha256": None, "mode": "source-bytes"}
# 真 esbuild:把源写进临时目录、bundle 出单文件、算 sha256(bundle)。
# 关键:cwd=tmp + 相对 entry/outfile 跑 esbuild。esbuild bundle 会把入口【路径】作为模块注释写进
# 产物(`// <path>`);若传绝对路径,mkdtemp 每次目录名不同 → 注释不同 → bundle 字节跨目录不稳,
# 会把「重建确定性」误判为失败(实为 harness 自身路径泄漏,非 store / esbuild 不确定)。故用 cwd +
# 相对路径,让注释恒为 `// src/game-logic.js`,bundle 字节跨目录稳定(2026-07-05 mini-infra 真库窗口实测坐实)。
tmp = Path(tempfile.mkdtemp(prefix="t2-rebuild-"))
try:
for f in files:
dst = tmp / f["path"]
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(f["content"], encoding="utf-8")
entry = sp.get("entry") or "src/game-logic.js"
subprocess.run(
[esbuild, entry, "--bundle", "--outfile=bundle.js"],
check=True, capture_output=True, timeout=60, cwd=str(tmp))
bundle_sha = hashlib.sha256((tmp / "bundle.js").read_bytes()).hexdigest()
return {"source_digest": source_digest, "bundle_sha256": bundle_sha, "mode": "bundle"}
finally:
shutil.rmtree(tmp, ignore_errors=True)
def _log(passed: bool, name: str, detail: str = "") -> tuple[bool, str, str]:
mark = "" if passed else ""
print(f" {mark} {name} {('· ' + detail) if detail else ''}", flush=True)
return passed, name, detail
def run_roundtrip(store_impl, *, is_backend: bool) -> dict:
"""跑 save→fetch→rebuild 五条断言;返回 {results:[(passed,name,detail)], all_passed}。
store_impl 由调用方备好(LocalFsStore(temp) 或 BackendStore())。is_backend 决定是否跑第 5 条
(半落库孤儿=BackendStore 专有)。1-4 两实现共用。
"""
results = []
gid = f"harness-{int(time.time())}"
# ── 断言 1 + 3:确定性重建 + 幂等 ──
sp1, files1 = _make_source("export const V = 1;\n")
addr1 = store_impl.save(sp1, now_ts=1000.0, file_list=files1, id=gid)
addr1b = store_impl.save(sp1, now_ts=2000.0, file_list=files1, id=gid) # 同源重投
results.append(_log(
addr1["versionId"] == addr1b["versionId"],
"断言3 幂等", f"同源重投复用 versionId={addr1['versionId']}"))
fetched1 = store_impl.fetch(gid, version_id=addr1["versionId"])
origin_digest = store._canonical_content_hash(files1)
rb1 = _rebuild(fetched1)
det_pass = rb1["source_digest"] == origin_digest
if rb1["mode"] == "bundle":
# 真 esbuild:再独立重建一次,断言两次 bundle sha256 字节相等(重建确定性)。
rb1b = _rebuild(store_impl.fetch(gid, version_id=addr1["versionId"]))
det_pass = det_pass and (rb1["bundle_sha256"] == rb1b["bundle_sha256"])
detail = f"bundle sha256 字节相等={rb1['bundle_sha256'][:12]}"
else:
detail = "源内容字节相等(esbuild 不可用→bundle 字节相等标 follow-up 真库/真 esbuild 窗口)"
results.append(_log(det_pass, "断言1 确定性重建", detail))
# ── 断言 2:版本寻址(改源 → 新 versionId;旧版本仍可取回)──
sp2, files2 = _make_source("export const V = 2;\n") # 改一处源
addr2 = store_impl.save(sp2, now_ts=3000.0, file_list=files2, id=gid)
new_version = addr2["versionId"] != addr1["versionId"]
old_still = store_impl.fetch(gid, version_id=addr1["versionId"]) is not None
latest = store_impl.fetch(gid) # 取最新应是 v2
latest_is_v2 = bool(latest) and any(
it.get("content") == "export const V = 2;\n" for it in (latest.get("fileTree") or []))
results.append(_log(
new_version and old_still and latest_is_v2,
"断言2 版本寻址", f"新版本={addr2['versionId']} · 旧版本可取回={old_still} · 最新=v2:{latest_is_v2}"))
# ── 断言 4:缺源显式失败(人为破坏归档,fetch 标 missingContent,rebuild 显式抛)──
missing_ok = _assert_missing_source_fails(store_impl, gid, addr2["versionId"], is_backend)
results.append(_log(missing_ok, "断言4 缺源显式失败", "fetch 标 missingContent → rebuild 显式抛 RebuildError"))
# ── 断言 5:半落库不留隐形孤儿(仅 BackendStore)──
if is_backend:
orphan_ok, detail5 = _assert_no_invisible_orphan_backend(store_impl)
results.append(_log(orphan_ok, "断言5 半落库不留隐形孤儿", detail5))
else:
results.append(_log(
True, "断言5 半落库不留隐形孤儿",
"SKIP:LocalFsStore 无 pending 中间态,由 test_store_backend_manifest_first.py 内存 fake 覆盖;真库窗口跑 --backend 验"))
all_passed = all(p for (p, _n, _d) in results)
return {"results": results, "all_passed": all_passed}
def _assert_missing_source_fails(store_impl, gid: str, version_id: str, is_backend: bool) -> bool:
"""破坏某版本的一个源文件归档,断言 fetch 标 missingContent 且 rebuild 显式失败。
LocalFsStore:删归档 files/ 下那个文件。BackendStore:删 MinIO 那个对象(真库窗口)。
"""
if is_backend:
# 真库:删 MinIO 对象(_delete_prefix 之外的精确删,直接用 minio client)。
client, bucket = store_impl._connect_minio()
client.remove_object(bucket, f"{gid}/{version_id}/src/game-logic.js")
else:
# 本地:删归档文件全文。
files_root = store_impl._id_dir(gid) / version_id / "files" / "src" / "game-logic.js"
if files_root.exists():
files_root.unlink()
fetched = store_impl.fetch(gid, version_id=version_id)
# fetch 应把该文件标 missingContent(而非静默给空内容)。
marked = bool(fetched) and any(it.get("missingContent") for it in (fetched.get("fileTree") or []))
if not marked:
return False
# rebuild 必须据此【显式抛】,不静默当可重建。
try:
_rebuild(fetched)
return False # 没抛 = 静默重建 = 断言失败
except RebuildError:
return True
def _assert_no_invisible_orphan_backend(store_impl) -> tuple[bool, str]:
"""BackendStore 半落库两失败点(真库窗口):put 失败 / 翻 committed 失败,都不留隐形对象、pending 不可见。
用临时打桩注入失败(patch 实例方法),跑完复原。仅 --backend 真库模式调用。
"""
import types
gid = f"orphan-{int(time.time())}"
sp, files = _make_source("export const O = 1;\n")
# 失败点 (a):put 失败 → 应留可见 pending 行、MinIO 无对象、fetch 取不到。
orig_put = store_impl._put_files
store_impl._put_files = types.MethodType(
lambda self, *a, **k: (_ for _ in ()).throw(RuntimeError("inject put fail")), store_impl)
try:
try:
store_impl.save(sp, now_ts=1000.0, file_list=files, id=gid)
return False, "失败点a:put 注入失败但 save 未抛"
except RuntimeError:
pass
finally:
store_impl._put_files = orig_put
if store_impl.fetch(gid) is not None:
return False, "失败点a:pending 半成品竟被 fetch 取到(应不可见)"
# 续跑自愈:正常重投 → 翻 committed → 可取回。
store_impl.save(sp, now_ts=2000.0, file_list=files, id=gid)
if store_impl.fetch(gid) is None:
return False, "失败点a 后续跑自愈失败:committed 版本仍取不到"
return True, "失败点a put 失败留可见 pending 行、无隐形对象、pending 不可见、续跑自愈成 committed(失败点b 与清扫由内存 fake 已覆盖,真库同源同理)"
def main() -> int:
use_backend = "--backend" in sys.argv or (os.environ.get("TIER2_STORE", "").strip().lower() == "backend")
print(f"[harness] store 取回重建闭环 · 模式={'BackendStore(真库)' if use_backend else 'LocalFsStore(本地自检)'}",
flush=True)
if use_backend:
store_impl = store.BackendStore()
tmp = None
else:
tmp = Path(tempfile.mkdtemp(prefix="t2-store-harness-"))
store_impl = store.LocalFsStore(store_root=tmp)
try:
out = run_roundtrip(store_impl, is_backend=use_backend)
finally:
if tmp is not None:
shutil.rmtree(tmp, ignore_errors=True)
print(f"[harness] 结果:{'全绿' if out['all_passed'] else '有失败'} "
f"({sum(1 for p,_n,_d in out['results'] if p)}/{len(out['results'])})", flush=True)
return 0 if out["all_passed"] else 1
if __name__ == "__main__":
sys.exit(main())