pbls/build.sh
agent.develop d9e1094d0c fix(build): 8d 基座包 wwwroot 挂载(owner 手动修复,Bug KMzNP/Vt3D8nvl/CJij/LSyc 根因闭环)
wheelhouse 基座 wheel 不含 wwwroot(rbac wheel wwwroot=0),venv editable 安装使代码可达
但 rbac 登录页与 bricks dist(header.tmpl) 从未挂进应用 wwwroot → index.ui 渲染
/bricks/header.tmpl 时 processor=None → 根路径与 /rbac/user/login.ui 全 500。
按宿主 9090 同款软链挂载:rbac wwwroot 经 PKGS_DIR 运行时定位(editable 源),
bricks dist 用全机唯一绝对路径(pbl 用户实测可读)。挂载后目标机实测:
/rbac/user/login.ui=200、/=200、/healthz=200。
2026-09-30 20:51:10 +08:00

766 lines
53 KiB
Bash
Executable File
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 bash
# =====================================================================
# PBL Agent OS 验证原型(Phase 0/1)—— 一键部署脚本(test 环境)
# 端口唯一事实源:projects/pbls/env/test.json(9380);本脚本不硬编码端口,从 env 读取
# 门禁:手写 DDL 32 张 + 模块 models 链 6 张(10a 步)+ 9 项种子全绿
# + self_check PASS 74/74 + py_compile 全通过
# =====================================================================
set -euo pipefail
APP_NAME="pbls"
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---- 0a. 机构工作空间根(WS_DIR)解析:可覆盖 + 逐级探测 + 事实源验证 ----
# [Bug BWY68rZR_Uh2A41JlGT7D / elS7VxwO7ufbPiPl-ucGA 修复]
# 原实现 WS_DIR="${APP_DIR}/../.." 只在机构工作空间 apps/pbls 下成立:
# APP_DIR=/d/pipeline/workspaces/0/sdlc_general/apps/pbls → WS_DIR=工作空间根 ✅
# 部署到 env.deploy.path=/d/pbl/pbls 后:
# APP_DIR=/d/pbl/pbls → WS_DIR=/d ❌(ENV_FILE=/d/projects/... 不存在 → 第 0 步即 die)
# 后果链:build.sh 从未跑通 → venv 未建 → DDL 未落库(pbl 库表数 0/37)→ 进程未起
# → 9380 无 LISTEN → healthz=000 → 42 条功能用例持续 blocked。
# 现改为三段式:① 环境变量显式覆盖(PBL_WS_DIR / WS_DIR)
# ② 从 APP_DIR 逐级向上探测含 projects/pbls/env/test.json 的目录
# ③ 兜底机构工作空间绝对路径(部署机与工作区同机,实测可读)
_ws_probe() {
local _c _d
for _c in "${PBL_WS_DIR:-}" "${WS_DIR_OVERRIDE:-}" "${WS_DIR:-}"; do
[ -n "${_c}" ] || continue
if [ -f "${_c}/projects/pbls/env/test.json" ]; then (cd "${_c}" && pwd); return 0; fi
done
_d="${APP_DIR}"
while [ "${_d}" != "/" ] && [ -n "${_d}" ]; do
if [ -f "${_d}/projects/pbls/env/test.json" ]; then (cd "${_d}" && pwd); return 0; fi
_d="$(dirname "${_d}")"
done
for _c in /d/pipeline/workspaces/0/sdlc_general "${HOME}/sdlc_general"; do
if [ -f "${_c}/projects/pbls/env/test.json" ]; then (cd "${_c}" && pwd); return 0; fi
done
return 1
}
RESOLVED_WS_DIR="$(_ws_probe || true)"
[ -n "${RESOLVED_WS_DIR}" ] || {
echo "[build.sh][FATAL] 无法定位机构工作空间根(需含 projects/pbls/env/test.json)。" >&2
echo "[build.sh][FATAL] APP_DIR=${APP_DIR};请显式导出 PBL_WS_DIR=<工作空间根> 后重跑。" >&2
exit 1
}
WS_DIR="${RESOLVED_WS_DIR}"
ENV_FILE="${WS_DIR}/projects/pbls/env/test.json"
DDL_FILE="${APP_DIR}/scripts/ddl/pbls_tables.sql"
# [Bug 4SxqQAulkH9eAzEys_h-e 根因闭环] 基座模块(appbase/rbac)models 链 DDL ——
# 单一真源 = ${BASE_PKGS_ROOT}/{appbase,rbac}/models/*.json,由 gen_base_ddl.py 生成,
# 禁止手工编辑 base_tables.sql。第 10a-base 步执行。
BASE_GEN="${APP_DIR}/scripts/gen_base_ddl.py"
BASE_DDL_FILE="${APP_DIR}/scripts/ddl/base_tables.sql"
BASE_ALIAS_MAP="${APP_DIR}/scripts/ddl/base_table_alias_map.json"
# 基座包根:显式取 WS_DIR/pkgs(build.sh 已三段式解析 WS_DIR),gen_base_ddl.py
# 内部再做存在性校验并打印解析出的绝对路径(web-application-spec 铁律 5)。
BASE_PKGS_ROOT="${PBL_BASE_PKGS_ROOT:-${WS_DIR}/pkgs}"
VENV_DIR="${APP_DIR}/venv"
PY="${VENV_DIR}/bin/python"
log() { echo "[build.sh][$(date '+%F %T')] $*"; }
die() { echo "[build.sh][FATAL] $*" >&2; exit 1; }
# ---- 0. 前置检查:env 唯一事实源必须存在且无占位 ----
[ -f "${ENV_FILE}" ] || die "缺少环境事实源 ${ENV_FILE}"
if grep -Eq '待明确|TBD|占位|CHANGE_ME|xxx' "${ENV_FILE}"; then
die "${ENV_FILE} 含占位/待明确字段,禁止部署"
fi
read_env() { python3 -c "import json,sys;print(json.load(open('${ENV_FILE}'))$1)"; }
PORT="$(read_env "['port']")"
DB_HOST="$(read_env "['db']['host']")"
DB_PORT="$(read_env "['db']['port']")"
DB_USER="$(read_env "['db']['user']")"
DB_PASS="$(read_env "['db']['password']")"
DB_NAME="$(read_env "['db']['dbname']")"
DEPLOY_PATH="$(read_env "['deploy']['path']")"
# 外网口自检用(复测门禁 ① 同时 curl 127.0.0.1 与 192.168.16.3 两个地址);
# env 无 ssh.host 时回退 127.0.0.1,不让 read_env 抛 KeyError 中断部署。
SSH_HOST="$(python3 -c "import json;print(json.load(open('${ENV_FILE}')).get('ssh',{}).get('host') or '127.0.0.1')")"
log "env 事实源: port=${PORT} db=${DB_NAME}@${DB_HOST}:${DB_PORT} deploy=${DEPLOY_PATH}"
# ---- 1. 端口一致性核验(conf/config.json 必须与 env 同值)----
CONF_PORT="$(python3 -c "import json;print(json.load(open('${APP_DIR}/conf/config.json'))['port'])")"
[ "${CONF_PORT}" = "${PORT}" ] || die "端口不一致: conf=${CONF_PORT} env=${PORT}"
log "端口一致性核验通过: ${PORT}"
# ---- 2. 应用仓库不得另存 env/(端口唯一事实源在项目侧)----
[ ! -d "${APP_DIR}/env" ] || die "应用仓库存在 env/ 目录,违反端口唯一事实源规则"
log "应用仓库无 env/ 目录,核验通过"
# ---- 3. 入口硬门禁核验(6.1 / 6.2)----
ENTRY="${APP_DIR}/app/${APP_NAME}.py"
[ -f "${ENTRY}" ] || die "缺少入口 ${ENTRY}"
grep -q 'from ahserver.webapp import webapp' "${ENTRY}" || die "入口未使用 ahserver.webapp.webapp"
grep -q 'webapp(init)' "${ENTRY}" || die "入口未调用 webapp(init)"
if grep -Eq 'Flask|FastAPI|Django|http\.server|jsonify|@app\.route' "${ENTRY}"; then
die "入口含禁用框架关键字(Flask/FastAPI/Django/http.server/jsonify/@app.route)"
fi
log "硬门禁 6.1 通过: ahserver.webapp + webapp(init),无禁用框架"
# ServerEnv() 行号必须早于 load_rbac() 行号
ENV_LINE="$(grep -n 'ServerEnv()' "${ENTRY}" | head -1 | cut -d: -f1)"
RBAC_LINE="$(grep -n 'load_rbac()' "${ENTRY}" | head -1 | cut -d: -f1)"
[ -n "${ENV_LINE}" ] && [ -n "${RBAC_LINE}" ] || die "入口缺 ServerEnv() 或 load_rbac() 显式调用"
[ "${ENV_LINE}" -lt "${RBAC_LINE}" ] || die "ServerEnv() 必须早于 load_rbac()(env 行 ${ENV_LINE} >= rbac 行 ${RBAC_LINE})"
grep -q 'load_appbase()' "${ENTRY}" || die "入口缺 load_appbase() 显式调用"
grep -q 'load_pybricks()' "${ENTRY}" || die "入口缺 load_pybricks() 显式调用"
log "硬门禁 6.2 通过: ServerEnv()(L${ENV_LINE}) < load_rbac()(L${RBAC_LINE}),load_appbase/load_rbac/load_pybricks 均显式调用"
# ---- 4. DDL 方言核验(6.4:mariadb,BIGINT AUTO_INCREMENT,零 SERIAL/FK/ENUM/TIMESTAMP)----
# 【Q4 修复 / deploy_test 第 3 轮实测】旧版直接对 DDL **原文** grep,存在两类假阳性:
# (1) DDL 文末「契约自检」注释块为说明"零命中项",逐字写出了
# FOREIGN KEY / REFERENCES / ENUM( / SERIAL / BIGSERIAL / nextval / TIMESTAMP
# (实测原文命中 3 行:L6 头部说明、L220 COMMENT 串内的 references、L891 契约自检块),
# 旧 `grep -Eiq` 一律触发 die —— 注释/COMMENT 串被当成真实 DDL,门禁必然误杀;
# (2) `CREATE TABLE` / `AUTO_INCREMENT` 计数同理会把注释行与 COMMENT '...' 串内字样算进去,
# 表数不可信(且旧断言 36 与 DDL 实际 37 张不符,新增 pbl_compile_task 后未同步)。
# 修复:改为「先剥 SQL 注释 + 字符串字面量(含 COMMENT 串),再在纯 DDL 正文上扫描」,
# 由 scripts/ddl_gate_check.py 单遍字符扫描实现(非逐行正则,串内 -- / # 不会误判为注释),
# 期望值与 DDL 实际表数对齐(表数 / BIGINT AUTO_INCREMENT 主键数同值);
# [2026-09-28 方案 A] governance/template 6 表迁出后为 32。
[ -f "${DDL_FILE}" ] || die "缺少 DDL ${DDL_FILE}"
DDL_GATE="${APP_DIR}/scripts/ddl_gate_check.py"
[ -f "${DDL_GATE}" ] || die "缺少 DDL 门禁扫描器 ${DDL_GATE}(6.4 依赖,禁止回退到原文 grep)"
# 4a. 扫描器自身可信度:先跑内置最小单测(剥注释/剥串/幂等/真实违规不漏报)
SELFTEST_RC=0
python3 "${DDL_GATE}" --selftest || SELFTEST_RC=$?
[ "${SELFTEST_RC}" = "0" ] || die "DDL 门禁扫描器自检未通过(--selftest rc=${SELFTEST_RC}),扫描结果不可信,终止部署"
# 4b. 在降噪后的纯 DDL 正文上核验:方言零命中 + 表数 32 + BIGINT AUTO_INCREMENT 32
# [2026-09-28 方案 A] pbl_governance 4 表 + pbl_template 2 表迁出至模块 models/*.json
# 单一真源(id=str(32) 应用层 gen_pk,M5a-DISC.1 裁决(a)),手写 DDL 37→32。
# 模块 6 表由第 10a 步 gen/apply 链建,不进本门禁的 autoinc 口径。
GATE_RC=0
python3 "${DDL_GATE}" --ddl "${DDL_FILE}" --expect-tables 32 --expect-autoinc 32 || GATE_RC=$?
[ "${GATE_RC}" = "0" ] || die "硬门禁 6.4 未通过(ddl_gate_check.py rc=${GATE_RC}):非 mariadb 方言关键字命中 / 表数≠32 / BIGINT AUTO_INCREMENT≠32,详见上方回显"
log "硬门禁 6.4 通过: mariadb 方言(剥注释+COMMENT 串后零命中),32 表 / 32 处 BIGINT AUTO_INCREMENT"
# ---- 5. 依赖声明核验(6.5)----
for dep in ahserver sqlor apppublic appbase rbac PyMySQL; do
grep -qi "^${dep}" "${APP_DIR}/requirements.txt" || die "requirements.txt 缺依赖 ${dep}"
done
log "硬门禁 6.5 通过: ahserver/sqlor/apppublic/appbase/rbac/PyMySQL 均已声明"
# ---- 6.6 依赖声明「可满足性」静态门禁 ----
# [Bug BWY68rZR_Uh2A41JlGT7D 修复] 门禁 6.5 只 grep「声明存在」,不校验「声明可满足」,
# 于是三类必然让 `pip install -r requirements.txt` 失败(BUILD_RC=1)的缺陷被放行到部署阶段:
# 1) 版本下界凭空抬高、公共源无对应发行物(ahserver>=1.4.0 而实装/最高仅 1.2.0)
# 2) 幻影包 pybricks(平台前端能力实由 bricks_for_python 提供;公共源同名包是 LEGO 机器人库)
# 3) 不可编译包 mysqlclient(目标机无 mysql_config,pbl 无 sudo 装不了 mariadb-devel)
# 6.6 把可满足性前移为静态门禁,并按任务要求「检查所有模块的同类问题」一并扫描
# 全部 modules/*/pyproject.toml(幻影包 / 标准库误当第三方 / 与 wheelhouse 版本不一致)。
REQ_SAT_GATE="${APP_DIR}/scripts/check_requirements_satisfiable.py"
[ -f "${REQ_SAT_GATE}" ] || die "缺少依赖可满足性门禁脚本 ${REQ_SAT_GATE}"
EXTRA_REQ_ARGS=""
[ -f "${APP_DIR}/requirements-test.txt" ] && EXTRA_REQ_ARGS="--extra-requirements ${APP_DIR}/requirements-test.txt"
python3 "${REQ_SAT_GATE}" \
--requirements "${APP_DIR}/requirements.txt" \
${EXTRA_REQ_ARGS} \
--wheelhouse "${WS_DIR}/wheelhouse" \
--modules-glob "${WS_DIR}/modules/*/pyproject.toml" || die "硬门禁 6.6 未通过:依赖声明不可满足(幻影包/版本与 wheelhouse 不一致/失效内网源/相对 find-links),详见上方逐条回显"
log "硬门禁 6.6 通过: 依赖声明可满足(基座版本与 wheelhouse 逐字一致,零幻影包,全模块 pyproject 同类缺陷零命中)"
# ---- 6. website.processors / indexes 核验(缺一会导致 / 返回 500)----
python3 - <<'PYEOF'
import json, sys
conf = json.load(open('conf/config.json'))
procs = dict((p[0], p[1]) for p in conf['website']['processors'])
for ext, name in (('.tmpl', 'tmpl'), ('.ui', 'bui'), ('.dspy', 'dspy')):
assert procs.get(ext) == name, 'processors 缺 %s->%s,实际 %r' % (ext, name, procs.get(ext))
assert 'index.ui' in conf['website']['indexes'], 'indexes 缺 index.ui'
print('[build.sh] website 核验通过: processors=%s indexes=%s' % (procs, conf['website']['indexes']))
PYEOF
# ---- 7. venv + 依赖安装 ----
if [ ! -x "${PY}" ]; then
log "创建 venv: ${VENV_DIR}"
python3 -m venv "${VENV_DIR}"
fi
"${PY}" -m pip install --upgrade pip wheel setuptools
# [deploy_test R5 修复 #1] 平台基础包(ahserver/sqlor/apppublic/appbase/rbac/bricks_for_python/accounting)
# 原走内网私有源 http://DEAD-INTERNAL-PYPI-REMOVED:8081 —— 目标机 192.168.16.3 实测 connect timeout(5 次重试全失败),
# build.sh 在此处 BUILD_RC=1 终止(R4 部署失败根因,见 evidence/r4_build.log)。
# 改为:机构 wheelhouse 离线供给(--find-links,绝对路径,不依赖 cwd)+ 公网 pypi.org 取第三方包
# (目标机实测 https://pypi.org/simple/pymysql/ HTTP=200)。
WHEELHOUSE="${PBL_WHEELHOUSE:-${WS_DIR}/wheelhouse}"
[ -d "${WHEELHOUSE}" ] || die "缺少机构 wheelhouse ${WHEELHOUSE}(平台基础包离线源,内网 pypi 在目标机不可达);部署机路径不同时请导出 PBL_WHEELHOUSE=<wheelhouse 绝对路径>"
for _w in ahserver sqlor apppublic appbase rbac bricks_for_python accounting; do
ls "${WHEELHOUSE}"/${_w}-*.whl >/dev/null 2>&1 || die "wheelhouse 缺 ${_w} 的 whl(${WHEELHOUSE})"
done
log "wheelhouse 就位: $(ls "${WHEELHOUSE}"/*.whl | wc -l) 个 whl @ ${WHEELHOUSE}"
"${PY}" -m pip install --find-links "${WHEELHOUSE}" -r "${APP_DIR}/requirements.txt"
# ---- 7b. 测试前置依赖(DISC.2)----
# 运行时依赖(requirements.txt)刻意不含 pytest;测试依赖单列 requirements-test.txt,
# 由本步装进同一个 venv,使
# source venv/bin/activate && python3 -m pytest tests/test_m5a_idempotency.py -v
# 在**不手工注入 PYTHONPATH** 的前提下可直接执行(此前靠 PYTHONPATH=<ws>/.pylibs
# 临时注入跑通,换机器/换角色即失效,test 角色会误判为环境不可用)。
# 只要运行时依赖的场景(如生产镜像)用 PBL_SKIP_TEST_DEPS=1 显式跳过。
REQ_TEST="${APP_DIR}/requirements-test.txt"
if [ "${PBL_SKIP_TEST_DEPS:-0}" = "1" ]; then
log "PBL_SKIP_TEST_DEPS=1:跳过测试前置依赖(venv 内将没有 pytest,测试角色需自行引导)"
elif [ ! -f "${REQ_TEST}" ]; then
die "缺测试依赖清单 ${REQ_TEST}(DISC.2 要求显式声明;确要跳过请设 PBL_SKIP_TEST_DEPS=1)"
else
log "安装测试前置依赖: ${REQ_TEST}"
"${PY}" -m pip install -r "${REQ_TEST}"
# 自证:装完必须真能 import + 跑 --version,否则本步等于没做(不让注释与事实矛盾)
"${PY}" -c 'import pytest' >/dev/null 2>&1 \
|| die "安装后 venv 内仍无法 import pytest(${PY})"
log "7b 自证通过: $("${PY}" -m pytest --version 2>&1 | head -1) @ ${PY}"
fi
# ---- 8. 本地安装 15 个 pbl_* 模块 + 复用模块(可编辑模式)----
# [deploy_test R5 修复 #2] 原循环只匹配 modules/pbl_*,但入口 app/pbls.py 还显式 import
# world / scene / entity / script_engine / scense_runtime / scense / scense_game
# 这 7 个复用域模块(pbls_spec.json referenced_modules)。漏装 → 入口 ImportError →
# 进程起不来(端口无 LISTEN、healthz=000)。此处按 spec.json referenced_modules 补装,
# 并做「入口 import 全部可解析」自证门禁,装不上就 die,不留到启动阶段才炸。
REUSE_MODULES="$(python3 -c "import json;print(' '.join(json.load(open('${WS_DIR}/projects/pbls/pbls_spec.json'))['referenced_modules']))")"
# [deploy_test R8 修复] 安装顺序改为拓扑序:pbl_common 必须最先装。
# 原实现用 glob `modules/pbl_*` 按字母序安装,pbl_agent_runtime 排在 pbl_common 之前,
# 而 pbl_agent_runtime 的 pyproject 依赖 pbl_common(本地模块,任何索引都没有)→
# pip 报 "ERROR: No matching distribution found for pbl_common",BUILD_RC=1
# (实测 /d/pbl/pbls_ws/build_r8.log L170-176)。故先装 pbl_common,再装其余 pbl_*。
INSTALL_DIRS=""
if [ -d "${WS_DIR}/modules/pbl_common" ]; then
INSTALL_DIRS="${WS_DIR}/modules/pbl_common"
fi
for mod_dir in "${WS_DIR}"/modules/pbl_*; do
[ -d "${mod_dir}" ] || continue
[ "$(basename "${mod_dir}")" = "pbl_common" ] && continue
INSTALL_DIRS="${INSTALL_DIRS} ${mod_dir}"
done
for rm in ${REUSE_MODULES}; do
# 平台基础包(apppublic/sqlor/ahserver/appbase/rbac/accounting)已由 wheelhouse 装好,跳过
case " ${rm} " in *" apppublic "*|*" sqlor "*|*" ahserver "*|*" appbase "*|*" rbac "*|*" accounting "*) continue;; esac
[ -d "${WS_DIR}/modules/${rm}" ] || die "spec.json referenced_modules 声明 ${rm},但 ${WS_DIR}/modules/${rm} 不存在"
INSTALL_DIRS="${INSTALL_DIRS} ${WS_DIR}/modules/${rm}"
done
for mod_dir in ${INSTALL_DIRS}; do
if [ -f "${mod_dir}/pyproject.toml" ] || [ -f "${mod_dir}/setup.py" ]; then
log "安装模块 $(basename "${mod_dir}")"
"${PY}" -m pip install --find-links "${WHEELHOUSE}" -e "${mod_dir}"
else
export PYTHONPATH="${mod_dir}:${PYTHONPATH:-}"
log "模块 $(basename "${mod_dir}") 无打包描述,加入 PYTHONPATH"
fi
done
# ---- 8a. 入口 import 可解析性自证(部署前拦截 ImportError,不留到启动阶段)----
log "入口 import 自证:$(basename "${ENTRY}") 全部 import 必须可解析"
IMPORT_RC=0
"${PY}" - <<'PYIMP' || IMPORT_RC=$?
import ast, importlib.util, sys
src = open('app/pbls.py', encoding='utf-8').read()
mods = set()
for node in ast.walk(ast.parse(src)):
if isinstance(node, ast.ImportFrom) and node.module:
mods.add(node.module.split('.')[0])
elif isinstance(node, ast.Import):
for a in node.names:
mods.add(a.name.split('.')[0])
mods -= set(sys.stdlib_module_names)
mods -= {'json', 'os', 'sys', 'ast'}
missing = sorted(m for m in mods if importlib.util.find_spec(m) is None)
print('[import-check] 需解析顶层模块 %d 个: %s' % (len(mods), ' '.join(sorted(mods))))
if missing:
print('[import-check][FAIL] 无法解析: %s' % ' '.join(missing))
sys.exit(1)
print('[import-check] PASS 全部可解析')
PYIMP
[ "${IMPORT_RC}" = "0" ] || die "入口 import 自证失败(rc=${IMPORT_RC}):上列模块在 venv 内不可解析,应用必然启动失败,终止部署"
# ---- 8b. M9 覆盖层页面壳生成(构建产物,不入库)+ 一致性门禁 ----
# 页面壳由 pbl_scense_ext 的生成器产出;产物目录 wwwroot/pbl_overlay/ 在模块仓
# .gitignore 中被排除,因此必须在构建期生成,否则新克隆/新部署环境该目录为空、
# /pbl_overlay/<key>.ui 导航全量 404。位置:模块安装之后、模块 wwwroot 软链(紧随其后的
# 「8c. 模块 wwwroot 挂载」段)之前——生成物落在模块仓,必须再经 8c 挂载到应用 wwwroot
# 才能在应用侧 URL /pbl_scense_ext/pbl_overlay/<key>.ui 上取到。
OVERLAY_GEN="${WS_DIR}/modules/pbl_scense_ext/scripts/gen_overlay_pages.py"
[ -f "${OVERLAY_GEN}" ] || die "缺少覆盖层页面壳生成器: ${OVERLAY_GEN}(modules/pbl_scense_ext 未就位)"
log "生成 PBL 覆盖层页面壳: ${OVERLAY_GEN}"
# 注意:不能用 `if ! cmd; then die "... rc=$?"` —— 进入分支时 $? 已被 if 取反为 0,
# 打印出的 rc 恒为 0 会误导排障;改为显式捕获真实返回码。
GEN_RC=0
"${PY}" "${OVERLAY_GEN}" || GEN_RC=$?
if [ "${GEN_RC}" -ne 0 ]; then
die "覆盖层页面壳生成失败(gen_overlay_pages.py rc=${GEN_RC}):wwwroot/pbl_overlay/ 将为空,/pbl_overlay/<key>.ui 全量 404,终止部署"
fi
log "覆盖层页面壳门禁校验(gen_overlay_pages.py --check)"
CHK_RC=0
"${PY}" "${OVERLAY_GEN}" --check || CHK_RC=$?
if [ "${CHK_RC}" -ne 0 ]; then
die "覆盖层页面壳门禁校验未通过(--check rc=${CHK_RC}):i18n zh/en key 不一致,或页面壳与 PAGES 注册表不一致,终止部署"
fi
log "覆盖层页面壳生成 + 门禁校验通过(12 个页面壳 / i18n key 一致)"
# ---- 8c. 模块 wwwroot 挂载(软链)+ 应用侧可达性门禁(QC 退回意见 #1/#2)----
# 上一版只做了 8b「生成」,生成物落在模块仓 modules/<m>/wwwroot/ 下,但本脚本从未把
# 模块 wwwroot 挂进应用 wwwroot —— 应用 website.root=wwwroot 只指向应用自身目录,
# 因此 build.sh 跑完后应用侧依然取不到 /pbl_scense_ext/pbl_overlay/<key>.ui(可达链路
# 断在挂载环节,全量 404 并未消除)。此处按 web-application-spec「挂载模块 9 步流程」
# 第 5 步(wwwroot 软链,非 cp,保持与模块仓单一副本同步)补齐,并立即以应用侧路径
# 实测 12 个页面壳数量作为门禁,rc≠0 终止部署。
LINKED_MODULES=""
for mod_dir in "${WS_DIR}"/modules/pbl_*; do
[ -d "${mod_dir}/wwwroot" ] || continue
mod_name="$(basename "${mod_dir}")"
www_link="${APP_DIR}/wwwroot/${mod_name}"
# 已存在但不是软链(应用仓自带同名真实目录)时不覆盖,直接终止让人工处置,
# 避免 ln -sfn 把软链建进既有目录里、或静默删掉应用仓跟踪文件。
if [ -e "${www_link}" ] && [ ! -L "${www_link}" ]; then
die "wwwroot 挂载冲突:${www_link} 已存在且不是软链(应用仓自带目录?),请人工处置后重试"
fi
ln -sfn "${mod_dir}/wwwroot" "${www_link}"
LINKED_MODULES="${LINKED_MODULES} ${mod_name}"
done
[ -n "${LINKED_MODULES}" ] || die "未发现任何含 wwwroot/ 的 pbl_* 模块,模块 wwwroot 挂载为空"
log "模块 wwwroot 软链挂载完成:${LINKED_MODULES}"
# ---- 8d. 平台基座包 wwwroot 挂载(软链)+ 可达性门禁(owner 2026-09-30 手动修复)----
# 根因(Bug KMzNPfKwI7Byk2SzLR / Vt3D8nvlALCIbdye / CJijeS7Mz6UekBB_YwNuR / LSycNWkqDbOlAeqSSna5z):
# wheelhouse 基座 wheel 只含 .py(rbac wheel 14 文件、wwwroot=0),venv editable 安装使 Python
# 代码可达(healthz/dspy 200),但 rbac 登录页与 bricks dist(header.tmpl/bricks.js)等基座包
# wwwroot 静态资源从未挂进应用 wwwroot/ → index.ui 渲染 /bricks/header.tmpl 时 fpath=None →
# processor None → be_call AttributeError → 根路径与 /rbac/user/login.ui 全 500。
# 宿主 9090 正确参照即 wwwroot/rbac→pkgs/rbac/wwwroot、wwwroot/bricks→pkgs/bricks/dist 两条软链。
# 定位方式(2026-09-30 pbl 用户实测):PKGS_DIR 经 editable 源运行时定位(→ scense/pkgs,
# pbl 可读 rbac/wwwroot/user/login.ui);bricks dist 全机唯一在
# /d/pipeline/pipeline-app/pkgs/bricks/dist(scense/pkgs 下无 bricks),pbl 实测可读 header.tmpl。
# 实测结果:挂载后 /rbac/user/login.ui=200、/=200、/healthz=200(平台约定登录路径是
# /rbac/user/login.ui,非 /main/rbac/ 前缀——宿主实测 /main/ 前缀本身也 500)。
BASE_PKGS_DIR="$("${PY}" -c 'import rbac,os;print(os.path.dirname(os.path.dirname(rbac.__file__)))' 2>/dev/null)"
BRICKS_DIST="/d/pipeline/pipeline-app/pkgs/bricks/dist"
[ -n "${BASE_PKGS_DIR}" ] && [ -d "${BASE_PKGS_DIR}/rbac/wwwroot" ] || die "基座 rbac wwwroot 定位失败: BASE_PKGS_DIR=${BASE_PKGS_DIR:-<empty>}(editable 源不可达?)"
[ -f "${BRICKS_DIST}/header.tmpl" ] || die "bricks dist 不可达: ${BRICKS_DIST}/header.tmpl 不存在(若部署用户无权限,参照 wheelhouse 先例 chmod o+rX + 父链 o+x)"
if [ -e "${APP_DIR}/wwwroot/rbac" ] && [ ! -L "${APP_DIR}/wwwroot/rbac" ]; then
die "wwwroot/rbac 已存在且不是软链(应用仓自带目录?),请人工处置后重试"
fi
if [ -e "${APP_DIR}/wwwroot/bricks" ] && [ ! -L "${APP_DIR}/wwwroot/bricks" ]; then
die "wwwroot/bricks 已存在且不是软链(应用仓自带目录?),请人工处置后重试"
fi
ln -sfn "${BASE_PKGS_DIR}/rbac/wwwroot" "${APP_DIR}/wwwroot/rbac"
ln -sfn "${BRICKS_DIST}" "${APP_DIR}/wwwroot/bricks"
log "基座包 wwwroot 挂载完成: rbac→${BASE_PKGS_DIR}/rbac/wwwroot bricks→${BRICKS_DIST}"
# 挂载后应用侧可达性门禁:覆盖层页面壳必须能通过应用 wwwroot 路径取到
APP_OVERLAY_DIR="${APP_DIR}/wwwroot/pbl_scense_ext/pbl_overlay"
APP_UI_COUNT=0
if [ -d "${APP_OVERLAY_DIR}" ]; then
APP_UI_COUNT="$(find -L "${APP_OVERLAY_DIR}" -maxdepth 1 -name '*.ui' | wc -l | tr -d '[:space:]')"
fi
if [ "${APP_UI_COUNT}" != "12" ]; then
die "应用侧覆盖层页面壳应为 12 个,实际 ${APP_UI_COUNT}(${APP_OVERLAY_DIR}):模块 wwwroot 未挂载成功或生成器未产出,/pbl_scense_ext/pbl_overlay/<key>.ui 将全量 404,终止部署"
fi
log "应用侧可达性核验通过: ${APP_OVERLAY_DIR}/*.ui = ${APP_UI_COUNT}"
# ---- 9. 语法验证:全部 .py 跑 py_compile(QC 退回意见 #16)----
log "py_compile 全量语法验证"
FAIL_SYNTAX=0
while IFS= read -r pyfile; do
if ! "${PY}" -m py_compile "${pyfile}"; then
echo "[build.sh][SYNTAX FAIL] ${pyfile}"
FAIL_SYNTAX=1
fi
done < <(find "${APP_DIR}/app" "${WS_DIR}/modules/pbl_common" "${WS_DIR}/modules/pbl_appcodes" -name '*.py' -not -path '*/venv/*' 2>/dev/null)
[ "${FAIL_SYNTAX}" = "0" ] || die "存在语法错误文件,终止部署"
log "py_compile 全部通过"
# ---- 10. 建表(mariadb,显式 TCP,禁 socket)----
log "执行 DDL: ${DDL_FILE}"
mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
--default-character-set=utf8mb4 "${DB_NAME}" < "${DDL_FILE}"
# [2026-09-28 方案 A] 计数口径从 LIKE 'pbl_%' 改为 IN 点名 32 张手写表——
# LIKE 会被其它模块自建表(rtx pbl_entity_state 等)/迁移备份表干扰而虚高判死。
HAND_TABLES="'pbl_blueprint','pbl_blueprint_version','pbl_blueprint_lock','pbl_subobject','pbl_subobject_attr','pbl_subobject_rel','pbl_subobject_asset','pbl_subobject_ref','pbl_subobject_review','pbl_blueprint_tag','pbl_blueprint_publish','pbl_validation_ruleset','pbl_validation_run','pbl_validation_finding','pbl_compile_job','pbl_compile_artifact','pbl_game_definition','pbl_compile_log','pbl_agent_session','pbl_agent_message','pbl_agent_tool_call','pbl_agent_arbitration','pbl_artifact','pbl_evidence','pbl_evidence_idem','pbl_evidence_binding','pbl_rubric','pbl_rubric_criterion','pbl_assessment_report','pbl_domain_binding','pbl_analytics_snapshot','pbl_compile_task'"
ACTUAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN (${HAND_TABLES})" "${DB_NAME}")"
# Q4 修复:与 DDL 实际表数对齐;[2026-09-28 方案 A] governance/template 6 表迁出
# 至模块 models 链(第 10a 步建),本文件手写 DDL 37→32。
[ "${ACTUAL_TABLES}" = "32" ] || die "建表数应为 32(手写 DDL 段),实际 ${ACTUAL_TABLES}"
log "手写 DDL 建表完成: pbl_* 共 ${ACTUAL_TABLES} 张(模块 models 链 6 张见 10a 步)"
# ---- 10a. 模块 models 链建表(owner 裁决 2026-09-28 方案 A)----
# pbl_governance 4 表 + pbl_template 2 表:models/*.json 四段式 → json2ddl 生成
# → apply_ddl.py 执行(存在性门禁,绝不 DROP)。先 --check 校验 sql/ 产物与
# models 指纹一致(改表忘重跑 gen 即 die),再逐模块 apply + verify。
for MOD in pbl_governance pbl_template; do
MOD_DIR="${WS_DIR}/modules/${MOD}"
[ -f "${MOD_DIR}/scripts/gen_ddl_from_models.py" ] || die "${MOD} 缺 gen_ddl_from_models.py(models 链不完整)"
log "[10a] ${MOD}: gen --check(models↔sql 指纹幂等门禁)"
"${PY}" "${MOD_DIR}/scripts/gen_ddl_from_models.py" --check \
|| die "${MOD}: sql/ 产物与 models/*.json 指纹不一致——改表结构后必须重跑 gen_ddl_from_models.py"
log "[10a] ${MOD}: apply_ddl(存在性门禁建表 + information_schema 列核验)"
"${PY}" "${MOD_DIR}/scripts/apply_ddl.py" --verify \
|| die "${MOD}: apply_ddl 失败(详见上方真实错误链)"
done
# 模块链 6 表终态断言(含补建 pbl_privacy_consent)
MOD_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN ('pbl_tenant','pbl_role_binding','pbl_audit_log','pbl_privacy_consent','pbl_template','pbl_template_item')" "${DB_NAME}")"
[ "${MOD_TABLES}" = "6" ] || die "模块 models 链建表应为 6 张,实际 ${MOD_TABLES}"
log "[10a] 模块 models 链建表完成: 6/6(governance 4 含 privacy_consent + template 2)"
# ---- 10a-base. 基座模块(appbase / rbac)models 链建表 ----
# [Bug 4SxqQAulkH9eAzEys_h-e 根因闭环 / QC 未过项 #1]
# 症状链:POST /main/rbac/user/up_login.dspy → 500 "Server got itself in trouble"
# ← ERROR 1146 (42S02) Table 'pbl.users' doesn't exist
# ← build.sh 建表段只覆盖 scripts/ddl/pbls_tables.sql(32 张 pbl_* 手写表)
# 与 modules/pbl_*/models(6 张治理/模板域表,第 10a 步),
# 基座 appbase(appcodes/appcodes_kv/params/svgicon)与
# rbac(users/role/permission/rolepermission/userrole/organization/orgtypes/audit_log)
# 的 models 链从未被建表。
# 本段职责:① 从 models 单一真源重新生成 base_tables.sql + alias map(脚本自带
# 「表数<8 或 users 缺失即 die」门禁);② 用 env 派生的连接参数执行建表;
# ③ information_schema 逐张点名自校验(防计数虚高);④ 全部 IF NOT EXISTS,幂等。
# 口径边界:本段只新增 12 张基座表,不触碰第 10 步 32 张手写表门禁、
# 第 10a 步 6 张模块链表门禁,也不改动后续 38 关键表终态门禁的口径。
[ -f "${BASE_GEN}" ] || die "缺少基座 DDL 生成器 ${BASE_GEN}"
# gen_base_ddl.py 只用标准库;优先用 venv 解释器(与 10a 一致),venv 不在位时回退 python3
BASE_GEN_PY="${PY}"
[ -x "${BASE_GEN_PY}" ] || BASE_GEN_PY="python3"
# 把 WS_DIR 交给生成器(其候选链第 2 项 = ${WS_DIR}/pkgs);BASE_PKGS_ROOT 仅在
# 真实存在时导出,否则留给生成器自带的三段式探测(会逐条打印候选与存在性,不静默)。
export WS_DIR
if [ -d "${BASE_PKGS_ROOT}" ]; then
export BASE_PKGS_ROOT
log "[10a-base] BASE_PKGS_ROOT=${BASE_PKGS_ROOT} exists=yes(显式导出)"
else
log "[10a-base][WARN] BASE_PKGS_ROOT=${BASE_PKGS_ROOT} exists=no —— 交由 gen_base_ddl.py 三段式探测基座包根(其候选含 /d/pipeline/pipeline-app/pkgs),探测过程逐条回显"
fi
log "[10a-base] 生成基座 DDL: ${BASE_GEN_PY} ${BASE_GEN}"
log "[10a-base] 输出: ${BASE_DDL_FILE} / ${BASE_ALIAS_MAP}"
"${BASE_GEN_PY}" "${BASE_GEN}" \
--out "${BASE_DDL_FILE}" \
--alias-map "${BASE_ALIAS_MAP}" \
|| die "[10a-base] gen_base_ddl.py 失败(基座 models 读取不完整 / users 缺失 / BASE_PKGS_ROOT 不可达,详见上方 [gen_base_ddl] 真实错误链)"
# 生成产物存在性 + 生成器 SELF_CHECK 口径复核(不依赖 stderr 抓取,直接数 SQL 文件)
[ -f "${BASE_DDL_FILE}" ] || die "[10a-base] 生成器未产出 ${BASE_DDL_FILE}"
[ -f "${BASE_ALIAS_MAP}" ] || die "[10a-base] 生成器未产出 ${BASE_ALIAS_MAP}(声明表名→真源表名对照,test/deploy 探针据此改用真名消除 1146 误报)"
BASE_SQL_TABLES="$(grep -c 'CREATE TABLE IF NOT EXISTS' "${BASE_DDL_FILE}" || true)"
[ "${BASE_SQL_TABLES}" -ge 8 ] || die "[10a-base] base_tables.sql 仅含 ${BASE_SQL_TABLES} 张表(<8),基座 models 读取不完整"
grep -q 'CREATE TABLE IF NOT EXISTS `users`' "${BASE_DDL_FILE}" \
|| die "[10a-base] base_tables.sql 不含 users 表 —— up_login.dspy 500 根因未消除"
log "[10a-base] 生成校验通过: base_tables.sql 含 ${BASE_SQL_TABLES} 张表,users=present"
log "[10a-base] 执行基座 DDL: ${BASE_DDL_FILE} → ${DB_NAME}@${DB_HOST}:${DB_PORT}"
mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
--default-character-set=utf8mb4 "${DB_NAME}" < "${BASE_DDL_FILE}" \
|| die "[10a-base] 基座 DDL 执行失败(连接参数取自 ${ENV_FILE},请核对 db.host/port/user/password 可达性)"
# 自校验:逐张点名(IN 列表),不用 LIKE —— LIKE 会被同前缀表/迁移备份表干扰而虚高判死。
# 期望 12 张:appbase 4(appcodes/appcodes_kv/params/svgicon)
# + rbac 8(audit_log/organization/orgtypes/permission/role/rolepermission/userrole/users)
BASE_TABLES="'appcodes','appcodes_kv','params','svgicon','audit_log','organization','orgtypes','permission','role','rolepermission','userrole','users'"
BASE_ACTUAL="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN (${BASE_TABLES})" "${DB_NAME}")"
log "[10a-base] 基座表点名核验: ${BASE_ACTUAL}/12(appbase 4 + rbac 8)"
[ "${BASE_ACTUAL}" = "12" ] || {
MISSING="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT t.n FROM (SELECT 'appcodes' n UNION ALL SELECT 'appcodes_kv' UNION ALL SELECT 'params' UNION ALL SELECT 'svgicon' UNION ALL SELECT 'audit_log' UNION ALL SELECT 'organization' UNION ALL SELECT 'orgtypes' UNION ALL SELECT 'permission' UNION ALL SELECT 'role' UNION ALL SELECT 'rolepermission' UNION ALL SELECT 'userrole' UNION ALL SELECT 'users') t LEFT JOIN information_schema.tables i ON i.table_schema='${DB_NAME}' AND i.table_name=t.n WHERE i.table_name IS NULL" "${DB_NAME}" | tr '\n' ' ')"
die "[10a-base] 基座表在场数应为 12,实际 ${BASE_ACTUAL};缺失: ${MISSING:-(无法枚举)}"
}
# users 表单独点名(登录链路命门,QC 验收项要求显式回显 users=present)
USERS_PRESENT="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name='users'" "${DB_NAME}")"
[ "${USERS_PRESENT}" = "1" ] || die "[10a-base] users 表不存在 —— up_login.dspy 仍会 ERROR 1146 → 500"
# up_login.dspy 锁定检查/成功登录写的三列必须在位(来源 pkgs/rbac/migration/*.sql,
# models/users.json 未含,由 gen_base_ddl.py 补齐;此处对已存在的旧表做终态核验)
USERS_LOGIN_COLS="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='${DB_NAME}' AND table_name='users' AND column_name IN ('username','login_fail_count','last_login_fail','last_login')" "${DB_NAME}")"
[ "${USERS_LOGIN_COLS}" = "4" ] || die "[10a-base] users 表登录链列不全(username/login_fail_count/last_login_fail/last_login 应在场 4,实际 ${USERS_LOGIN_COLS})"
log "[10a-base] SELF_CHECK base_tables: PASS tables=${BASE_ACTUAL}/12 users=present login_cols=${USERS_LOGIN_COLS}/4"
# ---- 10b. 运行目录前置创建(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)----
# 原脚本到步骤 13 才 mkdir logs/run,而步骤 11 已经 `tee ${APP_DIR}/logs/self_check.log`。
# 全新克隆(仓库不含 logs/,.gitignore 也排除 *.log)时 tee 打不开文件 →
# set -euo pipefail 下整条管道非零 → die,部署在「建表已成功、自检未跑」处中断,
# 表现为门禁 ③ 表数 0/37(DDL 事务已提交但脚本没走到断言)与门禁 ②③ 全红。
mkdir -p "${APP_DIR}/logs" "${APP_DIR}/run"
log "运行目录就位: ${APP_DIR}/logs ${APP_DIR}/run"
# ---- 11. 枚举数量契约自检(QC 退回意见 #15:必须真实可运行输出)----
"${PY}" -m pbl_appcodes.self_check | tee "${APP_DIR}/logs/self_check.log"
# [deploy_test R11 修复] 原门禁断言字面量 'PASS 74/74' 与 pbl_appcodes.self_check 真实输出格式不符:
# 实测输出为 'SELF_CHECK pbl_appcodes: PASS 18/18'(18 = 自检项数),74 项枚举契约由其中
# '[PASS] meta: _meta.groups=8 / items=74' 与 '[PASS] groups: 每组项数符合契约(8/5/8/6/6/10/16/15 = 74)'
# 两行承载(r11 build 日志 1411-1430 行实测)。故门禁改为「PASS 汇总行 + 74 项契约双证据」联合断言,
# 语义不放宽(仍要求自检全绿且枚举数=74),仅修正过期字面量。
grep -qE 'SELF_CHECK pbl_appcodes: PASS [0-9]+/[0-9]+' "${APP_DIR}/logs/self_check.log" \
|| die "pbl_appcodes 数量契约自检未通过(无 PASS 汇总行)"
grep -q 'items=74' "${APP_DIR}/logs/self_check.log" \
|| die "pbl_appcodes 数量契约自检未通过(_meta items != 74)"
grep -q '= 74)' "${APP_DIR}/logs/self_check.log" \
|| die "pbl_appcodes 数量契约自检未通过(每组项数合计 != 74)"
# 注:set -euo pipefail 下 `grep && die` 在 grep 无命中(rc=1)时会误触发脚本退出,故用 if 结构
if grep -q 'FAIL' "${APP_DIR}/logs/self_check.log"; then
die "pbl_appcodes 数量契约自检存在 FAIL 项"
fi
log "枚举数量契约自检通过: 74 项 / 自检全绿(无 FAIL)"
# ---- 12. 9 项种子数据幂等注入(重复执行零差异)----
log "种子注入(第 1 轮)"
# [2026-09-29 修复] 种子段是**独立 python 进程**(heredoc),必须自己做 DB bootstrap:
# ServerEnv.get_module_dbname(库名唯一事实源=conf/config.json 的 module_dbname 段)
# + DBPools(cfg.databases)。原脚本从不做这一步,于是 get_db() 解析不到库名 →
# get_module_dbname fail-closed 抛 'NoneType' object is not callable,
# _table_exists 退化成误报「共享编码表 appcodes 不存在(appbase 未初始化)」,
# 最终 DbError 中断部署。此前该缺陷被 seed.py 的 `SHOW TABLES LIKE %s` 旧式传参
# (sqlor v2 只认 ${name}$ 具名 dict)以更早的 PBL_E_PARAM/AttributeError 形式
# 掩盖,两处必须一起修才能真正走通。
# bootstrap 序列与 modules/pbl_*/scripts/apply_ddl.py 及 app/pbls.py init() 同口径
# (本修复已在测试机实测:种子注入两轮幂等零差异通过)。
export SEED_APP_DIR="${APP_DIR}"
export SEED_WS_DIR="${WS_DIR}"
"${PY}" - <<'PYEOF'
import json, asyncio, os, sys
# ---- DB bootstrap(独立进程必做,见上方注释)----
_APP_DIR = os.environ.get('SEED_APP_DIR') or os.getcwd()
_WS_DIR = os.environ.get('SEED_WS_DIR') or os.path.normpath(
os.path.join(_APP_DIR, '..', '..'))
os.chdir(_APP_DIR) # conf/config.json 相对路径口径
for _p in (os.path.join(_WS_DIR, 'modules', 'pbl_common'),
os.path.join(_WS_DIR, 'modules', 'pbl_appcodes'),
os.path.join(_WS_DIR, 'pkgs'),
'/d/pipeline/binpkg_overlay'):
if os.path.isdir(_p) and _p not in sys.path:
sys.path.insert(0, _p)
from ahserver.globalEnv import getConfig, DBPools
from ahserver.serverenv import ServerEnv
with open(os.path.join(_APP_DIR, 'conf', 'config.json'), encoding='utf-8') as _fh:
_MODULE_DBNAME = json.load(_fh).get('module_dbname') or {}
def _gmd(_m):
_n = _MODULE_DBNAME.get(_m) or _MODULE_DBNAME.get('default')
if not _n:
raise RuntimeError('conf/config.json 缺 module_dbname 映射: %r' % _m)
return _n
_cfg = getConfig()
_env = ServerEnv() # MUST be before DBPools(同 pbls.py init)
_env.get_module_dbname = _gmd
DBPools(_cfg.databases)
print('[seed] DB bootstrap 完成: 目标库=%s(module_dbname 映射,非硬编码)'
% _gmd('pbl_appcodes'))
from pbl_appcodes.seed import seed_all
# [deploy_test R12 定点补丁] seed_all 是 async def,原脚本同步调用 → TypeError:
# Object of type coroutine is not JSON serializable + RuntimeWarning: never awaited。
# [2026-09-29 修复] 两轮必须跑在**同一个事件循环**里,不能各自 asyncio.run():
# sqlor 连接池的 Future 绑定首次建池的 loop,第二次 asyncio.run() 新建 loop 后
# 复用同一连接池 → "got Future attached to a different loop"(build_run7 实测,
# 第 1 轮 inserted=82 成功、第 2 轮崩)。幂等对比口径不变:同 loop 内顺序两轮,
# 第 2 轮仍须零插入零更新。
async def _seed_two_rounds():
r1 = await seed_all()
print('[seed round1]', json.dumps(r1, ensure_ascii=False, sort_keys=True))
r2 = await seed_all()
print('[seed round2]', json.dumps(r2, ensure_ascii=False, sort_keys=True))
return r1, r2
s1, s2 = asyncio.run(_seed_two_rounds())
# [deploy_test R12 定点补丁 #2] 原断言用 groups_inserted/items_inserted/idempotent 三个键,
# 但 seed_all 真实返回契约(seed.py:494-499)是 ok/groups/items/inserted/updated/skipped/per_group,
# 无上述键 → 同步调用修好后必然 KeyError。改按真实返回键断言,幂等口径不变(第二轮零插入零更新)。
assert s1['ok'] is True and s1['groups'] == 8 and s1['items'] == 74, '第 1 轮种子契约不满足(8 组 74 项)'
assert s2['ok'] is True and s2['inserted'] == 0 and s2['updated'] == 0, '重复执行有差异(幂等失败)'
assert s2['groups'] == 8 and s2['items'] == 74, '第 2 轮种子契约不满足'
print('[seed] 幂等零差异校验通过: 8 组 74 项')
PYEOF
# ---- 12.5 RBAC 路径注册(Bug WIu0Rkn0KwB0XTjYZz0qk 修复:原脚本从不注册)----
# 中央聚合器 apps/pbls/scripts/load_path.py 把各模块 + 宿主自有端点(含本次新增的
# /healthz、/healthz.dspy、/api/public/health)逐条写入 permission/rolepermission。
# 不注册的后果:进程起来了、端口监听了,但 curl 一律 401/403 → 冒烟门禁 ① HTTP!=200。
# 先跑 --check 做通配符 + 聚合完整性门禁(rc!=0 即终止,禁止带缺口部署)。
LOAD_PATH="${APP_DIR}/scripts/load_path.py"
[ -f "${LOAD_PATH}" ] || die "缺少 RBAC 注册入口 ${LOAD_PATH}"
# ---- 12.45 RBAC 写入工具定位(set_role_perm.py)----
# [2026-09-29 修复] load_path.py 的 find_set_perm() 候选根是 ~/repos/sage、
# ~/test/sage、/d/ymq/repos/sage、/opt/sage 四个**外部 sage 根**加 PATH;本机
# (pipeline-app 部署形态)一个都不命中(/d/ymq/repos/sage 存在但对 pipeline
# 账号 Permission denied,os.path.exists 返回 False 故不会误选),于是
# register() 把 308 条路径全部记为 PENDING 并返回 rc=1 → 12.5 die。
# 本机真实副本是 /d/pipeline/pipeline-app/set_role_perm.py(全机唯一 find 命中,
# conf/config.json 完备)。该工具实测:
# · 可写库:`Registered: logined(logined) -> /pipeline-pbls/healthz_probe.ui`,rc=0
# (退出时 aiomysql 的 "Event loop is closed" 是 GC 噪音,不影响写入)
# · 先查后写天然幂等:连跑 3 次 permission / rolepermission 各仍只 1 行
# · 单次约 0.65s → 308 条约 200s,在本脚本 timeout 预算内
# 修法用 load_path.py **原生支持的 RBAC_SET_PERM 覆盖**,不改 load_path.py
# (该文件属上游 agent.develop,其候选口径面向 sage 根,改它会波及其他宿主)。
if [ -n "${RBAC_SET_PERM:-}" ] && [ -f "${RBAC_SET_PERM}" ]; then
log "RBAC 写入工具: ${RBAC_SET_PERM}(外部 RBAC_SET_PERM 指定)"
else
RBAC_SET_PERM=""
for _cand in /d/pipeline/pipeline-app/set_role_perm.py \
"${PIPE_APP_DIR:-/nonexistent}/set_role_perm.py" \
"$(command -v set_role_perm.py || true)"; do
if [ -n "${_cand}" ] && [ -f "${_cand}" ]; then RBAC_SET_PERM="${_cand}"; break; fi
done
if [ -z "${RBAC_SET_PERM}" ]; then
die "未找到 RBAC 写入工具 set_role_perm.py:load_path.py 候选根(~/repos/sage、~/test/sage、/d/ymq/repos/sage、/opt/sage)本机均不存在或不可读。处置:导出 RBAC_SET_PERM=<绝对路径> 后重跑(本机参考位置 /d/pipeline/pipeline-app/set_role_perm.py)"
fi
log "RBAC 写入工具: ${RBAC_SET_PERM}(本机候选探测)"
fi
export RBAC_SET_PERM
log "RBAC 一致性门禁: load_path.py --check"
CHK_RBAC=0
WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" --check || CHK_RBAC=$?
[ "${CHK_RBAC}" = "0" ] || die "RBAC 聚合门禁未通过(rc=${CHK_RBAC}):存在通配符或整模块聚合失败,终止部署"
log "RBAC 注册: load_path.py(逐条显式,无通配符)"
REG_RC=0
WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" || REG_RC=$?
if [ "${REG_RC}" != "0" ]; then
# set_role_perm.py 不在本机时 register() 返回 PENDING 清单且 rc=1;
# 这不是代码缺陷而是部署机缺工具,明确回显后终止(不静默放过 → 否则又是 403)。
die "RBAC 注册未成功(rc=${REG_RC}):set_role_perm.py 不可用或写库失败,端点将 401/403,终止部署"
fi
log "RBAC 注册完成"
# ---- 12.8 落地部署目录 DEPLOY_PATH(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)----
# 原脚本把 DEPLOY_PATH 读出来只用于最后一行日志 echo,从未创建/同步任何内容,
# 于是复测门禁 ④「ls -la /d/pbl/pbls」恒为 No such file or directory,
# 且 env/test.json 的 start_cmd(${DEPLOY_PATH}/venv/bin/python ${DEPLOY_PATH}/app/pbls.py)
# 指向的解释器与入口都不存在 → 门禁 ②③(进程数 0 / 端口未监听)不可能转绿。
# 处置:把应用树同步到 DEPLOY_PATH,venv 以软链复用(venv 内 shebang/符号链接是
# 绝对路径,复制换目录必坏,软链才安全),随后从 DEPLOY_PATH 启动。
if [ "${DEPLOY_PATH}" = "${APP_DIR}" ]; then
log "DEPLOY_PATH 与仓库目录同址(${DEPLOY_PATH}),跳过同步"
RUN_DIR="${APP_DIR}"
RUN_PY="${PY}"
RUN_ENTRY="${ENTRY}"
else
log "同步应用树到部署目录: ${DEPLOY_PATH}"
mkdir -p "${DEPLOY_PATH}"
# -a 保留权限/时间并**保留软链本身**(8c 建的模块 wwwroot 软链是绝对路径,换机仍可解析);
# 排除 venv/logs/run/.git:venv 不可搬移,logs/run 由目标侧自建,.git 不属于运行态。
# [Bug elS7VxwO7ufbPiPl-ucGA 修复] rsync 缺失兜底:部署机(pbl 用户,无 sudo)实测
# 可能未装 rsync,原实现直接调用 → command not found → set -e 下 die,
# 部署目录 /d/pbl/pbls 永远建不出来(复测门禁 ④ 恒红)。
# 无 rsync 时退化为 tar 管道复制(POSIX 必备,保留权限与软链本身),并显式清理运行态目录。
# [Bug r13-start2 修复·续] exclude 模式必须无尾斜杠:'venv/' 只匹配目录,venv 是软链时
# 不命中 → --delete 会把部署侧 venv 软链删掉(实测发生)。并排除 venv.stale-* 保留目录。
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \
--exclude 'venv' --exclude '.venv' --exclude 'venv.stale-*' --exclude '.venv.stale-*' \
--exclude 'logs' --exclude 'run' \
--exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \
"${APP_DIR}/" "${DEPLOY_PATH}/"
else
log "rsync 不可用,退化 tar 管道同步(保留权限/软链)"
( cd "${APP_DIR}" && tar cf - \
--exclude='./venv' --exclude='./.venv' --exclude='./logs' --exclude='./run' \
--exclude='./.git' --exclude='__pycache__' --exclude='*.pyc' . ) \
| ( cd "${DEPLOY_PATH}" && tar xf - )
# tar 路径无 --delete 语义:手工清理运行态残留,避免旧文件污染
rm -rf "${DEPLOY_PATH}/.git" "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run" 2>/dev/null || true
fi
mkdir -p "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run"
# venv 软链复用(目标侧 start_cmd 要求 ${DEPLOY_PATH}/venv/bin/python 可用)
# [Bug r13-start2 修复] 原条件 [ ! -e venv ] 对部署目录里**真实存在的陈旧 venv 目录**
# 恒为假 → 软链永远建不出来 → 启动实际用旧 venv 的旧 rbac(无 event_dispatcher 判空守卫),
# load_rbac 即 AttributeError: NoneType.bind,进程数=0/healthz=000。
# 处置:venv 是目录(非软链)或软链目标不符时,同目录 rename 保留为 venv.stale-<ts>
# 再建软链——不跨文件系统 mv(会退化 copy+delete 失败),不 rm(保留证据可回退)。
if [ -L "${DEPLOY_PATH}/venv" ] && [ "$(readlink -f "${DEPLOY_PATH}/venv")" = "$(readlink -f "${VENV_DIR}")" ]; then
log "部署目录 venv 软链已指向 ${VENV_DIR},跳过"
else
if [ -e "${DEPLOY_PATH}/venv" ] || [ -L "${DEPLOY_PATH}/venv" ]; then
STALE="${DEPLOY_PATH}/venv.stale-$(date +%Y%m%d%H%M%S)"
mv "${DEPLOY_PATH}/venv" "${STALE}" || die "陈旧 venv 无法移开: ${DEPLOY_PATH}/venv -> ${STALE}"
log "陈旧 venv 已保留为 ${STALE}(属主 $(stat -c %U:%G "${STALE}" 2>/dev/null || echo unknown))"
fi
ln -sfn "${VENV_DIR}" "${DEPLOY_PATH}/venv"
log "venv 软链建立: ${DEPLOY_PATH}/venv -> ${VENV_DIR}"
fi
[ -x "${DEPLOY_PATH}/venv/bin/python" ] || die "部署目录 venv 不可用: ${DEPLOY_PATH}/venv/bin/python"
[ -f "${DEPLOY_PATH}/app/${APP_NAME}.py" ] || die "部署目录缺入口: ${DEPLOY_PATH}/app/${APP_NAME}.py"
[ -f "${DEPLOY_PATH}/conf/config.json" ] || die "部署目录缺配置: ${DEPLOY_PATH}/conf/config.json"
log "部署目录就位: ${DEPLOY_PATH}(app/conf/wwwroot/scripts + venv 软链 + logs/run)"
RUN_DIR="${DEPLOY_PATH}"
RUN_PY="${DEPLOY_PATH}/venv/bin/python"
RUN_ENTRY="${DEPLOY_PATH}/app/${APP_NAME}.py"
fi
# ---- 13. 启动应用(唯一入口 + 唯一端口,从部署目录启动)----
# 先停旧进程(幂等重跑不撞端口):按 pid 文件 + 入口路径双口径清理。
if [ -f "${RUN_DIR}/run/${APP_NAME}.pid" ]; then
OLD_PID="$(cat "${RUN_DIR}/run/${APP_NAME}.pid" 2>/dev/null || true)"
if [ -n "${OLD_PID:-}" ] && kill -0 "${OLD_PID}" 2>/dev/null; then
log "停止旧进程 pid=${OLD_PID}"
kill "${OLD_PID}" 2>/dev/null || true
sleep 2
fi
fi
pkill -f "${RUN_ENTRY}" 2>/dev/null || true
sleep 1
log "启动 ${APP_NAME} 于端口 ${PORT}(cwd=${RUN_DIR})"
# cwd 必须是 RUN_DIR:conf/config.json 的 website.root="wwwroot" 是相对路径,
# 从别处启动会解析不到 wwwroot → 所有 .ui/.dspy 404(含 /healthz)。
( cd "${RUN_DIR}" && nohup "${RUN_PY}" "${RUN_ENTRY}" \
> "${RUN_DIR}/logs/${APP_NAME}.out" 2>&1 & echo $! > "${RUN_DIR}/run/${APP_NAME}.pid" )
sleep 6
# 进程 + 端口双自证(对应复测门禁 ②):起不来就把日志尾部打出来再 die,
# 避免「HTTP=000 但没人知道为什么」的四轮同构复测。
PROC_N="$(ps -ef | grep -F "${APP_NAME}.py" | grep -v grep | wc -l | tr -d '[:space:]')"
log "进程数=${PROC_N}"
if [ "${PROC_N}" = "0" ]; then
echo "----- ${RUN_DIR}/logs/${APP_NAME}.out (tail 60) -----"
tail -60 "${RUN_DIR}/logs/${APP_NAME}.out" 2>/dev/null || true
echo "----------------------------------------------------"
die "应用进程未存活(ps 计数 0),门禁 ② 不成立,详见上方日志尾部"
fi
# ---- 14. 健康检查(两条口径都验:/healthz = 复测门禁 ①,/api/public/health = 原自检口径)----
check_http() {
curl -s -m 8 -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || echo 000
}
HZ_CODE="$(check_http "http://127.0.0.1:${PORT}/healthz")"
AH_CODE="$(check_http "http://127.0.0.1:${PORT}/api/public/health")"
IP_CODE="$(check_http "http://${SSH_HOST:-127.0.0.1}:${PORT}/healthz")"
log "健康检查: /healthz=${HZ_CODE} /api/public/health=${AH_CODE} 外网口 /healthz=${IP_CODE}"
if [ "${HZ_CODE}" != "200" ] || [ "${AH_CODE}" != "200" ]; then
echo "----- ${RUN_DIR}/logs/${APP_NAME}.out (tail 60) -----"
tail -60 "${RUN_DIR}/logs/${APP_NAME}.out" 2>/dev/null || true
echo "----------------------------------------------------"
die "健康检查失败(/healthz=${HZ_CODE}, /api/public/health=${AH_CODE}),门禁 ① 不成立"
fi
# 表数终态复述(对应复测门禁 ③):部署完成后 pbl 库必须**逐张在场** 38 张关键表
# (手写 DDL 32 + 模块 models 链 6:governance 4 表含补建 pbl_privacy_consent + template 2 表)。
# [2026-09-28 方案 A] 断言从「LIKE 计数」升级为「关键表逐张点名」——裸计数会被
# 其它模块启动期自建表/迁移备份表干扰(计数虚高同样判死),点名在场才是确定性门禁。
MOD6_TABLES="'pbl_tenant','pbl_role_binding','pbl_audit_log','pbl_privacy_consent','pbl_template','pbl_template_item'"
REQUIRED_TABLES="${MOD6_TABLES},${HAND_TABLES}"
FINAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN (${REQUIRED_TABLES})" "${DB_NAME}")"
TOTAL_PBL="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name LIKE 'pbl\\_%'" "${DB_NAME}")"
log "终态表数: 关键表 ${FINAL_TABLES}/38 在场(pbl_* 总计 ${TOTAL_PBL} 张,含模块自建/扩展表)"
[ "${FINAL_TABLES}" = "38" ] || {
MISSING="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT GROUP_CONCAT(t.n ORDER BY t.n) FROM (SELECT 'pbl_tenant' AS n UNION ALL SELECT 'pbl_role_binding' AS n UNION ALL SELECT 'pbl_audit_log' AS n UNION ALL SELECT 'pbl_privacy_consent' AS n UNION ALL SELECT 'pbl_template' AS n UNION ALL SELECT 'pbl_template_item' AS n UNION ALL SELECT 'pbl_blueprint' AS n UNION ALL SELECT 'pbl_blueprint_version' AS n UNION ALL SELECT 'pbl_blueprint_lock' AS n UNION ALL SELECT 'pbl_subobject' AS n UNION ALL SELECT 'pbl_subobject_attr' AS n UNION ALL SELECT 'pbl_subobject_rel' AS n UNION ALL SELECT 'pbl_subobject_asset' AS n UNION ALL SELECT 'pbl_subobject_ref' AS n UNION ALL SELECT 'pbl_subobject_review' AS n UNION ALL SELECT 'pbl_blueprint_tag' AS n UNION ALL SELECT 'pbl_blueprint_publish' AS n UNION ALL SELECT 'pbl_validation_ruleset' AS n UNION ALL SELECT 'pbl_validation_run' AS n UNION ALL SELECT 'pbl_validation_finding' AS n UNION ALL SELECT 'pbl_compile_job' AS n UNION ALL SELECT 'pbl_compile_artifact' AS n UNION ALL SELECT 'pbl_game_definition' AS n UNION ALL SELECT 'pbl_compile_log' AS n UNION ALL SELECT 'pbl_agent_session' AS n UNION ALL SELECT 'pbl_agent_message' AS n UNION ALL SELECT 'pbl_agent_tool_call' AS n UNION ALL SELECT 'pbl_agent_arbitration' AS n UNION ALL SELECT 'pbl_artifact' AS n UNION ALL SELECT 'pbl_evidence' AS n UNION ALL SELECT 'pbl_evidence_idem' AS n UNION ALL SELECT 'pbl_evidence_binding' AS n UNION ALL SELECT 'pbl_rubric' AS n UNION ALL SELECT 'pbl_rubric_criterion' AS n UNION ALL SELECT 'pbl_assessment_report' AS n UNION ALL SELECT 'pbl_domain_binding' AS n UNION ALL SELECT 'pbl_analytics_snapshot' AS n UNION ALL SELECT 'pbl_compile_task' AS n) t WHERE NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name=t.n)" "${DB_NAME}" 2>/dev/null)"
die "关键表在场 ${FINAL_TABLES} != 38(32 手写 + 6 模块 models 链),门禁 ③ 不成立——缺表: ${MISSING:-未知(mysql查询失败)}"
}
log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH},/healthz=200,关键表 ${FINAL_TABLES}/38)"