chore(stable): stable.sh 在途改动落下

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 02:56:39 +08:00
co-authored by Claude Opus 5
parent 91840067d4
commit 2d1a2ff442
+176 -17
View File
@@ -4,27 +4,41 @@
# 开发那边改代码、重启、崩掉,这边一个字都不动;要发新版,显式跑一次 update。 # 开发那边改代码、重启、崩掉,这边一个字都不动;要发新版,显式跑一次 update。
# #
# ./stable.sh 起(没有快照就先构建;已在跑的不动) # ./stable.sh 起(没有快照就先构建;已在跑的不动)
# ./stable.sh update 用当前代码重建快照,然后重启两个 —— 这才是「发一版」 # ./stable.sh update 重建快照 + 重启两个 —— 这才是「发一版」
# ./stable.sh update <ref> **从某个分支/标签/commit 发版**(例:update backup/20260828)。
# 记住这个 ref,以后不带参数的 update 继续跟它走;回到当前工作树:update local
# ./stable.sh restart 不重建,只重启 # ./stable.sh restart 不重建,只重启
# ./stable.sh stop 停掉两个 # ./stable.sh stop 停掉两个
# ./stable.sh status 只看状态,一个进程都不动 # ./stable.sh status 只看状态,一个进程都不动
# ./stable.sh logs [api|web] 看日志(tail -f) # ./stable.sh logs [api|web] 看日志(tail -f)
# ./stable.sh env-from-dev 把开发服务(:8787)进程里的环境变量抄成 .stable/env # ./stable.sh env-from-dev 把开发服务(:8787)进程里的环境变量抄成 .stable/env
# #
# **给了 ref 就在独立 git worktree(.stable/src)里签出并构建 —— 你的工作树一个字不动**,
# 想改什么改什么、切分支也随意;稳定版跑的是那个 ref 定死的代码。
# worktree 一律 detached 签出:那个分支仍归你,别处照样 checkout 得动。
# lockfile 与主仓库一致时 `pnpm install` 是秒级的(全从同一个 store 硬链)。
#
# 落盘位置(全部 gitignore): # 落盘位置(全部 gitignore):
# apps/api/.build/stable/ 后端快照(tsup 一次性构建,不 watch)—— 必须住在 apps/api 下, # apps/api/.build/stable/ 后端快照(tsup 一次性构建,不 watch)—— 必须住在 apps/api 下,
# 产物里 `import "hono"` 这类裸名要从 apps/api/node_modules 解析 # 产物里 `import "hono"` 这类裸名要从 apps/api/node_modules 解析
# apps/web/.build/stable/ 前端快照(vite build),由 vite preview 托静态 + /api 反代到稳定版后端 # apps/web/.build/stable/ 前端快照(vite build),由 vite preview 托静态 + /api 反代到稳定版后端
# (跟 ref 发版时,这两处在 .stable/src/ 里那一份)
# .stable/src/ 跟 ref 发版用的独立 worktree(detached)
# .stable/REF 现在跟着哪个 ref;`local` = 当前工作树
# .stable/env 环境变量(KEY=VALUE,600 权限)。缺它起不来 —— Keycloak 三样是硬门 # .stable/env 环境变量(KEY=VALUE,600 权限)。缺它起不来 —— Keycloak 三样是硬门
# .stable/logs/ api.log / web.log / build.log # .stable/logs/ api.log / web.log / build.log
# .stable/RELEASE 这份快照是哪个 commit、什么时候构的 # .stable/RELEASE 这份快照是哪个 ref / commit、什么时候构的
# #
# .stable/env 长这样(env-from-dev 会自动照开发进程抄一份;要单独一套自己改): # .stable/env 长这样(env-from-dev 会自动照开发进程抄一份;要单独一套自己改):
# KEYCLOAK_URL=https://… KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=task-dispatcher # KEYCLOAK_URL=https://… KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=task-dispatcher
# KEYCLOAK_PROVISIONING_CLIENT_ID=… KEYCLOAK_PROVISIONING_CLIENT_SECRET=…(可选,名册同步) # KEYCLOAK_PROVISIONING_CLIENT_ID=… KEYCLOAK_PROVISIONING_CLIENT_SECRET=…(可选,名册同步)
# JCAI_TEST_ACCOUNTS=… JCAI_SECRET_KEY=…(开发没设的话这里也别设 —— 库里的密文 # JCAI_TEST_ACCOUNTS=… JCAI_SECRET_KEY=…(开发没设的话这里也别设 —— 库里的密文
# 是用哪把钥匙封的就得用哪把开) # 是用哪把钥匙封的就得用哪把开)
# DATABASE_URL=… (不填 = 与开发共用一个库,见下) # STABLE_DATABASE_URL=… 稳定版自己的库(不填 = 与开发共用一个,见下)。
# **注意名字带 STABLE_ 前缀**:这个文件是给人 source 的,
# 写成通用的 DATABASE_URL 会让 source 过它的人误把
# activities:install / db:migrate / reset-runs 打到稳定库上(踩过)
# STABLE_ATTACHMENT_ROOT=… 稳定版自己的附件根(同上,别写成 JCAI_ATTACHMENT_ROOT)
# STABLE_API_PORT=8790 STABLE_WEB_PORT=5180 # STABLE_API_PORT=8790 STABLE_WEB_PORT=5180
# STABLE_ALLOWED_HOSTS=jcai-bpos.akhr-ai.com 经域名/反代访问时要列在这儿(逗号分隔,`*` 全放)—— # STABLE_ALLOWED_HOSTS=jcai-bpos.akhr-ai.com 经域名/反代访问时要列在这儿(逗号分隔,`*` 全放)——
# vite preview 有宿主校验,不列会 403「This host is not allowed」 # vite preview 有宿主校验,不列会 403「This host is not allowed」
@@ -44,10 +58,8 @@ REPO=$(pwd)
STABLE="$REPO/.stable" STABLE="$REPO/.stable"
ENV_FILE="$STABLE/env" ENV_FILE="$STABLE/env"
LOGDIR="$STABLE/logs" LOGDIR="$STABLE/logs"
API_DIR="$REPO/apps/api" REF_FILE="$STABLE/REF"
WEB_DIR="$REPO/apps/web" WORKTREE="$STABLE/src"
API_OUT="$API_DIR/.build/stable"
WEB_OUT="$WEB_DIR/.build/stable"
DEV_API_PORT=8787 DEV_API_PORT=8787
mkdir -p "$LOGDIR" mkdir -p "$LOGDIR"
WHAT=${1:-start} WHAT=${1:-start}
@@ -55,6 +67,25 @@ WHAT=${1:-start}
say() { printf '%s\n' "$*"; } say() { printf '%s\n' "$*"; }
die() { say " ✗ $*"; exit 1; } die() { say " ✗ $*"; exit 1; }
# ── 源码从哪儿来 ────────────────────────────────────────────────────────
# `local` = 当前工作树(默认,老行为);别的 = .stable/src 那个 detached worktree。
# 记在 .stable/REF 里:不带参数的 update / restart 继续跟着它,不会悄悄跳回工作树 ——
# 「稳定版跑的是哪份代码」这件事,得由上一次显式的决定说了算。
REF=$(cat "$REF_FILE" 2>/dev/null || echo local)
use_source() {
if [ "$REF" = 'local' ]; then
SRC="$REPO"
else
SRC="$WORKTREE"
fi
API_DIR="$SRC/apps/api"
WEB_DIR="$SRC/apps/web"
API_OUT="$API_DIR/.build/stable"
WEB_OUT="$WEB_DIR/.build/stable"
}
use_source
# ── 环境 ──────────────────────────────────────────────────────────────── # ── 环境 ────────────────────────────────────────────────────────────────
ENV_KEYS='KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET JCAI_TEST_ACCOUNTS JCAI_SECRET_KEY JCAI_DEEPSEEK_API_KEY JCAI_LLM_API_KEY JCAI_LLM_BASE_URL DATABASE_URL JCAI_ATTACHMENT_ROOT JCAI_DISPATCH_INTERVAL_MS JCAI_MAIL_POLL_TICK_MS NODE_ENV' ENV_KEYS='KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET JCAI_TEST_ACCOUNTS JCAI_SECRET_KEY JCAI_DEEPSEEK_API_KEY JCAI_LLM_API_KEY JCAI_LLM_BASE_URL DATABASE_URL JCAI_ATTACHMENT_ROOT JCAI_DISPATCH_INTERVAL_MS JCAI_MAIL_POLL_TICK_MS NODE_ENV'
@@ -94,6 +125,18 @@ load_env() {
set +a set +a
API_PORT=${STABLE_API_PORT:-8790} API_PORT=${STABLE_API_PORT:-8790}
WEB_PORT=${STABLE_WEB_PORT:-5180} WEB_PORT=${STABLE_WEB_PORT:-5180}
# 库与附件根**只在这里**翻成通用名,而且只翻给马上要 exec 的那个进程。
#
# 这条是踩出来的:头一版直接把 `DATABASE_URL=…jcai_stable` 写进 .stable/env,
# 而这个文件是设计成给人 `source` 的(应急起后端、抄环境都这么干)——
# 于是任何人 source 完再跑 `pnpm activities:install` / `db:migrate` / `reset-runs`,
# 就**毫无察觉地打在稳定库上**。实测发生过一次:2026-08-28 15:32:59
# 一个新活动包版本被装进了本该冻住的稳定库(15:30 刚装过开发库,2 分半后又中一枪)。
# **通用变量名放在会被 source 的文件里,等于给每个 source 它的人都上了一发膛**。
export DATABASE_URL=${STABLE_DATABASE_URL:-${DATABASE_URL:-}}
export JCAI_ATTACHMENT_ROOT=${STABLE_ATTACHMENT_ROOT:-${JCAI_ATTACHMENT_ROOT:-}}
[ -z "$DATABASE_URL" ] && unset DATABASE_URL
[ -z "$JCAI_ATTACHMENT_ROOT" ] && unset JCAI_ATTACHMENT_ROOT
for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID; do for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID; do
eval "v=\${$k:-}" eval "v=\${$k:-}"
# shellcheck disable=SC2154 # shellcheck disable=SC2154
@@ -101,28 +144,124 @@ load_env() {
done done
} }
# ── 冻库:不许把活动包装进稳定库 ────────────────────────────────────────
#
# 为什么是**触发器**而不是权限:开发用的 `jcai` 是**超级用户**,REVOKE 对它无效
# (超级用户绕过一切权限检查)。触发器是数据逻辑,超级用户照样被 RAISE EXCEPTION 拦下 —— 实测过。
#
# 冻的是「代码与配置」那几张表(装活动包 / 装工具 / 播种会写它们),
# **不冻业务表** —— 稳定版自己还要写任务、台账、事件,冻了它就跑不动了。
#
# 起因:2026-08-28 15:32:59 一个新活动包被装进了本该冻住的稳定库。
# 而且 `install-activities` 的失败路径是「打印一行然后 continue,永远 exit 0」——
# 拦下 INSERT 它也不会停,还会接着写别的表,所以**得把这一族一起冻上**。
# **只冻 INSERT**(= 装进来一个新东西),不冻 UPDATE ——
# 运行时会更新这些表的状态(触发器的上次触发、模型的可用性…),治理台上启停也是 UPDATE;
# 冻了 UPDATE 稳定版自己就跑不动了。要防的是「装新版本」,那正是 INSERT。
FREEZE_TABLES='sys.activity_package sys.activity_asset sys.tool sys.tool_allowlist sys.llm_instance sys.process_package sys."trigger"'
db_url_of() { printf '%s' "${STABLE_DATABASE_URL:-}"; }
# 在容器里对稳定库跑一段 SQL(共用开发那个 PG 实例,只是库不同)
stable_psql() {
local db
db=$(printf '%s' "$(db_url_of)" | sed 's#.*/##')
[ -n "$db" ] || die "没有 STABLE_DATABASE_URL —— 稳定版还在跟开发共用一个库,冻不了"
docker exec -i jcai-postgres psql -U jcai -d "$db" -v ON_ERROR_STOP=1
}
db_freeze() {
local t n=0
say "冻结稳定库(不许装活动包 / 工具 / 播种):"
{
cat <<'SQL'
CREATE OR REPLACE FUNCTION sys.frozen_guard() RETURNS trigger AS $fn$
BEGIN
RAISE EXCEPTION '稳定库已冻结:不许对 %.% 做 %。装活动包 / 播种请对开发库做;确实要动稳定库,先 ./stable.sh db-unfreeze',
TG_TABLE_SCHEMA, TG_TABLE_NAME, TG_OP
USING HINT = '这道墙是触发器不是权限 —— 超级用户同样拦';
END $fn$ LANGUAGE plpgsql;
SQL
for t in $FREEZE_TABLES; do
printf 'DROP TRIGGER IF EXISTS zz_frozen ON %s;\n' "$t"
printf 'CREATE TRIGGER zz_frozen BEFORE INSERT ON %s FOR EACH ROW EXECUTE FUNCTION sys.frozen_guard();\n' "$t"
done
} | stable_psql > "$LOGDIR/db-freeze.log" 2>&1 || {
# 表不存在之类的照说,但别把整件事说成成功
grep -iE 'error|不存在' "$LOGDIR/db-freeze.log" | head -3
say ' ! 有语句没跑成(全文 .stable/logs/db-freeze.log)'
}
# 数一遍再报 —— stable_psql 是交互式格式,这里单开一条 -tA 的连接现数
n=$(docker exec jcai-postgres psql -U jcai -d "$(printf '%s' "$(db_url_of)" | sed 's#.*/##')" \
-tAc "select count(*) from pg_trigger where tgname='zz_frozen'" 2>/dev/null | tr -d ' \n')
say " ✓ 已冻 ${n:-?} 张表(只拦 INSERT):$FREEZE_TABLES"
}
db_unfreeze() {
local t
say '解冻稳定库(记得办完再 ./stable.sh db-freeze 冻回去):'
for t in $FREEZE_TABLES; do printf 'DROP TRIGGER IF EXISTS zz_frozen ON %s;\n' "$t"; done | stable_psql > "$LOGDIR/db-freeze.log" 2>&1
say ' ✓ 已解冻 —— 现在装得进去了,**这扇门开着的时候开发侧的 install 会打中它**'
}
# ── 跟 ref 发版:独立 worktree ──────────────────────────────────────────
# detached 签出 —— 那个分支仍归你,别处照样 checkout 得动(worktree 占着分支会拦下别人)。
prepare_worktree() {
local target=$1 sha
sha=$(git -C "$REPO" rev-parse --short "$target^{commit}" 2>/dev/null) \
|| die "认不出这个 ref:$target(分支/标签/commit 都试过了)"
if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then
say " worktree .stable/src → $target ($sha)"
git -C "$WORKTREE" checkout --detach --force "$sha" > "$LOGDIR/build.log" 2>&1 \
|| { tail -10 "$LOGDIR/build.log"; die 'worktree 切不过去(全文 .stable/logs/build.log)'; }
else
say " 新建 worktree .stable/src → $target ($sha)"
rm -rf "$WORKTREE"
git -C "$REPO" worktree add --detach "$WORKTREE" "$sha" > "$LOGDIR/build.log" 2>&1 \
|| { tail -10 "$LOGDIR/build.log"; die 'worktree 建不起来(全文 .stable/logs/build.log)'; }
fi
# 依赖装在 worktree 自己的 node_modules 里。lockfile 与主仓库一致时是秒级(同一个 store 硬链);
# 不加 --ignore-scripts:esbuild 的二进制要靠 postinstall 落地,少了它 tsup/vite 一步都跑不动
say ' pnpm install(worktree)…'
( cd "$WORKTREE" && pnpm install --frozen-lockfile ) >> "$LOGDIR/build.log" 2>&1 \
|| { tail -20 "$LOGDIR/build.log"; die 'worktree 装依赖失败(全文 .stable/logs/build.log)'; }
}
# 这份快照到底是哪份代码 —— ref、commit、脏没脏,一行说清
snapshot_id() {
local sha dirty
sha=$(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo unknown)
if [ "$REF" = 'local' ]; then
dirty=$([ -n "$(git -C "$SRC" status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '')
printf 'local %s%s' "$sha" "$dirty"
else
printf '%s %s' "$REF" "$sha"
fi
}
# ── 构建快照 ──────────────────────────────────────────────────────────── # ── 构建快照 ────────────────────────────────────────────────────────────
build() { build() {
local sha dirty at local id at where
where=$([ "$REF" = 'local' ] && echo 'apps' || echo '.stable/src/apps')
say '构建快照:' say '构建快照:'
say " api → apps/api/.build/stable …" say " api → $where/api/.build/stable …"
# --out-dir 盖过 tsup.config 的 outDir;config 里的 clean:true 清的就是这个目录,碰不到开发的 dist/ # --out-dir 盖过 tsup.config 的 outDir;config 里的 clean:true 清的就是这个目录,碰不到开发的 dist/
if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) > "$LOGDIR/build.log" 2>&1; then # >> 而不是 > :跟 ref 发版时,前面 worktree 那几步的日志还要留着
if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) >> "$LOGDIR/build.log" 2>&1; then
tail -20 "$LOGDIR/build.log" tail -20 "$LOGDIR/build.log"
die 'api 构建失败(全文 .stable/logs/build.log)' die 'api 构建失败(全文 .stable/logs/build.log)'
fi fi
[ -f "$API_OUT/main.js" ] || die "构建说成功,可 $API_OUT/main.js 不在" [ -f "$API_OUT/main.js" ] || die "构建说成功,可 $API_OUT/main.js 不在"
say " web → apps/web/.build/stable …" say " web → $where/web/.build/stable …"
if ! (cd "$WEB_DIR" && pnpm exec vite build --outDir .build/stable --emptyOutDir) >> "$LOGDIR/build.log" 2>&1; then if ! (cd "$WEB_DIR" && pnpm exec vite build --outDir .build/stable --emptyOutDir) >> "$LOGDIR/build.log" 2>&1; then
tail -20 "$LOGDIR/build.log" tail -20 "$LOGDIR/build.log"
die 'web 构建失败(全文 .stable/logs/build.log)' die 'web 构建失败(全文 .stable/logs/build.log)'
fi fi
[ -f "$WEB_OUT/index.html" ] || die "构建说成功,可 $WEB_OUT/index.html 不在" [ -f "$WEB_OUT/index.html" ] || die "构建说成功,可 $WEB_OUT/index.html 不在"
sha=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) id=$(snapshot_id)
dirty=$([ -n "$(git status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '')
at=$(date '+%Y-%m-%d %H:%M:%S') at=$(date '+%Y-%m-%d %H:%M:%S')
printf 'commit=%s%s\nbuilt_at=%s\n' "$sha" "$dirty" "$at" > "$STABLE/RELEASE" printf 'source=%s\nbuilt_at=%s\n' "$id" "$at" > "$STABLE/RELEASE"
say " ✓ 快照:$sha$dirty · $at" say " ✓ 快照:$id · $at"
} }
have_snapshot() { [ -f "$API_OUT/main.js" ] && [ -f "$WEB_OUT/index.html" ]; } have_snapshot() { [ -f "$API_OUT/main.js" ] && [ -f "$WEB_OUT/index.html" ]; }
@@ -299,6 +438,7 @@ finish() {
status() { status() {
local ip local ip
say '稳定版状态:' say '稳定版状态:'
say " 跟着 $REF$([ "$REF" = 'local' ] && echo '(当前工作树)' || echo "(独立 worktree .stable/src,你的工作树随便改)")"
[ -f "$STABLE/RELEASE" ] && sed 's/^/ 快照 /' "$STABLE/RELEASE" || say ' 快照 (还没构建过)' [ -f "$STABLE/RELEASE" ] && sed 's/^/ 快照 /' "$STABLE/RELEASE" || say ' 快照 (还没构建过)'
say " api :$API_PORT $(http_code "http://127.0.0.1:$API_PORT/api/health") $(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) pid $(owner_of "$API_PORT")" say " api :$API_PORT $(http_code "http://127.0.0.1:$API_PORT/api/health") $(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) pid $(owner_of "$API_PORT")"
say " web :$WEB_PORT $(http_code "http://127.0.0.1:$WEB_PORT/") pid $(owner_of "$WEB_PORT")" say " web :$WEB_PORT $(http_code "http://127.0.0.1:$WEB_PORT/") pid $(owner_of "$WEB_PORT")"
@@ -322,6 +462,16 @@ case "$WHAT" in
status status
exit 0 exit 0
;; ;;
db-freeze)
load_env
db_freeze
exit 0
;;
db-unfreeze)
load_env
db_unfreeze
exit 0
;;
stop) stop)
load_env load_env
say '停止稳定版:' say '停止稳定版:'
@@ -331,13 +481,22 @@ case "$WHAT" in
;; ;;
start) start)
load_env load_env
[ -n "${2:-}" ] && { REF=$2; use_source; printf '%s\n' "$REF" > "$REF_FILE"; }
[ "$REF" != 'local' ] && [ ! -d "$WORKTREE/apps" ] && prepare_worktree "$REF"
have_snapshot || build have_snapshot || build
say "起稳定版(api :$API_PORT · web :$WEB_PORT):" say "起稳定版(api :$API_PORT · web :$WEB_PORT · 源 $REF):"
start_api start_api
start_web start_web
;; ;;
update) update)
load_env load_env
# 给了 ref 就换源并记住;没给就沿用 .stable/REF 上一次的决定
if [ -n "${2:-}" ]; then
REF=$2
use_source
printf '%s\n' "$REF" > "$REF_FILE"
fi
[ "$REF" != 'local' ] && prepare_worktree "$REF"
build build
say '重启稳定版:' say '重启稳定版:'
stop_one api "$API_PORT" "$STABLE/api.pid" stop_one api "$API_PORT" "$STABLE/api.pid"
@@ -355,7 +514,7 @@ case "$WHAT" in
start_web start_web
;; ;;
*) *)
say "不认识的参数:$WHAT(可用:start / update / restart / stop / status / logs [api|web] / env-from-dev)" say "不认识的参数:$WHAT(可用:start / update [ref|local] / restart / stop / status / logs [api|web] / env-from-dev)"
exit 2 exit 2
;; ;;
esac esac