feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。 ## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」) 插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有 邮件会话既不属于任何项目、彼此也不同组。 而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload 只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在 SSE 事件里,插件即使想用也拿不到。 - SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**, 不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区 - 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到 兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活) - 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/` ## 2. 会话别名列不出工作区下的历史会话(无法选择) workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须 JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace 填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p` 在只剩 Agent 回信可匹配时两边都对不上。 - `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入 - `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace 为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失 - `FindOrCreateDefaultSession` 同步改用会话的 workspace ## 3. 平台侧会话在补全里根本不存在 人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。 新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。 **上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway, Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。 - 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有 本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断 这条到底是不是真的本侧会话 - **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是 三态语义,指向不存在的会话直接 404 - **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像), 而不是传空数组把镜像抹掉 - **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是 派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名; 它们是父 agent 内部的工作单元,人往里发邮件毫无意义 - **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里 出现几个点哪个都不确定的候选 - DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着 补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与 第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是 「缓存选型」而不是 brisk-harbor 这种随机短名。 ## 4. 对话树看不见抄送与转发产生的分支 旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是 它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到 另一个;挂在原件上的转发分支同理。 改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向, 因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点 为什么并列,唯一的解释就是父邮件抄送给了两个人。 ## 5. DSH 插件(Phase 7.7) 卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是 `followup()` 的参数形状:DSH 要完整的 UserMessage(content + source), 而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。 - `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的 session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道 - `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的 permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall, 可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回 - 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开) - 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处 炸一个无关错误」 ## 其他 - `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup` 而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的 foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰 systemd(演练时误停过一次生产服务) - 插件 dist/ 不进版本库,install.sh 负责构建 - `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话
This commit is contained in:
@ -76,6 +76,10 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
// 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。
|
||||
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
|
||||
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
|
||||
// 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推
|
||||
// (Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会
|
||||
// 造出一批看起来有值实际是错的数据。
|
||||
{"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"},
|
||||
// 派给该 Agent 的新任务默认多少个来回。
|
||||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||||
// 不能直接搬过来当单任务预算。
|
||||
|
||||
@ -51,6 +51,8 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_alias VARCHAR(128),
|
||||
-- 这条会话所属的工作目录(见 init_sqlite.sql 里的设计说明)
|
||||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||||
from_agent VARCHAR(64) NOT NULL,
|
||||
subject VARCHAR(512) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
@ -307,3 +309,39 @@ CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
expired BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);
|
||||
|
||||
-- ---------- 邮件场景下的可用模型(见 init_sqlite.sql 里的设计说明) ----------
|
||||
CREATE TABLE IF NOT EXISTS agent_model_catalog (
|
||||
agent_name VARCHAR(128) NOT NULL,
|
||||
provider VARCHAR(128) NOT NULL,
|
||||
model VARCHAR(256) NOT NULL,
|
||||
display_name VARCHAR(256) NOT NULL DEFAULT '',
|
||||
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_allowed_models (
|
||||
agent_name VARCHAR(128) NOT NULL,
|
||||
provider VARCHAR(128) NOT NULL,
|
||||
model VARCHAR(256) NOT NULL,
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank);
|
||||
|
||||
-- ---------- 平台会话镜像(见 init_sqlite.sql 里的设计说明) ----------
|
||||
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
|
||||
agent_name VARCHAR(128) NOT NULL,
|
||||
platform_id VARCHAR(256) NOT NULL,
|
||||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||||
slug VARCHAR(256) NOT NULL DEFAULT '',
|
||||
title VARCHAR(512) NOT NULL DEFAULT '',
|
||||
mail_driven BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ,
|
||||
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_name, platform_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
|
||||
ON agent_platform_sessions(agent_name, workspace);
|
||||
|
||||
@ -70,6 +70,14 @@ CREATE TABLE IF NOT EXISTS agents (
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
session_alias TEXT,
|
||||
-- workspace 是这条会话所属的工作目录(三维地址 name@path.session 的 path 位)。
|
||||
--
|
||||
-- 之前它只存在于 mails.to_workspace 上,于是「这个工作区下有哪些会话」
|
||||
-- 必须 JOIN mails 再从收发双方的 workspace 里猜,而 Agent 回信时
|
||||
-- from_workspace 填的是 Agent 名而不是路径 —— 猜出来的结果是错的,
|
||||
-- 别名候选列表因此列不出本工作区的历史会话。
|
||||
-- 会话归属哪个工作区是会话自己的属性,就该存在会话上。
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
from_agent TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
@ -264,3 +272,67 @@ CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
expired INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);
|
||||
|
||||
-- ---------- 邮件场景下的可用模型 ----------
|
||||
--
|
||||
-- 拆成两张表,因为它们是两种不同的真相:
|
||||
--
|
||||
-- agent_model_catalog —— 平台**上报**它当前看得见哪些模型。每次注册整表替换。
|
||||
-- agent_allowed_models —— 管理员**选定**其中哪些可以在邮件场景下用,rank 即优先级。
|
||||
--
|
||||
-- 不合成一张带 allowed 标记的表:那样一来模型从平台目录里消失(换了 provider 配置、
|
||||
-- 上游下线了某个模型)就会连带把管理员的选择删掉,等模型回来还得重新配一遍。
|
||||
-- 分开存之后,选择是持久的,目录只决定「这一项现在是否可用」。
|
||||
CREATE TABLE IF NOT EXISTS agent_model_catalog (
|
||||
agent_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
-- 人类可读名,平台给什么就存什么;为空时前端显示 model id
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_allowed_models (
|
||||
agent_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
-- rank 越小越先试。插件按它顺序降级,全部失败才回一封失败邮件。
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (agent_name, provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank);
|
||||
|
||||
-- ---------- 平台会话镜像 ----------
|
||||
--
|
||||
-- Agent 平台(opencode / DSH)自己也在开会话:有些经由邮件驱动,有些是人直接
|
||||
-- 在平台界面上开的。写信时想续谈某条会话,就得先知道那个工作区下有哪些会话
|
||||
-- 可以续 —— 而 Gateway 只看得见邮件驱动的那部分。
|
||||
--
|
||||
-- **由插件在心跳里上报,而不是 Gateway 反向拉取**:当前架构是单向的
|
||||
-- (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。让 Gateway 去调平台接口
|
||||
-- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。
|
||||
--
|
||||
-- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间,
|
||||
-- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处
|
||||
-- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。
|
||||
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
|
||||
agent_name TEXT NOT NULL,
|
||||
-- 平台侧的会话 id(opencode 的 ses_xxx / DSH 的 session id)
|
||||
platform_id TEXT NOT NULL,
|
||||
-- 平台侧 cwd,即三维地址的 path 位
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
-- 平台自己的可寻址短名(opencode 的 slug;DSH 由模型标题派生)
|
||||
slug TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
-- 该平台会话是否由 AgentMail 的邮件驱动。用来在候选列表里区分
|
||||
-- 「续谈已有邮件线索」与「接入一条平台侧已经在跑的会话」。
|
||||
mail_driven INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME,
|
||||
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||||
PRIMARY KEY (agent_name, platform_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
|
||||
ON agent_platform_sessions(agent_name, workspace);
|
||||
|
||||
@ -11,10 +11,26 @@ import (
|
||||
// ---------- Agent ----------
|
||||
|
||||
type registerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Workspaces []models.Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
// heartbeatRequest 是心跳可选带的上报体。
|
||||
//
|
||||
// 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。
|
||||
type heartbeatRequest struct {
|
||||
// PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。
|
||||
//
|
||||
// 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的
|
||||
// (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。反向拉取需要 Gateway
|
||||
// 保存各平台的地址与凭证,那是另一套信任模型。
|
||||
//
|
||||
// nil 与空数组语义不同:nil = 本次不上报(保留现有镜像),
|
||||
// 空数组 = 平台侧确实一条会话都没有(清空镜像)。
|
||||
// 拿不到会话列表的插件应当省略该字段,而不是传空数组把镜像抹掉。
|
||||
PlatformSessions []repo.PlatformSession `json:"platform_sessions"`
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/register
|
||||
@ -111,6 +127,22 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」,
|
||||
// 不该因为上报体格式不对就把 Agent 判成离线。
|
||||
var req heartbeatRequest
|
||||
if r.ContentLength > 0 {
|
||||
_ = Decode(r, &req)
|
||||
}
|
||||
syncedSessions := -1 // -1 = 本次未上报
|
||||
if req.PlatformSessions != nil {
|
||||
if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil {
|
||||
// 镜像写失败只影响候选补全,不影响投递,因此不报错
|
||||
syncedSessions = -1
|
||||
} else {
|
||||
syncedSessions = len(req.PlatformSessions)
|
||||
}
|
||||
}
|
||||
|
||||
// 心跳回传该 Agent 的累计统计与新任务默认预算。
|
||||
//
|
||||
// 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent,
|
||||
@ -121,11 +153,15 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
|
||||
stats = repo.AgentStats{AgentName: agentName}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
resp := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"pending_mails": pending,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
if syncedSessions >= 0 {
|
||||
resp["platform_sessions_synced"] = syncedSessions
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/v1/agents
|
||||
|
||||
@ -188,11 +188,26 @@ func SuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sessions, _ := repo.SuggestSessionsFor(r.Context(), user.Username, name, path)
|
||||
sessions = append(sessions, "new")
|
||||
sessions, err := repo.SuggestSessionCandidates(r.Context(), user.Username, name, path)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||||
return
|
||||
}
|
||||
// suggestions 保留纯字符串形式:已部署的前端与第三方客户端只认这一个字段。
|
||||
// 带标题与来源的完整形式另放 candidates,两个字段同序。
|
||||
aliases := make([]string, 0, len(sessions)+1)
|
||||
for _, c := range sessions {
|
||||
aliases = append(aliases, c.Alias)
|
||||
}
|
||||
// new 总是可选且永远在最后:它不是一条已存在的会话,
|
||||
// 排在前面会让人在想续谈时随手回车开出一条新线索。
|
||||
aliases = append(aliases, "new")
|
||||
sessions = append(sessions, repo.SessionCandidate{Alias: "new", Source: "new", Title: "新建会话"})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"kind": "session",
|
||||
"suggestions": sessions,
|
||||
"suggestions": aliases,
|
||||
"candidates": sessions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -86,7 +86,9 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
"新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。",
|
||||
repo.SessionRateLimit(), retry))
|
||||
}
|
||||
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject)
|
||||
// 带上 addr.Path:会话属于哪个工作区是会话自己的属性,
|
||||
// 不存下来的话「这个工作区下有哪些会话」就只能从 mails 反推。
|
||||
id, err := repo.CreateSession(r.Context(), aliasPtr, fromAgent, subject, addr.Path)
|
||||
if err != nil {
|
||||
// 建失败要把名额还回去:那次新建实际上没有发生
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
@ -267,8 +269,13 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
// notifyRecipients 向主收件人与抄送方推送 new_mail,并刷新相关方的会话列表。
|
||||
// 收件人可能是 Agent 也可能是人类用户(三维地址 name 位共享命名空间),
|
||||
// 因此统一用 SendToRecipient 同时试 Agent 通道与用户通道。
|
||||
//
|
||||
// **每个收件方拿到的 workspace 是自己那个地址的 path 位**,不是主收件人的:
|
||||
// 三维地址 name@path.session 的 path 就是工作目录,插件要靠它建会话。
|
||||
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个不同的工作区,共用一份 payload
|
||||
// 会让抄送方在别人的目录里开会话。
|
||||
func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
|
||||
payload := func(role string) map[string]interface{} {
|
||||
payload := func(role, workspace string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
@ -276,6 +283,10 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
|
||||
"subject": subject,
|
||||
"mail_type": "normal",
|
||||
"role": role, // to / cc
|
||||
// to_workspace 是收件方地址的 path 位,即希望它在哪个工作目录干活。
|
||||
// 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在
|
||||
// 不同的空目录里,DSH / opencode 按 cwd 分组时全进「未分组」。
|
||||
"to_workspace": workspace,
|
||||
}
|
||||
}
|
||||
|
||||
@ -287,7 +298,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
|
||||
// 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱)
|
||||
seen := map[string]bool{}
|
||||
|
||||
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to"))
|
||||
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path))
|
||||
sse.Default.SendToRecipient(to.Name, "session_update", update)
|
||||
seen[to.Name] = true
|
||||
|
||||
@ -296,7 +307,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc"))
|
||||
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path))
|
||||
sse.Default.SendToRecipient(c.Name, "session_update", update)
|
||||
}
|
||||
|
||||
|
||||
@ -90,7 +90,8 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID = id
|
||||
repo.TouchSession(r.Context(), sessionID)
|
||||
} else {
|
||||
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question)
|
||||
// workspace 空串:权限询问不经三维寻址,没有 path 位可归属。
|
||||
id, err := repo.CreateSession(r.Context(), nil, agentName, "权限请求: "+req.Question, "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to create session")
|
||||
return
|
||||
@ -228,6 +229,9 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) {
|
||||
"decision": req.Decision,
|
||||
"note": req.Note,
|
||||
"decided_by": user.Username,
|
||||
// 会话 id:插件重启丢了待决映射时,会退化成「把决策当一封通知投进会话」,
|
||||
// 那条路径要靠这个字段找到原会话,否则会凭空另开一个。
|
||||
"session_id": perm.SessionID.String(),
|
||||
}
|
||||
if key, kind := repo.RelayKeyForMail(r.Context(), mailID); key != "" {
|
||||
payload["relay_key"] = key
|
||||
|
||||
@ -9,20 +9,23 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 对话树(按方向分块加载) ----------
|
||||
// ---------- 对话树(从线索根整树展开,分块加载) ----------
|
||||
|
||||
// 分页参数。上限存在的意义是防止 ?limit=100000 一次把整条线索拉走 ——
|
||||
// 那就等于绕过了分块加载。
|
||||
const (
|
||||
threadDefaultLimit = 40
|
||||
threadDefaultLimit = 60
|
||||
threadMaxLimit = 200
|
||||
// anchorPathBudget 是「补齐根到锚点这条路径」时最多回填的层数。
|
||||
// 只在锚点没落进 BFS 首页时才用得上(几百封的巨型线索)。
|
||||
anchorPathBudget = 60
|
||||
)
|
||||
|
||||
// threadNode 是返回给前端的树节点。
|
||||
//
|
||||
// Detached 表示「这封的父邮件当前不在返回集里」,两种原因:
|
||||
// - 父邮件不可见(转发把线索引到别处,下游往来不回流给上游参与者)
|
||||
// - 父邮件还没加载(分块加载的边界,往上滑会补上)
|
||||
// - 父邮件还没加载(分块加载的边界,往下翻会补上)
|
||||
//
|
||||
// 前端据此画出断点,而不是因为找不到父节点就把它悄悄丢掉。
|
||||
// 两种原因用 ParentHidden 区分:不可见是永久的,未加载是暂时的。
|
||||
@ -35,14 +38,16 @@ type threadNode struct {
|
||||
|
||||
// GET /api/v1/mail/{id}/thread
|
||||
//
|
||||
// 以给定邮件为锚点,按方向分块返回线索:
|
||||
// 以给定邮件所在**线索的根**为起点,BFS 展开整棵树:
|
||||
//
|
||||
// ?dir=around(默认) 锚点 + 一批祖先 + 一批子孙,首屏用
|
||||
// ?dir=up&offset=N 继续往上取祖先(上滑加载)
|
||||
// ?dir=down&offset=N 继续往下取子孙
|
||||
// ?offset=0(默认) 从根开始的第一块
|
||||
// ?offset=N 继续往后取(下滑加载)
|
||||
//
|
||||
// offset 是**相对锚点**的偏移:up 方向按层数(已取到的祖先数),
|
||||
// down 方向按节点数(已取到的子孙数)。锚点本身只在 around/down&offset=0 时返回。
|
||||
// 曾经的实现是「锚点的祖先链 + 锚点的子树」两个方向各自分页,问题是
|
||||
// **兄弟节点整条分支都在盲区里**:一封抄送给两个 Agent 的邮件会收到两个回复,
|
||||
// 它们互为兄弟;从其中一个回复看树,另一个回复既不是它的祖先也不是它的子孙,
|
||||
// 于是永远不显示。挂在原件上的转发分支同理。改成从根整树 BFS 后,
|
||||
// 兄弟、抄送产生的平行回复、转发分支都是根的子孙,一次覆盖。
|
||||
//
|
||||
// 树可跨会话(转发是新线索但仍指向原件),因此**逐个会话鉴权**,
|
||||
// 只返回当前用户有权访问的节点。被过滤掉的计入 hidden。
|
||||
@ -74,17 +79,39 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
dir := r.URL.Query().Get("dir")
|
||||
if dir == "" {
|
||||
dir = "around"
|
||||
}
|
||||
if dir != "around" && dir != "up" && dir != "down" {
|
||||
Error(w, http.StatusBadRequest, "dir 只能是 around、up 或 down")
|
||||
return
|
||||
}
|
||||
limit := intQuery(r, "limit", threadDefaultLimit, 1, threadMaxLimit)
|
||||
offset := intQuery(r, "offset", 0, 0, 1<<20)
|
||||
|
||||
// 上溯到线索根:整棵树都是它的子孙。
|
||||
rootID, anchorDepth, err := repo.ThreadRootOf(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to locate thread root")
|
||||
return
|
||||
}
|
||||
|
||||
raw, hasMore, err := repo.DescendantsRaw(r.Context(), rootID, offset, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load thread")
|
||||
return
|
||||
}
|
||||
|
||||
// 锚点必须可见 —— 用户点开的就是它。巨型线索里 BFS 首页可能还没到锚点那一层,
|
||||
// 此时单独把「根 → 锚点」这条路径补进来,否则用户点开一封邮件却在树里找不到它。
|
||||
if offset == 0 && anchorDepth > 0 && !containsMail(raw, mailID) {
|
||||
path, _, pErr := repo.AncestorsRaw(r.Context(), mailID, 0, anchorPathBudget)
|
||||
if pErr == nil {
|
||||
// AncestorsRaw 给的是相对锚点的负 depth,换算成距根的绝对深度
|
||||
for i := range path {
|
||||
path[i].Depth += anchorDepth
|
||||
}
|
||||
raw = append(raw, path...)
|
||||
}
|
||||
// 锚点自己(AncestorsRaw 从父开始,不含锚点)
|
||||
if anchor, aErr := repo.TreeMailByID(r.Context(), mailID, anchorDepth); aErr == nil {
|
||||
raw = append(raw, *anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话鉴权结果按会话缓存:一条线索里同一会话通常有多封,逐封查是浪费
|
||||
seen := map[uuid.UUID]bool{}
|
||||
canSee := func(sid uuid.UUID) bool {
|
||||
@ -99,95 +126,58 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
return v
|
||||
}
|
||||
|
||||
var (
|
||||
raw []repo.TreeMail
|
||||
hasMoreUp bool
|
||||
hasMoreDn bool
|
||||
wantUp = dir == "around" || dir == "up"
|
||||
wantDown = dir == "around" || dir == "down"
|
||||
upOffset = offset
|
||||
downOffset = offset
|
||||
)
|
||||
|
||||
// around 时两个方向各取一半,避免首屏一次要求 2×limit。
|
||||
// 两边至少各给 1:否则 limit=1 时会算出 downLimit=0,连锚点自己都不返回。
|
||||
upLimit, downLimit := limit, limit
|
||||
if dir == "around" {
|
||||
upLimit = limit / 2
|
||||
if upLimit < 1 {
|
||||
upLimit = 1
|
||||
}
|
||||
downLimit = limit - upLimit
|
||||
if downLimit < 1 {
|
||||
downLimit = 1
|
||||
}
|
||||
upOffset, downOffset = 0, 0
|
||||
}
|
||||
|
||||
if wantUp {
|
||||
anc, more, err := repo.AncestorsRaw(r.Context(), mailID, upOffset, upLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load ancestors")
|
||||
return
|
||||
}
|
||||
raw = append(raw, anc...)
|
||||
hasMoreUp = more
|
||||
}
|
||||
if wantDown {
|
||||
// around 与 down&offset=0 会带上锚点自己(Depth 0);
|
||||
// up 方向单独请求时不带,前端已经有它了
|
||||
desc, more, err := repo.DescendantsRaw(r.Context(), mailID, downOffset, downLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to load descendants")
|
||||
return
|
||||
}
|
||||
raw = append(raw, desc...)
|
||||
hasMoreDn = more
|
||||
}
|
||||
|
||||
// 可见性过滤。父节点是否在**本次返回集**里决定 detached;
|
||||
// 父存在却不在集里,再判断是"无权看"还是"没加载"。
|
||||
// 父存在却不在集里,再判断是「无权看」还是「没加载」。
|
||||
visible := map[uuid.UUID]bool{}
|
||||
present := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
present[m.ID] = true
|
||||
if canSee(m.SessionID) {
|
||||
visible[m.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
nodes := []threadNode{}
|
||||
emitted := map[uuid.UUID]bool{}
|
||||
for _, m := range raw {
|
||||
if !visible[m.ID] {
|
||||
if !visible[m.ID] || emitted[m.ID] {
|
||||
// 补齐锚点路径时可能与 BFS 结果重叠,去重
|
||||
continue
|
||||
}
|
||||
emitted[m.ID] = true
|
||||
n := threadNode{TreeMail: m}
|
||||
if m.ParentMailID != nil && !visible[*m.ParentMailID] {
|
||||
n.Detached = true
|
||||
// 父邮件在本次结果里出现过但被过滤掉 = 确实无权查看;
|
||||
// 完全没出现过 = 只是还没加载到,往上滑会补上
|
||||
for _, other := range raw {
|
||||
if other.ID == *m.ParentMailID {
|
||||
n.ParentHidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// 完全没出现过 = 只是还没加载到,往下翻会补上
|
||||
n.ParentHidden = present[*m.ParentMailID]
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"anchor_mail_id": mailID,
|
||||
"dir": dir,
|
||||
"root_mail_id": rootID,
|
||||
"anchor_depth": anchorDepth,
|
||||
"nodes": nodes,
|
||||
"total": len(nodes),
|
||||
"hidden": len(raw) - len(nodes),
|
||||
"has_more": hasMore,
|
||||
// 下一页的 offset。前端把它原样回传即可,不必自己算已加载数量。
|
||||
"has_more_up": hasMoreUp,
|
||||
"has_more_down": hasMoreDn,
|
||||
"next_up": upOffset + upLimit,
|
||||
"next_down": downOffset + downLimit,
|
||||
"next_offset": offset + limit,
|
||||
})
|
||||
}
|
||||
|
||||
// containsMail 判断某封邮件是否已在结果集里。
|
||||
func containsMail(list []repo.TreeMail, id uuid.UUID) bool {
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// intQuery 读取整数 query 参数并夹到 [min, max]。
|
||||
// 非法值一律回落到默认值 —— 分页参数不该因为一个笔误就让整个请求失败。
|
||||
func intQuery(r *http.Request, key string, def, min, max int) int {
|
||||
|
||||
@ -16,7 +16,7 @@ import (
|
||||
func setupBudgetDB(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
setupTestDB(t)
|
||||
id, err := CreateSession(context.Background(), nil, "bot", "预算测试")
|
||||
id, err := CreateSession(context.Background(), nil, "bot", "预算测试", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
func seedMailTo(t *testing.T, recipient string, cc string) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
sid, err := CreateSession(ctx, nil, "sender", "t")
|
||||
sid, err := CreateSession(ctx, nil, "sender", "t", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
204
gateway/internal/repo/models_scope.go
Normal file
204
gateway/internal/repo/models_scope.go
Normal file
@ -0,0 +1,204 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// ---------- 邮件场景下的可用模型 ----------
|
||||
//
|
||||
// 两张表,两种真相:
|
||||
//
|
||||
// agent_model_catalog —— 平台**上报**它当前看得见哪些模型(注册时整表替换)
|
||||
// agent_allowed_models —— 管理员**选定**其中哪些能在邮件场景下用,rank 即优先级
|
||||
//
|
||||
// 为什么不合成一张带 allowed 标记的表:模型会从平台目录里消失(换了 provider 配置、
|
||||
// 上游临时下线),那时整行被删掉就连带把管理员的选择也删了,模型回来还得重配一遍。
|
||||
// 分开存之后,「选了什么」是持久的,目录只决定「这一项现在是否可用」。
|
||||
//
|
||||
// 为什么让平台上报而不是在 Gateway 里配一张静态表:模型清单是平台侧的事实 ——
|
||||
// opencode 的 provider 配置、DSH 的 llm 适配器注册,都可能随时变。
|
||||
// Gateway 猜不出来,猜错的后果是管理员在配置页选了一个平台其实调不到的模型。
|
||||
|
||||
// ModelRef 是一次「provider + model」路由。
|
||||
type ModelRef struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// CatalogModel 是平台上报的一个可选模型。
|
||||
type CatalogModel struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
// Allowed 表示它已被管理员选入邮件场景。
|
||||
// 与目录合并后一起返回,前端才能画出「已勾选」的复选框。
|
||||
Allowed bool `json:"allowed"`
|
||||
// Rank 仅在 Allowed 为真时有意义,越小越先试。
|
||||
Rank int `json:"rank,omitempty"`
|
||||
}
|
||||
|
||||
// maxCatalogModels 限制单个 Agent 上报的模型数。
|
||||
//
|
||||
// 有平台会把上游的全部模型都列出来(实测 opencode 的一个 provider 就有几十个),
|
||||
// 无上限的话一次注册能写进几千行,而配置页面上几千个复选框对人毫无用处。
|
||||
const maxCatalogModels = 300
|
||||
|
||||
// ReplaceModelCatalog 整表替换某 Agent 上报的模型目录。
|
||||
//
|
||||
// 整表替换而非增量合并:目录是平台当前状态的快照,
|
||||
// 增量合并会让已经下线的模型永远留在列表里,而那正是「选了却调不到」的来源。
|
||||
//
|
||||
// 事务包住删+插:中途失败留下一个空目录,会让配置页显示「该平台没有可用模型」
|
||||
// 而管理员根本没做任何操作。
|
||||
func ReplaceModelCatalog(ctx context.Context, agentName string, models []CatalogModel) error {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return nil
|
||||
}
|
||||
if len(models) > maxCatalogModels {
|
||||
models = models[:maxCatalogModels]
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_model_catalog WHERE agent_name = $1`, agentName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, m := range models {
|
||||
p := strings.TrimSpace(m.Provider)
|
||||
id := strings.TrimSpace(m.Model)
|
||||
if p == "" || id == "" {
|
||||
continue // 半条记录不如不要:它在配置页上是一个点不动的空复选框
|
||||
}
|
||||
key := p + "/" + id
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO agent_model_catalog (agent_name, provider, model, display_name, reported_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())`,
|
||||
agentName, p, id, strings.TrimSpace(m.DisplayName)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListModelCatalog 返回某 Agent 的模型目录,并标出哪些已被选入邮件场景。
|
||||
//
|
||||
// LEFT JOIN 而不是两次查询:前端要的是一份「带勾选状态的清单」,
|
||||
// 在 SQL 里合完比让前端自己对齐两个数组更难出错。
|
||||
func ListModelCatalog(ctx context.Context, agentName string) ([]CatalogModel, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT c.provider, c.model, c.display_name,
|
||||
CASE WHEN a.model IS NULL THEN 0 ELSE 1 END AS allowed,
|
||||
COALESCE(a.rank, 0)
|
||||
FROM agent_model_catalog c
|
||||
LEFT JOIN agent_allowed_models a
|
||||
ON a.agent_name = c.agent_name
|
||||
AND a.provider = c.provider
|
||||
AND a.model = c.model
|
||||
WHERE c.agent_name = $1
|
||||
ORDER BY c.provider, c.model
|
||||
`, agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []CatalogModel{}
|
||||
for rows.Next() {
|
||||
var m CatalogModel
|
||||
var allowed int
|
||||
if err := rows.Scan(&m.Provider, &m.Model, &m.DisplayName, &allowed, &m.Rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Allowed = allowed == 1
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAllowedModels 按 rank 返回该 Agent 在邮件场景下可用的模型。
|
||||
//
|
||||
// **不与目录做 JOIN**:目录是平台上次注册时的快照,插件重启前可能已经过期。
|
||||
// 真正能不能调通只有插件试过才知道 —— 这也正是插件要按顺序降级的原因。
|
||||
// 在这里用目录过滤,只会把「目录暂时没上报但其实可用」的模型挡掉。
|
||||
func ListAllowedModels(ctx context.Context, agentName string) ([]ModelRef, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT provider, model FROM agent_allowed_models
|
||||
WHERE agent_name = $1
|
||||
ORDER BY rank ASC, provider ASC, model ASC
|
||||
`, agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ModelRef{}
|
||||
for rows.Next() {
|
||||
var m ModelRef
|
||||
if err := rows.Scan(&m.Provider, &m.Model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetAllowedModels 整表替换某 Agent 的邮件场景可用模型,入参顺序即优先级。
|
||||
//
|
||||
// 允许传空列表:那表示「不限定」——插件回退到平台自己的默认模型。
|
||||
// 这与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误删的后果。
|
||||
func SetAllowedModels(ctx context.Context, agentName string, picks []ModelRef) error {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_allowed_models WHERE agent_name = $1`, agentName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rank := 0
|
||||
seen := map[string]bool{}
|
||||
for _, m := range picks {
|
||||
p := strings.TrimSpace(m.Provider)
|
||||
id := strings.TrimSpace(m.Model)
|
||||
if p == "" || id == "" {
|
||||
continue
|
||||
}
|
||||
key := p + "/" + id
|
||||
if seen[key] {
|
||||
// 重复项直接跳过而不是报错:它对最终顺序没有影响,
|
||||
// 为一次无害的重复让整次保存失败只会让人以为配置没生效。
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO agent_allowed_models (agent_name, provider, model, rank)
|
||||
VALUES ($1, $2, $3, $4)`, agentName, p, id, rank); err != nil {
|
||||
return err
|
||||
}
|
||||
rank++
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
222
gateway/internal/repo/platform_sessions.go
Normal file
222
gateway/internal/repo/platform_sessions.go
Normal file
@ -0,0 +1,222 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
)
|
||||
|
||||
// ---------- 平台会话镜像 ----------
|
||||
//
|
||||
// Agent 平台自己也在开会话:有些经由邮件驱动,有些是人直接在平台界面上开的。
|
||||
// 写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续 —— 而 Gateway
|
||||
// 只看得见邮件驱动的那部分。
|
||||
//
|
||||
// **由插件在心跳里上报,Gateway 不反向拉取。**
|
||||
// 当前架构是单向的(Agent 持密钥主动连 Gateway,Gateway 从不外呼);
|
||||
// 让 Gateway 去调平台接口需要它保存各平台的地址与凭证,那是另一套信任模型。
|
||||
// 代价是插件没运行时同步不了 —— 但插件没运行时邮件本来也投不进去。
|
||||
|
||||
// PlatformSession 是插件上报的一条平台侧会话。
|
||||
type PlatformSession struct {
|
||||
PlatformID string `json:"platform_id"`
|
||||
Workspace string `json:"workspace"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
MailDriven bool `json:"mail_driven"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// maxPlatformSessions 限制单次上报的会话数。
|
||||
//
|
||||
// 一个长期运行的平台可以累积上千条会话,而候选列表上千项对人没有意义。
|
||||
// 插件按最近活跃排序后上报前 N 条即可。
|
||||
const maxPlatformSessions = 200
|
||||
|
||||
// ReplacePlatformSessions 整表替换某 Agent 的平台会话镜像。
|
||||
//
|
||||
// 整表替换而非增量合并:镜像是平台当前状态的快照。增量合并会让已经删掉的
|
||||
// 平台会话永远留在候选列表里,而那正是「选了却送不到」的来源
|
||||
// —— session 位是三态语义,指向一条不存在的会话会直接 404。
|
||||
func ReplacePlatformSessions(ctx context.Context, agentName string, list []PlatformSession) error {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return nil
|
||||
}
|
||||
if len(list) > maxPlatformSessions {
|
||||
list = list[:maxPlatformSessions]
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_platform_sessions WHERE agent_name = $1`, agentName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, ps := range list {
|
||||
id := strings.TrimSpace(ps.PlatformID)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
driven := 0
|
||||
if ps.MailDriven {
|
||||
driven = 1
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO agent_platform_sessions
|
||||
(agent_name, platform_id, workspace, slug, title, mail_driven, updated_at, reported_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
`, agentName, id, strings.TrimSpace(ps.Workspace), strings.TrimSpace(ps.Slug),
|
||||
strings.TrimSpace(ps.Title), driven, ps.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SessionCandidate 是「续谈某条会话」的一个候选项。
|
||||
type SessionCandidate struct {
|
||||
// Alias 是填进 session 位的值 —— 候选项的实际用途就是它
|
||||
Alias string `json:"alias"`
|
||||
// Title 给人看,用来分辨两条别名相似的会话在谈什么
|
||||
Title string `json:"title,omitempty"`
|
||||
// Source 说明这条候选从哪来:
|
||||
// mail 本侧邮件线索(可直接送达)
|
||||
// platform 平台侧会话镜像(本侧还没有对应线索)
|
||||
Source string `json:"source"`
|
||||
// Unread 仅 mail 来源有意义
|
||||
Unread int `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// SuggestSessionCandidates 汇总某 name@path 下可续谈的会话。
|
||||
//
|
||||
// 两个来源合并:
|
||||
// 1. 本侧邮件线索(sessions.workspace 匹配,或历史数据里靠 mails 反推)
|
||||
// 2. 平台会话镜像里带 slug 的那些
|
||||
//
|
||||
// 本侧优先:邮件线索是「这个别名一定送得到」的保证,而镜像只是平台的说法。
|
||||
// 同名时保留本侧那条,并把镜像的标题补上去(镜像通常有更新的标题)。
|
||||
func SuggestSessionCandidates(ctx context.Context, forUser, peerName, path string) ([]SessionCandidate, error) {
|
||||
out := []SessionCandidate{}
|
||||
seen := map[string]int{} // alias -> out 下标
|
||||
|
||||
// ---- 来源 1:本侧邮件线索 ----
|
||||
//
|
||||
// sessions.workspace 是权威来源。它是新加的列,历史会话为空串,
|
||||
// 因此保留 mails 反推作为兜底:`s.workspace = $2 OR (s.workspace = '' AND <mails 反推>)`。
|
||||
// 反推只看 to_workspace —— Agent 回信时 from_workspace 存的是 Agent 名而非路径,
|
||||
// 拿它比路径永远匹配不上。
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT s.session_alias,
|
||||
COALESCE(s.subject, ''),
|
||||
(SELECT COUNT(*) FROM mails u
|
||||
WHERE u.session_id = s.session_id AND u.status = 'unread')
|
||||
FROM sessions s
|
||||
WHERE s.session_alias IS NOT NULL AND s.session_alias <> ''
|
||||
AND s.status <> 'archived'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
|
||||
)
|
||||
AND ($2 = ''
|
||||
OR s.workspace = $2
|
||||
OR (s.workspace = '' AND EXISTS (
|
||||
SELECT 1 FROM mails w
|
||||
WHERE w.session_id = s.session_id
|
||||
AND COALESCE(w.to_workspace,'') = $2
|
||||
)))
|
||||
AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mails mm
|
||||
WHERE mm.session_id = s.session_id
|
||||
AND (mm.from_name = $3 OR mm.to_name = $3
|
||||
OR `+db.CCHas("mm.cc_list", 3)+`)
|
||||
))
|
||||
ORDER BY s.updated_at DESC
|
||||
`, peerName, path, forUser)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var alias, title string
|
||||
var unread int
|
||||
if err := rows.Scan(&alias, &title, &unread); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if alias == "" {
|
||||
continue
|
||||
}
|
||||
seen[alias] = len(out)
|
||||
out = append(out, SessionCandidate{
|
||||
Alias: alias, Title: title, Source: "mail", Unread: unread,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ---- 来源 2:平台会话镜像 ----
|
||||
prows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT slug, title
|
||||
FROM agent_platform_sessions
|
||||
WHERE agent_name = $1
|
||||
AND slug <> ''
|
||||
AND ($2 = '' OR workspace = $2)
|
||||
-- 不用 NULLS LAST:它要 SQLite 3.30+,而驱动自带的版本不由我们控制。
|
||||
-- COALESCE 在两个方言里都成立,语义也更直接:没有 updated_at 就用上报时间。
|
||||
ORDER BY COALESCE(updated_at, reported_at) DESC
|
||||
`, peerName, path)
|
||||
if err != nil {
|
||||
// 镜像查不到不该让整个补全失败:本侧线索已经够用了
|
||||
return out, nil
|
||||
}
|
||||
defer prows.Close()
|
||||
|
||||
for prows.Next() {
|
||||
var slug, title string
|
||||
if err := prows.Scan(&slug, &title); err != nil {
|
||||
break
|
||||
}
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
if i, ok := seen[slug]; ok {
|
||||
// 本侧已有同名线索:保留 mail 来源(它保证送得到),
|
||||
// 但补上镜像的标题 —— 平台侧标题通常比会话建立时的主题更贴切
|
||||
if out[i].Title == "" && title != "" {
|
||||
out[i].Title = title
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[slug] = len(out)
|
||||
out = append(out, SessionCandidate{Alias: slug, Title: title, Source: "platform"})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetSessionWorkspace 记下会话所属的工作目录。
|
||||
//
|
||||
// 只在为空时写入:会话的工作区在建立时就定下了,之后不该被一封发往
|
||||
// 别处的邮件改掉 —— 那会让这条会话在候选列表里凭空换一个工作区。
|
||||
func SetSessionWorkspace(ctx context.Context, sessionID interface{ String() string }, workspace string) error {
|
||||
ws := strings.TrimSpace(workspace)
|
||||
if ws == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET workspace = $1 WHERE session_id = $2 AND workspace = ''`,
|
||||
ws, sessionID.String())
|
||||
return err
|
||||
}
|
||||
354
gateway/internal/repo/platform_sessions_test.go
Normal file
354
gateway/internal/repo/platform_sessions_test.go
Normal file
@ -0,0 +1,354 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// seedPlatformAgent 注册一个 Agent,平台会话镜像与它绑定。
|
||||
// 与 quota_test.go 的 seedAgent 区分开:那个要指定 default_rounds,这里不关心。
|
||||
func seedPlatformAgent(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)
|
||||
}
|
||||
}
|
||||
|
||||
// seedSessionWS 建一个带 workspace 与别名的会话。
|
||||
func seedSessionWS(t *testing.T, alias, workspace, subject string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO sessions (session_alias, workspace, from_agent, subject, alias_source)
|
||||
VALUES ($1, $2, 'admin', $3, 'platform')
|
||||
RETURNING session_id
|
||||
`, alias, workspace, subject).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed session %s: %v", alias, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// seedMailWS 插一封带明确 to_workspace 的邮件。
|
||||
func seedMailWS(t *testing.T, sessionID uuid.UUID, from, to, toWS, subject string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
|
||||
subject, body, cc_list, created_at)
|
||||
VALUES ($1, $2, '', $3, $4, $5, 'body', '[]', $6)
|
||||
RETURNING mail_id
|
||||
`, sessionID, from, to, toWS, subject, nextSeedTime()).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed mail: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// 这个测试是「会话别名没有正确显示曾经发生在工作区下的会话」那次故障的回归。
|
||||
//
|
||||
// 旧实现按 mails 反推工作区,条件是
|
||||
// `to_workspace = $path OR from_workspace = $path`。
|
||||
// 而 Agent 回信时 from_workspace 存的是 **Agent 名**(如 "dsh")而不是路径,
|
||||
// 于是一旦会话里只剩 Agent 的回信可匹配,反推就落空、别名列不出来。
|
||||
// 现在 workspace 存在会话自己身上,与邮件里那些脏数据无关。
|
||||
func TestSuggestSessionCandidatesUsesSessionWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
|
||||
sid := seedSessionWS(t, "brisk-harbor", "/home/program/agentmail", "缓存选型")
|
||||
// 只有 Agent 的回信:from_workspace 是脏的(Agent 名),to_workspace 是人类(空)
|
||||
if _, err := db.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
|
||||
subject, body, cc_list, created_at)
|
||||
VALUES ($1, 'dsh', 'dsh', 'admin', '', 'Re: 缓存选型', 'body', '[]', $2)
|
||||
`, sid, nextSeedTime()); err != nil {
|
||||
t.Fatalf("seed agent reply: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应有 1 个候选,实际 %d —— 会话的 workspace 列没被用上", len(got))
|
||||
}
|
||||
if got[0].Alias != "brisk-harbor" {
|
||||
t.Errorf("别名错误:%q", got[0].Alias)
|
||||
}
|
||||
if got[0].Source != "mail" {
|
||||
t.Errorf("来源应为 mail,实际 %q", got[0].Source)
|
||||
}
|
||||
if got[0].Title != "缓存选型" {
|
||||
t.Errorf("标题应带出来:%q", got[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 历史会话的 workspace 列是空的(新加的列),必须回退到 mails.to_workspace 反推,
|
||||
// 否则升级后所有老会话一夜之间从候选列表里消失。
|
||||
func TestSuggestSessionCandidatesFallsBackToMails(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "opencode")
|
||||
|
||||
// workspace 留空,模拟升级前建立的会话
|
||||
sid := seedSessionWS(t, "legacy-thread", "", "老线索")
|
||||
seedMailWS(t, sid, "admin", "opencode", "/home/legacy", "老线索")
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/legacy")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Alias != "legacy-thread" {
|
||||
t.Fatalf("老会话应能靠 mails 反推出来,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 工作区不匹配的会话不能出现:候选项点下去就会被填进 session 位,
|
||||
// 而 session 位是三态语义 —— 指向别处的会话会直接 404「无法送达」。
|
||||
func TestSuggestSessionCandidatesFiltersByWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
|
||||
mine := seedSessionWS(t, "here-thread", "/home/a", "本区")
|
||||
seedMailWS(t, mine, "admin", "dsh", "/home/a", "本区")
|
||||
other := seedSessionWS(t, "there-thread", "/home/b", "别区")
|
||||
seedMailWS(t, other, "admin", "dsh", "/home/b", "别区")
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/a")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Alias != "here-thread" {
|
||||
t.Fatalf("只应给出本工作区的会话,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// path 为空(地址写成 `dsh` 而不带 @/path)时不按工作区过滤:
|
||||
// 用户还没写到 path 段就该看到全部可续的会话。
|
||||
func TestSuggestSessionCandidatesEmptyPathReturnsAll(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
|
||||
a := seedSessionWS(t, "ws-a", "/home/a", "A")
|
||||
seedMailWS(t, a, "admin", "dsh", "/home/a", "A")
|
||||
b := seedSessionWS(t, "ws-b", "/home/b", "B")
|
||||
seedMailWS(t, b, "admin", "dsh", "/home/b", "B")
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("path 为空应给出全部 2 条,实际 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 平台侧会话(人直接在 opencode/DSH 界面上开的)经心跳上报后也要能被选中 ——
|
||||
// 这正是「定期从 agent 平台同步会话」要解决的问题。
|
||||
func TestSuggestSessionCandidatesIncludesPlatformMirror(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "opencode")
|
||||
|
||||
now := time.Now()
|
||||
err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{
|
||||
{PlatformID: "ses_1", Workspace: "/home/program/agentmail", Slug: "witty-planet",
|
||||
Title: "重构导入路径", MailDriven: false, UpdatedAt: &now},
|
||||
{PlatformID: "ses_2", Workspace: "/home/other", Slug: "brave-comet",
|
||||
Title: "别的工作区", MailDriven: false, UpdatedAt: &now},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应有 1 个平台候选,实际 %d:%+v", len(got), got)
|
||||
}
|
||||
if got[0].Alias != "witty-planet" || got[0].Source != "platform" {
|
||||
t.Errorf("平台候选错误:%+v", got[0])
|
||||
}
|
||||
if got[0].Title != "重构导入路径" {
|
||||
t.Errorf("标题应带出来:%q", got[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一别名两边都有时保留 mail 来源:它是「一定送得到」的保证,
|
||||
// 镜像只是平台的说法。但镜像的标题该补上去 —— 平台标题通常比会话主题更贴切。
|
||||
func TestSuggestSessionCandidatesMailWinsOverMirror(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "opencode")
|
||||
|
||||
// 本侧线索:有别名但主题为空
|
||||
sid := seedSessionWS(t, "witty-planet", "/home/x", "")
|
||||
seedMailWS(t, sid, "admin", "opencode", "/home/x", "某事")
|
||||
|
||||
now := time.Now()
|
||||
if err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{
|
||||
{PlatformID: "ses_1", Workspace: "/home/x", Slug: "witty-planet",
|
||||
Title: "平台侧的标题", UpdatedAt: &now},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplacePlatformSessions: %v", err)
|
||||
}
|
||||
|
||||
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/x")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestSessionCandidates: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("同名应合并成 1 条,实际 %d:%+v", len(got), got)
|
||||
}
|
||||
if got[0].Source != "mail" {
|
||||
t.Errorf("应保留 mail 来源(它保证送得到),实际 %q", got[0].Source)
|
||||
}
|
||||
if got[0].Title != "平台侧的标题" {
|
||||
t.Errorf("本侧标题为空时应补上镜像的:%q", got[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 上报是整表替换:平台侧删掉的会话必须从候选列表里消失。
|
||||
// 增量合并会让它永远留着,而 session 位指向一条不存在的会话会直接 404。
|
||||
func TestReplacePlatformSessionsIsFullReplace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "s1", Workspace: "/w", Slug: "one"},
|
||||
{PlatformID: "s2", Workspace: "/w", Slug: "two"},
|
||||
}); err != nil {
|
||||
t.Fatalf("首次上报: %v", err)
|
||||
}
|
||||
if got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w"); len(got) != 2 {
|
||||
t.Fatalf("首次上报应有 2 条,实际 %d", len(got))
|
||||
}
|
||||
|
||||
// 第二次只报一条:另一条在平台侧已被删除
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "s1", Workspace: "/w", Slug: "one"},
|
||||
}); err != nil {
|
||||
t.Fatalf("二次上报: %v", err)
|
||||
}
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
|
||||
if len(got) != 1 || got[0].Alias != "one" {
|
||||
t.Fatalf("整表替换失效,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 无 slug 的平台会话不进候选:slug 是填进 session 位的值,
|
||||
// 没有它这一项点下去只能得到一个空的 session 段。
|
||||
func TestPlatformSessionsWithoutSlugAreSkipped(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "s1", Workspace: "/w", Slug: ""},
|
||||
{PlatformID: "s2", Workspace: "/w", Slug: "named"},
|
||||
}); err != nil {
|
||||
t.Fatalf("上报: %v", err)
|
||||
}
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
|
||||
if len(got) != 1 || got[0].Alias != "named" {
|
||||
t.Fatalf("无 slug 的应被跳过,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 上报里的重复 platform_id 不该让整次事务失败(主键冲突)。
|
||||
func TestReplacePlatformSessionsDedupes(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
|
||||
{PlatformID: "dup", Workspace: "/w", Slug: "first"},
|
||||
{PlatformID: "dup", Workspace: "/w", Slug: "second"},
|
||||
}); err != nil {
|
||||
t.Fatalf("重复 id 不该报错: %v", err)
|
||||
}
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
|
||||
if len(got) != 1 || got[0].Alias != "first" {
|
||||
t.Fatalf("应保留第一条,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSessionWorkspace 只在为空时写入:会话的工作区在建立时就定下了,
|
||||
// 之后不该被一封发往别处的邮件改掉 —— 那会让它在候选列表里凭空换工作区。
|
||||
func TestSetSessionWorkspaceDoesNotOverwrite(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid := seedSessionWS(t, "fixed-ws", "/home/original", "某事")
|
||||
if err := SetSessionWorkspace(ctx, sid, "/home/hijacked"); err != nil {
|
||||
t.Fatalf("SetSessionWorkspace: %v", err)
|
||||
}
|
||||
var ws string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, sid).Scan(&ws); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if ws != "/home/original" {
|
||||
t.Errorf("已有 workspace 被覆盖成 %q", ws)
|
||||
}
|
||||
|
||||
// 空的那种要能补上(历史会话回填)
|
||||
empty := seedSessionWS(t, "empty-ws", "", "某事")
|
||||
if err := SetSessionWorkspace(ctx, empty, "/home/filled"); err != nil {
|
||||
t.Fatalf("SetSessionWorkspace(empty): %v", err)
|
||||
}
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, empty).Scan(&ws); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if ws != "/home/filled" {
|
||||
t.Errorf("空 workspace 未被补上,实际 %q", ws)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession 要把 workspace 存下来 —— 这是整条链的起点,
|
||||
// 漏在这里的话后面所有查询都只能靠 mails 反推。
|
||||
func TestCreateSessionStoresWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := CreateSession(ctx, nil, "admin", "带工作区", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
var ws string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspace FROM sessions WHERE session_id = $1`, id).Scan(&ws); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if ws != "/home/program/agentmail" {
|
||||
t.Errorf("workspace 未落库:%q", ws)
|
||||
}
|
||||
}
|
||||
|
||||
// 归档的会话不进候选:归档就是「这条线索结束了」,
|
||||
// 还出现在补全里等于邀请用户往一条已关闭的线索里发信。
|
||||
func TestSuggestSessionCandidatesExcludesArchived(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
seedPlatformAgent(t, "dsh")
|
||||
ctx := context.Background()
|
||||
|
||||
sid := seedSessionWS(t, "done-thread", "/home/a", "已完成")
|
||||
seedMailWS(t, sid, "admin", "dsh", "/home/a", "已完成")
|
||||
if _, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
|
||||
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/a")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("归档会话不该出现,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
@ -100,16 +100,16 @@ func VerifyAgent(ctx context.Context, name, secret string) (*models.Agent, error
|
||||
//
|
||||
// 显式传了 alias(发信时的 session_alias 参数)= 调用方亲自命名,标为 manual,
|
||||
// 平台后续自动同步不得覆盖;未传则等待平台命名,标为 platform。
|
||||
func CreateSession(ctx context.Context, alias *string, fromAgent, subject string) (uuid.UUID, error) {
|
||||
func CreateSession(ctx context.Context, alias *string, fromAgent, subject, workspace string) (uuid.UUID, error) {
|
||||
source := "platform"
|
||||
if alias != nil && *alias != "" {
|
||||
source = "manual"
|
||||
}
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO sessions (session_alias, from_agent, subject, alias_source)
|
||||
VALUES ($1, $2, $3, $4) RETURNING session_id`,
|
||||
alias, fromAgent, subject, source,
|
||||
`INSERT INTO sessions (session_alias, from_agent, subject, alias_source, workspace)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING session_id`,
|
||||
alias, fromAgent, subject, source, strings.TrimSpace(workspace),
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
@ -514,6 +514,10 @@ var ErrSessionNotFound = errors.New("session not found")
|
||||
|
||||
// FindNamedSessionFor 查找收件人 name@path 名下别名为 alias 的会话。
|
||||
// 严格匹配:会话必须存在、未归档,且该收件人确实参与过该会话,否则返回 ErrSessionNotFound。
|
||||
// FindNamedSessionFor 实现 session 位给具体别名时的语义:必须已存在。
|
||||
//
|
||||
// 不限定 workspace:别名全局唯一且本身就承担寻址职责,
|
||||
// 再叠一层工作区校验只会让「名字对上了却送不到」变成一种难查的失败。
|
||||
func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
@ -537,15 +541,27 @@ func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UU
|
||||
|
||||
// FindOrCreateDefaultSession 实现 session 位省略时的「默认会话」语义:
|
||||
// 复用 name@path 最近一次活跃的非归档会话;从未通过信则建立一个新的作为默认会话。
|
||||
//
|
||||
// 匹配工作区优先看 sessions.workspace(权威来源),旧会话那列为空时回退到
|
||||
// mails.to_workspace 反推。只看 to_workspace:Agent 回信时 from_workspace 存的是
|
||||
// Agent 名而不是路径,拿它比路径永远匹配不上(旧实现就挂在这里)。
|
||||
func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT s.session_id
|
||||
FROM sessions s
|
||||
JOIN mails m ON m.session_id = s.session_id
|
||||
WHERE s.status <> 'archived'
|
||||
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
|
||||
AND (COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
|
||||
)
|
||||
AND (s.workspace = $2
|
||||
OR (s.workspace = '' AND EXISTS (
|
||||
SELECT 1 FROM mails w
|
||||
WHERE w.session_id = s.session_id
|
||||
AND COALESCE(w.to_workspace,'') = $2
|
||||
)))
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT 1
|
||||
`, name, path).Scan(&id)
|
||||
@ -556,7 +572,7 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return CreateSession(ctx, nil, fromAgent, subject)
|
||||
return CreateSession(ctx, nil, fromAgent, subject, path)
|
||||
}
|
||||
|
||||
// SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。
|
||||
@ -864,40 +880,6 @@ func SuggestPaths(ctx context.Context, agentName string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SuggestSessionsFor 返回某 (peer, path) 下当前用户可见的会话别名(用于发信补全)
|
||||
func SuggestSessionsFor(ctx context.Context, forUser, peerName, path string) ([]string, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT s.session_alias
|
||||
FROM sessions s
|
||||
JOIN mails m ON m.session_id = s.session_id
|
||||
WHERE s.session_alias IS NOT NULL
|
||||
AND s.status <> 'archived'
|
||||
AND (m.to_name = $1 OR m.from_name = $1)
|
||||
AND ($2 = '' OR COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2)
|
||||
AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mails mm
|
||||
WHERE mm.session_id = s.session_id
|
||||
AND (mm.from_name = $3 OR mm.to_name = $3
|
||||
OR `+db.CCHas("mm.cc_list", 3)+`)
|
||||
))
|
||||
ORDER BY s.session_alias
|
||||
`, peerName, path, forUser)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var alias string
|
||||
if err := rows.Scan(&alias); err == nil && alias != "" {
|
||||
out = append(out, alias)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListSentBy 列出某发件人发出的邮件(发件箱),排除已归档会话
|
||||
func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
|
||||
@ -2,6 +2,7 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
@ -20,22 +21,21 @@ import (
|
||||
// 也正因如此,读取时必须按会话逐个鉴权(见 handler):
|
||||
// A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。
|
||||
//
|
||||
// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
// 按方向分页 —— 祖先向上、子孙向下,各自带游标。
|
||||
// **从根展开,而不是从锚点展开**:曾经的实现是「锚点的祖先链 + 锚点的子树」,
|
||||
// 于是兄弟节点整条分支都在盲区里 —— 一封抄送给两个 Agent 的邮件,两个回复
|
||||
// 互为兄弟,从其中一个看树看不到另一个;挂在原件上的转发同理。
|
||||
// 兄弟既不是锚点的祖先也不是它的子孙,只有先上溯到根、再整棵 BFS 才能覆盖。
|
||||
//
|
||||
// 游标用「相对锚点的原始层号偏移」而不是 mail_id:
|
||||
// - 偏移量每次从锚点重走一遍,无状态、不可伪造,也不需要额外证明
|
||||
// 「这个 cursor 真的在这条线索上」
|
||||
// - 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去),
|
||||
// 那就得单独校验它确实是锚点的祖先,反而更绕
|
||||
// - 祖先方向的层号天然稳定:新邮件只会追加成叶子,不会插进已有链条中间
|
||||
// **分块加载而非截断**:线索可以有几百封,一次全取要把几 MB 预览塞给前端。
|
||||
// 从根 BFS 后只剩一个方向,游标就是「已取到的节点数」。
|
||||
|
||||
// TreeMail 是树里的一个节点。正文只带预览:整棵线索带全文可能几百 KB,
|
||||
// 前端点开某封时再单取全文与附件清单。
|
||||
type TreeMail struct {
|
||||
models.Mail
|
||||
// Depth 是**相对锚点**的层级:0 = 锚点,-1 = 父,1 = 子。
|
||||
// 不用「距根深度」—— 分块加载时根可能还没取到,绝对深度无从得知。
|
||||
// Depth 是**距线索根**的层级:0 = 根,1 = 它的直接回复。
|
||||
// 从根展开后根一定在结果里,绝对深度因此总是可知的(早先按相对锚点算,
|
||||
// 是因为那时根可能还没取到)。
|
||||
Depth int `json:"depth"`
|
||||
AttachmentCount int `json:"attachment_count"`
|
||||
}
|
||||
@ -53,11 +53,40 @@ const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.status, m.created_at, s.session_alias,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
|
||||
|
||||
// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。
|
||||
// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数。
|
||||
// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。
|
||||
//
|
||||
// **不做可见性过滤** —— 不可见的中间段必须能穿过:转发把线索引进别人的会话,
|
||||
// 再往上却可能仍是自己参与的往来。过滤放在 handler 层(那里知道调用者是谁)。
|
||||
// 「根」= 链条最上面那封:parent_mail_id 为 NULL,或者指向一封已被删掉的邮件
|
||||
// (JOIN 断掉,递归自然停在这一层)。锚点自己没有父时返回它自己、depth 0。
|
||||
//
|
||||
// **不做可见性过滤**:不可见的中间段必须能穿过 —— 转发把线索引进别人的会话,
|
||||
// 再往上却可能仍是自己参与的往来。只返回 id 与层数,不泄露任何内容。
|
||||
func ThreadRootOf(ctx context.Context, anchorID uuid.UUID) (uuid.UUID, int, error) {
|
||||
var rootID uuid.UUID
|
||||
var lvl int
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
WITH RECURSIVE up(mail_id, parent_mail_id, lvl) AS (
|
||||
SELECT mail_id, parent_mail_id, 0 FROM mails WHERE mail_id = $1
|
||||
UNION ALL
|
||||
SELECT m.mail_id, m.parent_mail_id, up.lvl + 1
|
||||
FROM mails m JOIN up ON m.mail_id = up.parent_mail_id
|
||||
WHERE up.lvl < $2
|
||||
)
|
||||
SELECT mail_id, lvl FROM up ORDER BY lvl DESC LIMIT 1
|
||||
`, anchorID, descendantDepthCap).Scan(&rootID, &lvl)
|
||||
if err != nil {
|
||||
return uuid.Nil, 0, err
|
||||
}
|
||||
return rootID, lvl, nil
|
||||
}
|
||||
|
||||
// AncestorsRaw 沿 parent_mail_id 上溯,取第 offset+1 .. offset+limit 层的祖先。
|
||||
// 层号 1 = 父,2 = 祖父;返回的 Depth 为负数(相对锚点)。
|
||||
//
|
||||
// 从根 BFS 之后这个函数只在一处还有用:巨型线索里锚点没落在 BFS 首页时,
|
||||
// 用它把「根到锚点」这条路径单独补齐,保证点开的那封一定看得见。
|
||||
// 调用方需要自己把负 depth 换算成绝对深度(锚点绝对深度由 ThreadRootOf 给出)。
|
||||
//
|
||||
// **不做可见性过滤**,理由同 ThreadRootOf。过滤放在 handler 层(那里知道调用者是谁)。
|
||||
//
|
||||
// 第二个返回值表示 offset+limit 层之上还有节点。
|
||||
func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
@ -91,14 +120,16 @@ func AncestorsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// DescendantsRaw 取锚点及其子孙,BFS 顺序(同层按时间),按节点数分页。
|
||||
// DescendantsRaw 取给定节点及其全部子孙,BFS 顺序(同层按时间),按节点数分页。
|
||||
//
|
||||
// offset = 0 时结果的第一个是锚点自己(Depth 0)。
|
||||
// 同样不做可见性过滤,理由同 AncestorsRaw:不可见的子节点下面可能挂着可见的孙节点
|
||||
// 传线索的根(见 ThreadRootOf)就能覆盖整棵树:兄弟、抄送产生的平行回复、
|
||||
// 挂在原件上的转发分支,全都是根的子孙。offset = 0 时结果第一个是起点自己(Depth 0)。
|
||||
//
|
||||
// 同样不做可见性过滤:不可见的子节点下面可能挂着可见的孙节点
|
||||
// (别人把线索转走又转回来给我)。
|
||||
//
|
||||
// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一封邮件的子孙通常很少
|
||||
// (分支来自转发,不是回复),这个代价可以接受;真出现巨型子树时再加物化。
|
||||
// 注意 CTE 每次都会走完整棵子树,LIMIT 只截断输出。一条邮件线索通常几十封,
|
||||
// 这个代价可以接受;真出现巨型线索时再加物化。
|
||||
func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int) ([]TreeMail, bool, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
WITH RECURSIVE down(mail_id, lvl) AS (
|
||||
@ -129,6 +160,30 @@ func DescendantsRaw(ctx context.Context, anchorID uuid.UUID, offset, limit int)
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// TreeMailByID 取单封邮件的树节点形式,深度由调用方给定。
|
||||
//
|
||||
// 补齐「根 → 锚点」路径时用得上:AncestorsRaw 从父开始,不含锚点自己。
|
||||
// 同样不做可见性过滤,由 handler 负责。
|
||||
func TreeMailByID(ctx context.Context, id uuid.UUID, depth int) (*TreeMail, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT `+threadCols+`, $2
|
||||
FROM mails m
|
||||
JOIN sessions s ON m.session_id = s.session_id
|
||||
WHERE m.mail_id = $1
|
||||
`, id, depth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := scanTreeRows(rows, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return &out[0], nil
|
||||
}
|
||||
|
||||
// scanTreeRows 读出节点。negate 为真时把层号取负(祖先方向)。
|
||||
func scanTreeRows(rows interface {
|
||||
Next() bool
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
package repo
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) {
|
||||
// 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间
|
||||
@ -21,3 +27,260 @@ func TestPreviewKeepsShortBodyIntact(t *testing.T) {
|
||||
t.Fatalf("未超长却被改动:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// seedReply 插一封回复:parent 指向来信。
|
||||
func seedReply(t *testing.T, sessionID uuid.UUID, parent uuid.UUID, from, to, subject string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace,
|
||||
to_name, to_workspace, subject, body, cc_list, created_at)
|
||||
VALUES ($1, $2, $3, '', $4, '', $5, 'body', '[]', $6)
|
||||
RETURNING mail_id
|
||||
`, sessionID, parent, from, to, subject, nextSeedTime()).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed reply: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// 线索根定位:整棵树从根展开,所以这一步错了后面全错。
|
||||
func TestThreadRootOf(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "root-of")
|
||||
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "原件")
|
||||
mid := seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
|
||||
leaf := seedReply(t, sid, mid, "admin", "dsh", "Re: Re: 原件")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
from uuid.UUID
|
||||
depth int
|
||||
}{
|
||||
{"从根本身出发", root, 0},
|
||||
{"从中间一封出发", mid, 1},
|
||||
{"从叶子出发", leaf, 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotRoot, gotDepth, err := ThreadRootOf(context.Background(), c.from)
|
||||
if err != nil {
|
||||
t.Fatalf("ThreadRootOf: %v", err)
|
||||
}
|
||||
if gotRoot != root {
|
||||
t.Errorf("根定位错误:得到 %s,期望 %s", gotRoot, root)
|
||||
}
|
||||
if gotDepth != c.depth {
|
||||
t.Errorf("层数错误:得到 %d,期望 %d", gotDepth, c.depth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 这个测试是对话树那次故障的回归:
|
||||
// 一封抄送给两个 Agent 的邮件收到两个回复,它们互为**兄弟**。
|
||||
// 旧实现从锚点分「祖先方向 + 子孙方向」两路展开,兄弟既不是锚点的祖先
|
||||
// 也不是它的子孙,于是整条分支在树里根本不出现。
|
||||
// 从线索根 BFS 之后,兄弟都是根的子孙,必须一次全出来。
|
||||
func TestDescendantsFromRootIncludesSiblings(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "siblings")
|
||||
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "测试抄送")
|
||||
replyA := seedReply(t, sid, root, "dsh", "admin", "Re: 测试抄送")
|
||||
replyB := seedReply(t, sid, root, "opencode", "admin", "Re: 测试抄送")
|
||||
|
||||
// 从 replyA 出发定位根,再从根整树展开
|
||||
gotRoot, anchorDepth, err := ThreadRootOf(context.Background(), replyA)
|
||||
if err != nil {
|
||||
t.Fatalf("ThreadRootOf: %v", err)
|
||||
}
|
||||
if gotRoot != root || anchorDepth != 1 {
|
||||
t.Fatalf("根定位错误:root=%s depth=%d", gotRoot, anchorDepth)
|
||||
}
|
||||
|
||||
nodes, hasMore, err := DescendantsRaw(context.Background(), gotRoot, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
if hasMore {
|
||||
t.Error("三封邮件不该报 hasMore")
|
||||
}
|
||||
|
||||
byID := map[uuid.UUID]TreeMail{}
|
||||
for _, n := range nodes {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
for name, id := range map[string]uuid.UUID{"根": root, "回复A": replyA, "回复B": replyB} {
|
||||
if _, ok := byID[id]; !ok {
|
||||
t.Errorf("%s 不在树里 —— 兄弟分支又丢了", name)
|
||||
}
|
||||
}
|
||||
if byID[root].Depth != 0 {
|
||||
t.Errorf("根的深度应为 0,实际 %d", byID[root].Depth)
|
||||
}
|
||||
if byID[replyA].Depth != 1 || byID[replyB].Depth != 1 {
|
||||
t.Errorf("两个回复都应在深度 1:A=%d B=%d", byID[replyA].Depth, byID[replyB].Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// 转发落在**另一个会话**里,但 parent 仍指向原件。
|
||||
// 树必须跨会话展开,否则「这条线索转发给谁了」就看不见了。
|
||||
func TestDescendantsCrossSession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
srcSession := seedSessionRow(t, "fwd-src")
|
||||
dstSession := seedSessionRow(t, "fwd-dst")
|
||||
|
||||
root := seedMailIn(t, srcSession, "admin", "dsh", "原件")
|
||||
// 转发:新会话,parent 仍指原件
|
||||
fwd := seedReply(t, dstSession, root, "admin", "opencode", "Fwd: 原件")
|
||||
// 转发的下游回复,还在新会话里
|
||||
fwdReply := seedReply(t, dstSession, fwd, "opencode", "admin", "Re: Fwd: 原件")
|
||||
|
||||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
found := map[uuid.UUID]int{}
|
||||
for _, n := range nodes {
|
||||
found[n.ID] = n.Depth
|
||||
}
|
||||
if _, ok := found[fwd]; !ok {
|
||||
t.Error("转发不在树里 —— 跨会话展开失效")
|
||||
}
|
||||
if _, ok := found[fwdReply]; !ok {
|
||||
t.Error("转发的下游回复不在树里")
|
||||
}
|
||||
if found[fwd] != 1 || found[fwdReply] != 2 {
|
||||
t.Errorf("跨会话深度错误:fwd=%d fwdReply=%d(期望 1/2)", found[fwd], found[fwdReply])
|
||||
}
|
||||
// 会话不同 → session_id 必须如实反映,否则前端无法标出「线索去了别的会话」
|
||||
for _, n := range nodes {
|
||||
if n.ID == fwd && n.SessionID != dstSession {
|
||||
t.Errorf("转发的 session_id 错误:%s,期望 %s", n.SessionID, dstSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页:BFS 顺序稳定,两页拼起来等于一次全取。
|
||||
func TestDescendantsPaginationStable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "paging")
|
||||
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "原件")
|
||||
for i := 0; i < 5; i++ {
|
||||
seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
|
||||
}
|
||||
|
||||
full, hasMoreFull, err := DescendantsRaw(context.Background(), root, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("全取: %v", err)
|
||||
}
|
||||
if hasMoreFull {
|
||||
t.Error("6 封邮件一次取完不该报 hasMore")
|
||||
}
|
||||
if len(full) != 6 {
|
||||
t.Fatalf("应有 6 个节点,实际 %d", len(full))
|
||||
}
|
||||
|
||||
page1, hasMore1, err := DescendantsRaw(context.Background(), root, 0, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("第一页: %v", err)
|
||||
}
|
||||
if !hasMore1 {
|
||||
t.Error("还有 2 封没取,hasMore 应为真")
|
||||
}
|
||||
page2, hasMore2, err := DescendantsRaw(context.Background(), root, 4, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("第二页: %v", err)
|
||||
}
|
||||
if hasMore2 {
|
||||
t.Error("第二页已取完,hasMore 应为假")
|
||||
}
|
||||
|
||||
joined := append(append([]TreeMail{}, page1...), page2...)
|
||||
if len(joined) != len(full) {
|
||||
t.Fatalf("两页拼接 %d 个,全取 %d 个", len(joined), len(full))
|
||||
}
|
||||
for i := range full {
|
||||
if joined[i].ID != full[i].ID {
|
||||
t.Fatalf("第 %d 个节点顺序不一致:分页 %s,全取 %s —— BFS 顺序不稳定,"+
|
||||
"分页加载会重复或漏掉节点", i, joined[i].ID, full[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TreeMailByID 是「锚点没落进 BFS 首页」时的回填手段,深度由调用方给。
|
||||
func TestTreeMailByIDUsesGivenDepth(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "by-id")
|
||||
id := seedMailIn(t, sid, "admin", "dsh", "某封")
|
||||
|
||||
got, err := TreeMailByID(context.Background(), id, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("TreeMailByID: %v", err)
|
||||
}
|
||||
if got.ID != id {
|
||||
t.Errorf("取错了邮件:%s", got.ID)
|
||||
}
|
||||
if got.Depth != 7 {
|
||||
t.Errorf("深度应取调用方给的 7,实际 %d", got.Depth)
|
||||
}
|
||||
// 树视图只要预览,全文必须被清空 —— 否则整条线索会把几百 KB 正文塞给前端
|
||||
if got.Body != "" {
|
||||
t.Errorf("Body 应清空,实际 %q", got.Body)
|
||||
}
|
||||
if got.BodyPreview == "" {
|
||||
t.Error("BodyPreview 应有内容")
|
||||
}
|
||||
}
|
||||
|
||||
// 抄送列表必须原样带出来:树上两个兄弟节点为什么并列,
|
||||
// 唯一的解释就是父邮件抄送给了两个人。丢了 cc_list 前端就没法说明。
|
||||
func TestTreeCarriesCCList(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "cc-carry")
|
||||
|
||||
var root uuid.UUID
|
||||
err := db.DB.QueryRowContext(context.Background(), `
|
||||
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
|
||||
subject, body, cc_list, created_at)
|
||||
VALUES ($1, 'admin', '', 'dsh', '', '抄送两人', 'body',
|
||||
'[{"name":"opencode","path":"/home","session":"new","raw":"opencode@/home.new"}]', $2)
|
||||
RETURNING mail_id
|
||||
`, sid, nextSeedTime()).Scan(&root)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("应有 1 个节点,实际 %d", len(nodes))
|
||||
}
|
||||
if len(nodes[0].CCList) != 1 {
|
||||
t.Fatalf("抄送应有 1 人,实际 %d —— cc_list 没带出来", len(nodes[0].CCList))
|
||||
}
|
||||
if nodes[0].CCList[0].Raw != "opencode@/home.new" {
|
||||
t.Errorf("抄送 raw 错误:%q", nodes[0].CCList[0].Raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 无抄送时 cc_list 必须是空数组而不是 null:
|
||||
// Go 的 nil slice 会序列化成 null,前端 node.cc_list.length 直接抛异常。
|
||||
func TestTreeEmptyCCIsArrayNotNull(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
sid := seedSessionRow(t, "cc-empty")
|
||||
root := seedMailIn(t, sid, "admin", "dsh", "无抄送")
|
||||
|
||||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DescendantsRaw: %v", err)
|
||||
}
|
||||
if nodes[0].CCList == nil {
|
||||
t.Error("cc_list 为 nil,会序列化成 null")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user