diff --git a/deploy/archive-stale-sessions.sh b/deploy/archive-stale-sessions.sh new file mode 100644 index 0000000..792a9cf --- /dev/null +++ b/deploy/archive-stale-sessions.sh @@ -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 小时内有动静的会话。"