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 } // SessionParentPreview 是「若现在回填,将会写入哪条父子关系」。 type SessionParentPreview struct { SessionID string `json:"session_id"` Alias string `json:"alias"` WillSetParent string `json:"will_set_parent_id"` WillSetAlias string `json:"will_set_parent_alias"` ViaMail string `json:"via_mail_subject"` } // PreviewSessionParents 返回「若现在回填,会写入哪些父子关系」,**只读**。 // // ★ 为什么要有它:用户 2026-10-04 要求「一切数据调用都要接口化」, // // 于是验证也必须走接口 —— 而**不得**直连库。那么「回填到底会改什么」 // 就需要一条不写任何东西的接口来回答,否则只能「先写了再看看对不对」。 // // 顺带让无参 POST 变成安全的:默认走预览(见 handler 的 dry_run 处理)。 func PreviewSessionParents(ctx context.Context) ([]SessionParentPreview, error) { rows, err := db.DB.QueryContext(ctx, ` SELECT c.session_id, COALESCE(c.session_alias, c.subject, ''), ps.session_id, COALESCE(ps.session_alias, ps.subject, ''), COALESCE(pm.subject, '') FROM sessions c JOIN mails m ON m.session_id = c.session_id JOIN mails pm ON pm.mail_id = m.parent_mail_id JOIN sessions ps ON ps.session_id = pm.session_id WHERE c.parent_session_id IS NULL AND ps.session_id <> c.session_id ORDER BY c.updated_at DESC `) if err != nil { return nil, fmt.Errorf("预览会话父子: %w", err) } defer rows.Close() out := []SessionParentPreview{} seen := map[string]bool{} for rows.Next() { var p SessionParentPreview if err := rows.Scan(&p.SessionID, &p.Alias, &p.WillSetParent, &p.WillSetAlias, &p.ViaMail); err != nil { return nil, fmt.Errorf("读预览行: %w", err) } // 一个子节点只报一次(与 BackfillSessionParents 的 … LIMIT 1 一致) if seen[p.SessionID] { continue } seen[p.SessionID] = true out = append(out, p) } return out, rows.Err() }