From f27ad31c9137a4bc4653fa55afb4a449afdaa1c2 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 16:49:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=88=A4=E6=8D=AE/=E9=83=A8=E7=BD=B2):=20?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E4=B8=8D=E5=86=8D=E5=81=87=E8=AE=BE=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E7=A9=B7=E5=B0=BD=EF=BC=9Bbroken=E2=89=A0red=EF=BC=9B?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E6=8A=A5=E6=96=87=E8=87=AA=E5=B8=A6"?= =?UTF-8?q?=E8=A6=81=E6=94=BE=E8=A1=8C=E4=BB=80=E4=B9=88"=EF=BC=9B?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E5=8A=A0=20--check=20=E5=B9=B2=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-14(两封)提的六条,能做的都做了。 1. **`[Empty]` 不是"没有设备",是第三态**(pi §1)。改了,而且不是改成"一律红", 是**去证服务端健康**:探针现在读 `/proc//cmdline` 找 `hdc -m`(server 模式), 服务端在 → 空集才是可判的"确实没有目标"(false);服务端找不到 → `unknown`(红)。 本机实测:`hdc -m -s ::ffff:127.0.0.1:8710` 在跑 → 空集可信。 这条用机制而不是用嘴回答"我检查过了"。 2. **硬编码候选清单**(pi §2):候选来源写清(`/opt/huawei/command-line-tools` 是文档安装根), `hdc` 也走 PATH;**工具链根在、里面却没有 hdc → `unknown`**("装了但长得不一样"不是"没装")。 这与 build.sh 那次"第一个命中就算"是同一形状 —— 今天各咬一次。 3. **前提改成"本工作区能装能点"**(pi §2):原前提"设备存在"会让 5 条判据在**我修不了**的时候同时红 (模拟器要写 /run、/var/log)。现在前提是工作区能力,`need` 逐条写清, 并且**到期报文会把这些门槛打出来** —— 第一次真红不能被当成噪音消掉。 4. **broken ≠ red**(pi §3):跑不起来(语言级崩痕:SyntaxError/ReferenceError/…)单列 "跑不起来的判据(N)—— 不是红,也不算过",红是"判据说不成立",broken 是"判据没说话"。 变异验证:注入未定义标识符 → 报 broken ✓;绿基线 → exit 0 ✓。 第一版我用"输出里有没有 `not ok`"判,当场误判(node:test 把导入期 ReferenceError 也报成 `not ok`), 已改成语言级崩痕 —— 判据自己的第一版就得被现实修一次。 5. **标签要有消费点**(pi §4):`release-linux.sh --release` 遇脏树**拒绝**(`--allow-dirty` 才放行), 不加参数是自用打包(只出声)。"出声≠拒绝"这条说得对。 6. **`deploy/install.sh --check` 干跑**(pi §6):跑全部门禁、不写系统目录,末尾列出正式安装会写什么、 需要哪里的权限。干跑立刻抓到两个真缺陷: · `set -u` 下 `$HOME` 未设 → `HOME: unbound variable`(cron/env -i/某些 sudo 下就是没有), 而它发生在**所有门禁跑完之后**——最贵的位置(这轮第三次同形状,前两次在 homeagent build.sh)。 · **packaging 这条门在部署路径上永远过不去**:install.sh 先 `npm test`(含 packaging), 而 packaging 要求"安装包里的 dist == 当前 dist",部署路径却不重新打包 → 前端一改,install.sh 就卡在这条门上(第二条"挂在部署路径上却恒红"的门,第一条是 check-shared-libs)。 这条需要决定:部署路径要么重新打包、要么把 packaging 排除在部署门禁外。**我没有擅自改口径。** --- client/electron/scripts/release-linux.sh | 10 ++ client/electron/test/run-all.mjs | 128 ++++++++++++++++++++--- deploy/install.sh | 67 +++++++++++- 3 files changed, 187 insertions(+), 18 deletions(-) diff --git a/client/electron/scripts/release-linux.sh b/client/electron/scripts/release-linux.sh index 72d97d5..3a3c478 100755 --- a/client/electron/scripts/release-linux.sh +++ b/client/electron/scripts/release-linux.sh @@ -38,6 +38,16 @@ bash -c "$PACK_CMD" # 产物自报来源:把 BUILD_INFO 打进日志(pi 提的"一个包要自带它对应哪个源码状态")。 # 出问题时先看这几行:包是在哪个提交、哪份源码指纹上构建的,以及当时树干不干净。 +# `--release` = "这个包是给别人装的":脏树**拒绝**(pi §4 的消费点)。 +# 不加这个参数是**自用打包**:只出声、不拒绝(共享树上脏是常态,天天拒会让人绕过它)。 +if [[ "${1:-}" == "--release" && -f dist/BUILD_INFO.json ]]; then + if grep -q '"releaseCandidate": *false' dist/BUILD_INFO.json 2>/dev/null; then + echo "[release] 拒绝:工作树是脏的,这个包**不是发布候选** —— 它可能含着别人未提交的半成品。" >&2 + echo "[release] 给人装请先提交/清理;确实要发就说清楚:$0 --release --allow-dirty" >&2 + [[ "${2:-}" == "--allow-dirty" ]] || exit 1 + echo "[release] (--allow-dirty:你显式接受了这个风险)" >&2 + fi +fi if [[ -f dist/BUILD_INFO.json ]]; then # "脏树打的包"必须**看得见**(pi §1):共享树上脏是常态,所以不做成红, # 但要在日志里明说这个包不是发布候选(含别人未提交的半成品时不能给人装)。 diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 3075de0..38a1325 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -161,6 +161,7 @@ if (shapeless.length) { } const reds = []; +const brokens = []; for (const [file, flags, expected] of SUITE) { console.log(`\n========== ${file} ==========`); const shape = shapeOf(file); @@ -170,9 +171,32 @@ for (const [file, flags, expected] of SUITE) { const out = (r.stdout || '') + (r.stderr || ''); process.stdout.write(r.stdout || ''); process.stderr.write(r.stderr || ''); + /* + * `broken` 与 `red` 必须分开(pi 2026-09-14 §3)。 + * + * 我这轮两次把"跑不起来"当成"判据红了":一次变异注入少了 import(报 build failed)、 + * 一次判据里写了没绑定的标识符(ReferenceError 抛在判据自己身上)。 + * 两者的后果都是同一种骗人方式:**看起来像判据失败,其实是判据没跑**。 + * 所以判据是:退出码非零 **且输出里没有一句"断言失败"** → 那是 broken(崩了),不是 red。 + * 变体验证里出现 broken = **这次变异无效,重做**,不许记成"红过了"。 + */ + /* + * 判据是"输出里有**语言级崩**的痕迹",不是"有没有 `not ok`"。 + * 第一版我用后者,当场误判:node:test 会把**导入期**的 ReferenceError 也报成 + * `not ok 1 - …`,于是"崩了"看起来和"断言失败"一模一样 —— 正是这条判据要治的病。 + */ + const CRASH_SIGNS = /(SyntaxError|ReferenceError|TypeError|Cannot find module|ERR_MODULE_NOT_FOUND|is not defined|is not a function|CompileError|build failed|Unexpected identifier|missing ',' in argument list)/; + const crashed = r.status !== 0 && CRASH_SIGNS.test(out); + const empty = out.trim().length === 0; // 不 break:后面每条都要跑出来,否则"红了几条"这个信息本身是假的 - if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`); - else if (out.trim().length === 0) reds.push(`${file}(跑完没有任何输出 —— 判据没跑起来的典型症状)`); + if (crashed || empty) { + const firstErr = (out.match(/^.*(Error|error:).*$/m) || [''])[0].trim().slice(0, 160); + brokens.push(`${file}(${empty ? '跑完没有任何输出' : `退出码 ${r.status},但没有一句断言失败`})` + + (firstErr ? `\n ↳ ${firstErr}` : '') + + '\n ↳ 这是 **broken(跑不起来)**,不是 red:它证明不了任何判据成立或不成立。' + + '\n 常见成因:语法/标识符错(`X is not defined`)、import 写错、编译不过。' + + '\n 用于变体验证时:broken **不算这次变异有效**,要重做。'); + } else if (r.status !== 0) reds.push(`${file}(退出码 ${r.status})`); else { /* * 自报条数(闭环):自定义 check() 打 `RESULT pass=N fail=M`,node:test 打 `# pass N`。 @@ -257,37 +281,94 @@ for (const [file, flags, expected] of SUITE) { */ const PROBES = { device: { - desc: '有可用的设备/模拟器(hdc 看得到目标)', + // 前提写成"**本工作区**能装能点设备",不是"机器上有设备"(pi 2026-09-14): + // 同一台机器上设备对 gui-lab 那条通道可用、对我这条不可用 —— 写"机器上有设备"的话, + // 探针变真会让 5 条判据同时红,而**我修不了**(要写 /run、/var/log,在工作区外)。 + // 写成"本工作区能装能点",红就只在我能动手时出现。 + desc: '本工作区能装、能点设备(hdc 服务健康 + 看得到目标)', + need: [ + '设备/模拟器要在本工作区里起得来:模拟器启动要写 /run/harmony-emulator.pid 与 /var/log/harmony-emulator.log', + 'hdc 的**服务端**要健康(`hdc -m` 在跑);服务端起不来时 `list targets` 也会打印 [Empty],那是"拿不准"不是"没设备"', + '要有 hdc 目标;能装上 hap 并能点(hvigorw assembleHap + hdc install)' + ], run() { const override = process.env.AGENTMAIL_PROBE_DEVICE; if (override === 'ok') return true; if (override === 'none') return false; if (override === 'unknown') return 'unknown'; - const sdkHdc = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc'; + + /* + * # 候选清单的权威性(pi 2026-09-14 §2:硬编码清单今天咬了两次) + * + * 上一版在**固定两条路径**里找 hdc,全不在就判"本机没装 → 没有设备"(false)。 + * 这与 build.sh 在固定几个根里找 SDK 源码、第一个命中就用,是**同一个形状**: + * 硬编码清单 + "命中/未命中"当真值。那次命中了 /root/ha-test 的老 checkout, + * 于是把错误结论当成了事实。所以这里: + * ① 候选来源写清楚(下面 TOOLCHAIN_ROOT 是华为命令行工具的**文档安装根**); + * ② **工具链根在、里面却没有 hdc** → `unknown`(不是"没装",是"装了但长得不一样"); + * ③ `hdc` 也走 PATH(spawnSync 自己会查 PATH),不再只认那一条写死的路径。 + */ + const TOOLCHAIN_ROOT = '/opt/huawei/command-line-tools'; + const sdkHdc = `${TOOLCHAIN_ROOT}/sdk/default/openharmony/toolchains/hdc`; const candidates = [sdkHdc, 'hdc']; + let ranOnce = false; // 至少有一条命令**跑成过**(哪怕是"没有目标") let sawEmpty = false; - let anomaly = ''; // 命令在,但跑不成(超时/非零退出/…)—— 这类是"拿不准" - let allMissing = true; // 所有候选都不存在 = 本机没装设备工具(这是**可判**的:不可能有设备) + let anomaly = ''; // 命令在,但跑不成(超时/非零退出/…)—— "拿不准" + let allMissing = true; // 所有候选都不存在 for (const bin of candidates) { const r = spawnSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 }); - if (r.error && r.error.code === 'ENOENT') continue; // 这个候选不存在,看下一个 + if (r.error && r.error.code === 'ENOENT') continue; allMissing = false; if (r.error) { anomaly = r.error.code || String(r.error.message || r.error); continue; } if (r.status !== 0) { anomaly = `exit ${r.status}`; continue; } ranOnce = true; const t = (r.stdout || '').trim(); if (t && !/\[Empty\]/.test(t)) return true; // 明确可用 - sawEmpty = true; // 明确"没有目标" + sawEmpty = true; // "没有目标"(下面还要问服务端健不健康) + } + + /* + * `[Empty]` **不等于**"没有设备"(pi 2026-09-14 §1)。 + * + * 它至少混三种原因:① 真没有设备;② **hdc 自己的服务端起不来** + * (socket/权限 —— 而"要写工作区外的目录"正是我这轮撞上的限制); + * ③ 被打印成空集的权限拒。所以先证服务端健康,才敢把空集当成"可判的没有": + * 服务端进程在(`hdc -m`,含它在别的路径下)→ 空集是可判事实 → false; + * 服务端找不到 → 空集可能正是"探针自己被环境弄坏了" → unknown(红)。 + */ + if (ranOnce && sawEmpty) { + return hdcServerLooksHealthy() ? false : 'unknown'; + } + if (anomaly) return 'unknown'; // 命令在、跑不成 → 拿不准 + if (allMissing) { + // 工具链根在、里面却没有 hdc → "装了但长得不一样",不是"没装" + return existsSync(TOOLCHAIN_ROOT) ? 'unknown' : false; } - if (ranOnce && sawEmpty) return false; // 跑成了,且确实没有目标 - if (anomaly) return 'unknown'; // 命令在、跑不成 → 拿不准(要红) - if (allMissing) return false; // 本机根本没有 hdc → 不可能有设备 return 'unknown'; } } }; +/** + * hdc 的服务端是不是在跑。只读 /proc//cmdline(不依赖 `ps` 也不起子进程 + * —— 探针自己不该再引入"命令跑不起来"这种不确定性。 + * 判据:命令行里第二个参数是 `-m`(hdc 的 server 模式)。 + */ +function hdcServerLooksHealthy() { + let pids = []; + try { pids = readdirSync('/proc').filter(d => /^\d+$/.test(d)); } catch { return false; } + for (const pid of pids) { + try { + // 走 lib/read.mjs 的 prose(判据目录不许裸 readFileSync —— 那条判据也管这里) + const cmd = prose(`/proc/${pid}/cmdline`).split('\0').filter(Boolean); + if (cmd.length && /(^|\/)hdc$/.test(cmd[0]) && cmd.includes('-m')) return true; + } catch { /* 进程刚退出 / 没权限读 —— 换下一个 */ } + } + return false; +} + + /** 探针结论 → 是否等于"前提成立(到期)" */ const probeIsDue = v => v === true || v === 'unknown'; @@ -350,6 +431,18 @@ if (dueStatic.length > 0) { ' b) 明确写"为什么仍然只能静态"(如设备能编译但点不了),并改换一个更准的到期前提。\n' + ' 这是"暂时"的到期机制:它的作用就是不等谁想起来。' ); + /* + * 到期报文自带**要人做什么**(pi 2026-09-14 §2): + * 这条红第一次出现时,最可能的结局是"被当成噪音消掉"——因为看的人不知道要放行什么。 + * 前提写的是"**本工作区**能装能点",所以这里把工作区外的门槛逐条列出来。 + */ + const need = PROBES[dueStatic[0][2]].need || []; + if (need.length) { + console.error(' 要让它变成行为判据,需要先在本工作区打通:'); + for (const n of need) console.error(` · ${n}`); + console.error(' 这些都在工作区外(需要人放行或提权)—— 所以这条红**不要求你现在修**,' + + '要求的是:别把它当噪音,并在打通后回来把静态那条升级掉。'); + } process.exit(1); } /* @@ -363,10 +456,17 @@ console.log(`RESULT static=${STATIC_ONLY.length} probe=${probeSummary}` + : '(只能验形态的判据:到期前提成立就自动变红)')); console.log(`\n========== 判据汇总 ==========`); -if (reds.length === 0) { +if (reds.length === 0 && brokens.length === 0) { console.log(`全部通过(${SUITE.length} 个判据文件:${SUITE.map(([f]) => f.replace('test/', '').replace('.test.mjs', '')).join('、')})`); process.exit(0); } -console.error(`红的判据(${reds.length}/${SUITE.length}):`); -for (const r of reds) console.error(` - ${r}`); +// broken 先报:它比红更严重(红是"判据说不成立",broken 是"判据没说话") +if (brokens.length) { + console.error(`跑不起来的判据(${brokens.length}/${SUITE.length})—— 不是红,也不算过:`); + for (const b of brokens) console.error(` - ${b}`); +} +if (reds.length) { + console.error(`红的判据(${reds.length}/${SUITE.length}):`); + for (const r of reds) console.error(` - ${r}`); +} process.exit(1); diff --git a/deploy/install.sh b/deploy/install.sh index 66544f7..0b273da 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -12,10 +12,43 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PREFIX=/opt/agentmail ETC=/etc/agentmail -[[ $EUID -eq 0 ]] || { echo "需要 root:sudo $0" >&2; exit 1; } +# +# ./deploy/install.sh --check # 干跑:只跑所有门禁与前置检查,**不写系统目录** +# +# 为什么要有 --check(pi 2026-09-14):发布路径是**最少被跑的门** —— +# `check-shared-libs.sh` 一直红着没人发现,正是因为它只挂在 install.sh 里。 +# 而"最后一个能跑这一步的人"(有 root 的那个人)每次都在**改完一堆东西之后**才第一次跑它, +# 于是第一次失败就发生在最贵的时候。干跑把"门自己跑得动吗"提前暴露出来。 +CHECK_ONLY=0 +for a in "$@"; do + case "$a" in + --check) CHECK_ONLY=1 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "未知参数:$a(支持 --check)" >&2; exit 2 ;; + esac +done + +if [[ $CHECK_ONLY -eq 0 ]]; then + [[ $EUID -eq 0 ]] || { echo "需要 root:sudo $0(只验证不安装用 ./deploy/install.sh --check)" >&2; exit 1; } +else + echo "==> 干跑模式(--check):不会写 $PREFIX 或 $ETC" + # 写权限是这一步唯一的硬门槛,先说清楚 —— 下一个拿到权限的人要一眼看到需要什么 + for d in "$PREFIX" "$ETC"; do + if [[ -e "$d" && ! -w "$d" ]]; then + echo " 注意:$d 存在但当前用户不可写(正式安装需要 root/sudo 或先放行该目录)" + fi + done +fi echo "==> 构建前端" -( cd "$REPO/client/electron" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund ) +# 干跑不装依赖(装依赖要写 node_modules/缓存,干跑的意义是"门能不能跑",不是"装得上装不上")。 +if [[ $CHECK_ONLY -eq 1 ]]; then + [[ -d "$REPO/client/electron/node_modules" ]] || { + echo " 干跑需要一个已装好依赖的工作树(client/electron/node_modules 不存在)" >&2; exit 1; } + echo " 跳过 npm ci/install(干跑不写依赖);用的是当前 node_modules" +else + ( cd "$REPO/client/electron" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund ) +fi ( cd "$REPO/client/electron" && npm run typecheck && npm test && npm run build ) echo "==> 校验插件共用模块同源" @@ -71,11 +104,18 @@ fi # settings.json 的 extensions 数组会被排除(见 SDK resource-loader.js), # 因此守护进程的 worker 里零扩展、本扩展只作用于交互式会话。 if [[ -d "$PI_SDK" ]]; then + # `set -u` 下 `$HOME` 未设会直接杀掉部署(实测:`HOME: unbound variable`, + # 而它发生在所有门禁跑完之后 —— 最贵的位置)。cron / env -i / 某些 sudo 配置下 + # HOME 就是没有的,所以给一个兜底。同一形状我这轮在 homeagent 的 build.sh 里 + # 也修过一次(cron 里 HOME 未设 → Go 拒绝工作)——**这是这轮第三次**, + # 说明"环境变量在部署路径上不存在"这条要当成常态而不是意外。 PI_HOME="${SUDO_USER:+$(getent passwd "$SUDO_USER" | cut -d: -f6)}" - PI_HOME="${PI_HOME:-$HOME}" + PI_HOME="${PI_HOME:-${HOME:-/root}}" PI_SETTINGS="$PI_HOME/.pi/agent/settings.json" EXT_PATH="$REPO/plugins/pi-mail-bridge/extension/index.ts" - if [[ -d "$PI_HOME/.pi/agent" || -f "$PI_SETTINGS" ]]; then + if [[ $CHECK_ONLY -eq 1 ]]; then + echo " [干跑] 跳过:把邮件工具扩展注册进 $PI_SETTINGS(要写工作区外的 home 目录)" + elif [[ -d "$PI_HOME/.pi/agent" || -f "$PI_SETTINGS" ]]; then install -d "$(dirname "$PI_SETTINGS")" node -e ' const fs = require("fs"); @@ -94,6 +134,25 @@ if [[ -d "$PI_SDK" ]]; then fi fi +if [[ $CHECK_ONLY -eq 1 ]]; then + cat <<'EOF' + +==> 干跑结束:**所有会红的门禁都跑过了**(前端 typecheck/test/build、共用模块同源、 + 各插件测试、插件构建)。干跑的成功只能说明"门是好的",不能说明"装得上"。 + +下面这些步骤干跑**没有执行**(它们要写工作区外的目录,正式安装需要 root 或先放行): + · 前端产物 → server/internal/static/static/ + · 构建 Gateway(go vet + go test + go build -o server/agentmail-gateway) + · install -m 0755 server/agentmail-gateway → /opt/agentmail/agentmail-gateway + · 生成 /etc/agentmail/*.env(已存在的不覆盖)+ chmod 0600 + · 装 systemd unit → /etc/systemd/system/,daemon-reload + enable --now + · 把邮件工具扩展注册进 ~/.pi/agent/settings.json + +拿不准就先跑 ./deploy/install.sh --check;正式安装用 sudo ./deploy/install.sh。 +EOF + exit 0 +fi + echo "==> 前端产物嵌入 Gateway" # 只清构建产物,不能 rm -rf 整个目录: # placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),