feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
This commit is contained in:
@ -113,6 +113,20 @@ func main() {
|
||||
// 邮件场景下的可用模型范围。上报走心跳(agent/heartbeat 的 models 字段),
|
||||
// 这里只读 —— 给非插件的第三方客户端与排查用。
|
||||
r.Get("/agent/models/allowed", handler.GetAllowedModels)
|
||||
|
||||
// ---- 寻址发现(只读)----
|
||||
//
|
||||
// 没有这一组时,send_mail 的 to 是个只能靠记忆拼写的自由文本:
|
||||
// 想回给抄送方只能从收件箱里拄一段 `opencode@/home.new`,
|
||||
// 而 `.new` 是一次性的,拄过去只会再建一条会话。
|
||||
// 人类侧 AddressInput 逐段查 /contacts/suggest 从活数据里选,
|
||||
// 这一组就是把同一份能力给 Agent。均为只读:
|
||||
// 归档、改别名、权限决策仍然只有人能做。
|
||||
r.Get("/agent/contacts", handler.AgentListContacts)
|
||||
r.Get("/agent/contacts/suggest", handler.AgentSuggestAddress)
|
||||
r.Get("/agent/mail/{id}", handler.AgentGetMail)
|
||||
r.Get("/agent/mail/{id}/thread", handler.AgentGetMailThread)
|
||||
r.Get("/agent/sessions/{id}/participants", handler.AgentSessionParticipants)
|
||||
})
|
||||
|
||||
// ---- 人类登录态 ----
|
||||
@ -187,6 +201,12 @@ func main() {
|
||||
// 邮件场景下每个 Agent 可用的模型范围(勾选平台上报的目录)
|
||||
r.Get("/admin/agents/{name}/models", handler.AdminListAgentModels)
|
||||
r.Put("/admin/agents/{name}/models", handler.AdminSetAgentModels)
|
||||
|
||||
// 停用 / 恢复一个 Agent。停用是可逆的归档:邮件与会话保留,
|
||||
// 但从补全里消失、密钥被撤销、重新注册被拒。
|
||||
// 没有「彻底删除」—— Agent 名与人类用户名共用命名空间,
|
||||
// 删掉后同名注册者会让历史邮件看起来像是他发的。
|
||||
r.Put("/admin/agents/{name}/status", handler.AdminSetAgentStatus)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
330
gateway/internal/handler/agent_discovery.go
Normal file
330
gateway/internal/handler/agent_discovery.go
Normal file
@ -0,0 +1,330 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent 侧的寻址发现与线索读取。
|
||||
//
|
||||
// # 为什么需要这一组端点
|
||||
//
|
||||
// 在这之前,Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、
|
||||
// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在
|
||||
// `middleware.UserAuth` 后面,Agent 密钥一律 401。后果是 `send_mail` 的 `to`
|
||||
// 成了一个**只能靠记忆拼写的自由文本字段**:
|
||||
//
|
||||
// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段,
|
||||
// 而 `.new` 是一次性的,抄过去只会再建一条会话;
|
||||
// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次:
|
||||
// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是
|
||||
// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。
|
||||
//
|
||||
// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`,
|
||||
// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力
|
||||
// 给 Agent。
|
||||
//
|
||||
// # 为什么不直接给 Agent 复用人类那几条路由
|
||||
//
|
||||
// 两条理由:
|
||||
//
|
||||
// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是
|
||||
// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念,
|
||||
// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让
|
||||
// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。
|
||||
// 2. **审计与演进。** Agent 能读什么是插件契约的一部分(PLUGIN-CONTRACT 的
|
||||
// 能力矩阵),独立成组才能在一处看全。
|
||||
//
|
||||
// # 一律只读
|
||||
//
|
||||
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
|
||||
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
|
||||
|
||||
// GET /api/v1/agent/contacts
|
||||
//
|
||||
// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。
|
||||
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`),scope 固定为自己。
|
||||
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
archived := r.URL.Query().Get("archived") == "true"
|
||||
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||||
return
|
||||
}
|
||||
|
||||
// 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是
|
||||
// 会话首封邮件的 to_name(人类侧视角:对面是 Agent)。Agent 自己调用时,
|
||||
// 首封邮件的 to_name 往往就是自己,对面反而是 from_name。
|
||||
// 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。
|
||||
out := make([]map[string]any, 0, len(contacts))
|
||||
for _, c := range contacts {
|
||||
peer := c.AgentName
|
||||
if peer == agentName {
|
||||
peer = c.LastFrom
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"session_id": c.SessionID,
|
||||
"session_alias": c.SessionAlias,
|
||||
"subject": c.Subject,
|
||||
"path": c.Path,
|
||||
"status": c.Status,
|
||||
"mail_count": c.MailCount,
|
||||
"unread_count": c.UnreadCount,
|
||||
"last_activity": c.LastActivity,
|
||||
"last_from": c.LastFrom,
|
||||
"max_rounds": c.MaxRounds,
|
||||
"used_rounds": c.UsedRounds,
|
||||
// peer 是这条会话里可与之通信的另一方
|
||||
"peer": peer,
|
||||
// address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的
|
||||
// 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。
|
||||
"address": addressForSession(peer, c.Path, c.SessionAlias),
|
||||
})
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"contacts": out})
|
||||
}
|
||||
|
||||
// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。
|
||||
//
|
||||
// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的
|
||||
// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址,
|
||||
// 比给空串危险。
|
||||
func addressForSession(name, path, alias string) string {
|
||||
if alias == "" {
|
||||
return ""
|
||||
}
|
||||
return models.FormatAddress(name, path, alias)
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/contacts/suggest?name=&path=
|
||||
//
|
||||
// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义:
|
||||
//
|
||||
// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己)
|
||||
// 带 name 不带 path → 该 name 用过的工作目录
|
||||
// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后
|
||||
//
|
||||
// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。
|
||||
func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
|
||||
if name == "" {
|
||||
agents, err := repo.ListAgents(r.Context(), "")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
||||
return
|
||||
}
|
||||
users, _ := repo.ListActiveUsernames(r.Context())
|
||||
|
||||
names := make([]string, 0, len(agents)+len(users))
|
||||
for _, a := range agents {
|
||||
if a.Name == agentName {
|
||||
continue // 不建议给自己发信
|
||||
}
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
names = append(names, users...)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "name",
|
||||
"suggestions": emptySlice(names),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
paths, _ := repo.SuggestPaths(r.Context(), name)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "path",
|
||||
"suggestions": emptySlice(paths),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 可见性传自己的名字:只提示自己参与过的会话。
|
||||
// 传空会把别人的私下线索也列出来,那是越权。
|
||||
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||||
return
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(sessions)+1)
|
||||
addresses := make([]string, 0, len(sessions)+1)
|
||||
for _, c := range sessions {
|
||||
aliases = append(aliases, c.Alias)
|
||||
addresses = append(addresses, models.FormatAddress(name, path, c.Alias))
|
||||
}
|
||||
// new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时
|
||||
// 顺手开出一条新线索 —— 生产上已经发生过。
|
||||
aliases = append(aliases, "new")
|
||||
addresses = append(addresses, models.FormatAddress(name, path, "new"))
|
||||
sessions = append(sessions, repo.SessionCandidate{
|
||||
Alias: "new", Source: "new", Title: "新建会话",
|
||||
})
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"kind": "session",
|
||||
"suggestions": emptySlice(aliases),
|
||||
// addresses 与 suggestions 同序,可直接塞进 send_mail 的 to
|
||||
"addresses": emptySlice(addresses),
|
||||
"candidates": emptySlice(sessions),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/mail/{id}/thread
|
||||
//
|
||||
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
|
||||
// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
|
||||
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||||
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/mail/{id}
|
||||
//
|
||||
// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要,
|
||||
// 而要回给抄送方就必须先看清这封信到底发给了谁。
|
||||
func AgentGetMail(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mail, err := repo.GetMailByID(r.Context(), mailID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
return
|
||||
}
|
||||
|
||||
fillAttachments(r, mail)
|
||||
|
||||
alias := repo.SessionAliasOf(r.Context(), mail.SessionID)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail": mail,
|
||||
"session_alias": alias,
|
||||
// 回信地址与「我这个身份」都给现成的,省得插件自己拼。
|
||||
// mail.ToWorkspace 是收件方那个地址的 path 位。
|
||||
"reply_address": models.FormatAddress(mail.FromName, "", alias),
|
||||
"self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias),
|
||||
"participants": participantsOf(mail, alias),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/sessions/{id}/participants
|
||||
//
|
||||
// 列出该会话的全部参与方及各自的可投递地址。
|
||||
//
|
||||
// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。
|
||||
// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。
|
||||
func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
sessionID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||||
return
|
||||
}
|
||||
|
||||
parts, err := repo.SessionParticipants(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list participants")
|
||||
return
|
||||
}
|
||||
|
||||
alias := repo.SessionAliasOf(r.Context(), sessionID)
|
||||
out := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, map[string]any{
|
||||
"name": p.Name,
|
||||
"path": p.Path,
|
||||
"roles": p.Roles, // from / to / cc 的并集
|
||||
"is_self": p.Name == agentName,
|
||||
"mail_count": p.MailCount,
|
||||
// address 用**该参与方自己的 path**,不是调用方的:
|
||||
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区,
|
||||
// 用错 path 会让对方在别人的目录里开会话。
|
||||
"address": addressForSession(p.Name, p.Path, alias),
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"session_id": sessionID,
|
||||
"session_alias": alias,
|
||||
"participants": out,
|
||||
})
|
||||
}
|
||||
|
||||
// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。
|
||||
// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送),
|
||||
// 用于「回这封信时该带上谁」;那里看整条会话。
|
||||
func participantsOf(m *models.Mail, alias string) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
add := func(role, name, path string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": role,
|
||||
"name": name,
|
||||
"path": path,
|
||||
"address": addressForSession(name, path, alias),
|
||||
})
|
||||
}
|
||||
// from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留),
|
||||
// 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。
|
||||
add("from", m.FromName, "")
|
||||
add("to", m.ToName, m.ToWorkspace)
|
||||
for _, c := range m.CCList {
|
||||
add("cc", c.Name, c.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -1,11 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// ---------- Agent ----------
|
||||
@ -106,6 +110,14 @@ func RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil {
|
||||
// 已停用的 Agent 不得靠重新注册复活。回 403 而不是 500:
|
||||
// 这是一个明确的策略拒绝,插件应当停止重试并把原因打出来。
|
||||
if errors.Is(err, repo.ErrAgentDisabled) {
|
||||
Error(w, http.StatusForbidden,
|
||||
"Agent \""+req.Name+"\" 已被管理员停用,无法注册。"+
|
||||
"如需重新启用,请在管理页「默认预算」里恢复它。")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "Failed to register agent")
|
||||
return
|
||||
}
|
||||
@ -208,3 +220,57 @@ func ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
"agents": emptySlice(agents),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 停用 / 恢复 ----------
|
||||
|
||||
type setAgentStatusRequest struct {
|
||||
// Disabled true = 停用,false = 恢复
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/agents/{name}/status —— 停用或恢复一个 Agent
|
||||
//
|
||||
// 停用是可逆的「归档」,不是删除:
|
||||
// - 邮件、会话、权限记录、转发幂等键全部保留(往来里有一半是人自己写的)
|
||||
// - 从地址补全、GET /agents、可授权范围里消失
|
||||
// - 全部密钥被撤销,插件拿不到新任务也发不出信
|
||||
// - 重新注册会被拒(否则插件下次启动就把它复活了)
|
||||
//
|
||||
// 不提供彻底删除:Agent 名与人类用户名共用命名空间,删掉之后历史邮件的
|
||||
// from_name 指向一个不存在的名字,此时有人注册同名 Agent(或人类账号),
|
||||
// 那些旧邮件会看起来像是他发的。
|
||||
func AdminSetAgentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing agent name")
|
||||
return
|
||||
}
|
||||
|
||||
var req setAgentStatusRequest
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
revoked, err := repo.SetAgentDisabled(r.Context(), name, req.Disabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to update agent status")
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"agent_name": name,
|
||||
"disabled": req.Disabled,
|
||||
}
|
||||
if req.Disabled {
|
||||
resp["keys_revoked"] = revoked
|
||||
resp["detail"] = "已停用。邮件与会话保留;该 Agent 的密钥已全部撤销," +
|
||||
"恢复后需要重新签发。"
|
||||
} else {
|
||||
resp["detail"] = "已恢复为离线状态。需要重新签发密钥,插件连上后自动转为在线。"
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@ -170,7 +170,7 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
|
||||
attachedCount = n
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, newID, actor, subject)
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, newID, actor, subject)
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mail_id": newID.String(),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -92,14 +93,32 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
|
||||
if err != nil {
|
||||
// 建失败要把名额还回去:那次新建实际上没有发生
|
||||
repo.ReleaseNewSession(r.Context(), byAgent)
|
||||
return id, nil, err
|
||||
}
|
||||
return id, nil, err
|
||||
// `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠
|
||||
// `name@path.<别名>`。未命名会话既查不到(FindNamedSessionFor 的
|
||||
// `session_alias = $1` 对 NULL 不成立)也补全不出来,收件方与抄送方
|
||||
// 除了回复那一封之外再也无法寻址到它 —— 再发一次 `.new` 只会建第三条会话。
|
||||
// 因此这里立刻给一个别名,平台随后仍可用 SyncSessionAlias 改写它。
|
||||
if aliasPtr == nil {
|
||||
// 命名失败不该让发信失败:邮件本身能送达,代价只是这条会话暂时
|
||||
// 只能用 reply_to 续谈,比整封退回轻。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
}
|
||||
return id, nil, nil
|
||||
|
||||
case models.SessionDefault:
|
||||
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
|
||||
// 不构成暴开的手段,因此不计入速率限制。
|
||||
id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject)
|
||||
return id, nil, err
|
||||
if err != nil {
|
||||
return id, nil, err
|
||||
}
|
||||
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
|
||||
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
|
||||
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
|
||||
_, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject))
|
||||
return id, nil, nil
|
||||
|
||||
default: // models.SessionNamed
|
||||
id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session)
|
||||
@ -238,7 +257,7 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, mailID, agentName, req.Subject)
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, agentName, req.Subject)
|
||||
|
||||
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
|
||||
resp := map[string]any{
|
||||
@ -273,8 +292,17 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
|
||||
// 三维地址 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, workspace string) map[string]interface{} {
|
||||
//
|
||||
// 同理,**每个收件方拿到的 reply_address 也是自己那个地址**,并且 session 位已经
|
||||
// 把 `new` 换成真实别名:`.new` 建完会话就失效了,把原文那个 `x@/p.new`
|
||||
// 送给参与方只会让它下一次又建一条新会话。
|
||||
func notifyRecipients(ctx context.Context, to models.Address, cc []models.Address, sessionID, mailID uuid.UUID, from, subject string) {
|
||||
// 别名在此时已由 resolveTarget 保证存在(`.new` 与默认会话都过 EnsureSessionAlias)。
|
||||
// 仍可能为空的情形:命名写入失败(已吐日志)。此时退回省略 session 位,
|
||||
// 而不是把 "new" 写进去 —— 后者会让参与方反复建新会话。
|
||||
alias := repo.SessionAliasOf(ctx, sessionID)
|
||||
|
||||
payload := func(role, workspace, forName string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"mail_id": mailID.String(),
|
||||
"session_id": sessionID.String(),
|
||||
@ -286,6 +314,14 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
|
||||
// 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在
|
||||
// 不同的空目录里,DSH / opencode 按 cwd 分组时全进「未分组」。
|
||||
"to_workspace": workspace,
|
||||
// session_alias 是这条会话今后的寻址名。没有它的话,收到 `.new`
|
||||
// 邮件的一方只持有一个 send_mail 不接受的 session_id。
|
||||
"session_alias": alias,
|
||||
// reply_address 是「把回信发回这条会话」的现成地址。
|
||||
// 插件不必自己拼(拼错了就是静默开新会话)。
|
||||
"reply_address": models.FormatAddress(from, "", alias),
|
||||
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
|
||||
"self_address": models.FormatAddress(forName, workspace, alias),
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,7 +333,7 @@ func notifyRecipients(to models.Address, cc []models.Address, sessionID, mailID
|
||||
// 参与方去重:收件人 + 所有抄送 + 发件人自己(刷新他的发件箱)
|
||||
seen := map[string]bool{}
|
||||
|
||||
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path))
|
||||
sse.Default.SendToRecipient(to.Name, "new_mail", payload("to", to.Path, to.Name))
|
||||
sse.Default.SendToRecipient(to.Name, "session_update", update)
|
||||
seen[to.Name] = true
|
||||
|
||||
@ -306,7 +342,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", c.Path))
|
||||
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path, c.Name))
|
||||
sse.Default.SendToRecipient(c.Name, "session_update", update)
|
||||
}
|
||||
|
||||
|
||||
@ -121,7 +121,7 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
notifyRecipients(to, ccList, sessionID, mailID, user.Username, req.Subject)
|
||||
notifyRecipients(r.Context(), to, ccList, sessionID, mailID, user.Username, req.Subject)
|
||||
|
||||
resp := map[string]any{
|
||||
"mail_id": mailID.String(),
|
||||
|
||||
@ -57,6 +57,19 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||||
return repo.UserCanAccessSession(r.Context(), user, sid)
|
||||
})
|
||||
}
|
||||
|
||||
// serveMailThread 是人类与 Agent 两条对话树路径的公共实现。
|
||||
//
|
||||
// 差别只在**会话可见性判据**:人类走 UserCanAccessSession(管理员全可见、
|
||||
// 其余看参与过的会话),Agent 走 AgentCanAccessSession(只看自己参与过的)。
|
||||
// 其余全部逻辑——上溯线索根、BFS 分页、锚点路径回填、detached 标记——两侧必须
|
||||
// 完全一致:让 Agent 看到一棵与人类不同形状的树,只会让双方对「谁回了谁」
|
||||
// 产生分歧,而这正是抄送协作要靠对话树解决的问题。
|
||||
func serveMailThread(w http.ResponseWriter, r *http.Request, canAccess func(uuid.UUID) (bool, error)) {
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
@ -69,7 +82,7 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.UserCanAccessSession(r.Context(), user, mail.SessionID)
|
||||
allowed, err := canAccess(mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
@ -118,7 +131,7 @@ func GetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
if v, ok := seen[sid]; ok {
|
||||
return v
|
||||
}
|
||||
v, err := repo.UserCanAccessSession(r.Context(), user, sid)
|
||||
v, err := canAccess(sid)
|
||||
if err != nil {
|
||||
v = false // 查不出来就当看不到:宁可少给,不可多给
|
||||
}
|
||||
|
||||
@ -115,6 +115,36 @@ func ParseAddress(s string) (Address, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FormatAddress 把三段拼回可寻址的 name@path.session。
|
||||
//
|
||||
// **必须走这个函数而不是自己拼字符串**:path 为空时(人类用户没有工作区)
|
||||
// 朴素拼接得到 "admin.silent-harbor",而它没有 @,ParseAddress 会把整串当成
|
||||
// 名字,session 位丢失,地址静默失效。空 path 也必须留下那个 @ 与 . ——
|
||||
// "admin@.silent-harbor" 才解析成 name=admin path="" session=silent-harbor。
|
||||
//
|
||||
// session 传空则省略该位(默认会话语义)。
|
||||
func FormatAddress(name, path, session string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
path = strings.TrimSpace(path)
|
||||
session = strings.TrimSpace(session)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if session == "" {
|
||||
if path == "" {
|
||||
return name
|
||||
}
|
||||
return name + "@" + path
|
||||
}
|
||||
return name + "@" + path + "." + session
|
||||
}
|
||||
|
||||
// WithSession 返回同一收件方在指定会话下的地址。
|
||||
// 用于把 .new 换成刚建出来的会话别名 —— 参与方拿到的地址必须是能再次投递的那个。
|
||||
func (a Address) WithSession(session string) string {
|
||||
return FormatAddress(a.Name, a.Path, session)
|
||||
}
|
||||
|
||||
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC)
|
||||
func ParseAddressList(s string) ([]Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
|
||||
253
gateway/internal/repo/agent_disable_test.go
Normal file
253
gateway/internal/repo/agent_disable_test.go
Normal file
@ -0,0 +1,253 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 停用是可逆的「归档」,不是删除。这组测试钉住三件事:
|
||||
// 停用后从候选里消失、密钥被撤销、重新注册不能复活它。
|
||||
|
||||
func TestSetAgentDisabledHidesFromCandidates(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, n := range []string{"keeper", "goner"} {
|
||||
if err := CreateOrUpdateAgent(ctx, n, "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "goner", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
// 默认列表(地址补全、GET /agents、可授权范围都走这条)不含已停用的
|
||||
got, err := ListAgents(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgents: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, a := range got {
|
||||
names[a.Name] = true
|
||||
}
|
||||
if names["goner"] {
|
||||
t.Error("已停用的 Agent 仍出现在默认列表里 —— 人会把任务派给一个不会响应的地址")
|
||||
}
|
||||
if !names["keeper"] {
|
||||
t.Error("停用一个把别的也弄没了")
|
||||
}
|
||||
|
||||
// statusFilter="all" 时要能看到 —— 那是管理页恢复它的唯一入口
|
||||
all, err := ListAgents(ctx, "all")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgents(all): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, a := range all {
|
||||
if a.Name == "goner" {
|
||||
found = true
|
||||
if a.Status != "disabled" {
|
||||
t.Errorf("状态应为 disabled,实际 %q", a.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("statusFilter=all 也看不到已停用的,就再也无法恢复它了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentDisabledRevokesKeys(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
admin := seedAdminForTest(t, ctx)
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := CreateAgentKey(ctx, "bot", "permanent", "k", 0, admin, ""); err != nil {
|
||||
t.Fatalf("建密钥: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
revoked, err := SetAgentDisabled(ctx, "bot", true)
|
||||
if err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
if revoked != 2 {
|
||||
t.Errorf("应撤销 2 把密钥,实际 %d", revoked)
|
||||
}
|
||||
|
||||
keys, err := ListAgentKeys(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAgentKeys: %v", err)
|
||||
}
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("停用后仍留着 %d 把密钥 —— 插件还能用它调 /mail/send,"+
|
||||
"停用的语义是「不再参与工作」而不只是「不出现在补全里」", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledAgentCannotReRegister(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("首次注册: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
// 插件启动时会重新注册。不拒的话 status 被写回 online,停用等于没做。
|
||||
err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil)
|
||||
if !errors.Is(err, ErrAgentDisabled) {
|
||||
t.Fatalf("已停用的 Agent 重新注册应当被拒,实际 err=%v", err)
|
||||
}
|
||||
|
||||
disabled, err := AgentDisabled(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("AgentDisabled: %v", err)
|
||||
}
|
||||
if !disabled {
|
||||
t.Error("注册尝试把停用状态冲掉了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatDoesNotReviveDisabledAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
// 心跳是 30 秒一次的。不排除 disabled 的话停用最多维持半分钟。
|
||||
if _, err := HeartbeatAgent(ctx, "bot"); err != nil {
|
||||
t.Fatalf("心跳本身不该报错: %v", err)
|
||||
}
|
||||
|
||||
disabled, _ := AgentDisabled(ctx, "bot")
|
||||
if !disabled {
|
||||
t.Error("心跳把已停用的 Agent 改回在线了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAgentGoesOfflineNotOnline(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
if _, err := SetAgentDisabled(ctx, "bot", false); err != nil {
|
||||
t.Fatalf("恢复: %v", err)
|
||||
}
|
||||
|
||||
all, _ := ListAgents(ctx, "all")
|
||||
for _, a := range all {
|
||||
if a.Name != "bot" {
|
||||
continue
|
||||
}
|
||||
// 恢复成 online 会让界面显示一个其实没在跑的 Agent 为在线;
|
||||
// 它是否真的活着由下一次心跳决定。
|
||||
if a.Status != "offline" {
|
||||
t.Errorf("恢复后应为 offline,实际 %q", a.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复后能重新注册
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Errorf("恢复后应当能重新注册: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAgentDisabledUnknownAgent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := SetAgentDisabled(ctx, "nope", true)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("停用不存在的 Agent 应回 ErrNoRows,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 停用不得动邮件与会话 —— 往来里有一半是人自己写的。
|
||||
func TestSetAgentDisabledKeepsMailAndSessions(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
sid, err := CreateSession(ctx, nil, "bot", "一件事", "/tmp/ws")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, sid, nil,
|
||||
"human", "", "bot", "/tmp/ws", "主题", "正文", nil); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
mails, err := ListInbox(ctx, "bot", "all", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListInbox: %v", err)
|
||||
}
|
||||
if len(mails) != 1 {
|
||||
t.Errorf("停用把邮件删了:剩 %d 封。那些往来里有一半是人自己写的", len(mails))
|
||||
}
|
||||
}
|
||||
|
||||
// 模型范围与平台会话镜像也保留:恢复后不必重配。
|
||||
func TestSetAgentDisabledKeepsModelScope(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := CreateOrUpdateAgent(ctx, "bot", "s", "test", nil); err != nil {
|
||||
t.Fatalf("注册: %v", err)
|
||||
}
|
||||
if err := SetAllowedModels(ctx, "bot", []ModelRef{{Provider: "p", Model: "m"}}); err != nil {
|
||||
t.Fatalf("设范围: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SetAgentDisabled(ctx, "bot", true); err != nil {
|
||||
t.Fatalf("停用: %v", err)
|
||||
}
|
||||
|
||||
allowed, err := ListAllowedModels(ctx, "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllowedModels: %v", err)
|
||||
}
|
||||
if len(allowed) != 1 {
|
||||
t.Errorf("停用把模型范围清了,恢复后管理员得重配一遍:%+v", allowed)
|
||||
}
|
||||
}
|
||||
|
||||
// seedAdminForTest 插一个管理员并返回它的 user_id(CreateAgentKey 要 created_by)。
|
||||
func seedAdminForTest(t *testing.T, ctx context.Context) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('key-admin', 'Admin', 'x', 'admin') RETURNING user_id`).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
177
gateway/internal/repo/autoalias.go
Normal file
177
gateway/internal/repo/autoalias.go
Normal file
@ -0,0 +1,177 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 自动别名 —— 让 `.new` 建出来的会话立刻可被寻址。
|
||||
//
|
||||
// # 为什么必须自动命名
|
||||
//
|
||||
// `session` 位三态里 `new` 是**一次性动作**:它建出会话就用完了。之后要再投进
|
||||
// 同一条会话,只有两条路 —— `reply_to` 某封具体邮件,或者 `name@path.<别名>`。
|
||||
// 而 `CreateSession(alias=nil)` 建出来的会话别名是 NULL,于是:
|
||||
//
|
||||
// - `FindNamedSessionFor` 查不到它(`WHERE session_alias = $1` 对 NULL 不成立)
|
||||
// - `SuggestSessionCandidates` 跳过它(`session_alias IS NOT NULL AND <> ''`)
|
||||
// - 参与方拿到的 `new_mail` 里 `session_alias` 是空串
|
||||
//
|
||||
// 结果是:被抄送方收到一封 `x@/p.new` 的邮件,**除了回复那一封之外无法再投进这条
|
||||
// 会话**。再发一次 `x@/p.new` 只会建第三条会话。这不是能力缺失,是寻址断链。
|
||||
//
|
||||
// 原先的设计假定平台插件会通过 `POST /sessions/{id}/sync` 把模型生成的标题回写成
|
||||
// 别名,于是「未命名」只是短暂状态。但两件事让这个假定不成立:
|
||||
//
|
||||
// 1. 人类发的邮件根本没有平台侧,永远等不到回写;
|
||||
// 2. 回写发生在模型跑完第一轮之后,而抄送方**在那之前**就要决定回信地址。
|
||||
//
|
||||
// 因此本侧先给一个可用的别名,平台随后仍可用 `SyncSessionAlias` 改写它 ——
|
||||
// `alias_source` 保持 `platform` 正是为此:自动名不是人定的名,不该挡住平台命名。
|
||||
//
|
||||
// # 为什么不复用 SyncSessionAlias
|
||||
//
|
||||
// 那个函数假定「会话已存在、现在要改名」,并且会跳过 `manual`。这里的场景是
|
||||
// 「刚建完、还没有名字」,且必须在**建会话的同一个请求里**完成,否则中间那一瞬
|
||||
// 发出的 SSE 仍然带空别名。
|
||||
|
||||
// aliasMaxBytes 与 normalizeAlias 的截断上限一致(sessions.session_alias 为 VARCHAR(128))。
|
||||
const aliasMaxBytes = 128
|
||||
|
||||
// autoAliasAttempts 是撞名后追加 -2、-3… 的尝试次数上限。
|
||||
// 与 SyncSessionAlias 取同一个数量级:同一主题在同一天内开几十条会话已属异常,
|
||||
// 真到了上限说明调用方在刷会话,此时报错比继续找空位更有价值。
|
||||
const autoAliasAttempts = 50
|
||||
|
||||
// AutoAliasFor 依据收件人与主题拼一个候选别名(未做唯一性检查)。
|
||||
//
|
||||
// 形如 `dsh-重构导入路径`:前缀用收件方名字,后缀用主题。**两者都要**——
|
||||
// 只用主题时「服务恢复验证」这类通用主题会在不同 Agent 之间反复撞名,
|
||||
// 只用名字则同一个 Agent 的所有会话都叫 `dsh-2`、`dsh-3`,看不出在聊什么。
|
||||
//
|
||||
// 主题为空(少见但合法)时退回单独的名字,由调用方靠后缀去重。
|
||||
func AutoAliasFor(toName, subject string) string {
|
||||
base := sanitizeAliasPart(toName)
|
||||
topic := sanitizeAliasPart(subject)
|
||||
|
||||
switch {
|
||||
case base == "" && topic == "":
|
||||
// 两边都拿不出可用字符(例如主题全是标点、名字为空)。
|
||||
// 返回空串让调用方走随机兜底,不要在这里编造。
|
||||
return ""
|
||||
case base == "":
|
||||
return truncateAlias(topic)
|
||||
case topic == "":
|
||||
return truncateAlias(base)
|
||||
default:
|
||||
return truncateAlias(base + "-" + topic)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureSessionAlias 保证会话拥有一个可寻址的别名,返回最终别名。
|
||||
//
|
||||
// 已有别名时原样返回,不做任何写入 —— 这让它可以被无条件调用,
|
||||
// 包括「默认会话」路径上那条可能是刚建的、也可能是复用的会话。
|
||||
//
|
||||
// 撞名时追加 -2、-3… 后缀;`want` 为空或全部被占用时退回
|
||||
// `session-<uuid 前 8 位>`:一个能寻址的丑名字,远胜于没有名字。
|
||||
func EnsureSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) {
|
||||
if cur := SessionAliasOf(ctx, id); cur != "" {
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
cands := make([]string, 0, autoAliasAttempts+1)
|
||||
if want != "" {
|
||||
for i := 0; i < autoAliasAttempts; i++ {
|
||||
if i == 0 {
|
||||
cands = append(cands, want)
|
||||
continue
|
||||
}
|
||||
cands = append(cands, truncateAlias(fmt.Sprintf("%s-%d", want, i+1)))
|
||||
}
|
||||
}
|
||||
// 兜底:uuid 前 8 位。碰撞概率可忽略,且与 want 无关,
|
||||
// 因此即便主题里一个可用字符都没有也总能拿到别名。
|
||||
cands = append(cands, "session-"+id.String()[:8])
|
||||
|
||||
for _, c := range cands {
|
||||
// 条件写入:`session_alias IS NULL OR = ''` 保证并发下只有一方写成功,
|
||||
// 另一方 RowsAffected=0,随后重读拿到对方写的名字 ——
|
||||
// 两个请求都返回同一个别名,而不是各自以为自己命名成功。
|
||||
res, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET session_alias = $1, updated_at = NOW()
|
||||
WHERE session_id = $2 AND (session_alias IS NULL OR session_alias = '')`,
|
||||
c, id)
|
||||
if err != nil {
|
||||
if db.IsUniqueViolation(err) {
|
||||
continue // 别名被别的会话占了,试下一个后缀
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// 期间别人(并发请求或平台同步)已经命名过,尊重那个名字
|
||||
if cur := SessionAliasOf(ctx, id); cur != "" {
|
||||
return cur, nil
|
||||
}
|
||||
// 写不进去且读不到名字,只可能是会话刚被删
|
||||
return "", fmt.Errorf("会话 %s 已不存在,无法分配别名", id)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("别名 %q 连同 -2..-%d 后缀与 uuid 兜底均被占用", want, autoAliasAttempts)
|
||||
}
|
||||
|
||||
// sanitizeAliasPart 把任意文本压成别名可用的片段。
|
||||
//
|
||||
// 规则与 normalizeAlias 一致(非法字符换 -、压缩连续 -、去首尾 -),
|
||||
// 另外多做两件事:
|
||||
//
|
||||
// - **去掉 Markdown / 标点噪声**:主题里的 `[联调]`、`—`、`:` 变成一串
|
||||
// 破折号毫无信息量。只保留字母、数字与非标点的 Unicode 字符(中文、日文等)。
|
||||
// - **压缩空白**:`Re: 服务恢复验证` → `Re-服务恢复验证`,而不是 `Re--服务恢复验证`。
|
||||
//
|
||||
// 保留中文是刻意的:本项目的会话主题多为中文,转拼音需要额外依赖,
|
||||
// 而 `dsh-重构导入路径` 作为地址完全可用(三维寻址只忌 `. / @` 与空白)。
|
||||
func sanitizeAliasPart(s string) string {
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range s {
|
||||
keep := unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
if keep {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
// 其余一切(空白、标点、符号、寻址保留字符)都折成单个 -
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
// "new" 是寻址保留字,作为整体别名时必须避开。
|
||||
// 加前缀而不是拒绝:调用方给的素材没有错,是这个词恰好被占用。
|
||||
if out == "new" {
|
||||
return "session-new"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateAlias 按字节截断且不切坏多字节字符(中文主题很容易超 128 字节)。
|
||||
func truncateAlias(s string) string {
|
||||
if len(s) <= aliasMaxBytes {
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
cut := s[:aliasMaxBytes]
|
||||
for len(cut) > 0 && !utf8.ValidString(cut) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
return strings.Trim(cut, "-")
|
||||
}
|
||||
283
gateway/internal/repo/autoalias_test.go
Normal file
283
gateway/internal/repo/autoalias_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 这一组测试守的是一条不变量:**`.new` 建出来的会话必须立刻可寻址**。
|
||||
//
|
||||
// 破坏方式很隐蔽 —— 邮件照样送达、收件人照样能回复那一封,只有「指名投进同一条
|
||||
// 会话」这个动作静默失败(`FindNamedSessionFor` 查不到未命名会话),再发一次
|
||||
// `.new` 就多一条平行会话。所以这里的断言都落在「事后能不能按别名找回来」上,
|
||||
// 而不是「有没有报错」。
|
||||
|
||||
func TestAutoAliasForCombinesNameAndSubject(t *testing.T) {
|
||||
// 名字与主题都要在:只用主题时「服务恢复验证」这类通用主题会跨 Agent 撞名,
|
||||
// 只用名字则同一个 Agent 的会话全叫 dsh-2、dsh-3,看不出在聊什么。
|
||||
got := AutoAliasFor("dsh", "重构导入路径")
|
||||
if got != "dsh-重构导入路径" {
|
||||
t.Fatalf("want dsh-重构导入路径, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForStripsAddressingChars(t *testing.T) {
|
||||
// 别名要参与 name@path.session 的切分,含 . / @ 或空白会让地址解析歧义。
|
||||
// 主题里的 Markdown 与标点噪声([联调]、—、:)也不该变成一串破折号。
|
||||
cases := []struct{ in, want string }{
|
||||
{"[联调] llmsproxy / ModelRouter — 请提供部署现状", "x-联调-llmsproxy-ModelRouter-请提供部署现状"},
|
||||
{"a.b.c", "x-a-b-c"},
|
||||
{"has spaces", "x-has-spaces"},
|
||||
{"user@host", "x-user-host"},
|
||||
{"Re: 服务恢复验证", "x-Re-服务恢复验证"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := AutoAliasFor("x", c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("AutoAliasFor(x, %q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if strings.ContainsAny(got, ". \t/@") {
|
||||
t.Errorf("别名 %q 含寻址保留字符,会破坏地址解析", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForAvoidsReservedNew(t *testing.T) {
|
||||
// "new" 是 session 位的保留字。别名若正好是它,`x@/p.new` 就同时是
|
||||
// 「投进这条会话」与「再建一条」两种意思。
|
||||
if got := AutoAliasFor("", "new"); got == "new" {
|
||||
t.Fatal("别名不得为保留字 new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForEmptyMaterial(t *testing.T) {
|
||||
// 素材里一个可用字符都没有时返回空串,交由 EnsureSessionAlias 走 uuid 兜底,
|
||||
// 而不是在这里编造一个名字。
|
||||
if got := AutoAliasFor("", "···"); got != "" {
|
||||
t.Fatalf("want empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAliasForTruncatesAtByteLimit(t *testing.T) {
|
||||
// session_alias 是 VARCHAR(128),而中文主题很容易超;按字节截断时
|
||||
// 不能把多字节字符切坏(切坏后写库会得到非法 UTF-8)。
|
||||
got := AutoAliasFor("bot", strings.Repeat("中", 200))
|
||||
if len(got) > aliasMaxBytes {
|
||||
t.Fatalf("别名 %d 字节,超过上限 %d", len(got), aliasMaxBytes)
|
||||
}
|
||||
if !utf8Valid(got) {
|
||||
t.Fatal("截断切坏了多字节字符")
|
||||
}
|
||||
}
|
||||
|
||||
func utf8Valid(s string) bool {
|
||||
for _, r := range s {
|
||||
if r == '\uFFFD' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasMakesNewSessionAddressable(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 复刻 `.new` 的真实路径:CreateSession(alias=nil) —— 别名是 NULL。
|
||||
sid, err := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
|
||||
|
||||
// 命名前:按别名找不回来(这正是线上那条断链)
|
||||
if _, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", "dsh-重构导入路径"); err == nil {
|
||||
t.Fatal("未命名会话竟然能按别名找到,测试前提不成立")
|
||||
}
|
||||
|
||||
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
|
||||
if err != nil {
|
||||
t.Fatalf("命名: %v", err)
|
||||
}
|
||||
if alias == "" {
|
||||
t.Fatal("别名为空")
|
||||
}
|
||||
|
||||
// 命名后:收件方能指名投回这条会话,而不是又开一条
|
||||
got, err := FindNamedSessionFor(ctx, "dsh", "/home/program/agentmail", alias)
|
||||
if err != nil {
|
||||
t.Fatalf("按别名寻址: %v", err)
|
||||
}
|
||||
if got != sid {
|
||||
t.Fatalf("别名 %q 指向 %s,应指向 %s", alias, got, sid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasKeepsExistingName(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 调用方显式命名过(发信时传了 session_alias),标记为 manual。
|
||||
// 自动命名绝不能覆盖它 —— 人记住的地址不该下一秒失效。
|
||||
want := "llmsproxy-joint"
|
||||
sid, err := CreateSession(ctx, &want, "dsh", "联调", "/home/program/llmsproxy")
|
||||
if err != nil {
|
||||
t.Fatalf("建会话: %v", err)
|
||||
}
|
||||
|
||||
got, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("opencode", "别的主题"))
|
||||
if err != nil {
|
||||
t.Fatalf("命名: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("已有别名被改写成 %q,应保持 %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasIsIdempotent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 默认会话路径上 EnsureSessionAlias 会被每封邮件调用一次
|
||||
// (会话可能是刚建的也可能是复用的),因此重复调用必须返回同一个名字。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
|
||||
first, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
|
||||
if err != nil {
|
||||
t.Fatalf("首次命名: %v", err)
|
||||
}
|
||||
second, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "服务恢复验证"))
|
||||
if err != nil {
|
||||
t.Fatalf("二次命名: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("重复调用给出两个别名: %q vs %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasSuffixesOnCollision(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 同一个 Agent + 同一主题会反复出现(「服务恢复验证」发两次)。
|
||||
// 别名全局唯一(负责寻址),撞名必须让位而不是报错 —— 发信不该因为
|
||||
// 主题重复而失败。
|
||||
want := AutoAliasFor("dsh", "服务恢复验证")
|
||||
|
||||
a, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
|
||||
aliasA, err := EnsureSessionAlias(ctx, a, want)
|
||||
if err != nil {
|
||||
t.Fatalf("首个会话命名: %v", err)
|
||||
}
|
||||
|
||||
b, _ := CreateSession(ctx, nil, "admin", "服务恢复验证", "/tmp/ws")
|
||||
aliasB, err := EnsureSessionAlias(ctx, b, want)
|
||||
if err != nil {
|
||||
t.Fatalf("第二个会话命名: %v", err)
|
||||
}
|
||||
|
||||
if aliasA == aliasB {
|
||||
t.Fatalf("两条会话拿到同一个别名 %q", aliasA)
|
||||
}
|
||||
if aliasB != want+"-2" {
|
||||
t.Fatalf("撞名后缀应为 %s-2,实际 %q", want, aliasB)
|
||||
}
|
||||
|
||||
// 两个别名各自指向自己那条会话,没有相互覆盖
|
||||
for alias, expect := range map[string]uuid.UUID{aliasA: a, aliasB: b} {
|
||||
var got uuid.UUID
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("查别名 %q: %v", alias, err)
|
||||
}
|
||||
if got != expect {
|
||||
t.Errorf("别名 %q 指向 %s,应指向 %s", alias, got, expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasFallsBackToUUID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 主题与名字都拿不出可用字符时(AutoAliasFor 返回空串),
|
||||
// 仍必须得到一个能寻址的别名 —— 丑名字远胜于没有名字。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "···", "/tmp/ws")
|
||||
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("", "···"))
|
||||
if err != nil {
|
||||
t.Fatalf("兜底命名: %v", err)
|
||||
}
|
||||
if alias == "" {
|
||||
t.Fatal("兜底后别名仍为空")
|
||||
}
|
||||
if !strings.HasPrefix(alias, "session-") {
|
||||
t.Fatalf("兜底别名应形如 session-xxxxxxxx,实际 %q", alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionAliasSurfacesInSuggestions(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 补全候选只收「有别名的非归档会话」(`session_alias IS NOT NULL AND <> ''`)。
|
||||
// 自动命名的另一半价值就在这里:命名前这条会话在人类的三段式补全里
|
||||
// 也是不可见的,人同样只能靠回复某封邮件才能续谈。
|
||||
seedUser(t, ctx, "admin")
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "重构导入路径", "/home/program/agentmail")
|
||||
seedMailForSession(t, sid, "admin", "dsh", "/home/program/agentmail")
|
||||
|
||||
before, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("补全(命名前): %v", err)
|
||||
}
|
||||
for _, c := range before {
|
||||
if c.Source == "mail" {
|
||||
t.Fatalf("未命名会话不该出现在补全里,却拿到 %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
alias, err := EnsureSessionAlias(ctx, sid, AutoAliasFor("dsh", "重构导入路径"))
|
||||
if err != nil {
|
||||
t.Fatalf("命名: %v", err)
|
||||
}
|
||||
|
||||
after, err := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/program/agentmail")
|
||||
if err != nil {
|
||||
t.Fatalf("补全(命名后): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, c := range after {
|
||||
if c.Alias == alias {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("命名后 %q 仍未出现在补全候选里: %+v", alias, after)
|
||||
}
|
||||
}
|
||||
|
||||
// seedMailForSession 往会话里塞一封邮件。
|
||||
// FindNamedSessionFor 与 SuggestSessionCandidates 都要求「该收件人参与过」,
|
||||
// 只建会话不建邮件的话两者都查不到,测试会得出错误结论。
|
||||
func seedMailForSession(t *testing.T, sid uuid.UUID, from, to, workspace string) {
|
||||
t.Helper()
|
||||
if _, err := CreateMail(context.Background(), sid, nil,
|
||||
from, "", to, workspace, "主题", "正文", nil); err != nil {
|
||||
t.Fatalf("seed mail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedUser 插一个用户。SuggestSessionCandidates 的可见性条件要查 users 表。
|
||||
func seedUser(t *testing.T, ctx context.Context, username string) {
|
||||
t.Helper()
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ($1, $1, 'x', 'admin')`, username)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
135
gateway/internal/repo/participants.go
Normal file
135
gateway/internal/repo/participants.go
Normal file
@ -0,0 +1,135 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Participant 是一条会话里的一个参与方。
|
||||
//
|
||||
// Path 是该参与方**自己那个地址的 path 位**,不是别人的:一封主发给 dsh@/b、
|
||||
// 抄送给 opencode@/a 的邮件里,两人的工作目录不同,混用会让对方在别人的目录里
|
||||
// 开会话(生产上已发生过一次,见 PLUGIN-CONTRACT 9.3)。
|
||||
type Participant struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
// Roles 是该参与方在这条会话里出现过的全部身份,from / to / cc 的并集。
|
||||
// 用集合而非单值:同一个人常常既发过信也被抄送过,只留最后一个身份会让
|
||||
// 「谁是这件事的负责人」这个判断出错。
|
||||
Roles []string `json:"roles"`
|
||||
// MailCount 是该参与方作为发件人的邮件数。用来回答「谁还没回」——
|
||||
// 参与方列表里 from 计数为 0 的那个就是还没开口的人。
|
||||
MailCount int `json:"mail_count"`
|
||||
}
|
||||
|
||||
// SessionParticipants 列出会话的全部参与方及各自的地址素材。
|
||||
//
|
||||
// 为什么要逐封扫而不是看 sessions 表:**参与方是随往来增长的**。会话建立时
|
||||
// 只有发件人与收件人,一封抄送、一次转发都会带进新的人。sessions 表里只有
|
||||
// from_agent 一个名字,回答不了「这条线索上现在有谁」。
|
||||
//
|
||||
// 排序:按首次出现顺序(created_at)。这让主收件人稳定排在抄送方之前,
|
||||
// 模型据此判断「谁是负责人、谁是配合方」——按名字排序会丢掉这个信息。
|
||||
func SessionParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT from_name, COALESCE(from_workspace,''),
|
||||
to_name, COALESCE(to_workspace,''),
|
||||
cc_list
|
||||
FROM mails
|
||||
WHERE session_id = $1
|
||||
ORDER BY created_at ASC, mail_id ASC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type acc struct {
|
||||
p Participant
|
||||
roles map[string]bool
|
||||
order int
|
||||
}
|
||||
seen := map[string]*acc{}
|
||||
next := 0
|
||||
|
||||
// note 记录一次「某人以某身份出现」。
|
||||
//
|
||||
// path 只在**当前为空且新值非空**时补写:同一个人可能在不同邮件里带不同
|
||||
// path(先被抄送到 /a,后被主发到 /b)。保留首个非空值而不是最后一个,
|
||||
// 与排序口径一致(首次出现顺序),也避免一封转发把地址改指到别处。
|
||||
note := func(name, path, role string, isSender bool) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
a, ok := seen[name]
|
||||
if !ok {
|
||||
a = &acc{
|
||||
p: Participant{Name: name, Path: path},
|
||||
roles: map[string]bool{},
|
||||
order: next,
|
||||
}
|
||||
next++
|
||||
seen[name] = a
|
||||
}
|
||||
if a.p.Path == "" && path != "" {
|
||||
a.p.Path = path
|
||||
}
|
||||
a.roles[role] = true
|
||||
if isSender {
|
||||
a.p.MailCount++
|
||||
}
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var fromName, fromWS, toName, toWS string
|
||||
var ccRaw []byte
|
||||
if err := rows.Scan(&fromName, &fromWS, &toName, &toWS, &ccRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// **发件人一侧不取 from_workspace 当 path。** Agent 回信时那一列存的是
|
||||
// Agent 名而不是路径(历史遗留,FindOrCreateDefaultSession 的注释里也提到
|
||||
// 同一个坑)。拿它拼地址会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||||
note(fromName, "", "from", true)
|
||||
note(toName, toWS, "to", false)
|
||||
|
||||
if len(ccRaw) > 0 {
|
||||
var cc []models.Address
|
||||
if json.Unmarshal(ccRaw, &cc) == nil {
|
||||
for _, c := range cc {
|
||||
note(c.Name, c.Path, "cc", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Participant, 0, len(seen))
|
||||
for _, a := range seen {
|
||||
a.p.Roles = sortedKeys(a.roles)
|
||||
out = append(out, a.p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return seen[out[i].Name].order < seen[out[j].Name].order
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sortedKeys 给出稳定顺序的角色列表。
|
||||
// map 迭代顺序随机,不排序的话同一条会话每次返回的 roles 顺序都不同,
|
||||
// 插件侧做 diff 或缓存时会误判为「参与方变了」。
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
170
gateway/internal/repo/participants_test.go
Normal file
170
gateway/internal/repo/participants_test.go
Normal file
@ -0,0 +1,170 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 参与方列表要回答的是「这条线索上现在有谁、用什么地址找到他、谁还没开口」。
|
||||
// 三个问题里每一个都曾经答错过:
|
||||
// - 有谁:sessions 表只有 from_agent 一个名字,抄送方与转发引入的人都不在里面
|
||||
// - 什么地址:拿 from_workspace 当 path 会拼出 dsh@dsh.alias 这种投不出去的东西
|
||||
// - 谁还没回:只留最后一个身份的话,既发过信又被抄送过的人会被算成纯配合方
|
||||
|
||||
func TestSessionParticipantsIncludesCC(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "抄收联调", "/home/program/llmsproxy")
|
||||
// 复刻线上那封:admin 主发 dsh,抄送 opencode
|
||||
mustMail(t, sid, "admin", "", "dsh", "/home/program/llmsproxy",
|
||||
[]models.Address{{Name: "opencode", Path: "/home", Session: "new", Raw: "opencode@/home.new"}})
|
||||
|
||||
parts, err := SessionParticipants(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("列参与方: %v", err)
|
||||
}
|
||||
|
||||
byName := map[string]Participant{}
|
||||
for _, p := range parts {
|
||||
byName[p.Name] = p
|
||||
}
|
||||
for _, want := range []string{"admin", "dsh", "opencode"} {
|
||||
if _, ok := byName[want]; !ok {
|
||||
t.Errorf("参与方缺 %s,实得 %+v", want, parts)
|
||||
}
|
||||
}
|
||||
// 抄送方的 path 必须是它自己那个地址的 path 位,不是主收件人的。
|
||||
// 用错的后果:对方在别人的工作目录里开会话。
|
||||
if got := byName["opencode"].Path; got != "/home" {
|
||||
t.Errorf("opencode 的 path = %q,应为 /home(它自己地址的 path 位)", got)
|
||||
}
|
||||
if got := byName["dsh"].Path; got != "/home/program/llmsproxy" {
|
||||
t.Errorf("dsh 的 path = %q,应为 /home/program/llmsproxy", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsSenderPathStaysEmpty(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "回信", "/tmp/ws")
|
||||
// Agent 回信时 from_workspace 存的是 Agent 名而非路径(历史遗留)。
|
||||
// 若把它当 path,地址会拼成 dsh@dsh.alias —— 投不出去。
|
||||
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
for _, p := range parts {
|
||||
if p.Name == "dsh" && p.Path == "dsh" {
|
||||
t.Fatal("发件人的 path 取了 from_workspace(那列存的是 Agent 名),会拼出无效地址")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsMergesRoles(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "往返", "/tmp/ws")
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil) // admin=from, dsh=to
|
||||
mustMail(t, sid, "dsh", "dsh", "admin", "", nil) // dsh=from, admin=to
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
for _, p := range parts {
|
||||
if len(p.Roles) != 2 {
|
||||
t.Errorf("%s 的 roles = %v,双方都该同时有 from 与 to", p.Name, p.Roles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsCountsOnlySends(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "谁还没回", "/tmp/ws")
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
|
||||
[]models.Address{{Name: "opencode", Path: "/tmp/ws", Raw: "opencode@/tmp/ws"}})
|
||||
mustMail(t, sid, "dsh", "dsh", "admin", "", nil)
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
got := map[string]int{}
|
||||
for _, p := range parts {
|
||||
got[p.Name] = p.MailCount
|
||||
}
|
||||
// MailCount 只数「作为发件人」的邮件:抄送方 opencode 一封都没发,
|
||||
// 计数为 0 正是「还没开口的人」这个判断的依据。
|
||||
if got["opencode"] != 0 {
|
||||
t.Errorf("opencode 只被抄送未发信,MailCount 应为 0,实为 %d", got["opencode"])
|
||||
}
|
||||
if got["admin"] != 1 || got["dsh"] != 1 {
|
||||
t.Errorf("admin/dsh 各发过一封,实为 %d/%d", got["admin"], got["dsh"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsKeepsFirstSeenOrder(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "顺序", "/tmp/ws")
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws",
|
||||
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
// 按首次出现排序,让主收件人稳定排在抄送方之前 ——
|
||||
// 模型据此判断谁是负责人、谁是配合方;按名字排序会丢掉这个信息。
|
||||
want := []string{"admin", "dsh", "opencode"}
|
||||
if len(parts) != len(want) {
|
||||
t.Fatalf("参与方数量 %d,期望 %d: %+v", len(parts), len(want), parts)
|
||||
}
|
||||
for i, w := range want {
|
||||
if parts[i].Name != w {
|
||||
t.Errorf("第 %d 位是 %s,期望 %s", i, parts[i].Name, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsPrefersFirstNonEmptyPath(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "改指", "/tmp/a")
|
||||
// 同一个人先被抄送到 /home,后被主发到 /tmp/b。
|
||||
// 保留首个非空值,与排序口径一致,也避免一封转发把地址改指到别处。
|
||||
mustMail(t, sid, "admin", "", "dsh", "/tmp/a",
|
||||
[]models.Address{{Name: "opencode", Path: "/home", Raw: "opencode@/home"}})
|
||||
mustMail(t, sid, "admin", "", "opencode", "/tmp/b", nil)
|
||||
|
||||
parts, _ := SessionParticipants(ctx, sid)
|
||||
for _, p := range parts {
|
||||
if p.Name == "opencode" && p.Path != "/home" {
|
||||
t.Fatalf("opencode 的 path = %q,应保持首次出现的 /home", p.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionParticipantsEmptySession(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 会话刚建、还没有邮件。返回空列表而不是报错:
|
||||
// 调用方拿到空表能正常渲染「暂无参与方」,拿到 error 只能整个失败。
|
||||
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
|
||||
parts, err := SessionParticipants(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("空会话应正常返回: %v", err)
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("空会话应无参与方,实得 %+v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMail(t *testing.T, sid uuid.UUID, from, fromWS, to, toWS string, cc []models.Address) {
|
||||
t.Helper()
|
||||
if _, err := CreateMail(context.Background(), sid, nil,
|
||||
from, fromWS, to, toWS, "主题", "正文", cc); err != nil {
|
||||
t.Fatalf("建邮件: %v", err)
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,9 @@ type AgentStats struct {
|
||||
SentTotal int `json:"sent_total"`
|
||||
// ActiveSessions 该 Agent 参与的未归档会话数,配合默认值判断设多少合适
|
||||
ActiveSessions int `json:"active_sessions"`
|
||||
// Status 是 agents.status:online / offline / disabled。
|
||||
// 管理页靠它决定显示「停用」还是「恢复」。
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// DefaultRoundsFor 读取该 Agent 的新任务默认预算。
|
||||
@ -127,8 +130,12 @@ func BumpSentCount(ctx context.Context, agentName string) {
|
||||
|
||||
// ListAgentStats 列出所有 Agent 的默认预算与统计(管理员视图)。
|
||||
func ListAgentStats(ctx context.Context) ([]AgentStats, error) {
|
||||
// 带上 status:管理页靠它区分「在线 / 离线 / 已停用」并决定显示
|
||||
// 「停用」还是「恢复」按钮。不过滤 disabled —— 这里是唯一能把已停用的
|
||||
// Agent 恢复回来的地方,过滤掉就再也找不到它了。
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0)
|
||||
`SELECT agent_name, COALESCE(default_rounds, 0), COALESCE(used_rounds, 0),
|
||||
COALESCE(status, 'offline')
|
||||
FROM agents ORDER BY agent_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -138,7 +145,7 @@ func ListAgentStats(ctx context.Context) ([]AgentStats, error) {
|
||||
out := []AgentStats{}
|
||||
for rows.Next() {
|
||||
var st AgentStats
|
||||
if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal); err != nil {
|
||||
if err := rows.Scan(&st.AgentName, &st.DefaultRounds, &st.SentTotal, &st.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
|
||||
@ -16,7 +16,28 @@ import (
|
||||
|
||||
// ---------- Agent ----------
|
||||
|
||||
// ErrAgentDisabled 表示该 Agent 已被管理员停用。
|
||||
//
|
||||
// 停用是可逆的「归档」:邮件、会话、权限记录全部保留,只是不再接受新任务。
|
||||
// 与删除分开是因为往来邮件里有一半是人自己写的 —— 停用 Agent 不该删掉
|
||||
// 用户的东西;而 Agent 名与人类用户名共用命名空间,历史邮件里的 from_name
|
||||
// 指向一个已删除的名字时,下一个同名注册者会看起来像是当初的发信人。
|
||||
var ErrAgentDisabled = errors.New("agent disabled")
|
||||
|
||||
func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, workspaces []models.Workspace) error {
|
||||
// 已停用的 Agent 不得靠重新注册复活。
|
||||
//
|
||||
// 少了这一步的后果:管理员停用后,那个平台的插件下次启动就会重新注册
|
||||
// (注册是插件启动流程的一部分),status 被写回 online,停用等于没做。
|
||||
// 必须让插件收到一个明确的错误,而不是静默成功。
|
||||
disabled, dErr := AgentDisabled(ctx, name)
|
||||
if dErr != nil {
|
||||
return dErr
|
||||
}
|
||||
if disabled {
|
||||
return ErrAgentDisabled
|
||||
}
|
||||
|
||||
wsJSON, _ := json.Marshal(workspaces)
|
||||
// 注意 DO UPDATE 里【不】碰 default_rounds:
|
||||
// 那是管理员配的值,Agent 重启重新注册不应该把它冲回默认。
|
||||
@ -34,8 +55,13 @@ func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, wor
|
||||
}
|
||||
|
||||
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
|
||||
// 只把【非停用】的 Agent 标成在线。
|
||||
//
|
||||
// 不加这个条件的话,停用后插件的心跳会把 status 从 disabled 改回 online
|
||||
// —— 而心跳是每 30 秒一次的,停用最多维持半分钟。
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET last_seen = NOW(), status = 'online' WHERE agent_name = $1`,
|
||||
`UPDATE agents SET last_seen = NOW(), status = 'online'
|
||||
WHERE agent_name = $1 AND status <> 'disabled'`,
|
||||
agentName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@ -43,13 +69,83 @@ func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
|
||||
return CountUnread(ctx, agentName)
|
||||
}
|
||||
|
||||
// AgentDisabled 该 Agent 是否已被停用。Agent 不存在时返回 false ——
|
||||
// 「还没注册」与「被停用」是两件事,前者应当能正常注册。
|
||||
func AgentDisabled(ctx context.Context, agentName string) (bool, error) {
|
||||
var status string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT status FROM agents WHERE agent_name = $1`, agentName).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status == "disabled", nil
|
||||
}
|
||||
|
||||
// SetAgentDisabled 停用或恢复一个 Agent。
|
||||
//
|
||||
// 停用时连带撤销它的全部密钥:留着密钥的话,那个平台的插件仍然能用它调
|
||||
// /mail/send —— 停用的语义是「这个 Agent 不再参与工作」,不只是「不出现在
|
||||
// 补全列表里」。恢复时不会把密钥变回来,管理员需要重新签发。
|
||||
//
|
||||
// 返回撤销的密钥数,供界面提示。
|
||||
func SetAgentDisabled(ctx context.Context, agentName string, disabled bool) (int, error) {
|
||||
if !disabled {
|
||||
// 恢复:回到 offline 而不是 online —— 它是否真的在线由下一次心跳决定,
|
||||
// 直接写 online 会让界面显示一个其实没在跑的 Agent 为在线。
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET status = 'offline' WHERE agent_name = $1 AND status = 'disabled'`,
|
||||
agentName)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE agents SET status = 'disabled' WHERE agent_name = $1`, agentName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return 0, sql.ErrNoRows
|
||||
}
|
||||
|
||||
keyRes, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM agent_keys WHERE agent_name = $1`, agentName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
revoked, _ := keyRes.RowsAffected()
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(revoked), nil
|
||||
}
|
||||
|
||||
// ListAgents 列出 Agent。
|
||||
//
|
||||
// statusFilter 为空时默认**排除已停用的** —— 这个函数的三个调用点
|
||||
// (地址补全、GET /agents、可授权范围)都是在回答「现在能派活给谁」,
|
||||
// 而停用的 Agent 不该出现在那里。要连停用一起看,传 statusFilter="all"。
|
||||
func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) {
|
||||
// 带上 default_rounds:前端补全收件人时要显示「派给它的任务默认几个来回」,
|
||||
// 否则人得先去管理员页查一遍才敢派活。
|
||||
q := `SELECT agent_id, agent_name, workspaces, platform, status,
|
||||
COALESCE(default_rounds, 0) FROM agents`
|
||||
args := []any{}
|
||||
if statusFilter != "" {
|
||||
switch statusFilter {
|
||||
case "":
|
||||
q += ` WHERE status <> 'disabled'`
|
||||
case "all":
|
||||
// 不加条件
|
||||
default:
|
||||
q += ` WHERE status = $1`
|
||||
args = append(args, statusFilter)
|
||||
}
|
||||
@ -739,7 +835,7 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont
|
||||
SELECT 1 FROM mails mm
|
||||
WHERE mm.session_id = s.session_id
|
||||
AND (mm.from_name = $1 OR mm.to_name = $1
|
||||
OR `+db.CCHas("mm.cc_list", 1)+`)
|
||||
OR ` + db.CCHas("mm.cc_list", 1) + `)
|
||||
))`
|
||||
args = append(args, forUser)
|
||||
}
|
||||
@ -861,22 +957,76 @@ func FindSessionByAddress(ctx context.Context, name, path, alias string) (uuid.U
|
||||
return id, err
|
||||
}
|
||||
|
||||
// SuggestPaths 返回某 agent 已注册的工作区名(用于发信补全)
|
||||
// SuggestPaths 给出某个收件方可用的工作目录候选(三段式补全的 path 位)。
|
||||
//
|
||||
// 两个来源并集,**历史优先**:
|
||||
//
|
||||
// 1. mails.to_workspace 里真实投递过的目录(按最近使用倒序)
|
||||
// 2. agents.workspaces 里注册时自报的目录
|
||||
//
|
||||
// 早先只看第 2 项,而它对两个官方插件**永远是空的** —— 契约(W-2)
|
||||
// 明确要求 `workspaces: []`,因为工作目录由每封邮件的 to_workspace 决定而不是
|
||||
// 注册时固定。于是补全的第二段对 dsh / opencode 一直给空列表,
|
||||
// 人得手敲路径,Agent 更只能猜 —— 生产上 dsh 猜了 `opencode@/home`,
|
||||
// 解析通过、投递成功,但那不是 opencode 的工作目录。
|
||||
//
|
||||
// 按最近使用倒序而非字典序:同一个 Agent 常在几个项目间切,刚用过的那个
|
||||
// 几乎总是下一封想用的那个。
|
||||
func SuggestPaths(ctx context.Context, agentName string) ([]string, error) {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
|
||||
// 来源 1:真实投递历史。即使 Agent 未注册(人类收件方)也能给出候选。
|
||||
//
|
||||
// 只 SELECT 路径一列,排序用的 MAX(created_at) 不进结果集:
|
||||
// SQLite 把时间戳存为 TEXT,把它 Scan 进 time.Time 会失败,
|
||||
// 而那个值除了排序之外无用 —— 取回来只是多一个能静默失败的环节。
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT to_workspace
|
||||
FROM mails
|
||||
WHERE to_name = $1 AND COALESCE(to_workspace, '') <> ''
|
||||
GROUP BY to_workspace
|
||||
ORDER BY MAX(created_at) DESC
|
||||
`, agentName)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if rows.Scan(&p) != nil {
|
||||
continue
|
||||
}
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 来源 2:注册时自报。报了但还没收过信的目录靠这一步进入候选,
|
||||
// 否则新接入的 Agent 在第一封邮件之前仍然无路径可选。
|
||||
var wsJSON []byte
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
var ws []models.Workspace
|
||||
if len(wsJSON) > 0 {
|
||||
json.Unmarshal(wsJSON, &ws)
|
||||
}
|
||||
out := make([]string, 0, len(ws))
|
||||
for _, w := range ws {
|
||||
out = append(out, w.Name)
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON); err == nil {
|
||||
var ws []models.Workspace
|
||||
if len(wsJSON) > 0 {
|
||||
json.Unmarshal(wsJSON, &ws)
|
||||
}
|
||||
for _, w := range ws {
|
||||
// 三维地址的 path 位是**路径**,不是工作区的展示名。
|
||||
// 这里取 Path 而不是 Name:remotebot 报的是
|
||||
// {name:"demo", path:"/tmp/remotebot-ws"},取 Name 会给出 "demo"
|
||||
// —— 一个拉起会话时不存在的目录。
|
||||
p := w.Path
|
||||
if p == "" {
|
||||
p = w.Name
|
||||
}
|
||||
if p != "" && !seen[p] {
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -978,7 +1128,7 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S
|
||||
SELECT 1 FROM mails mm
|
||||
WHERE mm.session_id = s.session_id
|
||||
AND (mm.from_name = $1 OR mm.to_name = $1
|
||||
OR `+db.CCHas("mm.cc_list", 1)+`)
|
||||
OR ` + db.CCHas("mm.cc_list", 1) + `)
|
||||
))`
|
||||
args = append(args, forUser)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user