## 要解决的问题
用户 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 包全绿。
254 lines
8.6 KiB
Go
254 lines
8.6 KiB
Go
package repo
|
||
|
||
/*
|
||
会话级线索树的判据(2026-10-04)。
|
||
|
||
# 这一格最要紧的是**环**
|
||
|
||
`parent_session_id` 是自引用字段。加不加 FK 约束都可能被人填出环(A→B→A),
|
||
而读取走递归 CTE —— 环会让它**无限膨胀直到拖垮进程**。所以:
|
||
|
||
· 写入侧:回填只认「直接父邮件所在的会话」,且不回填已有值的会话
|
||
· 读取侧:**必须**带 depth 上限(maxTreeDepth)
|
||
|
||
第 2 条是兜底,不是装饰:环是**数据问题**,而数据问题必须在读取侧活下来,
|
||
否则一次误操作会变成「整个端点挂死」。
|
||
|
||
# 为什么不做「沿链上溯到根」
|
||
|
||
那样会把 A→B→C 三跳压成「A 是 C 的祖先」,而中间那条 B 线索恰恰是独立的
|
||
协作对象(参与方可能不同)。压平会让拓扑失真 —— 比没有树更糟。
|
||
所以只认直接跨会话分叉。
|
||
*/
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
)
|
||
|
||
func setupTreeDB(t *testing.T) {
|
||
t.Helper()
|
||
db.Close()
|
||
if err := db.Connect(context.Background(), "sqlite://"+t.TempDir()+"/tree.db"); err != nil {
|
||
t.Fatalf("连接测试库: %v", err)
|
||
}
|
||
if err := db.Migrate(context.Background()); err != nil {
|
||
t.Fatalf("迁移: %v", err)
|
||
}
|
||
t.Cleanup(db.Close)
|
||
}
|
||
|
||
func mkSession(t *testing.T, alias, subject string) string {
|
||
t.Helper()
|
||
var id string
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
|
||
VALUES ($1,$2,'active','/tmp','pi') RETURNING session_id`, alias, subject).Scan(&id); err != nil {
|
||
t.Fatalf("建会话 %s: %v", alias, err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
func mkMail(t *testing.T, sid, subject, parentID string) string {
|
||
t.Helper()
|
||
var p any
|
||
if parentID != "" {
|
||
p = parentID
|
||
}
|
||
var id string
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`INSERT INTO mails (mail_id, session_id, from_name, to_workspace, to_name, subject, body, status, parent_mail_id)
|
||
VALUES (gen_random_uuid(),$1,'pi','/tmp','dsh',$2,'x','unread',$3) RETURNING mail_id`,
|
||
sid, subject, p).Scan(&id); err != nil {
|
||
t.Fatalf("建邮件 %s: %v", subject, err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
// ★ 核心:跨会话分叉要连成树。
|
||
|
||
func TestBackfillLinksCrossSessionForks(t *testing.T) {
|
||
setupTreeDB(t)
|
||
parent := mkSession(t, "parent-line", "原始线索")
|
||
child := mkSession(t, "child-line", "接手继续")
|
||
|
||
pid := mkMail(t, parent, "交给 B", "")
|
||
mkMail(t, child, "B 接手", pid)
|
||
|
||
n, err := BackfillSessionParents(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("回填失败: %v", err)
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("应回填 1 条 parent_session_id,实际 %d", n)
|
||
}
|
||
|
||
nodes, err := ListSessionTree(context.Background(), nil)
|
||
if err != nil {
|
||
t.Fatalf("列树失败: %v", err)
|
||
}
|
||
if len(nodes) != 2 {
|
||
t.Fatalf("树应有 2 个节点,实际 %d:%+v", len(nodes), nodes)
|
||
}
|
||
// 父 depth=0 子 depth=1,且父的 child_count=1
|
||
var parentNode, childNode *SessionNode
|
||
for i := range nodes {
|
||
if nodes[i].SessionID == parent {
|
||
parentNode = &nodes[i]
|
||
} else if nodes[i].SessionID == child {
|
||
childNode = &nodes[i]
|
||
}
|
||
}
|
||
if parentNode == nil || childNode == nil {
|
||
t.Fatalf("两个节点都该在树里:%+v", nodes)
|
||
}
|
||
if childNode.Depth != 1 {
|
||
t.Errorf("子节点 depth 应为 1(缩进一级),实际 %d", childNode.Depth)
|
||
}
|
||
if parentNode.ChildCnt != 1 {
|
||
t.Errorf("父节点 child_count 应为 1,实际 %d", parentNode.ChildCnt)
|
||
}
|
||
}
|
||
|
||
// ★ 环不能挂死请求:库里已有环时,读取必须仍能返回。
|
||
|
||
func TestListSessionTreeSurvivesCycle(t *testing.T) {
|
||
setupTreeDB(t)
|
||
a := mkSession(t, "a", "A")
|
||
b := mkSession(t, "b", "B")
|
||
// 人为造环:A→B→A
|
||
if _, err := db.DB.ExecContext(context.Background(),
|
||
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, b, a); err != nil {
|
||
t.Fatalf("设 A 的父: %v", err)
|
||
}
|
||
if _, err := db.DB.ExecContext(context.Background(),
|
||
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, a, b); err != nil {
|
||
t.Fatalf("设 B 的父: %v", err)
|
||
}
|
||
|
||
// 有 depth 上限 ⇒ 这条递归会在 maxTreeDepth 处停下并返回,不会无限增长。
|
||
//
|
||
// ★ 这格真正要防的是「挂死」:初版把计数写成相关子查询,环下每个节点
|
||
// 都重跑一次 mails 计数 —— 实测 2 节点环打满 **55s 超时**。
|
||
// 改成 LEFT JOIN 聚合后是 0.01s 级。
|
||
//
|
||
// 注意环节点**不会**出现在结果里(根条件要求 parent 指向已不存在的会话,
|
||
// 而环上的 parent 真实存在 ⇒ 起点那条被排除,返回 0 个节点)。
|
||
// 那是**符合预期的**:环是坏数据,不该出现在树里。
|
||
// 但「被排除」不等于「不会打挂」—— 递归仍会跑,所以这格守的是后者。
|
||
st := time.Now()
|
||
nodes, err := ListSessionTree(context.Background(), []string{a})
|
||
elapsed := time.Since(st)
|
||
if err != nil {
|
||
t.Fatalf("★ 环不应让读取失败/挂死,实际报错: %v", err)
|
||
}
|
||
if elapsed > 5*time.Second {
|
||
t.Errorf("★ 环下查询耗时 %v(>5s)⇒ 防环/性能兜底失效", elapsed)
|
||
}
|
||
for _, n := range nodes {
|
||
if n.Depth > maxTreeDepth {
|
||
t.Fatalf("★ depth 超过上限 %d ⇒ 防环兜底失效", maxTreeDepth)
|
||
}
|
||
}
|
||
t.Logf("环下返回 %d 个节点(最大 depth=%d)", len(nodes), maxDepthOf(nodes))
|
||
}
|
||
|
||
func maxDepthOf(ns []SessionNode) int {
|
||
m := 0
|
||
for _, n := range ns {
|
||
if n.Depth > m {
|
||
m = n.Depth
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// ★ 回填不得覆盖人工设置的父。
|
||
|
||
func TestBackfillDoesNotOverwriteManual(t *testing.T) {
|
||
setupTreeDB(t)
|
||
parent := mkSession(t, "p", "父")
|
||
child := mkSession(t, "c", "子")
|
||
manual := mkSession(t, "manual-parent", "人工指定的父")
|
||
if _, err := db.DB.ExecContext(context.Background(),
|
||
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, manual, child); err != nil {
|
||
t.Fatalf("设人工父: %v", err)
|
||
}
|
||
// 再造一封真实分叉邮件(回填会想写它)
|
||
pid := mkMail(t, parent, "真分叉", "")
|
||
mkMail(t, child, "子里的回信", pid)
|
||
|
||
if _, err := BackfillSessionParents(context.Background()); err != nil {
|
||
t.Fatalf("回填失败: %v", err)
|
||
}
|
||
var got string
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
|
||
t.Fatalf("读父: %v", err)
|
||
}
|
||
if got != manual {
|
||
t.Errorf("★ 回填覆盖了人工设置的父:%s ⇒ 应为 %s", got, manual)
|
||
}
|
||
}
|
||
|
||
// ★ 同会话内的邮件父子**不该**产生会话父子(那会让会话自己指向自己)。
|
||
|
||
func TestBackfillIgnoresIntraSessionMail(t *testing.T) {
|
||
setupTreeDB(t)
|
||
sid := mkSession(t, "same", "同会话内续谈")
|
||
a := mkMail(t, sid, "第一封", "")
|
||
mkMail(t, sid, "第二封", a)
|
||
|
||
n, err := BackfillSessionParents(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("回填失败: %v", err)
|
||
}
|
||
if n != 0 {
|
||
t.Errorf("同会话内的邮件父子不该建会话父子,实际回填 %d 条", n)
|
||
}
|
||
}
|
||
|
||
// ★ 一次性守卫:回填**只能跑一次**。
|
||
//
|
||
// 为什么这格要紧:回填改的是会话**拓扑**(parent_session_id),不是派生数据。
|
||
// 若每次 Migrate 都跑,那么「某人手工把 parent 改对/改空」这个意图会在每次
|
||
// 重启时被悄悄改回去 —— 与既有 backfillMailReads 踩过的坑同族(它的注释写着:
|
||
// 每次跑会把「某抄送方读过」按主收件人写成已读,正是那次要修的语义错误)。
|
||
//
|
||
// 判据形状:回填后再手工改一个 parent,**再次 Migrate**,那个改动必须还在。
|
||
func TestBackfillSessionParentsRunsOnlyOnce(t *testing.T) {
|
||
setupTreeDB(t)
|
||
parent := mkSession(t, "p", "父")
|
||
child := mkSession(t, "c", "子")
|
||
pid := mkMail(t, parent, "分叉", "")
|
||
mkMail(t, child, "子里的回信", pid)
|
||
|
||
if _, err := BackfillSessionParents(context.Background()); err != nil {
|
||
t.Fatalf("首次回填: %v", err)
|
||
}
|
||
|
||
// 手工改一个**不同于自动推断**的值(自动会写 parent,这里写成手工的别的会话)
|
||
manual := mkSession(t, "manual", "人工指定的父")
|
||
if _, err := db.DB.ExecContext(context.Background(),
|
||
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, manual, child); err != nil {
|
||
t.Fatalf("设人工父: %v", err)
|
||
}
|
||
|
||
// 第二次回填(模拟重启后 Migrate 又跑)
|
||
if _, err := BackfillSessionParents(context.Background()); err != nil {
|
||
t.Fatalf("二次回填: %v", err)
|
||
}
|
||
|
||
var got string
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
|
||
t.Fatalf("读父: %v", err)
|
||
}
|
||
if got != manual {
|
||
t.Errorf("★ 二次回填覆盖了人工设置:%s ⇒ 应为 %s", got, manual)
|
||
}
|
||
}
|