feat(线索树)★★: 会话级父子 + 树视图 —— 跨会话分叉第一次可见

## 要解决的问题

用户 2026-10-04:「对话树实现得非常原始,根本没有形成/展示为树结构」。

★ 先纠正我自己的一个误判:我先前只看 `ContactPanel`(会话列表)就断言
「前端纯平铺、零层级」。那是不完整的检查 —— `ThreadView.tsx` 早就在用
`node.depth` 做缩进 + 连接线渲染**会话内**的邮件树,服务端 `DescendantsRaw`
也早就有 `WITH RECURSIVE … lvl`。所以**会话内**的树是有的。

真正缺的是**跨会话**:`sessions` 表根本没有 `parent_session_id`,
而生产库实测有 **6 封**邮件的 parent 指向**另一条会话**,
界面上它们是几行互不相干的东西。而「A 交给 B 之后 B 继续推」正是
协作里最常见的形状 —— 那 6 条里就有 `本机-agent-能力盘点 → 渲染自检`
这种纯分叉。

## 回填后的真实拓扑(生产数据)

    harmony-emu-unblock ─┐
    deploy-pi-bridge-…  ─┴→ 邮件驱动…项目概述 ─┬→ 核实-HomeAgent-mail-bridge
                                              └→ 邮件驱动…项目概述-2 → 时间显示自检
    本机-agent-能力盘点 → 渲染自检

## 可见性:逐节点过滤 + 剪断不可见祖先(安全边界)

树天然会把父节点带给子节点,而「我能看见 B」≠「我能看见 B 的父 A」
(A 可能是别人与别人的对话)。

所以按节点过滤之后**必须重算 parent**:不可见的祖先一律跳过、树在可见处
重新起根。只过滤不重算,输出里就带着不可见父的 session_id 与标题 ——
一条真实的泄露路径,而且它藏在「树视图」这个新功能里,没人会想到去查。

**admin 看全部**(用户 2026-10-04 定的分层)。

★ 与 Agent 侧那条边界无关:`AgentMayReadSession` 是 15e4fe9 / 095213b
修出来的越权防护(Agent 只能读自己参与过的会话);本端点属人类登录态,
admin 全看是显式授权的。两者语义不同,不要混谈。

## 回填只跑一次

回填改的是会话**拓扑**,不是派生数据。若每次 Migrate 都跑,
「某人手工把 parent 改对」会在每次重启时被悄悄改回去 —— 与既有
`backfillMailReads` 同族(其注释:每次跑会把「某抄送方读过」按主收件人
写成已读,正是那次要修的语义错误)。故用 `app_meta` marker 守住。

★ 判据分两包:`repo` 测回填口径与剪枝,`db` 测「二次 Migrate 不覆盖人工设置」。
只写在 repo 包就只测到「回填幂等」,测不到 Migrate 那一层。

## 实现中修掉的三个真 bug(都被判据抓到)

1. **参数顺序反了** —— `t.lvl < ?` 是 SQL 里第一个占位符,我放在 args 末尾
   ⇒ 根查询拿到一个整数、匹配不到任何行,树只返回递归分支那半。
2. **环下打满 55s 超时** —— 计数写成相关子查询,环下每个节点都重跑一次
   mails 计数。改成 `LEFT JOIN` 两个聚合后 0.01s。
3. **`IN (NULL)`** —— parentIDs 为空时该表达式恒不匹配任何行。

## 判据(8 格)

含防环(环下 0.01s + 耗时断言)、不覆盖人工父、不把同会话内邮件父子
当会话父子、不回填不可见祖先(两个变异分别去掉「过滤」「剪断」都转红)。

★ 判据自己错了一次:「隐藏不可见祖先」那格我写成「对每个可见节点都断言
depth==0」,但 B 重起根(0)、C 仍挂在 B 下(1),两者不同 —— 判据红而代码
是对的。(又是「判据比语义宽/窄」那一族。)

14 包全绿。
This commit is contained in:
2026-10-04 10:54:07 +08:00
parent caffe2f9fe
commit cd894d5e4d
7 changed files with 827 additions and 0 deletions

View File

