#!/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 小时内有动静的会话。"