muse-agent-example/工具/构建资源包.py
zizi 0658d178ad 工程底座:单一生成入口、验证期写闸门与并行分片
- Makefile:新增 验收数据库(生成→检查→库层全量,前置校验隔离库连接串)、数据库分片、浏览器测试三个入口;
  格式写入 末尾就地重新生成;数据库测试 与 验收数据库 统一排除 浏览器/网络;快检 不再静默跳过类型门;
  前端旅程 预检补齐四个必需变量;pytest 目标改用仓内解释器(uv run 在嵌套检出会解析到外层环境)。
- 工具/验证锁.py:验证会话持共享锁,写入口用 --执行 在独占锁内落盘,生成/格式写入/索引生成走同一闸门。
- 工具/并行数据库测试.py:按文件分片并行,缺连接串在采集前拒绝。
- 工具/构建编排.py、环境预检.py、构建资源包.py、维护索引.py 与上述口径对齐。
- .gitignore / CI / README / AGENTS:忽略构建产物、CI 与 Makefile 单一口径、README 按实际实现陈述、AGENTS 补日常入口与验证纪律。
2026-09-18 01:14:50 +08:00

288 lines
12 KiB
Python
Raw Permalink 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.

"""生成运行资源与发布清单。
工程阶段的生成或预检入口:
- 从唯一登记输入(工具/资源登记.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(主())