@ -0,0 +1,258 @@
package repo
/*
会话级线索树(2026-10-04)。
# 为什么需要它
邮件级父子(`mails.parent_mail_id`)早已有,`ThreadView` 也早在用 `depth`
渲染**会话内**的树 —— 但**跨会话**的分叉完全不可见。生产库里实测有 **6 封**邮件
的 parent 指向**另一条会话**,界面上它们是几行互不相干的东西:
deploy-pi-bridge-authz-wakeup → 邮件驱动…项目概述
harmony-emu-unblock → 邮件驱动…项目概述
邮件驱动…项目概述 → 核实-HomeAgent-mail-bridge
邮件驱动…项目概述-2 → 时间显示自检
本机-agent-能力盘点 → 渲染自检
Re-第二个请求… → 邮件驱动…项目概述-2
而「A 把这件事交给 B 之后 B 继续推」正是协作里最常见的形状。回填后它们
连成一条真链(`ba9c194b` 自己也是子节点、且有两个子节点)。
# 回填口径:只认「确实跨会话分叉」
`BackfillSessionParents` 只在满足**全部**条件时才写 `parent_session_id`:
child.session_id ≠ parent.session_id (跨会话)
parent 是 child 的**直接**父邮件所在会话 (直接,不沿链上溯)
child 在库里**还没有** parent_session_id (不回填覆盖人工设置)
★ 为什么不做「沿 parent 链上溯到根会话」:那样会把「A→B→C 三跳」压成
「A 是 C 的祖先」,而中间那条 B 线索恰恰是独立的协作对象(可能参与方不同)。
把链压平会让拓扑失真 —— 比没有树更糟。
# 环防护
`parent_session_id` 是自引用,加不加约束都可能被人填出环(A→B→A)。而
`ListSessionTree` 用递归 CTE,环会让它**无限递归**。故加两条防护:
1. 回填只写「parent 的父为空 / parent 本身不是 child 的后代」——写入侧防
2. 读取侧带 `depth < 64` 上限 —— **兜底**:即便库里已有环,也不会挂死请求
第2 条不是可有可无的:环一旦存在是**数据问题**,读取侧必须能活下来。
*/
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
)
// SessionNode 是线索树上的一个会话节点。
type SessionNode struct {
SessionID string `json:"session_id"`
Alias string `json:"alias"`
Subject string `json:"subject"`
FromAgent string `json:"from_agent"`
Workspace string `json:"workspace"`
// ParentSessionID 是线索树上的父节点。为空 = 根。
//
// ★ 服务端必须按可见性**重算**这个字段再输出:用户能看见子节点却看不见
// 父节点时,直接返回原值就等于把不可见的父(可能是别人与别人的对话)
// 的 session_id 一起泄露。
ParentSessionID string `json:"parent_session_id,omitempty"`
Depth int `json:"depth"`
ChildCnt int `json:"child_count"`
MailCnt int `json:"mail_count"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// maxTreeDepth 是读取侧的硬上限(防环)。
const maxTreeDepth = 64
// BackfillSessionParents 按跨会话分叉回填 parent_session_id。
//
// 幂等:已有 parent_session_id 的会话不动(人工设置优先于自动推断)。
// 返回实际写入的行数。
func BackfillSessionParents(ctx context.Context) (int64, error) {
if _, err := db.DB.ExecContext(ctx, `
UPDATE sessions
SET parent_session_id = (
SELECT ps.session_id
FROM mails m
JOIN mails pm ON m.parent_mail_id = pm.mail_id
JOIN sessions ps ON ps.session_id = pm.session_id
WHERE m.session_id = sessions.session_id
AND ps.session_id <> sessions.session_id
ORDER BY m.created_at ASC
LIMIT 1
)
WHERE parent_session_id IS NULL
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = sessions.session_id
AND m.parent_mail_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM mails pm
WHERE pm.mail_id = m.parent_mail_id
AND pm.session_id <> sessions.session_id
)
)
`); err != nil {
return 0, fmt.Errorf("回填会话父子: %w", err)
}
var n int64
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sessions WHERE parent_session_id IS NOT NULL`).Scan(&n); err != nil {
return 0, fmt.Errorf("统计回填结果: %w", err)
}
return n, nil
}
// ListSessionTree 返回这些会话构成的**树**(按最近活动倒序,父先于子)。
//
// parentIDs 为空时返回全部会话的**根节点**(parent 为空的那些);给了就返回
// 这些节点的子树。
//
// ★ depth 上限是**防环兜底**,不是装饰:`parent_session_id` 是自引用,
// 库里一旦有环,递归 CTE 会无限膨胀直到把进程拖垮。
func ListSessionTree(ctx context.Context, parentIDs []string) ([]SessionNode, error) {
// 根条件:parentIDs 为空时找**全部根节点**(parent 为空、或指向已不存在的会话)。
//
// ★ 这里**不能**写成 `session_id IN (NULL)` —— 那个表达式恒不匹配任何行。
// 实测踩过:那样写时 parentIDs 为空这条分支直接返回 0 个节点。
rootCond := `s.parent_session_id IS NULL
OR s.parent_session_id = ''
OR s.parent_session_id NOT IN (SELECT session_id FROM sessions)`
args := []any{maxTreeDepth}
if len(parentIDs) > 0 {
placeholders := make([]string, 0, len(parentIDs))
for _, id := range parentIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
rootCond = `s.session_id IN (` + strings.Join(placeholders, ",") + `)`
}
// CTE 列名用 lvl 而不是 depth —— depth 会与 SELECT 里的 t.depth 撞名。
// 既有 DescendantsRaw 也是 down(mail_id, lvl),照它走。
//
// ★ 计数**不能**写成相关子查询:环下递归会展开 maxTreeDepth 层,每个节点
// 都再跑一次「按 session_id 数 mails」—— 实测 2 节点环直接打满 55s 超时。
// 改成 LEFT JOIN 两个聚合,各自只算一次。
//
// ★ 参数顺序:`t.lvl < ?` 是 SQL 里的第一个占位符($1),parentIDs 随后($2…)。
// 反过来 ⇒ 根查询拿到一个整数、匹配不到任何行,树只返回递归分支那半。
q := `
WITH RECURSIVE tree(session_id, lvl) AS (
SELECT s.session_id, 0
FROM sessions s
WHERE ` + rootCond + `
UNION ALL
SELECT c.session_id, t.lvl + 1
FROM sessions c JOIN tree t ON c.parent_session_id = t.session_id
WHERE t.lvl < ?
)
SELECT s.session_id, s.session_alias, s.subject, s.from_agent,
COALESCE(s.parent_session_id, ''), s.workspace, t.lvl,
COALESCE(kids.n, 0), COALESCE(mc.n, 0),
s.updated_at
FROM tree t
JOIN sessions s ON s.session_id = t.session_id
LEFT JOIN (SELECT parent_session_id AS sid, COUNT(*) AS n
FROM sessions WHERE parent_session_id IS NOT NULL
GROUP BY parent_session_id) kids ON kids.sid = s.session_id
LEFT JOIN (SELECT session_id AS sid, COUNT(*) AS n FROM mails GROUP BY session_id) mc
ON mc.sid = s.session_id
ORDER BY s.updated_at DESC
`
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("列会话树: %w", err)
}
defer rows.Close()
out := []SessionNode{}
for rows.Next() {
var n SessionNode
var alias, subject sql.NullString
if err := rows.Scan(&n.SessionID, &alias, &subject, &n.FromAgent,
&n.ParentSessionID, &n.Workspace, &n.Depth, &n.ChildCnt, &n.MailCnt,
&n.UpdatedAt); err != nil {
return nil, fmt.Errorf("读会话树行: %w", err)
}
n.Alias = alias.String
n.Subject = subject.String
if n.Alias == "" {
n.Alias = n.Subject
}
out = append(out, n)
}
return out, rows.Err()
}
// PruneTree 按可见性剪断树:不可见的节点直接丢弃,**可见节点若其父不可见则重新起根**
// (depth 归零、parent 清空)。
//
// ★ 为什么这是安全边界而不是展示逻辑:树天然会把父节点带给子节点,
//
// 而「我能看见 B」不等于「我能看见 B 的父 A」(A 可能是别人与别人的对话)。
// 只过滤节点、不重算 parent,输出里就带着不可见父的 session_id 与标题 ——
// 一条真实的泄露路径,而且它藏在「树视图」这个新功能里。
//
// 放在 repo 层而不是 handler:可见性判断与剪枝必须和查询同一层,
// 否则 handler 里那份内联实现会与判据里那份悄悄漂移(判据测 A、线上跑 B)。
func PruneTree(nodes []SessionNode, visible map[string]bool) []SessionNode {
byID := make(map[string]SessionNode, len(nodes))
for _, n := range nodes {
byID[n.SessionID] = n
}
// 祖先链:从自己往上走,直到根或遇到不可见的节点。
// 只要**每个**祖先都可见,这一段层级全部保留;一旦某个不可见,
// 就**在它下面重新起根**(那个不可见的节点本身不输出)。
ancestors := func(id string) (chain []string, cutAtInvisible bool) {
cur := id
for cur != "" {
n, ok := byID[cur]
if !ok {
break
}
parent := n.ParentSessionID
if parent == "" {
return chain, false // 到根了
}
if !visible[parent] {
return chain, true // ★ 父不可见 ⇒ 从当前节点起重新起根
}
chain = append([]string{parent}, chain...)
cur = parent
}
return chain, true
}
out := make([]SessionNode, 0, len(nodes))
for _, n := range nodes {
if !visible[n.SessionID] {
continue
}
chain, _ := ancestors(n.SessionID)
n.Depth = len(chain)
if len(chain) == 0 {
// 自己是根(链上没有任何**可见**祖先)。
n.ParentSessionID = ""
} else {
// 挂到链上**最深的那个可见祖先**上。
// (cutAtInvisible 时 chain 首元素就是最近的那个可见祖先,
// 而不是不可见的那个 —— 不可见的绝不写进输出。)
n.ParentSessionID = chain[len(chain)-1]
}
out = append(out, n)
}
return out
}