Files
MailUI4Agents/server/internal/db/migrate_session_tree_test.go
JianFeeeee cd894d5e4d 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 包全绿。
2026-10-04 12:03:58 +08:00

64 lines
2.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package db
/*
一次性回填守卫的判据(2026-10-04)。
★ 这一格**必须放在 db 包**:守卫(`app_meta` 标记)在 migrate.go 里,
而回填实现在 repo 包。放在 repo 包测就只测到「回填本身幂等」,
测不到「Migrate 二次执行会不会覆盖人工设置」—— 那正是需要防的事。
后果不是理论上的:回填改的是会话**拓扑**。若每次 Migrate 都跑,
「某人手工把 parent 改对」这个意图会在每次重启时被悄悄改回去。
既有 backfillMailReads 踩过同族坑(其注释:每次跑会把「某抄送方读过」
按主收件人写成已读,正是那次要修的语义错误)。
*/
import (
"context"
"testing"
)
func TestSessionParentsBackfillRunsOnlyOnce(t *testing.T) {
Close()
Connect(context.Background(), "sqlite://"+t.TempDir()+"/once.db")
if err := Migrate(context.Background()); err != nil {
t.Fatalf("首次迁移: %v", err)
}
t.Cleanup(Close)
ctx := context.Background()
// Migrate 已跑过一次 ⇒ 标记已写 ⇒ 之后的回填都不该再动任何东西。
manual := mustSession(t, "manual-parent")
child := mustSession(t, "child")
if _, err := DB.ExecContext(context.Background(), `UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`,
manual, child); err != nil {
t.Fatalf("设人工父: %v", err)
}
// 第二次 Migrate(模拟重启)
if err := Migrate(context.Background()); err != nil {
t.Fatalf("二次迁移: %v", err)
}
var got string
if err := DB.QueryRowContext(ctx, `SELECT parent_session_id FROM sessions WHERE session_id = ?`,
child).Scan(&got); err != nil {
t.Fatalf("读父: %v", err)
}
if got != manual {
t.Errorf("★ 二次 Migrate 覆盖了人工设置的 parent_session_id:%s ⇒ 应为 %s", got, manual)
}
}
func mustSession(t *testing.T, alias string) string {
t.Helper()
ctx := context.Background()
var id string
if err := DB.QueryRowContext(ctx,
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
VALUES ($1,'x','active','/tmp','pi') RETURNING session_id`, alias).Scan(&id); err != nil {
t.Fatalf("建会话: %v", err)
}
return id
}