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) } }