Files
MailUI4Agents/deploy/redeploy-plugin.sh
JianFeeeee b53afd4523 chore(deploy): 部署漂移检查器 —— 快照是不是还等于仓库、进程到底在跑哪份代码
「部署脚本跑过了」不等于「线上跑的是当前代码」。三种各自独立的漂移,谁都不会
在意,直到出问题时才发现线上是几天前的行为:

  1. 仓库改了、快照没重新部署(快照是独立副本)
  2. **软链切了、进程没重启** —— `current` 只是个符号链接,切换它不会重载
     已在跑的进程,进程持有的是启动那一刻加载进内存的代码
  3. 单元/配置改了、没 daemon-reload 或没重装

`node deploy/check-deploy-drift.mjs`(`--self-check` / `--json`)把三件事变成
可判定的:逐字节比内容(**不用 diff** —— 本机 PATH 上那个是鸿蒙工具链里的,
对内容不同的文件仍返回 0)、读进程真实 argv、比进程启动时刻与软链切换时刻。

## 判据必须按宿主真实的加载方式分开写(这是踩出来的)

第一版对四个宿主用同一套判据(「单元 ExecStart 指向 current」+「进程 argv 里有
快照路径」),结果 **opencode 与 dsh 双双假红**:它们的插件由**宿主进程**加载,
argv 里永远只有宿主自己的可执行文件。永假条件在检查器里表现为「稳定的红灯」,
人会学会忽略它。

| 宿主 | 谁加载 | 从哪里读 | 切换后要重启吗 |
|---|---|---|---|
| pi / zcode | 自己的进程 | unit 的 ExecStart | 要(启动时加载) |
| dsh | dsh 宿主 | profile `link:` → `node_modules` 软链 | 要(服务启动时) |
| opencode | opencode 宿主 | `opencode.jsonc` 的 `plugin:` | 不要(会话创建时惰加载) |

## 判据自检也修了一次

第一版自检「宿主表:运行时加载的宿主必须标记需要重启」写的是
`h.restartOnSwitch !== false`,而 `undefined` 也被放过 —— 于是 pi/zcode 的
`restartOnSwitch` 缺省成 undefined、落进「惰加载」分支、判据**永真**。
现在这条判据抽成纯函数 `judgeRestart`,自检用**行为用例**盖住:
旧进程 + 启动时加载的宿主必须判红、惰加载的宿主不判红、切换后启动的一律放行、
读不到启动时刻时不据此判红、容差内不判红。

## 扰动实验(在真实对象上证明判据会红)

- 改一个仓库运行文件 → pi 的「① 运行文件与仓库一致」变红,恢复即绿 ✓
- 只把 pi 的软链 mtime 拨到刚刚(模拟「切了没重启」)→ 「④」变红并指出原因 ✓
- 把 dsh 与 opencode 的软链都拨动 → **dsh 红、opencode 绿**(惰加载该保持绿)✓
  全部拨动均已在实验后还原并复核为绿。

## 当前结论

四个宿主都在跑当前代码(pi/opencode/dsh 的快照自 11:45 起未变,其间无提交
动过它们的运行文件;zcode 是 18:57 的快照)。「三桥加载规范化」的切换本身
早已完成,缺的是这条可复跑的判据 —— 补齐了。

另外修了 `redeploy-plugin.sh` 的一个静默问题:`usage()` 按**行号范围**截取头部
注释(`sed -n '2,40p'`),我这次的注释把退出码那段挤到 40 行之后,`--help`
就少了半页。已同步范围并就地记下这个陷阱。
2026-09-12 20:39:22 +08:00

