chore(deploy): 会话归档脚本(判据 / 备份 / 回滚真跑一遍)—— 用户「清理过时的请求与邮件」

判据:active 且 > IDLE_HOURS 小时无动静,KEEP_IDS 保护人的在用来话。
归档(不删、不碰 updated_at);备份 .backup + integrity;id 清单落文件。
回滚脚本生成用**带引号 heredoc**,且把回滚在**副本库上真跑一遍**才算有回滚路径 ——
第一版用 awk printf 拼 IN 列表拼出 \"''id2\"(引号错位),判据当场抓住(rc=0 但
active 没回到 16);改成 bash 循环拼后副本库实测 12 条复活、active 回到 16。
本次落地:16 → 4 条 active,12 条过时会话(349 封信、76 条权限问询)离开收件箱。
This commit is contained in:
2026-09-15 07:00:35 +08:00
parent 0f7c817c1e
commit 18c9aef219

View File

@ -0,0 +1,170 @@
#!/usr/bin/env bash
# archive-stale-sessions.sh —— 归档**过时的会话**(默认干跑,--apply 才落地)
#
# # 为什么是"归档"而不是"删除"
#
# 邮件不可篡改:过时 ≠ 可以删。归档只是把它移出收件箱(`ListInbox` 排除
# `status='archived'`),数据、正文、权限记录全在。删除是另一件事,只在
# `prune-test-sessions.sh`(测试会话)里做,而且要带备份与子表清理。
#
# # 判据(可复现,不靠记性)
#
# `status='active'` 且 **> IDLE_HOURS 小时没有任何动静**(按 `sessions.updated_at`),
# 且不在保护名单里。保护名单(`KEEP_IDS`,逗号分隔的 id 前缀或别名片段)里默认放
# **正在用的那条会话** —— 它是人的寻址入口,归档掉等于让对端"消失"。
#
# 顺带清掉的就是用户说的「过时的**请求**」:`permission_request` 类邮件随它所在的
# 会话一起离开收件箱。这里**不动** `permission_requests` 表本身 —— 那是决策记录,
# 不是待办(实测未决数 = 0)。
#
# # 回滚
#
# 归档没有反向端点,所以判据是**真跑一遍回滚**:`--apply` 会写一份 id 清单 +
# 一个回滚脚本,然后把回滚脚本在**副本库**上跑一遍,确认 active 数回到原值。
#
# ★ 生成回滚脚本用**带引号的 heredoc**(不带引号会让 shell 提前展开正文里的
# `$(...)`:2026-09-14 那次真写进去一个空结果,脚本语法正确却一个 id 都匹配不上),
# id 清单落成文件、脚本运行时自己读 —— 生成时只替换一个纯路径。
#
# 用法:
# bash deploy/archive-stale-sessions.sh # 干跑
# KEEP_IDS=<在用的会话 id 前缀> IDLE_HOURS=6 bash deploy/archive-stale-sessions.sh --apply
set -uo pipefail
DB="${AGENTMAIL_DB:-/opt/agentmail/data/agentmail.db}"
OUT_DIR="${ARCHIVE_OUT_DIR:-/root/gotmp}"
IDLE_HOURS="${IDLE_HOURS:-6}"
KEEP_IDS="${KEEP_IDS:-}"
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1
TS="$(date +%Y%m%d-%H%M%S)"
PLAN="$OUT_DIR/archive-stale-plan-$TS.json"
[ -f "$DB" ] || { echo "找不到库:$DB" >&2; exit 2; }
WHERE="s.status = 'active'
AND s.updated_at < datetime('now', '-$IDLE_HOURS hours')"
if [ -n "$KEEP_IDS" ]; then
IFS=',' read -r -a keeps <<<"$KEEP_IDS"
for k in "${keeps[@]}"; do
[ -z "$k" ] && continue
WHERE="$WHERE AND s.session_id NOT LIKE '$k%' AND coalesce(s.session_alias,'') NOT LIKE '%$k%'"
done
fi
echo "会话归档判据:active 且 > $IDLE_HOURS 小时无动静${KEEP_IDS:+,保护 [$KEEP_IDS]}"
echo
sqlite3 -header -column "$DB" "
SELECT substr(s.session_id,1,8) AS id,
s.from_agent,
coalesce(nullif(s.session_alias,''),'(无别名)') AS alias,
(SELECT count(*) FROM mails m WHERE m.session_id=s.session_id) AS mails,
(SELECT count(*) FROM permission_requests p WHERE p.session_id=s.session_id) AS asks,
round((julianday('now')-julianday(s.updated_at))*24,1) AS h_ago
FROM sessions s WHERE $WHERE
ORDER BY s.updated_at DESC;" || exit 1
BEFORE_ACTIVE=$(sqlite3 "$DB" "SELECT count(*) FROM sessions WHERE status='active';")
N=$(sqlite3 "$DB" "SELECT count(*) FROM sessions s WHERE $WHERE;")
echo
echo " active 会话 $BEFORE_ACTIVE 条;本次将归档 $N 条(其余不动)。"
if [ "$N" = 0 ]; then
echo " 没有到期的会话 —— 什么都不做。"
exit 0
fi
sqlite3 -json "$DB" "
SELECT s.session_id, coalesce(s.session_alias,'') AS alias, s.updated_at,
(SELECT count(*) FROM mails m WHERE m.session_id=s.session_id) AS mails
FROM sessions s WHERE $WHERE;" > "$PLAN"
echo " 清单:$PLAN"
if [ "$APPLY" != 1 ]; then
echo " 干跑结束。确认无误后加 --apply。"
exit 0
fi
# ── 备份(.backup,不是 cp:WAL 下 cp 拿不到一致快照)──
BAK="$OUT_DIR/agentmail-pre-archive-$TS.db"
sqlite3 "$DB" ".backup '$BAK'" || { echo "备份失败" >&2; exit 1; }
INTEG=$(sqlite3 "$BAK" 'PRAGMA integrity_check;' | head -1)
[ "$INTEG" = "ok" ] || { echo "备份完整性异常:$INTEG" >&2; exit 1; }
chmod 600 "$BAK"
echo " 备份:$BAK(integrity ok,$(du -h "$BAK" | cut -f1))"
# ── id 清单(回滚脚本的输入;也是审计清单)──
IDS_FILE="$OUT_DIR/archive-ids-$TS.txt"
sqlite3 "$DB" "SELECT s.session_id FROM sessions s WHERE $WHERE;" > "$IDS_FILE"
echo " 回滚清单:$IDS_FILE($(wc -l < "$IDS_FILE") 行)"
# ── 归档(**不碰 updated_at**:那是"最后一次动静"这个事实,归档不该改写它)──
sqlite3 "$DB" "BEGIN;
UPDATE sessions SET status='archived' WHERE session_id IN (
SELECT s.session_id FROM sessions s WHERE $WHERE
);
COMMIT;" || { echo "归档失败" >&2; exit 1; }
AFTER_ACTIVE=$(sqlite3 "$DB" "SELECT count(*) FROM sessions WHERE status='active';")
echo " active:$BEFORE_ACTIVE → $AFTER_ACTIVE(期望 $((BEFORE_ACTIVE - N)))"
[ "$AFTER_ACTIVE" = "$((BEFORE_ACTIVE - N))" ] || { echo " ★ 计数不符,别继续" >&2; exit 1; }
# ── 回滚脚本(带引号 heredoc;只替换一个纯路径)──
UNDO="$OUT_DIR/undo-archive-stale-$TS.sh"
cat <<'EOF' | sed "s|__IDS_FILE__|$IDS_FILE|; s|__TS__|$TS|" > "$UNDO"
#!/usr/bin/env bash
# 回滚 __TS__ 那次归档:把 id 清单里的会话改回 active。
#
# 用法:bash <本脚本> [--db /path/to/another.db]
# (不给 --db 就打生产库;归档判据会在副本库上先跑一遍)
set -uo pipefail
IDS_FILE="__IDS_FILE__"
DB="/opt/agentmail/data/agentmail.db"
if [ "${1:-}" = "--db" ]; then DB="${2:?--db 后面要给库路径}"; fi
[ -f "$DB" ] || { echo "找不到库:$DB" >&2; exit 2; }
[ -f "$IDS_FILE" ] || { echo "找不到 id 清单:$IDS_FILE" >&2; exit 2; }
# ★ 用 bash 循环拼 IN 列表,不用 awk 的 printf —— 第一版用
# `printf "%s%s%s", sep, "\x27", $1` 拼出来是 `''id2`(关闭引号与开启引号错位),
# 语法正确、一个 id 都匹配不上。判据当场抓住了它(rc=0 但 active 没回到 16)。
IN=""
SEP=""
while IFS= read -r id; do
[ -z "$id" ] && continue
case "$id" in \#*) continue;; esac
IN="${IN}${SEP}'${id}'"
SEP=","
done < "$IDS_FILE"
[ -n "$IN" ] || { echo "清单为空,什么都不做"; exit 0; }
echo " 清单:$IDS_FILE"
echo " 库 :$DB"
sqlite3 "$DB" "SELECT count(*) FROM sessions WHERE session_id IN ($IN) AND status='archived';" \
| sed "s/^/ 待复活(当前 archived): /"
sqlite3 "$DB" "UPDATE sessions SET status='active' WHERE session_id IN ($IN) AND status='archived';"
sqlite3 "$DB" "SELECT count(*) FROM sessions WHERE status='active';" | sed "s/^/ active 现在: /"
EOF
chmod +x "$UNDO"
echo " 回滚脚本:$UNDO"
# ── 判据:把回滚**真跑一遍**(副本库,不碰生产)──
# 只"生成了一个回滚脚本"不算有回滚路径:2026-09-14 那版拼成了"一个带引号的元素",
# 语法检查能过、一个 id 都匹配不上。
TEST="$OUT_DIR/rollback-test-$TS.db"
sqlite3 "$DB" ".backup '$TEST'" || { echo "副本建不出来" >&2; exit 1; }
OUT=$(bash "$UNDO" --db "$TEST" 2>&1); RC=$?
RESTO=$(sqlite3 "$TEST" "SELECT count(*) FROM sessions WHERE status='active';")
rm -f "$TEST"
if [ "$RC" = 0 ] && [ "$RESTO" = "$BEFORE_ACTIVE" ]; then
echo " ★ 回滚判据:副本库上真跑过 → active 回到 $RESTO(原值 $BEFORE_ACTIVE)✓"
else
echo " ★ 回滚判据失败:rc=$RC、复活后 active=$RESTO(期望 $BEFORE_ACTIVE)" >&2
printf '%s\n' "$OUT" | tail -3 >&2
echo " 归档已执行,但回滚路径不可信 —— 立刻用备份 $BAK 处理" >&2
exit 1
fi
echo
echo " 归档完成:收件箱只剩最近 $IDLE_HOURS 小时内有动静的会话。"