diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 12e0a18..1c89ccc 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -304,6 +304,13 @@ func main() { // 刻意**不进 MCP 工具面**:调用方是模型,误判一次就是真丢数据。 // Agent 要结束线索走归档(那是不可逆性低得多的操作)。 r.Delete("/admin/sessions/{id}", handler.AdminDeleteSession) + + // 会话级线索树回填(2026-10-04)。★ 是接口而非 Migrate 里的 + // 自动步骤:它改的是**数据**(真实会话拓扑)而不是结构, + // 混用迁移通道会导致不可重跑、不可审计、看不出谁改的。 + // 默认 dry-run,显式 dry_run=0 才写入。 + r.Post("/admin/sessions/tree/backfill", handler.AdminBackfillSessionParents) + r.Get("/admin/sessions/tree/preview", handler.AdminPreviewSessionParents) r.Post("/admin/agent-keys/{id}/bind", handler.BindAgentKey) // Agent 发信配额 diff --git a/server/internal/db/migrate.go b/server/internal/db/migrate.go index a71eb3b..e51fa73 100644 --- a/server/internal/db/migrate.go +++ b/server/internal/db/migrate.go @@ -43,20 +43,6 @@ func Migrate(ctx context.Context) error { return fmt.Errorf("migrate: 未初始化的方言") } - // 会话级线索树的一次性回填(2026-10-04)。 - // - // 为什么**不**每次 Migrate 都跑:它会改**真实会话的拓扑**(给 6 条会话写上 - // parent_session_id)。若判据后来变宽、或有人手工调整过 parent,那些改动 - // 会在重启时被悄悄改回去 —— 「不可见的人工意图」正是既有 backfillMailReads - // 用 marker 守住的原因(见它的注释:每次跑会把「某抄送方读过」按主收件人 - // 写成已读)。 - // - // 回填口径见 repo.BackfillSessionParents:只认**直接跨会话分叉**,且不改 - // 已有 parent_session_id 的会话。 - if err := backfillSessionParentsOnce(ctx); err != nil { - return err - } - if err := backfillMailReads(ctx); err != nil { return err } @@ -417,53 +403,3 @@ func columnExists(ctx context.Context, table, column string) (bool, error) { table, column).Scan(&n) return n > 0, err } - -// backfillSessionParentsOnce 给跨会话分叉的会话补 parent_session_id,只跑一次。 -// -// 一次性守卫的原因见 Migrate 里那段注释:它改的是会话**拓扑**,不是派生数据。 -func backfillSessionParentsOnce(ctx context.Context) error { - const marker = "session_tree_backfill_v1" - var v string - err := DB.QueryRowContext(ctx, `SELECT value FROM app_meta WHERE key = $1`, marker).Scan(&v) - if err == nil { - return nil // 已回填过 - } - if !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("backfill session_tree: 读标记失败: %w", err) - } - - if _, err := 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 fmt.Errorf("backfill session_tree: 回填失败: %w", err) - } - - if _, err := DB.ExecContext(ctx, - `INSERT INTO app_meta (key, value) VALUES ($1, $2)`, marker, - time.Now().UTC().Format(time.RFC3339)); err != nil { - return fmt.Errorf("backfill session_tree: 写标记失败: %w", err) - } - fmt.Println("会话线索树回填完成") - return nil -} diff --git a/server/internal/handler/session_tree_admin.go b/server/internal/handler/session_tree_admin.go new file mode 100644 index 0000000..6f5733b --- /dev/null +++ b/server/internal/handler/session_tree_admin.go @@ -0,0 +1,83 @@ +package handler + +/* +POST /api/v1/admin/sessions/tree/backfill —— 会话级线索树回填(2026-10-04)。 + +# 为什么是接口而不是 Migrate 里的自动步骤 + +用户 2026-10-04 明确要求「一切数据调用都要接口化」。而回填是**数据操作**: +它改真实会话的拓扑、可重跑、需要留审计痕迹。`Migrate` 是**结构**变更通道 +(建表、加列),让��兼做数据改写是混用通道 —— 后果是不可重跑、不可通过 +接口触发、看不出谁在什么时候改的。 + +⇒ 从 Migrate 移到这里,挂在 AdminOnly 组下(同一档的还有 + `DELETE /admin/sessions/{id}`、`POST /admin/agent-keys`)。 + +# dry_run:先看后写 + +默认 `dry_run=1` —— **只返回将要写入的父子关系,不落库**。回填会改会话拓扑, +不能靠「先写了再检查对不对」。管理员确认后显式传 `dry_run=0` 才真正写入。 + +# 幂等 + +`BackfillSessionParents` 只写 `parent_session_id IS NULL` 的会话, +所以重跑安全;人工设置过的父**不会被覆盖**(判据 +`TestBackfillSessionParentsRunsOnlyOnce`)。 +*/ + +import ( + "net/http" + "strconv" + + "github.com/agentmail/gateway/internal/repo" +) + +// AdminPreviewSessionParents 只读预览:若现在回填,会写哪些父子关系。 +func AdminPreviewSessionParents(w http.ResponseWriter, r *http.Request) { + preview, err := repo.PreviewSessionParents(r.Context()) + if err != nil { + Error(w, http.StatusInternalServerError, "预览会话父子失败") + return + } + JSON(w, http.StatusOK, map[string]any{ + "would_set": preview, + "count": len(preview), + "dry_run": true, + }) +} + +// AdminBackfillSessionParents 执行回填。 +// +// 默认 dry-run;必须显式 `dry_run=0` 才写 —— 免得误点一次就改了真实拓扑。 +func AdminBackfillSessionParents(w http.ResponseWriter, r *http.Request) { + // ★ 默认 dry-run,**且 `dry_run` 未指定时也走预览**。 + // + // 初版写成 `if v := …; v != "" { … }` —— 参数缺失时整个 if 落空, + // 直接执行回填。也就是注释写着「默认 dry-run」而代码**默认就写**。 + // 后果:误点一次 POST 就改了 6 条真实会话的拓扑,且没有任何预览。 + // 判据 TestBackfillDefaultsToDryRun 抓到的就是这一条(它红得对)。 + dry := true // 默认预览 + if v := r.URL.Query().Get("dry_run"); v != "" { + parsed, err := strconv.ParseBool(v) + if err != nil { + Error(w, http.StatusBadRequest, "dry_run 必须是 true/false") + return + } + dry = parsed + } + if dry { + AdminPreviewSessionParents(w, r) + return + } + + n, err := repo.BackfillSessionParents(r.Context()) + if err != nil { + Error(w, http.StatusInternalServerError, "回填会话父子失败") + return + } + JSON(w, http.StatusOK, map[string]any{ + "status": "backfilled", + "parents": n, + "dry_run": false, + }) +} diff --git a/server/internal/handler/session_tree_admin_test.go b/server/internal/handler/session_tree_admin_test.go new file mode 100644 index 0000000..415796c --- /dev/null +++ b/server/internal/handler/session_tree_admin_test.go @@ -0,0 +1,248 @@ +package handler + +/* +回填端点的判据(2026-10-04)。 + +★ 这批判据盯的是**接口化本身**(用户 2026-10-04:「一切数据调用都要接口化」): + + 1. 回填**不在** Migrate 里 —— 那是结构变更通道,让它改数据(会话拓扑) + 会导致不可重跑、不可通过接口触发、看不出谁改的。 + 2. 端点挂在 AdminOnly 组 —— 会话拓扑是全站数据,非 admin 不该能改。 + 3. **默认 dry-run** —— 免得误点一次就改了 6 条真实会话的父子关系。 + 4. `PreviewSessionParents` 只读 —— 验证也必须走接口(不直连库), + 所以需要一个「不写也能看到将发生什么」的接口。 +*/ + +import ( + "context" + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/repo" + "github.com/google/uuid" +) + +func readMain(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("../../cmd/server/main.go") + if err != nil { + t.Fatalf("读 main.go: %v", err) + } + return string(b) +} + +// ★ 回填不得留在 Migrate 里。 +func TestSessionBackfillNotInMigrate(t *testing.T) { + b, err := os.ReadFile("../db/migrate.go") + if err != nil { + t.Fatalf("读 migrate.go: %v", err) + } + src := string(b) + for _, forbidden := range []string{"parent_session_id = (", "UPDATE sessions"} { + if strings.Contains(src, forbidden) { + t.Errorf("★ migrate.go 里出现了 %q —— 回填是**数据**改写,不该混进结构迁移通道。"+ + "\n 后果:不可重跑、不可经接口触发、看不出谁在什么时候改的。", forbidden) + } + } +} + +// ★ 两个 admin 端点都要挂在 AdminOnly 组里。 +func TestSessionTreeAdminRoutesGuarded(t *testing.T) { + src := readMain(t) + + adminGroupStart := strings.Index(src, "middleware.AdminOnly") + if adminGroupStart < 0 { + t.Fatal("找不到 AdminOnly 分组") + } + // 取 AdminOnly 分组之后的一段(到下一个 Group 或文件尾) + rest := src[adminGroupStart:] + if end := strings.Index(rest[1:], "r.Group("); end > 0 { + rest = rest[:end] + } + + for _, route := range []string{ + "/admin/sessions/tree/backfill", + "/admin/sessions/tree/preview", + } { + if !strings.Contains(rest, route) { + t.Errorf("★ %s 不在 AdminOnly 分组里 —— 会话拓扑是全站数据,非 admin 不该能改/能看", route) + } + } +} + +// ★ 默认必须 dry-run —— 测**行为**,不测源码形状。 +// +// 判据演进(2026-10-04):初版读源码、数 `AdminPreviewSessionParents` +// 出现几次。变异 `dry := false`(正是初版那个真 bug)之后,判据**依然全绿** +// —— 因为 `if dry {...}` 分支在 false 时也存在于源码里。 +// ⇒ 形状判据第四次被形状骗。改成真起 httptest、直接发一个不带参数的 POST, +// +// 然后去**库里**看 parent 有没有被写进去。 +func TestBackfillDefaultsToDryRun(t *testing.T) { + setupTreeAdminHandlerDB(t) + ctx := context.Background() + + parent := mustTreeSession(t, "父") + child := mustTreeSession(t, "子") + pm := mustTreeMail(t, parent, "父里的原邮件", "") + mustTreeMail(t, child, "子里的回信", pm) + + // 不带任何参数 —— 这正是「误点一次」的场景 + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/sessions/tree/backfill", nil) + AdminBackfillSessionParents(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("应 200,实际 %d:%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"dry_run":true`) { + t.Errorf("★ 无参 POST 的响应应表明这是预览,实际:%s", rec.Body.String()) + } + + // 真正的判据在库里:不该被写进去 + var got sql.NullString + if err := db.DB.QueryRowContext(ctx, + `SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil { + t.Fatalf("读父: %v", err) + } + if got.Valid { + t.Errorf("★ 无参 POST 就把 parent_session_id 写成了 %q —— 一次误点改了真实会话拓扑", got.String) + } + + // 显式 dry_run=0 才写 + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodPost, + "/api/v1/admin/sessions/tree/backfill?dry_run=0", nil) + AdminBackfillSessionParents(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("显式写入应 200,实际 %d:%s", rec2.Code, rec2.Body.String()) + } + if err := db.DB.QueryRowContext(ctx, + `SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil { + t.Fatalf("读父: %v", err) + } + if got.String != parent { + t.Errorf("dry_run=0 后应写入 %s,实际 %q", parent, got.String) + } + + // 预览端点在**有真实分叉待写**时也只读 + child2 := mustTreeSession(t, "子2") + mustTreeMail(t, child2, "又一条回信", pm) + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest(http.MethodGet, + "/api/v1/admin/sessions/tree/preview", nil) + AdminPreviewSessionParents(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("预览应 200,实际 %d", rec3.Code) + } + if !strings.Contains(rec3.Body.String(), child2) { + t.Errorf("★ 预览应列出将要写入的 %s(验证必须有不写库的路径),实际:%s", + child2, rec3.Body.String()) + } + if err := db.DB.QueryRowContext(ctx, + `SELECT parent_session_id FROM sessions WHERE session_id = ?`, child2).Scan(&got); err != nil { + t.Fatalf("读父: %v", err) + } + if got.Valid { + t.Errorf("★ 预览端点写了库(parent=%q)", got.String) + } +} + +// ★ 预览接口必须只读(不能含写操作)。 +func TestPreviewEndpointIsReadOnly(t *testing.T) { + b, err := os.ReadFile("../repo/session_tree.go") + if err != nil { + t.Fatalf("读 repo: %v", err) + } + src := string(b) + i := strings.Index(src, "func PreviewSessionParents") + if i < 0 { + t.Fatal("找不到 PreviewSessionParents —— 接口化后验证必须有不写库的路径") + } + // ★ 按**行**取到下一个顶格 `func ` 为止,而不是 strings.Index("\nfunc ")。 + // Index 那版在两次实测里都取错了段(一次取到别的函数的 UPDATE、 + // 一次在函数是文件最后一个时落空而取了全文)—— 同一族「观察窗口不对」。 + // 行扫描的语义就是我要的:这个函数体到下一个函数声明为止。 + lines := strings.Split(src[i:], "\n") + var body []string + for _, ln := range lines { + if len(body) > 0 && strings.HasPrefix(ln, "func ") { + break + } + body = append(body, ln) + } + seg := strings.Join(body, "\n") + // ★ 必须按**关键字边界**匹配,不能 strings.Contains。 + // 初版判据对整段做 Contains("UPDATE"),而查询里的 + // `ORDER BY c.updated_at DESC` 含有 "updated" ⇒ 判据把一个**只读**的 + // 预览函数判成会写库。这已是本轮第三次「匹配比语义宽」: + // 前两次是 .d.ts 的 `parent_mail_id = NULL` 命中注释、 + // 以及 session-{id,log} 的正则多写了一个 `\.`。 + for _, w := range []string{"INSERT", "UPDATE", "DELETE"} { + re := regexp.MustCompile(`(?i)\b` + w + `\s`) + if re.MatchString(seg) { + t.Errorf("★ PreviewSessionParents 里出现 SQL 关键字 %s ⇒ 预览接口会写库", w) + } + } +} + +var _ = http.StatusOK + +func setupTreeAdminHandlerDB(t *testing.T) { + t.Helper() + db.Close() + path := filepath.Join(t.TempDir(), "tree-admin-handler.db") + if err := db.Connect(context.Background(), "sqlite://"+path); err != nil { + t.Fatalf("连接测试库: %v", err) + } + if err := db.Migrate(context.Background()); err != nil { + t.Fatalf("迁移测试库: %v", err) + } + t.Cleanup(db.Close) +} + +func mustTreeSession(t *testing.T, alias 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,'x','active','/tmp','pi') RETURNING session_id`, alias).Scan(&id); err != nil { + t.Fatalf("建会话: %v", err) + } + return id +} + +func mustTreeMail(t *testing.T, session, subject, parent string) string { + t.Helper() + ctx := context.Background() + var parentPtr *uuid.UUID + if parent != "" { + pid, err := uuid.Parse(parent) + if err != nil { + t.Fatalf("解析父邮件: %v", err) + } + parentPtr = &pid + } + sid, err := uuid.Parse(session) + if err != nil { + t.Fatalf("解析会话: %v", err) + } + if _, err := repo.CreateMail(ctx, sid, parentPtr, "pi", "/tmp", "dsh", "/tmp", + subject, "", nil); err != nil { + t.Fatalf("建邮件: %v", err) + } + var id string + if err := db.DB.QueryRowContext(ctx, + `SELECT mail_id FROM mails WHERE session_id = ? ORDER BY created_at DESC LIMIT 1`, + session).Scan(&id); err != nil { + t.Fatalf("取邮件: %v", err) + } + return id +} diff --git a/server/internal/repo/session_tree.go b/server/internal/repo/session_tree.go index 4903ae8..95de631 100644 --- a/server/internal/repo/session_tree.go +++ b/server/internal/repo/session_tree.go @@ -256,3 +256,56 @@ func PruneTree(nodes []SessionNode, visible map[string]bool) []SessionNode { } 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() +}