"""生成运行资源与发布清单。 工程阶段的生成或预检入口: - 从唯一登记输入(工具/资源登记.json)打包 src/muse/资源/运行资源.zip; - 写出 src/muse/资源/清单.json(发布身份 + 每个资源的相对路径与 sha256); - 投影 web/dist → src/muse/资源/工作台/(web 构建存在时)。 边界:只打包已登记生产资源;不读取未审工作树内容、不打包 tests/.git/当前目录; 生成物幂等可重现(固定排序、固定压缩时间戳)。 """ from __future__ import annotations import argparse import hashlib import json import shutil import tempfile import tomllib import zipfile from pathlib import Path, PurePosixPath from typing import Any from muse.资源加载 import 解析能力声明 from 效果依赖 import 构建效果依赖 仓库根 = Path(__file__).resolve().parent.parent 登记文件 = 仓库根 / "工具" / "资源登记.json" 资源目录 = 仓库根 / "src" / "muse" / "资源" 压缩包名 = "运行资源.zip" 清单名 = "清单.json" _固定时间戳 = (2026, 1, 1, 0, 0, 0) def 读取登记() -> dict[str, Any]: if not 登记文件.is_file(): raise SystemExit(f"资源登记不存在:{登记文件}(打包前必须先登记生产资源)") 登记: Any = json.loads(登记文件.read_text(encoding="utf-8")) if not isinstance(登记, dict) or "资源" not in 登记: raise SystemExit("资源登记不合法:缺少 资源 表") return 登记 def _sha256(文件: Path) -> str: return hashlib.sha256(文件.read_bytes()).hexdigest() def 编译能力资源( 根: Path, 文件清单: list[str], 已实现命令: set[str] ) -> tuple[dict, dict[str, bytes]]: 目录: dict[str, dict] = {"role": {}, "method": {}, "operation": {}} 资源: dict[str, bytes] = {} for 相对 in sorted(文件清单): 文件 = (根 / 相对).resolve() if not 文件.is_relative_to(根.resolve()) or not 文件.is_file(): raise ValueError("能力资源必须是登记根内的实际文件") 数据 = 文件.read_bytes() 声明, _ = 解析能力声明(数据) 分类, 身份 = 声明["category"], 声明["id"] if 身份 in 目录[分类]: raise ValueError("能力稳定身份重复") 命令 = 声明.get("commands", []) if not isinstance(命令, list) or any( not isinstance(v, str) or v not in 已实现命令 for v in 命令 ): raise ValueError("能力声明包含未实现命令") if 分类 == "operation" and not 命令: raise ValueError("操作技能必须声明已实现命令") 路径 = f"能力/{分类}/{身份}.md" 资源[路径] = 数据 目录[分类][身份] = { "id": 身份, "name": 声明["name"], "description": 声明["description"], "contract_version": 声明["contract_version"], "commands": 命令, "resource_path": 路径, "sha256": hashlib.sha256(数据).hexdigest(), } return 目录, 资源 def _仓内源(相对: str, *, 目录: bool = False) -> Path: 路径 = 仓库根 / 相对 if Path(相对).is_absolute() or not 路径.resolve().is_relative_to(仓库根.resolve()): raise ValueError(f"登记源必须位于仓库内:{相对}") if not (路径.is_dir() if 目录 else 路径.is_file()): raise ValueError(f"登记源不存在或类型不符:{相对}") return 路径 def _预检资源(登记: dict, 能力资源: dict[str, bytes]) -> tuple[list[Path], list[Path]]: """先统一核对源边界和所有目标身份;此阶段不创建 ZIP 或读取 web 文件字节。""" 目标们: set[str] = set() def 登记目标(路径: str) -> None: 规范 = PurePosixPath(路径) if ( not 路径 or "\\" in 路径 or "\x00" in 路径 or 规范.is_absolute() or (规范.parts and ":" in 规范.parts[0]) or ".." in 规范.parts or 规范.as_posix() != 路径 or 路径 == "." ): raise ValueError(f"资源目标必须是规范化相对路径:{路径}") if 路径 in 目标们: raise ValueError(f"资源目标重复:{路径}") 目标们.add(路径) for 条 in 登记["资源"]: _仓内源(str(条["源路径"])) 登记目标(str(条["打包路径"])) for 路径 in 能力资源: 登记目标(路径) 迁移文件 = [] if 登记.get("迁移目录"): 源目录 = _仓内源(str(登记["迁移目录"]), 目录=True) for 文件 in sorted(源目录.glob("V*.sql")): _仓内源(文件.relative_to(仓库根).as_posix()) 登记目标(f"迁移/{文件.name}") 迁移文件.append(文件) web_dist = 仓库根 / "web/dist" if web_dist.is_symlink(): raise ValueError("web/dist 不得是软链") web文件 = [] if web_dist.exists(): if not web_dist.is_dir(): raise ValueError("web/dist 必须是目录") 后代 = sorted(web_dist.rglob("*")) if any(p.is_symlink() for p in 后代): raise ValueError("web/dist 后代不得含软链") for 文件 in 后代: if 文件.is_file(): _仓内源(文件.relative_to(仓库根).as_posix()) 登记目标("工作台/" + 文件.relative_to(web_dist).as_posix()) web文件.append(文件) return 迁移文件, web文件 def _写入资源(资源目录: Path) -> dict[str, Any]: 登记 = 读取登记() 发布身份 = str(登记.get("发布身份", "")) if not 发布身份.strip(): raise SystemExit("资源登记缺少 发布身份") 条目: list[dict[str, str]] = 登记["资源"] 能力资源: dict[str, bytes] = {} if 登记.get("能力文件"): from muse.接入.http.应用 import 导出合同 合同 = 导出合同() 命令 = { 值["operationId"] for 路径 in 合同["paths"].values() for 值 in 路径.values() if "operationId" in 值 } 目录, 能力资源 = 编译能力资源(仓库根, 登记["能力文件"], 命令) 能力资源["能力/目录.json"] = json.dumps( 目录, ensure_ascii=False, sort_keys=True, indent=2 ).encode() 迁移文件, web文件 = _预检资源(登记, 能力资源) 资源目录.mkdir(parents=True, exist_ok=True) 压缩包路径 = 资源目录 / 压缩包名 已打包: dict[str, str] = {} with zipfile.ZipFile(压缩包路径, "w", compression=zipfile.ZIP_DEFLATED) as zf: for 条 in sorted(条目, key=lambda x: str(x.get("打包路径", ""))): 源 = 仓库根 / str(条["源路径"]) 打包路径 = str(条["打包路径"]) if not 源.is_file(): raise SystemExit(f"登记资源不存在:{源}") 信息 = zipfile.ZipInfo(打包路径, date_time=_固定时间戳) 信息.compress_type = zipfile.ZIP_DEFLATED 信息.external_attr = 0o644 << 16 数据 = 源.read_bytes() zf.writestr(信息, 数据) 已打包[打包路径] = hashlib.sha256(数据).hexdigest() for 路径, 数据 in sorted(能力资源.items()): if 路径 in 已打包: raise ValueError("生成能力资源与直接登记路径重复") 信息 = zipfile.ZipInfo(路径, date_time=_固定时间戳) 信息.compress_type = zipfile.ZIP_DEFLATED 信息.external_attr = 0o644 << 16 zf.writestr(信息, 数据) 已打包[路径] = hashlib.sha256(数据).hexdigest() # 可编辑安装和正式 wheel 使用相同的包内迁移入口。 迁移源 = 登记.get("迁移目录") if 迁移源: 源目录 = 仓库根 / 迁移源 if not 源目录.is_dir(): raise SystemExit("登记的迁移目录不存在") 迁移资源 = 资源目录 / "迁移" if 迁移资源.exists(): shutil.rmtree(迁移资源) 迁移资源.mkdir() for 文件 in 迁移文件: 数据 = 文件.read_bytes() (迁移资源 / 文件.name).write_bytes(数据) 已打包[f"迁移/{文件.name}"] = hashlib.sha256(数据).hexdigest() # 效果依赖按登记/编译/迁移生成阶段取全量保守闭包,不纳入工作台产物。 效果依赖 = 构建效果依赖( 仓库根, dict(已打包), { "registered": sorted(str(条["打包路径"]) for 条 in 条目), "capabilities": sorted(能力资源), "migrations": sorted( set(已打包) - {str(条["打包路径"]) for 条 in 条目} - set(能力资源) ), }, ) # 工作台投影:web 构建产物存在时带上;不存在时如实留空。 工作台目录 = 资源目录 / "工作台" if 工作台目录.exists(): shutil.rmtree(工作台目录) web_dist = 仓库根 / "web" / "dist" if web_dist.is_dir(): 工作台目录.mkdir() for 文件 in web文件: if 文件.is_symlink() or any(p.is_symlink() for p in 文件.parents if p != 仓库根): raise ValueError("web/dist 在预检后变为软链") 相对 = 文件.relative_to(web_dist) 目标 = 工作台目录 / 相对 目标.parent.mkdir(parents=True, exist_ok=True) 数据 = 文件.read_bytes() 目标.write_bytes(数据) 已打包["工作台/" + 相对.as_posix()] = hashlib.sha256(数据).hexdigest() 清单 = { "schema_version": 1, "效果依赖": 效果依赖, "发布身份": 发布身份, "资源": {路径: {"sha256": 哈希} for 路径, 哈希 in sorted(已打包.items())}, } 代码文件 = { 文件.relative_to(仓库根).as_posix(): _sha256(文件) for 文件 in (仓库根 / "src/muse").rglob("*") if 文件.suffix in {".py", ".ts"} and "资源" not in 文件.relative_to(仓库根 / "src/muse").parts } 代码哈希 = hashlib.sha256(json.dumps(代码文件, sort_keys=True).encode()).hexdigest() with (仓库根 / "pyproject.toml").open("rb") as 文件: 包版本 = tomllib.load(文件)["project"]["version"] 清单.update({"代码版本": 包版本, "代码哈希": 代码哈希, "合同版本": 1}) 清单["构建身份"] = hashlib.sha256( json.dumps(清单, ensure_ascii=False, sort_keys=True).encode() ).hexdigest() (资源目录 / 清单名).write_text( json.dumps(清单, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) return 清单 def 打包() -> dict[str, Any]: """在独立目录完成全部输入校验与输出,再替换旧资源;失败保留旧产物。""" 资源目录.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".资源构建-", dir=资源目录.parent) as 临时: 暂存 = Path(临时) / "新资源" 清单 = _写入资源(暂存) 旧资源 = Path(临时) / "旧资源" if 资源目录.exists(): 资源目录.rename(旧资源) try: 暂存.rename(资源目录) except BaseException: if 旧资源.exists(): 旧资源.rename(资源目录) raise return 清单 def 主() -> int: 解析器 = argparse.ArgumentParser(description="生成运行资源与发布清单") 解析器.parse_args() 清单 = 打包() print(f"构建资源包:发布身份 {清单['发布身份']},资源 {len(清单['资源'])} 项") return 0 if __name__ == "__main__": raise SystemExit(主())