chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,236 @@
// Package notify 是「一封邮件落库之后要通知谁、推什么」的**唯一实现**。
//
// # 为什么单独成包
//
// 在此之前有两份几乎相同的推送代码:`handler.notifyRecipients`(人发信、
// Agent 发信、转发都走它)和 `scheduler` 里日历提醒自己拼的那一份。
//
// 两份代码的代价在生产上兑现过一次,而且症状离原因很远:给 `new_mail` 加
// `platform_session_id` 字段时只改了 handler 那份,调度器那份仍是旧的。
// 于是日历提醒投进一条**接管会话**时,插件收不到 `platform_session_id`
// 把它当成新会话另开了一条平台会话;那条新会话的名字随后经命名同步回写,
// **把接管会话的别名冲掉了** —— 人在补全里选中的「项目定位」变成了
// 「日程提醒:…」,同一条会话因此在候选列表里出现两次,而另一条真实会话
// 被按别名字符串去重吃掉了。
//
// 链条上每一环都不报错。根因只是「同一件事写了两遍」。
//
// 因此这个包对外只暴露一个入口:新增字段时不存在「另一处忘了改」的可能。
package notify
import (
"context"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// Mail 描述一封刚落库的邮件需要推给谁。
type Mail struct {
SessionID uuid.UUID
MailID uuid.UUID
// From 是发件方名字。人类用户名与 Agent 名共享命名空间,这里不区分。
From string
// To 是主收件方地址(三维寻址已解析)。
To models.Address
// CC 是抄送方地址列表。
CC []models.Address
// Subject 是邮件主题。
Subject string
// MailType 默认 "normal";权限请求等特殊类型由调用方指定。
MailType string
// Origin 标记这封信的来源,供插件与 UI 区分「定时提醒」与「有人在找它」。
// 空串表示普通邮件。
Origin string
// ReplyToName 是「把回信发回这条会话」时该写的收件人名。
//
// 默认取 From。日历提醒必须覆盖它发件人是 `calendar`,而那不是一个
// 收得到信的账号 —— 回给它的信投不出去。此时应当填主收件方自己的名字,
// 让模型把结果回报到同一条线索上。
ReplyToName string
// ParentMailID 非空表示这封是**回信**(回的那封的 mail_id
//
// 插件靠它区分「有人派了新活」与「我上一封信的回复到了」—— 两者对模型而言
// 是完全不同的处境,而在此之前 payload 里没有任何信号能分开它们。
//
// 后果在生产上兜现过pi 转发给 dshdsh 回确认pi 把那封确认当成新任务
// 又回一封,两边互相客套 6 轮直到撞上 hop 上限。
ParentMailID string
}
// Recipients 把一封邮件推给主收件人、所有抄送方,并刷新发件方的会话列表。
//
// # 每个收件方拿到的是**自己那个地址**
//
// 三维地址 `name@path.session` 的 path 就是工作目录,插件靠它建会话。
// 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区,共用一份
// payload 会让抄送方在别人的目录里开会话。`reply_address` / `self_address`
// 同理,且 session 位已经把 `.new` 换成真实别名 —— `.new` 建完会话就失效了,
// 把原文那个 `x@/p.new` 送给参与方只会让它下一次又建一条新会话。
//
// # 抄送方必须单独推
//
// 漏掉的后果很隐蔽:邮件的 cc_list 里有他们、他们**查**收件箱能看到这封信,
// 但没有任何事件推给他们 —— 插件不会唤起会话Agent 直到下一次补拉
// (重启时)才发现。对「知情方」而言等于没通知。
func Recipients(ctx context.Context, m Mail) {
// 别名此时应已由会话解析路径保证存在(`.new` 与默认会话都过
// EnsureSessionAlias。仍可能为空的情形命名写入失败已吐日志
// 此时退回省略 session 位,而不是把 "new" 写进去 —— 后者会让参与方
// 反复建新会话。
alias := repo.SessionAliasOf(ctx, m.SessionID)
// 这条会话是否接管了一条平台侧已存在的会话(人在 TUI/GUI 里开的那种),
// 以及那条平台会话属于哪个 Agent。插件据此决定 resume 还是新建;
// 空串就是过去的行为。
//
// **owner 必须参与分发判据**platform_id 是会话级的一个值,而一封邮件
// 可以有多个参与方。无差别下发会让抄送方拿一个属于别的平台的会话 id
// 去自己磁盘上找文件,找不到就抛「平台侧会话已删」—— 邮件静默消失。
// 生产实测过pi 的会话 `01a05a5e-…` 被推给了抄送方 dsh。
platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)
mailType := m.MailType
if mailType == "" {
mailType = "normal"
}
replyTo := m.ReplyToName
if replyTo == "" {
replyTo = m.From
}
// platformFor 只把 platform_session_id 给归属方。
//
// owner 为空镜像里没这条、sessions.from_agent 也空)时一律不下发:
// 宁可退回「当普通会话处理」(插件新建一条,人在界面上看不到),
// 也不能让一个抽不到归属的 id 把邮件弄丢。
platformFor := func(forName string) string {
if platformID == "" || platformOwner == "" || forName != platformOwner {
return ""
}
return platformID
}
// 发件方是人还是 Agent。
//
// 插件靠它选提示词里那句关键的话:**「插件会自动把你本轮结论发回去」只对
// 人类发件方成立**。发给另一个 Agent 时,那边的插件也会自动回一封,
// 于是两个模型都以为「我只要把话说完就行」,实际上彼此持续唤醒 ——
// 生产实测 pi 与 dsh 互相客套 6 轮直到撞上 hop 上限。
fromHuman, _ := repo.IsHumanUser(ctx, m.From)
// 会话级权限档位与强制力。
//
// 为什么跑在 payload 外:一封邮件可能推给十几个参与方(收件人 + 拄送),
// 而档位是**会话**的属性,每个人都一样 —— 放进闭包里就是每个参与方
// 查一次库。platformFor 那个坑(会话级的值推给所有人)教过的是反面:
// 会话级与参与方级的字段必须分清楚。档位确实是会话级的。
perm, permErr := repo.GetSessionPermission(ctx, m.SessionID)
if permErr != nil {
// 查不到时给默认档 + advisory不能因为一次查询失败就让插件以为自己拿到了全权。
perm = repo.SessionPermission{
Mode: models.DefaultPermissionMode,
Enforcement: models.EnforcementAdvisory,
}
}
payload := func(role, workspace, forName string) map[string]interface{} {
p := map[string]interface{}{
"mail_id": m.MailID.String(),
"session_id": m.SessionID.String(),
"from_name": m.From,
"subject": m.Subject,
"mail_type": mailType,
"role": role, // to / cc
// to_workspace 是收件方地址的 path 位,即希望它在哪个工作目录干活。
// 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在
// 不同的空目录里DSH / opencode 按 cwd 分组时全进「未分组」。
"to_workspace": workspace,
// session_alias 是这条会话今后的寻址名。没有它的话,收到 `.new`
// 邮件的一方只持有一个 send_mail 不接受的 session_id。
"session_alias": alias,
// reply_address 是「把回信发回这条会话」的现成地址。
// 插件不必自己拼(拼错了就是静默开新会话)。
"reply_address": models.FormatAddress(replyTo, "", alias),
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
"self_address": models.FormatAddress(forName, workspace, alias),
// platform_session_id 非空时,这封邮件要投进**平台侧已经存在的
// 那条会话**TUI 与邮箱是同一个 Agent 的两个入口)。
//
// 插件必须 resume 而不是新建:新建会让人在 TUI 里看不到这封邮件
// 带来的对话,而那正是接管这条会话的目的。
//
// **只发给归属方**:其余参与方拿到它只会去自己磁盘上找一个
// 不存在的会话文件,然后按 N-8 报错丢掉这封邮件。
"platform_session_id": platformFor(forName),
// in_reply_to 非空 = 这封是对收件方某封信的**回复**,不是新派的活。
//
// 插件据此换一套提示词:把它当新任务会让模型又“处理”一遍并再回一封,
// 于是两个 Agent 互相客套直到撞上 hop 上限(生产实测 6 轮)。
"in_reply_to": m.ParentMailID,
// from_human 区分「人在找你」与「另一个 Agent 在找你」。
//
// 插件据此不再对 Agent → Agent 的信说「回信不用你自己发」:
// 那句话在那种情形下是假的,而它让模型以为自己只需要「把话说完」。
"from_human": fromHuman,
// permission_mode 声明本任务允许动手到什么程度plan / workspace / full。
//
// 插件必须把它**翻译成平台原生的沙箱/审批配置**,而不是自己按工具名猜着拦:
// 那会同时违反 I-1平台原生信号是唯一真相来源与 I-4插件只搬运不决策
// 而且四个插件对「workspace 到底管什么」必然各猜一套。
"permission_mode": perm.Mode,
// permission_enforcement 是建会话时快照的**事实**native / advisory。
//
// 与 permission_mode 成对下发:前者是要求,后者是对方平台实际做得到。
// 只给前者会让人以为 plan 档把 homeagent 管住了 —— 它的核心没有
// 工具调用拦截点,档位在那里只能写进提示词。
"permission_enforcement": perm.Enforcement,
}
if m.Origin != "" {
p["origin"] = m.Origin
}
return p
}
update := map[string]interface{}{
"session_id": m.SessionID.String(),
"status": "active",
}
// 参与方去重:收件人 + 所有抄送 + 发件方自己(刷新他的发件箱)
seen := map[string]bool{}
sse.Default.SendToRecipient(m.To.Name, "new_mail", payload("to", m.To.Path, m.To.Name))
sse.Default.SendToRecipient(m.To.Name, "session_update", update)
seen[m.To.Name] = true
for _, c := range m.CC {
if seen[c.Name] {
continue
}
seen[c.Name] = true
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path, c.Name))
sse.Default.SendToRecipient(c.Name, "session_update", update)
}
if !seen[m.From] {
sse.Default.SendToRecipient(m.From, "session_update", update)
}
}
// SessionActive 只刷新某一方的会话列表,不推 new_mail。
//
// 用于「日历事件的创建者该知道提醒发出去了」这类场景:他不是收件方,
// 不该收到一封信的通知,但需要看到那条会话活跃起来 —— 否则
// 「我设的提醒到底触发了没有」只能去翻 journalctl。
func SessionActive(name string, sessionID uuid.UUID) {
if name == "" {
return
}
sse.Default.SendToRecipient(name, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
}

View File

@ -0,0 +1,171 @@
package notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
func setupTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), filepath.Join(dir, "test.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(db.Close)
}
func seedAgent(t *testing.T, name string) {
t.Helper()
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO agents (agent_name, secret, platform, status) VALUES ($1, 'x', $1, 'online')`,
name); err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
}
// attach 挂一个真实的 SSE 客户端并返回「读出这个 Agent 收到的 new_mail payload」的闭包。
//
// 走真实的 sse.Default 而不是替换发送函数:要验的正是「谁收到什么」,
// 而分发逻辑就在 Manager 里 —— 把它换掉等于不验。
func attach(t *testing.T, agentName string) func() map[string]any {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/events/stream", nil)
c := sse.Default.AddClient(rec, req, agentName, "")
if c == nil {
t.Fatalf("AddClient(%s) 返回 nil", agentName)
}
t.Cleanup(func() { sse.Default.RemoveClient(c.ID) })
return func() map[string]any {
// SSE 帧形如 `id: N\nevent: new_mail\ndata: {…}\n\n`
for _, frame := range strings.Split(rec.Body.String(), "\n\n") {
if !strings.Contains(frame, "event: new_mail") {
continue
}
for _, line := range strings.Split(frame, "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &m); err == nil {
return m
}
}
}
return nil
}
}
// seedAdopted 建一条接管了 owner 的平台会话的本侧会话。
func seedAdopted(t *testing.T, owner, platformID, workspace string) uuid.UUID {
t.Helper()
ctx := context.Background()
if err := repo.ReplacePlatformSessions(ctx, owner, []repo.PlatformSession{
{PlatformID: platformID, Workspace: workspace, Slug: "项目定位", Title: "项目定位"},
}); err != nil {
t.Fatalf("ReplacePlatformSessions: %v", err)
}
id, err := repo.AdoptPlatformSession(ctx, owner, platformID, "项目定位", workspace, "项目定位")
if err != nil {
t.Fatalf("AdoptPlatformSession: %v", err)
}
return id
}
// platform_session_id 只该发给归属方。
//
// 生产事故:会话接管了 pi 的 `01a05a5e-…`,而那封邮件抄送了 dsh。DSH 收到同一个
// id在 `~/.dsh/sessions/` 里查不到(那是 `/root/.pi/agent/sessions/` 下的文件),
// 于是按 N-8 抛「平台侧会话已删」——邮件静默消失,日志里一个字都没有。
func TestRecipients_PlatformIDOnlyToOwner(t *testing.T) {
setupTestDB(t)
seedAgent(t, "pi")
seedAgent(t, "dsh")
readPi, readDsh := attach(t, "pi"), attach(t, "dsh")
sessionID := seedAdopted(t, "pi", "pid-pi-1", "/w")
Recipients(context.Background(), Mail{
SessionID: sessionID,
MailID: uuid.New(),
From: "jianf",
To: models.Address{Name: "pi", Path: "/w"},
CC: []models.Address{{Name: "dsh", Path: "/w"}},
Subject: "任务",
})
pi, dsh := readPi(), readDsh()
if pi == nil {
t.Fatal("归属方 pi 没收到 new_mail")
}
if dsh == nil {
t.Fatal("抄送方 dsh 没收到 new_mail抄送方必须单独推")
}
if v := pi["platform_session_id"]; v != "pid-pi-1" {
t.Errorf("归属方 pi 的 platform_session_id = %v, want pid-pi-1", v)
}
if v := dsh["platform_session_id"]; v != "" {
t.Errorf("抄送方 dsh 的 platform_session_id = %v, want 空串(那是 pi 的会话文件)", v)
}
}
// 归属方在抄送位上也要拿到:归属与收件角色无关。
func TestRecipients_PlatformIDToOwnerEvenAsCC(t *testing.T) {
setupTestDB(t)
seedAgent(t, "pi")
seedAgent(t, "dsh")
readPi, readDsh := attach(t, "pi"), attach(t, "dsh")
sessionID := seedAdopted(t, "pi", "pid-pi-2", "/w")
Recipients(context.Background(), Mail{
SessionID: sessionID,
MailID: uuid.New(),
From: "jianf",
To: models.Address{Name: "dsh", Path: "/w"},
CC: []models.Address{{Name: "pi", Path: "/w"}},
Subject: "任务",
})
if v := readPi()["platform_session_id"]; v != "pid-pi-2" {
t.Errorf("抄送位上的归属方 pi = %v, want pid-pi-2", v)
}
if v := readDsh()["platform_session_id"]; v != "" {
t.Errorf("主收件人 dsh = %v, want 空串", v)
}
}
// 普通(非接管)会话:谁都不该拿到 platform id。
func TestRecipients_PlainSessionNoPlatformID(t *testing.T) {
setupTestDB(t)
seedAgent(t, "pi")
readPi := attach(t, "pi")
id, err := repo.CreateSession(context.Background(), nil, "pi", "普通", "/w")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
Recipients(context.Background(), Mail{
SessionID: id, MailID: uuid.New(), From: "jianf",
To: models.Address{Name: "pi", Path: "/w"}, Subject: "任务",
})
if v := readPi()["platform_session_id"]; v != "" {
t.Errorf("普通会话 = %v, want 空串", v)
}
}