321 lines
15 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
#
# 把 JS 桥插件部署成**仓库外的快照**,并原子切换 —— 替代「让生产直接跑仓库工作区」。
#
# # 为什么必须有这个脚本
#
# 在此之前的实际形态(实测):
#
# pi systemd: ExecStart=node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
# opencode opencode.jsonc: "file:///home/program/agentmail/plugins/opencode-mail-bridge"
# dsh profiles/web/package.json: "dsh-mail-bridge": "link:/home/program/.../dsh-mail-bridge"
#
# 三个桥跑的都是**仓库工作区**。于是:
#
# - 一次编辑 + 重启 = 上线,没有构建、没有评审、没有版本;
# - 没有回滚目标:网关有 `.bak-<时间戳>`,三个桥一个都没有;
# - `git checkout` / `git stash` / 半成品编辑会**静默**改变线上行为;
# - 仓库同时兼作构建目录(`dist/`、`node_modules/` 都在里面)。
#
# 对照homeagent 插件本来就是这个规范形态(跑的是
# `/home/newqqagent/plugins/homeagent-mail-bridge/plugin.bin` 部署副本),
# 所以这里做的不是发明新办法,而是把已有的那个形态推广到三个 JS 桥。
#
# # 纪律(与 redeploy-gateway.sh 同一套)
#
# 1 前置断言 → 2 staging 拷贝 → 3 门禁(语法/构建)→ 4 原子切换
# → 5 重启 → 6 后置验证(服务 active **且** 桥日志出现「已接入」)
# → 任一步失败即切回 .prev 并重启
#
# # 事后自查:`deploy/check-deploy-drift.mjs`
#
# 这个脚本管的是「这一刻切没切对」;那份快照**过一段时间之后**是否还等于
# 仓库 HEAD、以及进程是否真的在跑那份代码是另一个问题需要另一个检查
#
# node deploy/check-deploy-drift.mjs # 四个宿主一起看
# node deploy/check-deploy-drift.mjs --self-check # 先证明判据能发现差异
#
# 它把三种**各自独立**的漂移变成可判定的:仓库改了没重部署;软链切了没重启
# `current` 只是个符号链接,切换它**不会**重载已在跑的进程);单元/配置改了
# 没 daemon-reload 或没重装。四种宿主的插件加载方式不同,判据也按各自的来
# (自有进程看 argvdsh 看 node_modules 软链opencode 惰加载不强制重启)。
#
# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题
#
set -uo pipefail
REPO=${REPO:-/home/program/agentmail}
GATEWAY_DB=${GATEWAY_DB:-/opt/agentmail/data/agentmail.db}
DEST_ROOT=${DEST_ROOT:-/opt/agentmail/plugins}
STAGE_ONLY=0
PLUGIN=""
# 头部注释的**行号范围**用来当 usage改注释时必须同步上下文
# 否则 `--help` 会默默截断(不报错,只是少一段)——这本身就是个静默漂移。
usage() { sed -n '2,44p' "$0"; }
for arg in "$@"; do
case "$arg" in
-h|--help) usage; exit 0 ;;
--stage-only) STAGE_ONLY=1 ;;
pi|opencode|dsh|zcode) PLUGIN="$arg" ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
done
[ -n "$PLUGIN" ] || { echo "用法: $0 <pi|opencode|dsh|zcode> [--stage-only]" >&2; exit 2; }
say() { printf '\n=== %s\n' "$*"; }
ok() { printf ' [ OK ] %s\n' "$*"; }
bad() { printf ' [FAIL] %s\n' "$*"; }
warn() { printf ' [WARN] %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
TS=$(date +%Y%m%d-%H%M%S)
SRC="$REPO/plugins/$PLUGIN-mail-bridge"
DEST="$DEST_ROOT/$PLUGIN-mail-bridge"
SNAP="$DEST/$TS"
STAGING="$DEST/.$TS.staging"
# 每个桥的差异集中在这张表里:入口、宿主、构建需求。
#
# HOST 决定「切换之后拿什么判活」,两者完全不同:
# systemd → 重启单元 + 看网关库里有没有新心跳
# zcode → **没有我们的单元可重启**ZCode 是宿主,它在会话开始时读
# `plugins.dirs`。于是判活改成「从快照起 MCP 服务器并走一遍握手」——
# 这与「宿主加载插件」走的是同一份入口代码。
case "$PLUGIN" in
pi) ENTRY="src/index.mjs"; UNIT="pi-mail-bridge"; HOST="systemd"; NEEDS_BUILD=0 ;;
opencode) ENTRY="index.js"; UNIT="opencode-serve"; HOST="systemd"; NEEDS_BUILD=0 ;;
dsh) ENTRY="dist/index.js"; UNIT="dsh"; HOST="systemd"; NEEDS_BUILD=1 ;;
zcode) ENTRY="src/index.mjs"; UNIT=""; HOST="zcode"; NEEDS_BUILD=0 ;;
esac
say "部署 $PLUGIN-mail-bridge → $SNAP"
# ── 1. 前置断言 ────────────────────────────────────────────────
[ -d "$SRC" ] || { bad "源目录不存在: $SRC"; exit 2; }
[ -f "$SRC/package.json" ] || { bad "缺少 package.json: $SRC"; exit 2; }
if [ "$HOST" = "systemd" ]; then
command -v systemctl >/dev/null 2>&1 || { bad "缺少 systemctl"; exit 2; }
[ -f "$GATEWAY_DB" ] || { bad "找不到网关库 $GATEWAY_DB(无法验证桥是否连上)"; exit 2; }
if ! systemctl cat "$UNIT" >/dev/null 2>&1; then
bad "找不到 systemd 单元 $UNIT(插件要先有一个运行宿主才能谈部署)"; exit 2
fi
else
# zcode 的宿主是应用本身:要能跑 CLI 才能自查「插件被发现了吗」。
ZCODE_CLI=${ZCODE_CLI:-/opt/ZCode/resources/glm/zcode.cjs}
[ -f "$ZCODE_CLI" ] || { bad "找不到 ZCode CLI: $ZCODE_CLI"; exit 2; }
command -v node >/dev/null 2>&1 || { bad "缺少 node"; exit 2; }
fi
if [ "$NEEDS_BUILD" = 1 ]; then
[ -f "$SRC/tsconfig.json" ] || { bad "dsh 需要 tsconfig.json 才能构建"; exit 2; }
command -v npx >/dev/null 2>&1 || { bad "缺少 npx无法构建 dsh 插件"; exit 2; }
fi
# ── 3. staging 拷贝(仓库外的快照)──────────────────────────────
mkdir -p "$DEST"
rm -rf "$STAGING"
mkdir -p "$STAGING"
# 整包复制,再剔除开发用目录。
#
# **不能白名单列出要拷什么。** 第一版白名单是 `package.json lib src index.js dist`
# 漏掉了 dsh 的 `cordis.patch.yml`dsh 读它做 overlay 配置)。后果不是"少个文件"
# 而是**服务起不来**,而且只在重启那一刻才暴露:
#
# Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT
#
# 白名单的失败模式天生如此:漏一个就等着启动时炸,而启动时旧版本已经被换掉了。
# 黑名单反过来 —— 默认带走,只排除明确不需要的。
cp -a "$SRC/." "$STAGING/"
rm -rf "$STAGING/test" "$STAGING/.git" "$STAGING/node_modules/.cache" \
"$STAGING/.DS_Store" "$STAGING/dist.old" 2>/dev/null
find "$STAGING" -maxdepth 1 -name '*.log' -delete 2>/dev/null
# 依赖必须进快照:仓库外没有 node_modules 可借,缺了它入口根本起不来。
if [ -d "$SRC/node_modules" ]; then
cp -a "$SRC/node_modules" "$STAGING/"
ok "已拷入 node_modules生产不借用仓库的依赖"
else
warn "源目录没有 node_modules —— 若入口依赖外部包,启动时会失败"
fi
# 需要编译的插件:**产出到 staging**,不写仓库里的 dist/。
#
# 先在仓库里构建再拷贝会有两个问题:一是失败的构建也会 emittsc 默认
# noEmitOnError=false于是仓库的 dist/ 被半成品覆盖;二是生产产物与
# 工作区之间多了一条看不见的耦合。
if [ "$NEEDS_BUILD" = 1 ]; then
if ( cd "$SRC" && TMPDIR=${TMPDIR:-/tmp} npx tsc -p tsconfig.json --outDir "$STAGING/dist" >/tmp/"$PLUGIN"-tsc.log 2>&1 ); then
ok "tsc 构建完成(产出到 staging"
else
bad "tsc 构建失败(见 /tmp/$PLUGIN-tsc.log"
sed 's/^/ /' /tmp/"$PLUGIN"-tsc.log | head -8 >&2
rm -rf "$STAGING"; exit 1
fi
fi
[ -f "$STAGING/$ENTRY" ] || { bad "staging 里没有入口 $ENTRY"; rm -rf "$STAGING"; exit 1; }
ok "staging 就绪: $STAGING"
# ── 4. 门禁:语法检查(不启动服务,因此不会碰生产)────────────
if node --check "$STAGING/$ENTRY" 2>/tmp/"$PLUGIN"-check.log; then
ok "入口语法检查通过($ENTRY"
else
bad "入口语法检查失败:"; sed 's/^/ /' /tmp/"$PLUGIN"-check.log >&2
rm -rf "$STAGING"; exit 1
fi
# 逐个解析入口的 import 图,确认快照自足。
#
# **不能只比对 package.json 的 dependencies**pi 的 dependencies 是 `{}`
# 而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的。只查声明
# 等于空跑而漏掉的代价出现在最糟的时刻current 已切、服务重启、插件起不来,
# 旧版本已被换掉)。
if ! node "$REPO/deploy/check-plugin-snapshot.mjs" "$STAGING" "$ENTRY" 2>&1 | sed 's/^/ /'; then
bad "快照不自足(缺依赖),销毁 staging 且不切换"
rm -rf "$STAGING"
exit 1
fi
ok "快照自足(入口的 import 图全部可解析)"
if [ "$STAGE_ONLY" = 1 ]; then
# 干跑:把快照留在 .staging**不切 current、不重启**。
say "干跑结束(--stage-only"
info "快照留在: $STAGING"
info "未做: 原子切换${UNIT:+ / 重启 $UNIT} / 后置验证"
info "要真部署:$0 $PLUGIN"
exit 0
fi
# ── 5. 原子切换 ────────────────────────────────────────────────
PREV=""
[ -L "$DEST/current" ] && PREV=$(basename "$(readlink -f "$DEST/current")")
mv "$STAGING" "$SNAP" || { bad "staging → 快照 移动失败"; exit 1; }
ln -sfn "$TS" "$DEST/current"
ok "current → $TS${PREV:+(上一版 $PREV}"
rollback() {
if [ -n "$PREV" ] && [ -d "$DEST/$PREV" ]; then
ln -sfn "$PREV" "$DEST/current"
[ -n "$UNIT" ] && systemctl restart "$UNIT" >/dev/null 2>&1
warn "已回滚到 $PREV${UNIT:+ 并重启 $UNIT}"
else
warn "无上一版可回滚(这是首次部署)—— 请手工处理"
fi
}
# ── 5b. zcode宿主不是我们的 unit判活方式不同 ──────────────
# 判据:**从快照里起一次 MCP 服务器并走完握手**。
# 这与 ZCode 加载插件走的是同一份入口代码,所以能发现「快照缺文件」
# 「相对导入断了」「清单被改坏」这类只有加载时才暴露的问题。
# 检查系统 MCP 握手stdin 进、stdout 出),返回响应里的 name 字段个数(失败回声 0
#
# 计数必须用 `grep -o | wc -l`(数**次数**),不能用 `grep -c`(数**行**
# 每一条响应只占一行tools/list 的 11 个工具名全在同一行里,
# 用 -c 会得到 2就那么两行于是好快照也会被判失败实测踩过
# 期望值11 个工具 + initialize 里的 serverInfo.name = 12。
mcp_handshake() {
local root="$1"
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| AGENTMAIL_AGENT_NAME=probe timeout 30 node "$root/mcp/server.mjs" 2>/dev/null \
| grep -o '"name":"' | wc -l | tr -d ' ' || true
}
MIN_NAMES=11
if [ "$HOST" = "zcode" ]; then
say "后置验证zcode宿主是应用不能重启它"
TOOLS=$(mcp_handshake "$DEST/current")
if [ "${TOOLS:-0}" -ge "$MIN_NAMES" ]; then
ok "从快照起的 MCP 服务器握手成功,报出 $TOOLS 个 name 字段"
else
bad "快照里的 MCP 服务器握手失败(只报出 ${TOOLS:-0} 个 name 字段,期望 ≥ $MIN_NAMES"
rollback
exit 1
fi
# 反向对照:握手判据必须能发现坏快照,否则「全绿」没有意义。
if [ "$(mcp_handshake "$DEST/__definitely_missing__")" -ge "$MIN_NAMES" ]; then
bad "握手判据无法发现坏路径 —— 判据本身失效,拒绝通过"
rollback
exit 1
fi
ok "握手判据自检通过(坏路径能被发现)"
# ZCode 从 plugins.dirs 发现插件;没有指向 current 就还没真的切换。
LIST=$(timeout 60 node "$ZCODE_CLI" plugins list 2>/dev/null || true)
if printf '%s' "$LIST" | grep -q "agentmail@inline"; then
WHERE=$(printf '%s' "$LIST" | grep -A 1 'agentmail@inline' | grep -o '/opt/[^ ]*' | head -1)
if [ "$WHERE" = "$DEST/current" ]; then
ok "ZCode 已从快照发现插件($WHERE"
else
warn "ZCode 已发现 agentmail但路径是 $WHERE(不是 $DEST/current"
info "改这一处即可:~/.zcode/cli/config.json 的 plugins.dirs"
info " \"dirs\": [\"$DEST/current\"]"
info "改完要让 ZCode 重新读取(它会话开始时读;重启应用最保险)"
fi
else
warn "ZCode 还没发现 agentmail 插件plugins.dirs 尚未指向 current"
info " \"dirs\": [\"$DEST/current\"]"
fi
say "结论: 快照已切换"
info "运行路径: $DEST/current/$ENTRY"
info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current'"
exit 0
fi
# ── 6. 重启 + 后置验证 ─────────────────────────────────────────
# 记下重启时刻,后面用「网关库里的 last_seen 是否比它新」判断桥真的连上了。
# **必须用 UTC。** `agents.last_seen` 是 UTCSQLite 的 CURRENT_TIMESTAMP 语义),
# 而 `date` 默认给本地时间。拿本地时间(如 11:44去比 UTC 值03:44会**永远为假**
# 于是每一次部署都被判成"桥没连上"并回滚 —— 判据写错会让门禁主动破坏生产。
RESTART_AT=$(date -u '+%Y-%m-%d %H:%M:%S')
systemctl restart "$UNIT" || { bad "重启 $UNIT 失败"; rollback; exit 1; }
# 存活判据分两层:
#
# 1. `systemctl is-active` —— 进程在不在。**不够**:桥可能进程活着却没连上
# Gateway密钥过期、Gateway 未起、依赖在惰加载时才暴露)。
# 2. **网关库里 `last_seen` 是否比重启时刻新** —— 平台无关,且是真的端到端
# (桥 → 心跳 → 服务端落库)。这一条替代了第一版的「日志出现『已接入』」:
# 那句话只有 **pi 与 opencode** 会打印dsh 启动时只输出
# `dsh web: http://127.0.0.1:3080` —— 照那个判据,**一次成功的 dsh 部署
# 会被判成失败并回滚**(实测就是这么把 dsh 弄进崩溃循环的:回滚到缺
# cordis.patch.yml 的坏快照)。
#
# 教训:判据里不要写某一个平台特有的措辞。
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3无法验证桥是否连上网关"; rollback; exit 1; }
DEADLINE=$(( $(date +%s) + 90 ))
HEARTBEAT=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
SEEN=$(sqlite3 "$GATEWAY_DB" \
"SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT';" 2>/dev/null)
if [ "${SEEN:-0}" != "0" ]; then HEARTBEAT=1; break; fi
sleep 3
done
ACTIVE=$(systemctl is-active "$UNIT" 2>/dev/null)
say "后置验证"
[ "$ACTIVE" = active ] && ok "$UNIT 处于 active" || bad "$UNIT 状态为 $ACTIVE"
if [ "$HEARTBEAT" = 1 ]; then
ok "网关收到 $PLUGIN 的新心跳last_seen 晚于重启时刻)—— 桥真的连上了"
else
bad "90 秒内网关未收到 $PLUGIN 的新心跳 —— 桥没连上(进程活着不等于连上了)"
info "诊断journalctl -u $UNIT --since '-3 minutes' | tail -30"
fi
if [ "$ACTIVE" != active ] || [ "$HEARTBEAT" != 1 ]; then
say "结论: 验证不过 —— 回滚,不要「先上着再修」"
rollback
exit 1
fi
say "结论: 部署成功"
info "运行路径: $DEST/current/$ENTRY(仓库不再是生产代码)"
info "回滚命令: ln -sfn '${PREV:-$TS}' '$DEST/current' && systemctl restart $UNIT"
exit 0