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:
@ -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