feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心: - 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点 - DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields - 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag) - 日历 status 校验(create 补字段 + update 拦非法值) - 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例 A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按 解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了 A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*), SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查, 48h 年龄下限兜上传窗口。已接进每小时 sweep 循环 C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human (EXISTS users 判定),models.Mail 加 ToHuman。前端判据从 workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart 从 session_workspace 取 path(修 dsh@dsh 拼接 bug) 契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/ permission_mode/permission_enforcement),B-5 加 B-5.6 (Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement + unknown_fields,demo 死链修复 + from_human 检查 验收清单加 Agent→Agent 负向对照项
This commit is contained in:
@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge)
|
||||
fail=0
|
||||
|
||||
for peer in "${PEERS[@]}"; do
|
||||
for f in relay-dedup relay-policy inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do
|
||||
for f in relay-dedup relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do
|
||||
if [[ ! -f "$peer/lib/$f.js" ]]; then
|
||||
echo "共用模块缺失:$peer/lib/$f.js" >&2
|
||||
fail=1
|
||||
@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do
|
||||
done
|
||||
# 测试同样要同源:共用模块的行为约定写在测试里,
|
||||
# 只同步实现不同步测试,等于允许一侧偷偷放宽约定。
|
||||
for f in relay-policy inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do
|
||||
for f in relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt; do
|
||||
if [[ ! -f "$peer/test/$f.test.mjs" ]]; then
|
||||
echo "共用测试缺失:$peer/test/$f.test.mjs" >&2
|
||||
fail=1
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
python3 remote-agent-demo.py [运行秒数]
|
||||
|
||||
它做四件事:注册 → 心跳(带模型目录)→ SSE 长连 → 收到邮件就回一封。
|
||||
真正的插件还要做权限转发、会话命名回写、附件等,见 docs/PLUGIN-GUIDE.md。
|
||||
真正的插件还要做权限转发、会话命名回写、附件等,见 docs/PLUGIN-CONTRACT.md。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@ -83,6 +83,13 @@ def reply_to_unread(event):
|
||||
box = api("/mail/inbox?status=unread&limit=5")
|
||||
mails = box.get("mails", box if isinstance(box, list) else [])
|
||||
for m in mails:
|
||||
# B-5.6:收件方是 Agent → 不自动转发。from_human 由服务端判定,
|
||||
# 不依赖插件自己的猜测(早期靠 "from_name == 'human'" 的写法
|
||||
# 在多用户下恒为假,导致回信发给了自己)。
|
||||
if not m.get("from_human", True):
|
||||
print(" 跳过 Agent 来信:", m.get("from_name"), m.get("subject"))
|
||||
api("/mail/read", {"mail_ids": [m.get("mail_id")]})
|
||||
continue
|
||||
print(" 收到:", m.get("subject"), "| 工作目录:", event.get("to_workspace"))
|
||||
api("/mail/send", {
|
||||
"to": m.get("from_name"),
|
||||
|
||||
@ -193,6 +193,7 @@ name@path.session
|
||||
| B-2.3 | 带上模型目录(拉不到则**省略字段**,不传空数组) | SHOULD |
|
||||
| B-2.4 | 带上平台会话快照(同上) | SHOULD |
|
||||
| B-2.5 | 心跳失败不影响 SSE 与投递 | MUST |
|
||||
| B-2.6 | 请求体带 `mode_enforcement`(`native` / `advisory`),告知平台本侧的权限强制力 | MUST |
|
||||
|
||||
> **B-2.1 为什么失败不报错**:网络抖动很常见,而 Gateway 已经有可见的失败信号
|
||||
> —— 持续连不上时 `last_seen` 会让它显示为离线。插件自己再打一串错误日志只会
|
||||
@ -200,6 +201,14 @@ name@path.session
|
||||
>
|
||||
> **B-2.2 为什么范围随心跳回传而不是插件轮询**:管理员在配置页改了范围后最多
|
||||
> 一个周期(30 秒)生效,不需要重启插件;也不需要插件多起一个请求。
|
||||
>
|
||||
> **B-2.6 为什么上报 mode_enforcement**:平台表达的档位(`plan` / `workspace` / `full`)
|
||||
> 是「我要求你做到什么」,而平台能实际做到的(沙箱、审批、仅通知)取决于本侧的
|
||||
> 强制力。两者分开记录,人在界面才能看到「这个平台无法强制这一档」。
|
||||
>
|
||||
> **响应里的 `unknown_fields`**:心跳是唯一走宽容解码的端点(`DecodeLenient`),
|
||||
> 容忍插件带了平台不认识的字段。但容忍不等于咽下去 —— 响应里会回 `unknown_fields`
|
||||
> 数组,插件应据此判断自己是否比平台新得太多,必要时降级。
|
||||
|
||||
### B-3 收到 `new_mail`(MUST)
|
||||
|
||||
@ -222,7 +231,7 @@ new_mail 到达
|
||||
| B-3.1 | cwd **必须**取自 `to_workspace`,不得自己拼临时目录 | MUST |
|
||||
| B-3.2 | 新建会话时**不传**占位标题(会掐掉平台自己的命名机制) | MUST |
|
||||
| B-3.3 | 维护 `mail session_id ↔ 平台 session id` 双向映射 | MUST |
|
||||
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」 | MUST |
|
||||
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」**(仅 `from_human === true` 时)** | SHOULD |
|
||||
| B-3.5 | 提示词里带 `mail_id`,让模型能自己查这封 | SHOULD |
|
||||
| B-3.6 | 投递失败要让人看到(日志 + 见 `B-6`) | MUST |
|
||||
| B-3.7 | `platform_session_id` 非空时**必须**投进那条平台会话,不得新建 | MUST |
|
||||
@ -235,6 +244,10 @@ new_mail 到达
|
||||
> **B-3.4 为什么要在提示词里说**:不说的话模型会自己调 `send_mail` 回信,
|
||||
> 而插件在轮次结束时也会自动转发一次 —— 同一件事两封邮件。生产里真实发生过。
|
||||
> 说了之后仍要保留 `B-5.3` 的去重兜底:提示词是建议,去重是保证。
|
||||
>
|
||||
> **为什么改为 SHOULD**:`from_human === false` 时自动转发规则 `B-5.6` 已经拦住,
|
||||
> 不需要也不应该在提示词里说「回信由插件自动发」—— 那对 Agent 收件方是假话。
|
||||
> 提示词是建议,去重是保证,这条不变。
|
||||
|
||||
#### B-3.7 接管平台会话(MUST)
|
||||
|
||||
@ -301,12 +314,19 @@ pi 的会话是磁盘上的 `.jsonl`,**没有任何锁机制**(SDK 里 `floc
|
||||
|
||||
| # | 要求 | 强度 |
|
||||
|---|---|---|
|
||||
| B-5.6 | **收件方是 Agent**(`from_human === false`)→ **不转发** | MUST |
|
||||
| B-5.1 | 只取最后一条 assistant 消息里 **`type === 'text'`** 的块 | MUST |
|
||||
| B-5.2 | 带 `relay: "summary"` + 平台侧稳定 id 作 `relay_key` | MUST |
|
||||
| B-5.3 | 本轮模型已亲手回过这条线索 → **不转发** | MUST |
|
||||
| B-5.4 | 文本为空 → 不发空邮件 | MUST |
|
||||
| B-5.5 | 只对**邮件驱动**的会话转发(人在平台 UI 里开的会话不转) | MUST |
|
||||
|
||||
> **B-5.6 为什么排在最前面**:Agent → Agent 的邮件如果自动转发,两边插件都认为
|
||||
> 「我只要把话说完就行」,实际上彼此持续唤醒 —— 生产实测 pi 与 dsh 互相客套 6 轮
|
||||
> 直到撞上 hop 上限。此规则的判据是 `from_human`:它由服务端用
|
||||
> `EXISTS (SELECT 1 FROM users WHERE username = from_name)` 判定,
|
||||
> 不依赖插件自己的猜测。
|
||||
|
||||
> **B-5.1 丢掉 reasoning**:思考过程不该出现在邮件里 —— 它对收件人没有意义,
|
||||
> 而且经常包含「我先假设…」这类会被误读为结论的内容。
|
||||
>
|
||||
@ -434,13 +454,16 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
| # | 要求 | 强度 |
|
||||
|---|---|---|
|
||||
| B-8.1 | `relay_key` 用平台的权限 id;平台不给 id 时用 `会话:工具:callId` 拼一个 | MUST |
|
||||
| B-8.2 | 转发失败 → 让位给平台本地 UI,不要占着钩子 | MUST |
|
||||
| B-8.2 | 转发**暂时**失败(5xx / 408 / 429 / 网络)→ 让位给平台本地 UI | MUST |
|
||||
| B-8.2b | 转发**永久**失败(4xx,除 408 / 429)→ 当场拒绝并把原因告诉模型 | MUST |
|
||||
| B-8.2c | 拒绝给模型的文本必须是**真实原因**,不能是平台的「用户拒绝了」写死文案 | MUST |
|
||||
| B-8.3 | 同一次询问重复触发只产生一封邮件(服务端按 `relay_key` 幂等) | MUST |
|
||||
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要、**触发这次询问的任务与派活人**),让人能判断 | SHOULD |
|
||||
| B-8.5 | 权限询问**不消耗配额** | MUST |
|
||||
| B-8.6 | **不传 `to`** —— 决策人由服务端解析 | MUST |
|
||||
| B-8.7 | 平台支持「永久允许」时,选项里必须给出「一直同意」并**真的记住它** | MUST |
|
||||
| B-8.8 | 免批的作用域是 **(会话, 工具名)**;决策文本判定用 `lib/permission-grants.js` | MUST |
|
||||
| B-8.9 | `relay_key` 发出前必须用 `lib/relay-key.js` 的 `clampRelayKey` 收敛长度 | MUST |
|
||||
|
||||
> **B-8.1 为什么必须是平台的 id**:服务端会随决策事件把 `relay_key` 回传,
|
||||
> 插件重启丢了内存映射也能对上(`B-4.2`)。自己生成的随机 id 重启后就对不上了。
|
||||
@ -459,9 +482,74 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
> 409** 解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文,
|
||||
> 猜不出「这条 Agent 链最初是谁派的活」。
|
||||
>
|
||||
> 收到 409(整条链上没有人类)时按 `B-8.2` 处理 —— 服务端已经判定没人可问,
|
||||
> 收到 409(整条链上没有人类)时按 `B-8.2b` 处理 —— 服务端已经判定没人可问,
|
||||
> 继续等下去就是死锁。
|
||||
>
|
||||
> **B-8.2 / B-8.2b 为什么必须分开(生产事故)**:原本只有「除 409 一律让位」一条。
|
||||
> 实测碰到 pi 侧 `relay_key 过长(上限 160 字节)` 返回 **400**,被归入「暂时失败」
|
||||
> 让位给本地决策 —— 而邮件驱动的会话根本没有本地 UI,**那条 bash 就在无人
|
||||
> 批准的情况下执行了**。同一条会话 22 秒后另一次 key 正常则成功发出询问 ——
|
||||
> 所以守卫是**随机**失效的,比稳定失效更难发现。
|
||||
>
|
||||
> 判据(`lib/relay-key.js` 的 `isPermanentFailure`,homeagent 侧是 `relay_key.go`):
|
||||
>
|
||||
> | 状态 | 类别 | 理由 |
|
||||
> |---|---|---|
|
||||
> | 4xx(除 408 / 429) | 永久 | 请求本身有问题,重试一万次还是同一个结果 |
|
||||
> | 408 / 429 | 暂时 | 超时与限流,等一会儿真的可能成功 |
|
||||
> | 5xx | 暂时 | 服务端的问题 |
|
||||
> | 无状态码 | 暂时 | 网络层(DNS、连接被拒) |
|
||||
>
|
||||
> 401 归到**永久**:密钥无效要人去后台重新登记,不是等一等就好的事。
|
||||
> (实测过一次:opencode 被停用后拿着已撤销的密钥重试了 18 小时,2690 次 401。)
|
||||
>
|
||||
> **永久失败必须 fail closed**:宁可让模型看到「权限系统坏了」并自己改道,
|
||||
> 也不能悄悄放行一条没人看过的命令。拒绝时把原因写进 `reason`(平台会当工具
|
||||
> 报错回给模型),它才知道下一步该换什么做法。
|
||||
>
|
||||
> **B-8.2c 为什么单列一条(DSH 实例)**:“当场拒绝”在有些平台上不等于
|
||||
> “模型知道为什么被拒”。DSH 把 `approval/request` 的返回值翻译成模型可见
|
||||
> 文本时用的是 `@deepseek-ai/dsh-tools` 里写死的句子:
|
||||
>
|
||||
> ```js
|
||||
> case "rejected": reason = `the user rejected tool "${exec.name}"`
|
||||
> case "unavailable": reason = `... no approval channel is available`
|
||||
> ```
|
||||
>
|
||||
> 于是插件因为「这条链上没有人类」主动拒绝时,模型看到的是
|
||||
> 「the user rejected tool bash」—— **没有任何用户拒绝过它**。模型会以为人
|
||||
> 不同意,而不会去换一条路;服务端给的 `suggestion` 只进了日志。
|
||||
>
|
||||
> 三个平台的出口不同:
|
||||
>
|
||||
> | 平台 | reason 能不能直达模型 | 做法 |
|
||||
> |---|---|---|
|
||||
> | pi | 能 | `return { block: true, reason }` |
|
||||
> | opencode | 能 | `output.status = "deny"` + `output.reason` |
|
||||
> | DSH | **不能** | 在 `approval/request` 里记下 `(agentId, callId) → 原因`,再在 `tools/post-execute` 返回 `{kind:'block', feedback}` 换掉那句写死的文案 |
|
||||
>
|
||||
> DSH 那条路可行的依据:门禁拒绝的调用**也会**进 post-execute
|
||||
> (`pre-execute` 的 deny 走 `{kind:"post-result"}` → `finalizeScheduledExecution`
|
||||
> → `postExecute`)。替换必须是**一次性**的(同一 callId 只换一次)、
|
||||
> 按 `(会话, callId)` 隔离、只对 `isError` 的结果生效,否则会把一个原因
|
||||
> 贴到别的失败上。
|
||||
>
|
||||
> **B-8.9 为什么不能直接截断**:toolCallId 的长度不在插件控制下。启用
|
||||
> extended thinking 时 Bedrock 把**思考签名**拼进了 toolCallId,实测同一条会话里
|
||||
> 两种形态混着出现:
|
||||
>
|
||||
> ```
|
||||
> toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
|
||||
> toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
|
||||
> ```
|
||||
>
|
||||
> 直接截断会让前缀相同的两次调用**撞成同一个键** —— 而这个键的全部意义
|
||||
> 是幂等,撞键意味着第二次询问被服务端当重复请求丢掉。`clampRelayKey`
|
||||
> 保留可读前缀(日志里还能 grep 会话 id)+ `:sha256:<原始键的完整哈希>`,
|
||||
> 且**未超限时原样返回** —— 否则插件升级前后会算出不同的键,等于把已发出的
|
||||
> 询问变成新询问。Node 与 Go 两侧必须对同一输入算出同一输出(已用跨语言
|
||||
> 比对验证)。
|
||||
>
|
||||
> **B-8.4 为什么要带派活人**:决策人未必是这条会话的参与者。Agent 转派出来的
|
||||
> 会话,人从没见过它,只给一句「是否允许执行 bash」无从判断 —— 得知道这活是
|
||||
> 谁派的、为的什么事。
|
||||
@ -642,11 +730,17 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
| # | 要求 | 强度 |
|
||||
|---|---|---|
|
||||
| D-2.1 | 钩子内**不得**阻塞等待(会挂死整个平台请求) | MUST |
|
||||
| D-2.2 | 转发失败时让位给本地 UI(`return next()` 或保持「询问中」) | MUST |
|
||||
| D-2.2 | 转发**暂时**失败时让位给本地 UI(`return next()` 或保持「询问中」) | MUST |
|
||||
| D-2.2b | 转发**永久**失败(4xx)时当场拒绝,不让位 | MUST |
|
||||
| D-2.3 | 记住 `平台权限 id → 挂起项` 的映射 | MUST |
|
||||
|
||||
> **D-2.2 为什么必须让位**:转不出去还占着那个钩子,平台会挂在那儿等一个永远
|
||||
> 不会来的回答。让位之后本地 UI 还能接管。
|
||||
> **D-2.2 为什么要让位**:转不出去还占着那个钩子,平台会挂在那儿等一个永远
|
||||
> 不会来的回答。让位之后本地 UI 还能接管 —— **但这个前提只对人坐在 TUI 前面
|
||||
> 的会话成立**。邮件驱动的会话没有人在看,让位等于无人把关。
|
||||
>
|
||||
> 因此「让位」只能给**暂时**失败:网络抖动、Gateway 正在重启 —— 那些情形下
|
||||
> 插件不知道下一秒会不会好,而人确实可能在本地看到弹窗。永久失败(4xx)
|
||||
> 已经知道结果了,让位就是静默放行(见 `B-8.2b`)。
|
||||
|
||||
### D-3 无法指定单轮模型(缺 `C-13`)
|
||||
|
||||
@ -828,7 +922,8 @@ POST /api/v1/agent/heartbeat
|
||||
],
|
||||
"models": [
|
||||
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
|
||||
]
|
||||
],
|
||||
"mode_enforcement": "native"
|
||||
}
|
||||
```
|
||||
|
||||
@ -842,7 +937,8 @@ POST /api/v1/agent/heartbeat
|
||||
"platform_sessions_synced": 12,
|
||||
"models_synced": 9,
|
||||
"allowed_models": [{ "provider": "llmsproxy", "model": "AUTO" }],
|
||||
"models_unrestricted": false
|
||||
"models_unrestricted": false,
|
||||
"unknown_fields": []
|
||||
}
|
||||
```
|
||||
|
||||
@ -881,7 +977,11 @@ Agent 侧只会收到两个事件:
|
||||
"session_alias": "refactor-imports",
|
||||
"reply_address": "admin@.refactor-imports",
|
||||
"self_address": "pi@/home/program/agentmail.refactor-imports",
|
||||
"platform_session_id": ""
|
||||
"platform_session_id": "",
|
||||
"in_reply_to": "",
|
||||
"from_human": true,
|
||||
"permission_mode": "workspace",
|
||||
"permission_enforcement": "native"
|
||||
}
|
||||
```
|
||||
|
||||
@ -894,6 +994,10 @@ Agent 侧只会收到两个事件:
|
||||
| `reply_address` | 「把回信发回这条会话」的现成地址 |
|
||||
| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 |
|
||||
| `platform_session_id` | 非空 = 投进**这条已存在的平台会话**(见 `B-3.7`);空 = 照旧 |
|
||||
| `in_reply_to` | 父邮件 id:这封信是回复哪封的;空串 = 线索根 |
|
||||
| `from_human` | `true` = 发件方是人类(服务端用 `EXISTS users` 判定)。**`B-5.6`** 据此决定是否自动转发 |
|
||||
| `permission_mode` | 所属会话的权限档位(`plan` / `workspace` / `full`)—— 插件应据此设置沙箱/审批策略 |
|
||||
| `permission_enforcement` | 平台对该档位的实际强制力(`native` / `advisory`)—— 插件据此决定是**强制执行**还是**打日志告警** |
|
||||
|
||||
> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`),
|
||||
> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址
|
||||
@ -1059,6 +1163,71 @@ GET /api/v1/attachments/{id}
|
||||
| `discovery.js` | 寻址发现工具的渲染(`T-8`~`T-12`) |
|
||||
| `rename-proposal.js` | 会话改名标记的构造与回执文案(`T-13`) |
|
||||
| `permission-grants.js` | 权限决策文本判定 + 免批授权表(`B-8.7` / `B-8.8`) |
|
||||
| `relay-policy.js` | 自动转发适用范围 + 据此给模型说什么话(Agent 间不转) |
|
||||
| `relay-key.js` | `relay_key` 长度收敛 + 永久/暂时失败分类(`B-8.2b` / `B-8.9`) |
|
||||
| `adopt.js` | 接管平台会话的 id 提取与缺失报文(`B-3.7`) |
|
||||
| `permission-mode.js` | 权限档位翻译(AgentMail 声明什么 → 平台怎么下发) |
|
||||
| `bounded.js` | 有界 Map/Set:给常驻进程里「只增不减」的映射表兜上界 |
|
||||
|
||||
> **Go 子进程插件的例外**:homeagent 是 Go,import 不了 Node 模块。
|
||||
> 那几个模块在它那边是 `relay_policy.go` / `relay_key.go` / `bounded.go`,
|
||||
> 注释与判据原样搬过去,测试逐条对齐(`relay_policy_test.go` / `relay_key_test.go` /
|
||||
> `bounded_test.go`)。`clampRelayKey` 还额外要求**两边对同一输入算出同一输出**
|
||||
> (幂等键分叉就失去意义);`bounded.go` 的上限常量必须与 Node 侧同值
|
||||
> (一侧偷偷调小会让「重复投递」只在那个平台出现),但**淘汰策略允许不同** ——
|
||||
> Go 的 map 不保证遍历顺序,那边是 FIFO 而不是 LRU,理由写在文件顶部。
|
||||
|
||||
### 常驻进程里的表必须有出口
|
||||
|
||||
四个桥都是常驻进程(pi 的守护进程能跑几十天,另三个跟着平台一起活)。里面每一张
|
||||
「这条会话/这封邮件我处理过吗」的表,键都来自外部事件流 —— 会话数与邮件数随时间
|
||||
单调增长。**每张这样的表都必须有出口**,两条:
|
||||
|
||||
| 出口 | 时机 | 性质 |
|
||||
|---|---|---|
|
||||
| `session_archived` 事件 | 会话归档 | 确定性:归档后别名 404、不会再有邮件投进来,映射再无用处 |
|
||||
| 上限淘汰(`bounded.js`) | 超过上限 | 兜底:兜的是「一直不归档」 |
|
||||
|
||||
> **确定性的出口优先**:能确切知道该删的时候不该靠上限去猜。
|
||||
> `session_archived` 是 SSE 事件里唯一一个「这条会话到此为止」的信号,
|
||||
> 四个桥原来全都没处理它。
|
||||
|
||||
**不要给「还在等结果的东西」套上界**:待决权限询问(opencode 的
|
||||
`pendingPermissions`、DSH 的 `pendingApprovals`)里存的是 `resolve` 回调,
|
||||
静默淘汰一条会让对应的 `await` 永远不返回 —— 平台侧那次工具调用直接挂死。
|
||||
那些表有确定的清理路径(决策到达 / 超时 / 拆插件时 fail closed),不需要上界。
|
||||
上界只适合「记录已经发生过的事实」的表。
|
||||
|
||||
> **这类表不是内存暴涨的原因**:单条成本只有几十到几百字节。症状是跑够久之后
|
||||
> 进程里躺着几十万个再也不会被查到的条目,且 GC 回收不了(还被强引用着)——
|
||||
> 不会在开发和测试里出现,只在生产上跑了几周后表现为「重启一下就好了」。
|
||||
|
||||
### pi 专属:不要在心跳路径上调 `SessionManager.listAll()`
|
||||
|
||||
心跳每 30 秒要上报平台会话快照,而 `snapshotPiSessions` 只用四个字段
|
||||
(`id` / `cwd` / `name` / `modified`)。`listAll()` 为了拿这四个字段会把
|
||||
`~/.pi/agent/sessions` 下**每个 `.jsonl` 的每一行**读进来并 `JSON.parse`,
|
||||
还把所有消息正文拼成一个 `allMessagesText` 大字符串。
|
||||
|
||||
本机实测(115 个文件 / 145MB,其中单个会话 29MB、单行最长 2.63MB):
|
||||
|
||||
| 做法 | 耗时 | RSS |
|
||||
|---|---|---|
|
||||
| `listAll()` | 1431ms | 41 → 323MB(heapUsed 141MB) |
|
||||
| 只读 header 首行 | 3ms | 41 → 46MB |
|
||||
| `src/session-scan.mjs`(冷启动) | 516ms | 41 → 131MB |
|
||||
| `src/session-scan.mjs`(稳态) | 3ms | 重扫 0 字节 |
|
||||
|
||||
那 282MB 每 30 秒分配一次、随即变成垃圾。GC 收得掉(所以 RSS 呈锯齿而不是单调
|
||||
上升),但代价是常驻内存被垃圾撑到 300MB 上下,且每拍有 1.4 秒的**同步解析跑在
|
||||
事件循环上** —— 那期间 SSE 读循环停着,新邮件事件在 TCP 缓冲区排队。
|
||||
|
||||
`src/session-scan.mjs` 的三条省法:`id`/`cwd` 只在首行 header(读 4KB 就够);
|
||||
`name` 来自 `session_info` 行而那种行只有几百字节(按行扫描时长度超上限的行直接
|
||||
跳过、不 materialize);文件是 append-only 的,缓存 `size` 之后每拍只扫新增的尾巴。
|
||||
|
||||
> **它必须建一次并复用**:省内存全靠跨拍存活的 size 缓存。每拍新建一个等于每拍
|
||||
> 都冷启动,退回全量读的开销。
|
||||
|
||||
> **为什么必须逐字节相同而不是「行为一致」**:一侧改了另一侧没改,两个平台的行为
|
||||
> 会悄悄分叉 —— 同一封邮件在 A 平台标了已读、在 B 平台没标,而两处代码看起来都
|
||||
@ -1130,7 +1299,7 @@ GET /api/v1/attachments/{id}
|
||||
[ ] sqlite3 <db> "SELECT status, last_seen FROM agents WHERE agent_name='<name>'"
|
||||
→ status=online,last_seen 每 30 秒推进(B-2.1)
|
||||
[ ] 断网 60 秒再恢复:SSE 自动重连,期间的邮件通过 Last-Event-ID 补回(D-7.2)
|
||||
[ ] kill 插件:未决权限询问全部 fail closed(B-8.2;平台无审批环节时跳过)
|
||||
[ ] kill 插件:未决权限询问全部 fail closed(B-9.2;平台无审批环节时跳过)
|
||||
```
|
||||
|
||||
### 7.3 主链路
|
||||
@ -1170,6 +1339,14 @@ GET /api/v1/attachments/{id}
|
||||
[ ] 模型主动调 send_mail 回信的那一轮
|
||||
→ 只有一封邮件,没有额外的自动转发(B-5.3)
|
||||
|
||||
[ ] Agent → Agent 负向对照(B-5.6):
|
||||
→ 向另一个 Agent 发一封(from_human === false)
|
||||
→ 收件方 Agent 的日志里**没有**「自动转发」相关条目
|
||||
→ 收件方 Agent 的 sessions.used_rounds 不因自动转发而涨
|
||||
→ 如果收件方 Agent 的模型跑了但没调 send_mail → 邮件链到此为止,发件方收不到任何回信
|
||||
→ 如果收件方 Agent 的模型调了 send_mail 回信 → 那封回信的 from_human === false
|
||||
→ 收件方的收件方也不自动转发
|
||||
|
||||
[ ] 模型带 propose_alias 发信(T-13)
|
||||
→ 入库正文里**没有** agentmail:rename-session 标记(已被剥掉)
|
||||
→ mails.rename_alias / rename_reason 记下了提议
|
||||
@ -1232,8 +1409,30 @@ GET /api/v1/attachments/{id}
|
||||
→ 权限邮件的 to_name 是**人**(会话 owner 或线索里最近的人类),不是 Agent
|
||||
sqlite3 "SELECT to_name FROM mails WHERE mail_type='permission_request' …"
|
||||
→ 正文里带得出「触发任务」与「任务来自」(B-8.4)
|
||||
→ 整条链上确实没有人类时:服务端返回 409,插件让位给本地 UI,
|
||||
**不是**无声挂起(日志里要能看到让位那一行)
|
||||
→ 整条链上确实没有人类时:服务端返回 409,插件**当场拒绝**并把原因告诉模型,
|
||||
**不是**无声挂起(日志里要能看到拒绝那一行)
|
||||
|
||||
[ ] **永久失败不得静默放行**(B-8.2b)
|
||||
造:把转发请求里的 relay_key 换成 200 字节的串(超服务端 160 上限),
|
||||
或把密钥改错造 401
|
||||
→ 服务端返回 4xx
|
||||
→ 插件**当场 block / deny / rejected**,日志里有「永久失败」字样
|
||||
→ 那次工具调用**没有执行**(这是生产事故的反面:
|
||||
原本 400 被当暂时失败让位,bash 就在无人批准下跑了)
|
||||
→ 改造 503(停接 Gateway):插件才该让位给本地 UI
|
||||
|
||||
[ ] **模型看到的拒绝理由是真实原因**(B-8.2c)
|
||||
造:让 Agent 把活派给自己另一条会话并要求跑 bash(整条链上无人类)
|
||||
→ 模型收到的工具报错里带得出「没有人类用户」与服务端的 suggestion
|
||||
→ **不得**是平台写死的「用户拒绝了」/ 「the user rejected tool X」
|
||||
(那句话是假的 —— 没有任何用户看过这次询问)
|
||||
→ 模型随后改道或在回信里说明需要人工执行,而不是反复重试同一个工具
|
||||
|
||||
[ ] **relay_key 收敛后能通过**(B-8.9)
|
||||
造:clampRelayKey("<36 字节会话 id>:" + "A".repeat(500))
|
||||
→ 结果≤ 160 字节、保留会话 id 前缀、尾部是 :sha256:<64 位 hex>
|
||||
→ 直接 POST /permission/request 得 200(未收敛的原始键得 400)
|
||||
→ Node 与 Go 两侧对同一输入算出**完全相同**的键
|
||||
|
||||
[ ] **「一直同意」真的免批**(B-8.7 / B-8.8)
|
||||
造:一封信里要求连续三次单独调用 bash
|
||||
@ -1286,6 +1485,7 @@ GET /api/v1/attachments/{id}
|
||||
| 轮次结束 | `session.idle` 事件 | `agent/status` → `idle` | `prompt()` 的 promise resolve;事件是 `agent_end` | 无「轮次」事件;靠 `RegisterOutputChannel` 的 handler 被调用 |
|
||||
| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` | 无(核心不把模型错误暴露给插件) |
|
||||
| 权限钩子 | `permission.ask`(**同步,不能等**) | `approval/request`(异步 waterfall,**能等**) | `tool_call` 扩展事件(**能 await**,实测) | **无审批环节**(核心不问人)。有 `StageBeforeToolcall` 可否决,但语义不同 —— 见 `B-8` |
|
||||
| 拒绝理由能不能递给模型 | 能:`output.reason` | **不能** —— `'rejected'` 被翻译成写死的 `the user rejected tool "X"`;需在 `tools/post-execute` 返回 `{kind:'block', feedback}` 换掉(`B-8.2c`) | 能:`{block:true, reason}` | N/A |
|
||||
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`(**不传参**,传字符串会被当自定义目录) | N/A |
|
||||
| 模型目录 | `client.config.providers()`(`models` 是**对象**) | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`(**不是** `getModels()`:1221 条里只有 1 条能用) | N/A(模型由核心配置,插件不选) |
|
||||
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**(SDK 会话没有平台标题,见下) | 邮件主题派生(无平台标题) |
|
||||
|
||||
@ -333,32 +333,53 @@ func bootstrapAdmin(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// sweepOrphanAttachments 周期清理「已上传但从未随邮件发出」的附件。
|
||||
// sweepOrphanAttachments 周期清理无人引用的附件。
|
||||
//
|
||||
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下孤立记录与文件。
|
||||
// 保留 24 小时再清:足以覆盖一次正常的写信过程,也不至于让废弃文件长期占盘。
|
||||
// 两个方向,缺一不可:
|
||||
//
|
||||
// 1. **正向**(repo.SweepOrphanAttachments):库里还有记录但从未挂到邮件上。
|
||||
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下这类记录。
|
||||
// 保留 24 小时再清:足以覆盖一次正常的写信过程。
|
||||
//
|
||||
// 2. **反向**(repo.SweepUnreferencedBlobs):磁盘上有文件但库里连记录都没有。
|
||||
// 一旦记录本身消失(清库、手工 DELETE、迁移),正向那条 SQL 就永远看不见它——
|
||||
// 本机实测磁盘 8 个 blob 里 7 个属于这种,全部来自一次清库,之后一直占着盘。
|
||||
//
|
||||
// 反向清理的年龄下限取得比正向更宽(48 小时):它删的是「库里查无此物」的文件,
|
||||
// 判据比正向弱,多留一天换取更小的误删面。上传窗口(落盘与入库之间)也靠它兜住。
|
||||
func sweepOrphanAttachments(blobs *blob.Store) {
|
||||
const (
|
||||
interval = 1 * time.Hour
|
||||
keepFor = 24 * time.Hour
|
||||
interval = 1 * time.Hour
|
||||
keepFor = 24 * time.Hour
|
||||
keepUnlinked = 48 * time.Hour
|
||||
)
|
||||
|
||||
sweep := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 正向失败不能 return:反向那一步与它相互独立,
|
||||
// 一层坏掉不该让另一层也停工。
|
||||
sums, err := repo.SweepOrphanAttachments(ctx, keepFor)
|
||||
if err != nil {
|
||||
log.Printf("附件 GC 失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, sum := range sums {
|
||||
if err := blobs.Remove(sum); err != nil {
|
||||
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], err)
|
||||
} else {
|
||||
for _, sum := range sums {
|
||||
if rErr := blobs.Remove(sum); rErr != nil {
|
||||
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], rErr)
|
||||
}
|
||||
}
|
||||
if len(sums) > 0 {
|
||||
log.Printf("附件 GC 清理了 %d 个孤立记录", len(sums))
|
||||
}
|
||||
}
|
||||
if len(sums) > 0 {
|
||||
log.Printf("附件 GC 清理了 %d 个孤立文件", len(sums))
|
||||
|
||||
// 反向:库里查无此物的磁盘文件。与正向分开报数——
|
||||
// 两个数字的含义不同,合成一个会让「哪一层在漏」看不出来。
|
||||
if n, uErr := repo.SweepUnreferencedBlobs(ctx, blobs, keepUnlinked); uErr != nil {
|
||||
log.Printf("附件反向 GC 失败: %v", uErr)
|
||||
} else if n > 0 {
|
||||
log.Printf("附件反向 GC 清理了 %d 个无引用文件", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
gateway/internal/blob/list_test.go
Normal file
176
gateway/internal/blob/list_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package blob
|
||||
|
||||
// List() 的测试 —— 反向 GC 的前提。
|
||||
//
|
||||
// 事故背景:附件 GC 原先只从**库记录**出发(`WHERE mail_id IS NULL`),于是一旦
|
||||
// 记录本身消失(清库、手工 DELETE、迁移),对应文件就永远脱离了视野。本机实测
|
||||
// 磁盘 8 个 blob 里 7 个没有任何库记录,全部来自一次清库,之后一直占着盘。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListReturnsStoredContents(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
|
||||
sumA, _, err := s.Put(bytes.NewReader([]byte("alpha")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put a: %v", err)
|
||||
}
|
||||
sumB, _, err := s.Put(bytes.NewReader([]byte("beta")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put b: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应列出 2 个内容,实际 %d:%v", len(got), got)
|
||||
}
|
||||
for _, sum := range []string{sumA, sumB} {
|
||||
mod, ok := got[sum]
|
||||
if !ok {
|
||||
t.Errorf("缺少 %s", sum[:8])
|
||||
continue
|
||||
}
|
||||
if mod.IsZero() {
|
||||
t.Errorf("%s 的修改时间为零值 —— GC 靠它判断「是否可能正在上传」", sum[:8])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEmptyStore(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("空库不该报错: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("空库应返回空,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **关键用例**:`.upload-*` 临时文件绝不能进列表。
|
||||
//
|
||||
// 报给调用方会让 GC 去删一个正在写入的文件 —— 上传是「先落盘再入库」,
|
||||
// 那一瞬间的临时文件既没有库记录也不是合法 sha256 命名。
|
||||
func TestListSkipsTempUploads(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("real")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
// 模拟一个正在进行的上传
|
||||
tmp, err := os.CreateTemp(root, ".upload-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp: %v", err)
|
||||
}
|
||||
tmp.WriteString("half written")
|
||||
tmp.Close()
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("只该列出 1 个真实内容,实际 %d:%v", len(got), got)
|
||||
}
|
||||
if _, ok := got[sum]; !ok {
|
||||
t.Errorf("真实内容 %s 应在列表里", sum[:8])
|
||||
}
|
||||
for k := range got {
|
||||
if strings.HasPrefix(k, ".upload-") {
|
||||
t.Errorf("临时文件 %q 不该出现在列表里 —— GC 会删掉一个正在写入的文件", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非 sha256 命名的异物一律忽略(人手工丢进去的、别的程序留下的)。
|
||||
func TestListSkipsForeignFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
if _, _, err := s.Put(bytes.NewReader([]byte("real")), 0); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
// 放几个不合法命名的文件在两级目录里
|
||||
for _, name := range []string{"README", "ABCDEF", "notasha256"} {
|
||||
dir := filepath.Join(root, "ab", "cd")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("异物不该被列出,实际 %d:%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 修改时间必须是真实的文件时间 —— GC 用它跳过「可能正在上传」的文件。
|
||||
func TestListReportsRealModTime(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
before := time.Now().Add(-time.Second)
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("timed")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
after := time.Now().Add(time.Second)
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
mod := got[sum]
|
||||
if mod.Before(before) || mod.After(after) {
|
||||
t.Fatalf("修改时间 %v 不在 [%v, %v] 内", mod, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 根目录不存在时必须报错,不能返回空列表。
|
||||
//
|
||||
// 返回空会让调用方以为「库里什么都没有」—— 那个判断会传导到 GC 的计数上,
|
||||
// 让运维以为磁盘是干净的。
|
||||
func TestListFailsOnMissingRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatalf("rm root: %v", err)
|
||||
}
|
||||
if _, err := s.List(); err == nil {
|
||||
t.Fatal("根目录不存在时必须报错,返回空列表会让调用方以为库是空的")
|
||||
}
|
||||
}
|
||||
@ -16,9 +16,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store 是附件的磁盘存储。
|
||||
@ -133,6 +135,46 @@ func (s *Store) Exists(sum string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// List 枚举库中全部内容文件的 sha256 与修改时间。
|
||||
//
|
||||
// # 为什么需要它
|
||||
//
|
||||
// 附件 GC 原先只从**库记录**出发(`WHERE mail_id IS NULL`),于是一旦记录本身
|
||||
// 消失(清库、手工 DELETE、迁移),对应文件就永远脱离了视野:本机实测磁盘 8 个
|
||||
// blob 里 7 个没有任何库记录,全部来自 09-03 那次清库,之后一直占着盘。
|
||||
//
|
||||
// 反向清理必须能枚举磁盘,因此这个方法是 `repo.SweepUnreferencedBlobs` 的前提。
|
||||
//
|
||||
// 只认文件名是合法 sha256 的项:`.upload-*` 临时文件不属于内容库,
|
||||
// 把它们报给调用方会让 GC 去删一个正在写入的文件。
|
||||
func (s *Store) List() (map[string]time.Time, error) {
|
||||
out := map[string]time.Time{}
|
||||
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// 单个子目录读不了不该让整次枚举失败 —— 但**根目录**读不了必须报:
|
||||
// 那时返回空 map 会让调用方以为「库里什么都没有」,
|
||||
// 于是把仍被引用的文件当成孤儿(这里不会删,但计数会骗人)。
|
||||
if path == s.root {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !sha256Re.MatchString(d.Name()) {
|
||||
return nil // .upload-* 临时文件与其他异物
|
||||
}
|
||||
info, iErr := d.Info()
|
||||
if iErr != nil {
|
||||
return nil
|
||||
}
|
||||
out[d.Name()] = info.ModTime()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("blob: 枚举 %s: %w", s.root, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Remove 删除某内容。
|
||||
//
|
||||
// 注意:内容寻址意味着多条附件记录可能指向同一个文件,
|
||||
|
||||
@ -338,7 +338,39 @@ func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []u
|
||||
if len(ids) == 0 {
|
||||
return true
|
||||
}
|
||||
err := repo.AttachToMail(r.Context(), mailID, ids, uploader)
|
||||
return writeAttachErr(w, repo.AttachToMail(r.Context(), mailID, ids, uploader))
|
||||
}
|
||||
|
||||
// checkAttachable 在**产生任何副作用之前**校验附件可不可挂。
|
||||
//
|
||||
// 返回 false 表示已写出错误响应,调用方应立即返回。
|
||||
//
|
||||
// # 为什么不能只靠 attachAll
|
||||
//
|
||||
// attachAll 在 CreateMail **之后**调用,于是附件不合法时请求返回 403/409,
|
||||
// 但那封邮件**已经入库、已经通知了收件人、已经扣掉了会话预算**。
|
||||
// 生产实测:两封探针邮件(一封 403「只能附加自己上传的附件」、一封 409
|
||||
// 「附件已随其他邮件发出」)都躺在 mails 表里,used_rounds 也涨了。
|
||||
// 发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
//
|
||||
// 纯输入校验必须在副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
|
||||
//
|
||||
// 它**不取代** attachAll:两次调用之间仍有竞态窗口(另一个请求把同一个附件
|
||||
// 挂走了),那一次由 attachAll 的原子 UPDATE 拦下、并由调用方回滚。
|
||||
// 双层分工:这里挡住绝大多数(拼错 id、拿别人的附件、重复挂),
|
||||
// attachAll 挡住真正的并发。
|
||||
func checkAttachable(w http.ResponseWriter, r *http.Request, ids []uuid.UUID, uploader string) bool {
|
||||
if len(ids) == 0 {
|
||||
return true
|
||||
}
|
||||
return writeAttachErr(w, repo.EnsureAttachable(r.Context(), ids, uploader))
|
||||
}
|
||||
|
||||
// writeAttachErr 把 repo 层的附件错误映射成 HTTP 响应。
|
||||
//
|
||||
// checkAttachable 与 attachAll 共用一份:同一种错误在两条路径上必须给出同一个
|
||||
// 状态码与同一句话 —— 分开写早晚会分叉,而调用方无法区分自己碰上的是哪一层。
|
||||
func writeAttachErr(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case err == nil:
|
||||
return true
|
||||
|
||||
@ -48,6 +48,13 @@ func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
RemindBefore int `json:"remind_before"`
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end"`
|
||||
// Status 在创建时存在只为与更新端点同形:前端的 CalendarEventInput 是
|
||||
// **一份**类型,新建与编辑发的是同一个对象。不接这个字段的后果在
|
||||
// 严格解码下是新建日程直接 400。
|
||||
//
|
||||
// 新建时它只能是 active(新建一个已取消的提醒没有意义),
|
||||
// 但传 paused/cancelled 也不报错 —— 照字面履行比推回去更有用。
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !DecodeBody(w, r, &req) {
|
||||
return
|
||||
@ -56,6 +63,14 @@ func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusBadRequest, "Missing title")
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = models.EventActive
|
||||
}
|
||||
if !validEventStatus(req.Status) {
|
||||
Error(w, http.StatusBadRequest,
|
||||
"status 必须是 active/paused/cancelled 之一")
|
||||
return
|
||||
}
|
||||
if req.EventTime.IsZero() {
|
||||
Error(w, http.StatusBadRequest, "Missing event_time")
|
||||
return
|
||||
@ -112,7 +127,7 @@ func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
RemindBefore: req.RemindBefore,
|
||||
Recurrence: req.Recurrence,
|
||||
RecurrenceEnd: req.RecurrenceEnd,
|
||||
Status: "active",
|
||||
Status: req.Status,
|
||||
CreatedBy: user.Username,
|
||||
}
|
||||
|
||||
@ -209,6 +224,16 @@ func UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
|
||||
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
|
||||
return
|
||||
}
|
||||
// status 直接写进库,所以必须先校验:一个拼错的值(比如 "pause")会变成
|
||||
// 调度器不认识的状态 —— DueEvents 只查 active,那条提醒于是静默失效,
|
||||
// 而界面下拉框里没有这个选项,人再也改不回来。
|
||||
if req.Status == "" {
|
||||
req.Status = models.EventActive
|
||||
}
|
||||
if !validEventStatus(req.Status) {
|
||||
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled 之一")
|
||||
return
|
||||
}
|
||||
recipients, badAddr := normalizeRecipients(req.Recipients)
|
||||
if badAddr != "" {
|
||||
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
|
||||
@ -376,6 +401,12 @@ func DeleteCalendarAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// 必须白名单而不是「未知值当 none」:把 `lunar_montly`(拼错)静默当成
|
||||
// 不重复,用户设的每月提醒只会响一次,而没有任何地方报错。
|
||||
// validEventStatus 校验日历事件状态(包装 models.ValidEventStatus,与
|
||||
// validRecurrence 保持同一种调用形状)。
|
||||
func validEventStatus(s string) bool {
|
||||
return models.ValidEventStatus(s)
|
||||
}
|
||||
|
||||
func validRecurrence(r string) bool {
|
||||
switch r {
|
||||
case models.RecurNone, models.RecurDaily, models.RecurWeekly, models.RecurMonthly,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
@ -25,9 +27,170 @@ func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// Decode 从请求体解析 JSON
|
||||
// Decode 从请求体解析 JSON。**拒绝未知字段。**
|
||||
//
|
||||
// # 为什么必须严格
|
||||
//
|
||||
// 宽容解码把「字段名写错」变成一种**静默成功**:请求返回 200,服务端却什么都
|
||||
// 没收到。生产实测过最坏的一种形状 —— homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`:
|
||||
//
|
||||
// $ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
|
||||
// HTTP 200 {"mail_id":"2a64fdc8…", …}
|
||||
// $ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
|
||||
// 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 在库里活了很久 ——
|
||||
// **正因为没人会去核对一个返回 200 的请求**。
|
||||
//
|
||||
// 严格解码把它变成一个当场可见的 400。这是 `I-5`(失败必须当场可见)在
|
||||
// 请求解析层的落点:宁可让调用方收到一句「字段 X 不认识」,
|
||||
// 也不要让它以为自己传的东西生效了。
|
||||
//
|
||||
// 需要宽容的地方只有一处(心跳,见 DecodeLenient),且必须显式说明理由。
|
||||
func Decode(r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// DecodeLenient 解析请求体但**容忍未知字段**,同时把认不出的字段名报回来。
|
||||
//
|
||||
// 只给心跳用,理由是那条路径的职责是「我还活着」:插件比服务端新、多带了一个
|
||||
// 服务端还不认识的字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。
|
||||
//
|
||||
// 但**容忍不等于咽下去**。返回的 unknown 列表必须被调用方回报给插件
|
||||
// (心跳响应里的 `unknown_fields`),否则又变成一次静默忽略 —— 那正是
|
||||
// `attachments` vs `attachment_ids` 能拖那么久的原因。
|
||||
//
|
||||
// 实现上要解两遍(宽容一遍取值、严格一遍找未知字段),所以先把 body 读进内存。
|
||||
func DecodeLenient(r *http.Request, v interface{}) (unknown []string, err error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxLenientBodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 取值这一遍必须宽容:未知字段不能让整个心跳体作废。
|
||||
if uErr := json.Unmarshal(raw, v); uErr != nil {
|
||||
return nil, uErr
|
||||
}
|
||||
|
||||
// 再严格解一遍**只为找出未知字段**。json 每遇到一个未知字段就立即返回,
|
||||
// 所以要循环剥:不循环的话「多带了三个字段」只会报出第一个。
|
||||
probeType := reflect.TypeOf(v)
|
||||
for probeType != nil && probeType.Kind() == reflect.Ptr {
|
||||
probeType = probeType.Elem()
|
||||
}
|
||||
if probeType == nil {
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < maxUnknownFieldsReported; i++ {
|
||||
probe := reflect.New(probeType).Interface()
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
dErr := dec.Decode(probe)
|
||||
if dErr == nil {
|
||||
break
|
||||
}
|
||||
name := unknownFieldName(dErr)
|
||||
// 不是未知字段错误(宽容那遍已经成功,所以这里本不应出现其他错),
|
||||
// 或者同一个名字又出现一次 —— 都说明剥不下去了,停。
|
||||
if name == "" || seen[name] {
|
||||
break
|
||||
}
|
||||
seen[name] = true
|
||||
unknown = append(unknown, name)
|
||||
stripped, sErr := stripTopLevelKey(raw, name)
|
||||
if sErr != nil {
|
||||
break
|
||||
}
|
||||
raw = stripped
|
||||
}
|
||||
return unknown, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// maxLenientBodyBytes 是心跳体的读取上限。会话快照 200 条 + 模型目录 300 条,
|
||||
// 每条百来字节,2MB 有充足余量;超出的部分被截断后 json 解析会报错,
|
||||
// 那正是我们想要的(一个畸形巨大的心跳体不该被当成有效上报)。
|
||||
maxLenientBodyBytes = 2 << 20
|
||||
// maxUnknownFieldsReported 是回报的未知字段数上限。
|
||||
// 报头几个足够定位问题,无上限循环会让一个塞满垃圾键的请求变成 CPU 消耗。
|
||||
maxUnknownFieldsReported = 8
|
||||
)
|
||||
|
||||
// stripTopLevelKey 从一个 JSON 对象里删掉一个顶层键。
|
||||
//
|
||||
// 只动顶层:未知字段错误报的就是顶层键名。嵌套结构里的未知字段报的名字
|
||||
// 在顶层找不到,这里返回错误,循环随即停下 —— 那个字段仍会被报出来。
|
||||
func stripTopLevelKey(raw []byte, key string) ([]byte, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := m[key]; !ok {
|
||||
return nil, errors.New("key not at top level")
|
||||
}
|
||||
delete(m, key)
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// unknownFieldName 从 encoding/json 的未知字段错误里取出那个字段名。
|
||||
//
|
||||
// json 包没有为这种错误定义类型(返回的是 *errors.errorString),
|
||||
// 只能按文本匹配 `json: unknown field "xxx"`。
|
||||
// 匹配不上时返回空串,调用方回落到笼统文案。
|
||||
func unknownFieldName(err error) string {
|
||||
const prefix = `json: unknown field "`
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, prefix)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := msg[i+len(prefix):]
|
||||
j := strings.IndexByte(rest, '"')
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// jsonFieldNames 反射列出一个请求结构体接受的 JSON 键。
|
||||
//
|
||||
// 用途是把「字段 X 不认识」补成「应为 a / b / c 之一」——
|
||||
// 少了这半句,调用方只知道自己错了,仍要去翻服务端源码才知道对的是什么。
|
||||
// 那正是 `attachments` vs `attachment_ids` 当初拖了那么久的原因。
|
||||
func jsonFieldNames(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
for t != nil && t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue // 非导出字段不参与 JSON
|
||||
}
|
||||
name := f.Tag.Get("json")
|
||||
if idx := strings.IndexByte(name, ','); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeBody 解析请求体,失败时直接写 400 并返回 false。
|
||||
@ -38,7 +201,7 @@ func Decode(r *http.Request, v interface{}) error {
|
||||
// 就是那句固定文案,只能靠翻服务端结构体才发现。第三方客户端没有这个条件。
|
||||
func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
if err := Decode(r, v); err != nil {
|
||||
Error(w, http.StatusBadRequest, decodeErrMsg(err))
|
||||
Error(w, http.StatusBadRequest, decodeErrMsg(err, v))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@ -48,10 +211,21 @@ func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
//
|
||||
// 刻意不回显 json 包的原文:它带 Go 的类型名(如 models.Workspace),
|
||||
// 那是本侧的实现细节,对调用方没有意义,也不该出现在公开 API 的响应里。
|
||||
func decodeErrMsg(err error) string {
|
||||
func decodeErrMsg(err error, target interface{}) string {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "请求体为空"
|
||||
}
|
||||
|
||||
// 未知字段:把认识的键一并列出来。只说「不认识 x」的话,调用方还得去翻
|
||||
// 服务端源码才知道对的拼法 —— 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
//(宽容解码时它连报错都没有,见 Decode 的注释)。
|
||||
if bad := unknownFieldName(err); bad != "" {
|
||||
msg := "不认识的字段 \"" + bad + "\""
|
||||
if names := jsonFieldNames(target); len(names) > 0 {
|
||||
msg += ";本端点接受:" + strings.Join(names, " / ")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
// 截断的 JSON 走的不是 SyntaxError 而是 ErrUnexpectedEOF ——
|
||||
// 不单独处理的话会落到最后那句笼统的兜底文案里
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
@ -141,7 +315,7 @@ func writeKeyErr(w http.ResponseWriter, err error) {
|
||||
} else {
|
||||
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
|
||||
@ -217,3 +391,20 @@ func agentLimiterKey(isAgent bool, actor string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validPermissionModeInput 校验人显式指定的权限档位。
|
||||
//
|
||||
// 与 repo 层的 Normalize 分工不同:**人显式传了一个认不出的档位时必须报错**,
|
||||
// 不能静默用默认档。他以为自己给了 plan,实际拿到 workspace —— 那是比报错
|
||||
// 更坏的结果(他会以为自己收紧了)。
|
||||
//
|
||||
// 而 repo 层的 Normalize 面向的是「库里的历史脏数据」与「省略该字段」,
|
||||
// 那两种情形下静默回落到默认档才是对的。
|
||||
func validPermissionModeInput(w http.ResponseWriter, mode string) bool {
|
||||
if mode == "" || models.ValidPermissionMode(mode) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusBadRequest,
|
||||
"permission_mode 非法:"+mode+"(应为 plan / workspace / full)")
|
||||
return false
|
||||
}
|
||||
|
||||
247
gateway/internal/handler/strictdecode_test.go
Normal file
247
gateway/internal/handler/strictdecode_test.go
Normal file
@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
// 严格解码的回归测试。
|
||||
//
|
||||
// 事故背景(生产实测):homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`。
|
||||
// 宽容解码让这变成一种**静默成功**:
|
||||
//
|
||||
// POST /mail/send {…,"attachments":[{"attachment_id":"598f100e…"}]}
|
||||
// → HTTP 200 {"mail_id":"2a64fdc8…"}
|
||||
// → SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…' → 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 活了很久,
|
||||
// 正因为没人会去核对一个返回 200 的请求。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这就是那次事故的最小复现:把 attachment_ids 写成 attachments。
|
||||
func TestStrictDecodeRejectsMisspelledField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","subject":"x","attachments":[{"attachment_id":"598f100e"}]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if DecodeBody(w, r, &req) {
|
||||
t.Fatal("拼错的字段名必须被拒绝 —— 否则又是一次静默成功")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("状态码应为 400,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v", err)
|
||||
}
|
||||
msg := resp["error"]
|
||||
|
||||
// 报出错的字段名
|
||||
if !strings.Contains(msg, "attachments") {
|
||||
t.Errorf("信息里应指出 attachments,实际 %q", msg)
|
||||
}
|
||||
// **并且**列出对的拼法 —— 少了这半句,调用方仍要去翻服务端源码,
|
||||
// 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
if !strings.Contains(msg, "attachment_ids") {
|
||||
t.Errorf("信息里应列出正确字段 attachment_ids,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 负向对照:合法字段必须原样通过,不能被严格解码误伤。
|
||||
func TestStrictDecodeAcceptsCorrectField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","attachment_ids":["a","b"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("合法请求体被拒了:%s", w.Body.String())
|
||||
}
|
||||
if w.Code != http.StatusOK { // recorder 默认 200,即「没写过响应」
|
||||
t.Fatalf("不该写任何响应,实际状态码 %d", w.Code)
|
||||
}
|
||||
if len(req.AttachmentIDs) != 2 {
|
||||
t.Fatalf("附件 id 应解出 2 个,实际 %#v", req.AttachmentIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// 省略可选字段仍然合法 —— 严格针对的是「多」而不是「少」。
|
||||
func TestStrictDecodeAllowsOmittedFields(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
CC string `json:"cc"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(`{"to":"jianf"}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("省略可选字段被拒了:%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DecodeLenient(心跳唯一的例外)───
|
||||
|
||||
func TestDecodeLenientKeepsKnownFieldsAndReportsUnknown(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":["a"],"mode_enforcement":"native","futureField":1}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("心跳体不该整体作废: %v", err)
|
||||
}
|
||||
// 已知字段必须照常取到 —— 这正是心跳要宽容的理由:
|
||||
// 插件比服务端新时,代价不该是会话快照与模型目录一起丢掉
|
||||
if len(req.Models) != 1 || req.ModeEnforcement != "native" {
|
||||
t.Fatalf("已知字段应正常解析,实际 %#v", req)
|
||||
}
|
||||
// 但**必须报出来**,否则又是一次静默忽略
|
||||
if len(unknown) != 1 || unknown[0] != "futureField" {
|
||||
t.Fatalf("未知字段应报 [futureField],实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 多个未知字段要全报出来。
|
||||
//
|
||||
// json 每遇到一个未知字段就立刻返回,所以实现里必须循环剥 ——
|
||||
// 不循环的话「多带了三个字段」只会报出第一个,而人改完那一个又撞上下一个。
|
||||
func TestDecodeLenientReportsAllUnknownFields(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":[],"aaa":1,"bbb":2,"ccc":3}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
if len(unknown) != 3 {
|
||||
t.Fatalf("应报出 3 个未知字段,实际 %#v", unknown)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, u := range unknown {
|
||||
got[u] = true
|
||||
}
|
||||
for _, want := range []string{"aaa", "bbb", "ccc"} {
|
||||
if !got[want] {
|
||||
t.Errorf("未报出 %q(实际 %#v)", want, unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientCleanBodyReportsNothing(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":["a"]}`))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
// 正常心跳的响应里不该多一个空数组 —— 调用方据此决定是否带 unknown_fields
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("干净的体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientEmptyBody(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(``))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("空体应静默通过(心跳可以不带 body): %v", err)
|
||||
}
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("空体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 语法错误仍然要报 —— 宽容的是「多字段」,不是「烂 JSON」。
|
||||
func TestDecodeLenientStillRejectsMalformedJSON(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("截断的 JSON 必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 类型不对也要报:`models` 要的是数组,给字符串说明插件写错了结构,
|
||||
// 那不是「服务端还不认识的新字段」。
|
||||
func TestDecodeLenientStillRejectsWrongType(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":"oops"}`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("类型不匹配必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── jsonFieldNames ───
|
||||
|
||||
func TestJSONFieldNamesListsAcceptedKeys(t *testing.T) {
|
||||
type req struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
Skipped string `json:"-"`
|
||||
NoTag string
|
||||
unexported string //nolint:unused // 刻意保留:验证非导出字段不进清单
|
||||
}
|
||||
|
||||
names := jsonFieldNames(&req{})
|
||||
joined := strings.Join(names, ",")
|
||||
|
||||
for _, want := range []string{"to", "attachment_ids"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("应含 %q,实际 %q", want, joined)
|
||||
}
|
||||
}
|
||||
// json:"-" 的字段不该出现在「本端点接受」的清单里 —— 它确实不接受
|
||||
if strings.Contains(joined, "Skipped") || strings.Contains(joined, "-") {
|
||||
t.Errorf("json:\"-\" 的字段不该列出,实际 %q", joined)
|
||||
}
|
||||
// 无 tag 时用字段名(json 包也是这么匹配的)
|
||||
if !strings.Contains(joined, "NoTag") {
|
||||
t.Errorf("无 tag 字段应按字段名列出,实际 %q", joined)
|
||||
}
|
||||
// 非导出字段 json 根本不看
|
||||
if strings.Contains(joined, "unexported") {
|
||||
t.Errorf("非导出字段不该列出,实际 %q", joined)
|
||||
}
|
||||
}
|
||||
@ -80,6 +80,32 @@ func IsLunarRecurrence(r string) bool {
|
||||
return r == RecurLunarMonthly || r == RecurLunarYearly
|
||||
}
|
||||
|
||||
// 事件状态常量。
|
||||
//
|
||||
// 此前这三个值只以裸字符串形式散落在 handler、scheduler 与前端里,而更新端点
|
||||
// 把 `status` 原样写进库 —— 于是一个拼错的值(比如 "pause")会变成一个
|
||||
// **调度器不认识的状态**:DueEvents 只查 status='active',那条提醒于是静默失效。
|
||||
// 人以为自己只是暂停了它,实际上再也恢复不了(界面的下拉框里没有这个选项)。
|
||||
//
|
||||
// 提成常量后,handler.validEventStatus 能对着这一份清单校验。
|
||||
const (
|
||||
// EventActive 生效中:到点会触发提醒。
|
||||
EventActive = "active"
|
||||
// EventPaused 暂停:保留事件与重复规则,但不触发。
|
||||
EventPaused = "paused"
|
||||
// EventCancelled 已取消:保留历史记录,不再触发也不再推进重复。
|
||||
EventCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// ValidEventStatus 判断状态取值是否合法。
|
||||
func ValidEventStatus(s string) bool {
|
||||
switch s {
|
||||
case EventActive, EventPaused, EventCancelled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 投递模式常量。
|
||||
const (
|
||||
DeliverSeparate = "separate"
|
||||
|
||||
@ -89,6 +89,35 @@ func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachm
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EnsureAttachable 只做**读取校验**:这批附件是否存在、属于该上传者、且尚未挂载。
|
||||
//
|
||||
// # 为什么要有一个「只查不改」的版本
|
||||
//
|
||||
// 原先只有 AttachToMail,而它在 CreateMail **之后**调用。于是附件不合法时
|
||||
// (不属于我 / 已随别的邮件发出)请求返回 403/409,但那封邮件**已经入库、已经
|
||||
// 通知了收件人、已经扣掉了会话预算**。实测两封探针邮件(403 与 409)都躺在库里,
|
||||
// used_rounds 也涨了。发件方看到 4xx 会重试,收件方于是收到两封。
|
||||
//
|
||||
// 纯输入校验必须在产生任何副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
|
||||
//
|
||||
// 它不能取代 AttachToMail 里的原子判断:两次调用之间仍有竞态窗口
|
||||
// (另一个请求把同一个附件挂走了)。那条路径靠调用方回滚,见 handler.attachAll。
|
||||
func EnsureAttachable(ctx context.Context, ids []uuid.UUID, uploader string) error {
|
||||
for _, id := range ids {
|
||||
a, err := GetAttachment(ctx, id)
|
||||
if err != nil {
|
||||
return err // ErrAttachmentNotFound 或库错误
|
||||
}
|
||||
if a.Uploader != uploader {
|
||||
return ErrAttachmentNotOwned
|
||||
}
|
||||
if a.MailID != nil {
|
||||
return ErrAttachmentAlreadyAttached
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachToMail 把一批待挂载附件绑到某封邮件上。
|
||||
//
|
||||
// 每条都要求:存在、属于该上传者、且尚未挂载。
|
||||
@ -212,6 +241,87 @@ func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, e
|
||||
return removable, nil
|
||||
}
|
||||
|
||||
// SweepUnreferencedBlobs 删掉磁盘上没有任何库记录指向的内容文件。
|
||||
//
|
||||
// # 为什么 SweepOrphanAttachments 不够
|
||||
//
|
||||
// 那个函数走的是 `SELECT … FROM attachments WHERE mail_id IS NULL` —— 它只能看见
|
||||
// **库里还有记录**的孤儿。一旦记录本身消失(清库、手工 DELETE、迁移),
|
||||
// 对应的文件就永远脱离了 GC 的视野:本机实测磁盘 8 个 blob 里 7 个没有任何库记录,
|
||||
// 全部来自 09-03 那次清库,之后一直躺在那里。
|
||||
//
|
||||
// 这个反向清理从**磁盘**出发:枚举全部内容文件,凡是 attachments 与
|
||||
// calendar_attachments 都不引用的就删。返回删掉的数量。
|
||||
//
|
||||
// # 为什么要 minAge
|
||||
//
|
||||
// 上传是「先落盘、再入库」(顺序不能反,否则会出现「库里有记录、磁盘没文件」的
|
||||
// 下载 500)。那两步之间有一个窗口,此刻文件确实没有任何库记录 —— 不设年龄下限
|
||||
// 会把正在上传的文件删掉。取一个远大于单次上传耗时的值。
|
||||
func SweepUnreferencedBlobs(ctx context.Context, blobs BlobLister, minAge time.Duration) (int, error) {
|
||||
if blobs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
sums, err := blobs.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(sums) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 一次查回全部被引用的 sha256。逐个文件查一次库是 N 次往返,
|
||||
// 而这两张表加起来通常只有几百行。
|
||||
referenced := map[string]struct{}{}
|
||||
for _, q := range []string{
|
||||
`SELECT sha256 FROM attachments`,
|
||||
`SELECT sha256 FROM calendar_attachments`,
|
||||
} {
|
||||
rows, qErr := db.DB.QueryContext(ctx, q)
|
||||
if qErr != nil {
|
||||
return 0, qErr
|
||||
}
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if sErr := rows.Scan(&s); sErr != nil {
|
||||
rows.Close()
|
||||
return 0, sErr
|
||||
}
|
||||
referenced[s] = struct{}{}
|
||||
}
|
||||
rows.Close()
|
||||
if rErr := rows.Err(); rErr != nil {
|
||||
return 0, rErr
|
||||
}
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-minAge)
|
||||
removed := 0
|
||||
for sum, mod := range sums {
|
||||
if _, ok := referenced[sum]; ok {
|
||||
continue
|
||||
}
|
||||
if mod.After(cutoff) {
|
||||
continue // 可能正在上传(落盘与入库之间的窗口)
|
||||
}
|
||||
if rErr := blobs.Remove(sum); rErr != nil {
|
||||
continue // 删不掉就下一轮再试,不该让整次清理中断
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// BlobLister 是 SweepUnreferencedBlobs 需要的存储能力。
|
||||
//
|
||||
// 用 map[string]time.Time 而不是自定义结构体:那样 blob 包就不必 import repo
|
||||
// (底层存储依赖上层仓储会很怪),而 Go 的接口是结构化匹配的,签名一致即可。
|
||||
type BlobLister interface {
|
||||
// List 返回 sha256 → 该内容文件的修改时间。
|
||||
List() (map[string]time.Time, error)
|
||||
Remove(sum string) error
|
||||
}
|
||||
|
||||
// AttachmentAccessible 判断某人是否有权读取某附件:
|
||||
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
|
||||
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {
|
||||
|
||||
181
gateway/internal/repo/defaultsession_test.go
Normal file
181
gateway/internal/repo/defaultsession_test.go
Normal file
@ -0,0 +1,181 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这一组测试钉住「省略 session 位复用默认会话」与「真的新建会话」必须可区分。
|
||||
//
|
||||
// 事故背景:handler 层曾用 `parentMailID == nil` 判断「是不是新建会话」,
|
||||
// 据此决定要不要写往返预算与权限档位。但省略 session 位复用默认会话时
|
||||
// parentMailID 也是 nil —— 于是每一封续谈的信都会把这两个字段重置成默认值。
|
||||
//
|
||||
// 线上实测(修复前):
|
||||
//
|
||||
// 第一封 to=pi@/tmp/budgetprobe max_rounds=7 → budget_max 7
|
||||
// 第二封 to=pi@/tmp/budgetprobe(省略该字段) → budget_max 20 ← 被静默改写
|
||||
//
|
||||
// 而那段代码的注释本身正在论证这不该发生(「续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算」)—— 意图是对的,守卫写错了。
|
||||
//
|
||||
// 修法:FindOrCreateDefaultSessionCreated 额外返回 created,
|
||||
// handler 改用它而不是 parentMailID。
|
||||
|
||||
func TestDefaultSessionFirstCallCreates(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("从未通信过的 name@path,第一次必须报告 created=true")
|
||||
}
|
||||
if id.String() == "" {
|
||||
t.Fatal("应返回有效会话 id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSessionReuseReportsNotCreated(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 复用的前提是这条会话里有该收件人参与过的邮件(EXISTS 子查询)
|
||||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatalf("第二封应复用同一条默认会话:first=%s second=%s", first, second)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用已有默认会话时 created 必须为 false —— 这正是预算被冲掉的根因")
|
||||
}
|
||||
}
|
||||
|
||||
// 这条是上面那个线上事故的最小复现:走 created 判据时预算不被改写。
|
||||
func TestBudgetSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 模拟 handler:只有 created 为真才设预算
|
||||
if _, err := SetSessionBudget(ctx, id, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
// 若这里为真,handler 就会重设预算 —— 事故重现
|
||||
t.Fatal("复用时 created 为真会让 handler 重设预算")
|
||||
}
|
||||
|
||||
b, err := GetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Max != 7 {
|
||||
t.Fatalf("续谈不得改写预算:want 7, got %d", b.Max)
|
||||
}
|
||||
}
|
||||
|
||||
// 档位与预算同一个判据,一起钉住:plan 档不能因为第二封信而升成 workspace。
|
||||
func TestPermissionModeSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
if _, err := SetSessionPermissionMode(ctx, id, "plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用时 created 为真会让 handler 把档位重置成默认档")
|
||||
}
|
||||
|
||||
if got := SessionPermissionMode(ctx, id); got != "plan" {
|
||||
t.Fatalf("续谈不得改写档位:want plan, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 不同工作目录是不同的默认会话,两边各自新建。
|
||||
// 这条防的是「把 created 实现成一个全局标志」之类的偷懒写法。
|
||||
func TestDefaultSessionPerWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
a, createdA, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w1", "jianf", "甲")
|
||||
if err != nil || !createdA {
|
||||
t.Fatalf("/w1 应新建:%v %v", createdA, err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, a, nil, "jianf", "", "pi", "/w1", "甲", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, createdB, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w2", "jianf", "乙")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !createdB {
|
||||
t.Fatal("/w2 是另一个工作目录,应当另建一条默认会话")
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("不同工作目录不该共用同一条默认会话")
|
||||
}
|
||||
}
|
||||
|
||||
// 旧签名仍在别处被调用,保持行为不变(只是丢掉 created)。
|
||||
func TestLegacyWrapperStillWorks(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != id {
|
||||
t.Fatalf("包装函数应与原行为一致:%s vs %s", id, again)
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,9 @@ const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.cc_list, m.subject, m.body, m.mail_type,
|
||||
COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human`
|
||||
|
||||
// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。
|
||||
//
|
||||
@ -202,7 +204,8 @@ func scanTreeRows(rows interface {
|
||||
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
|
||||
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
|
||||
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
|
||||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount, &lvl); err != nil {
|
||||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount,
|
||||
&t.FromHuman, &t.ToHuman, &lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
|
||||
@ -425,22 +425,6 @@ func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UU
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人)
|
||||
func FirstAdminUsername(ctx context.Context) (string, error) {
|
||||
var name string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT username FROM users
|
||||
WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1`).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
|
||||
func RandomPassword(n int) string {
|
||||
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
52
plugins/dsh-mail-bridge/lib/bounded.d.ts
vendored
Normal file
52
plugins/dsh-mail-bridge/lib/bounded.d.ts
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 类型声明 —— 实现在 bounded.js(三平台共用,逐字节相同)。
|
||||
*
|
||||
* DSH 插件是 TypeScript,import 一个纯 JS 模块需要这份声明才能过 tsc。
|
||||
* 声明与实现分开维护有分叉风险,所以这里只描述**插件真正调用的**那些成员,
|
||||
* 少写一个方法会在编译期被抓到,而多写一个不存在的方法不会 —— 后者靠
|
||||
* bounded.test.mjs 里那些逐方法的断言兜住。
|
||||
*/
|
||||
|
||||
/** 已投递邮件 id 的记忆上限。 */
|
||||
export const MAX_TRACKED_MAILS: number;
|
||||
|
||||
/** 会话级映射的条目上限。 */
|
||||
export const MAX_TRACKED_SESSIONS: number;
|
||||
|
||||
/** 有界 Map,超过上限时丢弃最久未访问的条目。 */
|
||||
export class BoundedMap<K, V> {
|
||||
constructor(limit: number);
|
||||
readonly limit: number;
|
||||
readonly size: number;
|
||||
/** 累计淘汰条数,观测用。 */
|
||||
evicted: number;
|
||||
has(key: K): boolean;
|
||||
/** 取值并刷新活跃度。 */
|
||||
get(key: K): V | undefined;
|
||||
/** 取值但不刷新活跃度。 */
|
||||
peek(key: K): V | undefined;
|
||||
set(key: K, value: V): this;
|
||||
delete(key: K): boolean;
|
||||
clear(): void;
|
||||
keys(): IterableIterator<K>;
|
||||
values(): IterableIterator<V>;
|
||||
entries(): IterableIterator<[K, V]>;
|
||||
[Symbol.iterator](): IterableIterator<[K, V]>;
|
||||
}
|
||||
|
||||
/** 有界 Set,超过上限时丢弃最久未访问的成员。 */
|
||||
export class BoundedSet<T> {
|
||||
constructor(limit: number);
|
||||
readonly limit: number;
|
||||
readonly size: number;
|
||||
evicted: number;
|
||||
/** 判断存在并刷新活跃度。 */
|
||||
has(value: T): boolean;
|
||||
/** 判断存在但不刷新活跃度。 */
|
||||
peek(value: T): boolean;
|
||||
add(value: T): this;
|
||||
delete(value: T): boolean;
|
||||
clear(): void;
|
||||
values(): IterableIterator<T>;
|
||||
[Symbol.iterator](): IterableIterator<T>;
|
||||
}
|
||||
208
plugins/dsh-mail-bridge/lib/bounded.js
Normal file
208
plugins/dsh-mail-bridge/lib/bounded.js
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
|
||||
*
|
||||
* # 为什么需要它
|
||||
*
|
||||
* 桥是**常驻进程**(pi 的守护进程能跑几十天,opencode/DSH 的插件跟着平台一起活)。
|
||||
* 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部
|
||||
* 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。
|
||||
*
|
||||
* 单条成本很小(uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆
|
||||
* 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目,
|
||||
* 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现,
|
||||
* 只在生产上跑了几周后表现为「重启一下就好了」。
|
||||
*
|
||||
* # 淘汰策略:丢最久没被访问的
|
||||
*
|
||||
* JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。
|
||||
* 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 ——
|
||||
* 被淘汰的总是「很久没人问过」的那些。
|
||||
*
|
||||
* # 上限分表定义,因为丢一条的后果差别很大
|
||||
*
|
||||
* - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种
|
||||
* 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级,
|
||||
* 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。
|
||||
*
|
||||
* - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。
|
||||
* 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived`
|
||||
* 主动清理,让上限只当兜底。
|
||||
*
|
||||
* # 不要用它装「还在等结果的东西」
|
||||
*
|
||||
* 待决权限询问(opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`)
|
||||
* 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** ——
|
||||
* 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件
|
||||
* 时 fail closed),不该套上界。上界只适合「记录已经发生过的事实」的表。
|
||||
*
|
||||
* 三平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 已投递邮件 id 的记忆上限。
|
||||
*
|
||||
* 2000 覆盖的是去重真正需要的时间窗:SSE 重放最多回放服务端环形缓冲的 500 条
|
||||
* 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。
|
||||
*/
|
||||
export const MAX_TRACKED_MAILS = 2000;
|
||||
|
||||
/**
|
||||
* 会话级映射的条目上限。
|
||||
*
|
||||
* 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的
|
||||
* 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。
|
||||
*
|
||||
* 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性
|
||||
* 时机;上限兜的是「一直不归档」。
|
||||
*/
|
||||
export const MAX_TRACKED_SESSIONS = 500;
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
const n = Number(limit);
|
||||
// 上限必须是正整数:0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部
|
||||
// 失效且不报错),NaN 会让 while 条件恒假(退化成无界)。两种都是静默的
|
||||
// 错误行为,不如当场拒绝。
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`);
|
||||
}
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Map,超过上限时丢弃最久未访问的条目。
|
||||
*
|
||||
* 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖
|
||||
* 「这张表是有界的」这个必须被看见的事实。
|
||||
*/
|
||||
export class BoundedMap {
|
||||
/** @param {number} limit 条目上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Map<any, any>} */
|
||||
this.map = new Map();
|
||||
/** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取值并把该键移到队尾。
|
||||
*
|
||||
* 读也算访问:一条会话只要还在收信就会被反复 get,不刷新的话它会因为
|
||||
* 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。
|
||||
*/
|
||||
get(key) {
|
||||
if (!this.map.has(key)) return undefined;
|
||||
const value = this.map.get(key);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */
|
||||
peek(key) {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
// 已存在时先删:Map 的 set 不改变已有键的位置,不删就刷不了活跃度。
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
while (this.map.size > this.limit) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
this.map.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
return this.map.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.map.keys();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.map.values();
|
||||
}
|
||||
|
||||
entries() {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.map[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Set,超过上限时丢弃最久未访问的成员。
|
||||
*
|
||||
* `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着
|
||||
* 「刚被去重挡下的那封」会留得更久,正合语义。
|
||||
*/
|
||||
export class BoundedSet {
|
||||
/** @param {number} limit 成员上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Set<any>} */
|
||||
this.set = new Set();
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.set.size;
|
||||
}
|
||||
|
||||
has(value) {
|
||||
if (!this.set.has(value)) return false;
|
||||
this.set.delete(value);
|
||||
this.set.add(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 判断存在但**不**刷新顺序。 */
|
||||
peek(value) {
|
||||
return this.set.has(value);
|
||||
}
|
||||
|
||||
add(value) {
|
||||
if (this.set.has(value)) this.set.delete(value);
|
||||
this.set.add(value);
|
||||
while (this.set.size > this.limit) {
|
||||
const oldest = this.set.values().next().value;
|
||||
this.set.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(value) {
|
||||
return this.set.delete(value);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set.clear();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.set.values();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.set[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
10
plugins/dsh-mail-bridge/lib/catchup.d.ts
vendored
10
plugins/dsh-mail-bridge/lib/catchup.d.ts
vendored
@ -13,8 +13,16 @@ export interface CatchupEvent {
|
||||
|
||||
export declare function mailToEvent(mail: any): CatchupEvent;
|
||||
|
||||
/**
|
||||
* `seen` 只被用来做 `has()` 去重查询(见 lib/catchup.js)。
|
||||
*
|
||||
* 声明成结构类型而不是 `Set<string>`:调用方传的是 `BoundedSet`(有界去重表,
|
||||
* 见 lib/bounded.js),它故意只实现桥真正用到的方法 —— 不做 Set 的完整替身,
|
||||
* 那样会掩盖「这张表是有界的」这个必须被看见的事实。把这里写成 `Set` 会逼着
|
||||
* BoundedSet 去补 forEach/entries/keys 一堆没人调的成员。
|
||||
*/
|
||||
export declare function selectCatchup(
|
||||
mails: any,
|
||||
seen: Set<string> | undefined,
|
||||
seen: { has(value: string): boolean } | undefined,
|
||||
max?: number,
|
||||
): CatchupEvent[];
|
||||
|
||||
8
plugins/dsh-mail-bridge/lib/relay-dedup.d.ts
vendored
8
plugins/dsh-mail-bridge/lib/relay-dedup.d.ts
vendored
@ -1,9 +1,15 @@
|
||||
import type { BoundedMap } from './bounded.js';
|
||||
|
||||
export interface RelayRecord {
|
||||
names: Set<string>;
|
||||
replyTos: Set<string>;
|
||||
}
|
||||
|
||||
export declare const explicitSends: Map<string, RelayRecord>;
|
||||
/**
|
||||
* 有界(见 bounded.js):清理路径都要求「这个会话之后还有事发生」,
|
||||
* 而只发过一次信就没动静的会话会让条目永久留下。
|
||||
*/
|
||||
export declare const explicitSends: BoundedMap<string, RelayRecord>;
|
||||
|
||||
export function addrName(addr: string): string;
|
||||
export function noteExplicitSend(sessionID: string | undefined, to: string, replyTo: string): void;
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
|
||||
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
|
||||
|
||||
import { BoundedMap, MAX_TRACKED_SESSIONS } from './bounded.js';
|
||||
|
||||
/** 取三维地址的名字段:admin@root.alias -> admin */
|
||||
export function addrName(addr) {
|
||||
return String(addr || "").split("@")[0].trim();
|
||||
@ -25,8 +27,18 @@ export function addrName(addr) {
|
||||
*
|
||||
* 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始),
|
||||
* relaySummary 用完即清。
|
||||
*
|
||||
* # 为什么仍要有界
|
||||
*
|
||||
* 上面那些清理路径都要求「这个会话之后还有事发生」。一条只发过一次信、
|
||||
* 之后既没有新邮件也没有 idle 的会话(模型自己发完就没动静了,或者插件在
|
||||
* 那一轮之后重连),条目就永久留下。桥是常驻进程,这种残留会一直累积。
|
||||
*
|
||||
* 淘汰是安全的:条目的语义是「本轮已经亲手回过」,而「本轮」是分钟级的。
|
||||
* 丢掉一条很久以前的记录最坏结果是那条会话下一次 idle 时多转一封总结,
|
||||
* 而服务端的 relay_key 幂等还会兜一层。
|
||||
*/
|
||||
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
|
||||
export const explicitSends = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> { names:Set, replyTos:Set }
|
||||
|
||||
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
|
||||
export function noteExplicitSend(sessionID, to, replyTo) {
|
||||
|
||||
152
plugins/dsh-mail-bridge/test/bounded.test.mjs
Normal file
152
plugins/dsh-mail-bridge/test/bounded.test.mjs
Normal file
@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
|
||||
// ─── 上限常量 ───
|
||||
|
||||
test('两个上限的相对大小编码了「丢一条的后果」', () => {
|
||||
// 会话级映射丢一条会让那条会话失去平台侧上下文(真的行为退化),
|
||||
// 而 deliveredMails 丢一条只是理论上可能重复投递一封几千封之前的邮件。
|
||||
// 所以邮件窗口可以给得比会话映射宽。
|
||||
assert.ok(MAX_TRACKED_MAILS >= MAX_TRACKED_SESSIONS,
|
||||
'已投递邮件的窗口应当比会话映射更宽(它的淘汰代价更小)');
|
||||
assert.ok(MAX_TRACKED_SESSIONS > 0);
|
||||
});
|
||||
|
||||
// ─── BoundedMap ───
|
||||
|
||||
test('BoundedMap 未达上限时与普通 Map 行为一致', () => {
|
||||
const m = new BoundedMap(10);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 1);
|
||||
assert.equal(m.get('b'), 2);
|
||||
assert.equal(m.has('a'), true);
|
||||
assert.equal(m.has('zzz'), false);
|
||||
assert.equal(m.get('zzz'), undefined);
|
||||
assert.equal(m.evicted, 0);
|
||||
});
|
||||
|
||||
test('BoundedMap 超过上限时丢最老的,size 不再增长', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3).set('d', 4);
|
||||
assert.equal(m.size, 3, '上限之后 size 必须封顶 —— 这正是泄露的反面');
|
||||
assert.equal(m.has('a'), false, 'a 是最老的,应当被淘汰');
|
||||
assert.deepEqual([...m.keys()], ['b', 'c', 'd']);
|
||||
assert.equal(m.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 get 刷新活跃度,长期被读的键不会被淘汰', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.get('a'); // a 变成最新
|
||||
m.set('d', 4); // 淘汰最老的 —— 现在是 b,不是 a
|
||||
assert.equal(m.has('a'), true, '读也算访问:还在收信的会话不该因为建得早被丢');
|
||||
assert.equal(m.has('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 peek 不刷新活跃度', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.peek('a');
|
||||
m.set('d', 4);
|
||||
assert.equal(m.has('a'), false, 'peek 是「只看一眼」,不该改变淘汰顺序');
|
||||
});
|
||||
|
||||
test('BoundedMap 重复 set 同一个键只占一个位置且刷新顺序', () => {
|
||||
const m = new BoundedMap(2);
|
||||
m.set('a', 1).set('b', 2).set('a', 9);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 9);
|
||||
m.set('c', 3);
|
||||
assert.equal(m.has('b'), false, 'a 被重新 set 过,b 才是最老的');
|
||||
assert.equal(m.has('a'), true);
|
||||
});
|
||||
|
||||
test('BoundedMap 支持 delete / clear / 迭代', () => {
|
||||
const m = new BoundedMap(5);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.delete('a'), true);
|
||||
assert.equal(m.delete('a'), false);
|
||||
assert.deepEqual([...m.entries()], [['b', 2]]);
|
||||
assert.deepEqual([...m.values()], [2]);
|
||||
assert.deepEqual([...m], [['b', 2]]);
|
||||
m.clear();
|
||||
assert.equal(m.size, 0);
|
||||
});
|
||||
|
||||
// ─── BoundedSet ───
|
||||
|
||||
test('BoundedSet 超过上限时丢最老的成员', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('m1').add('m2').add('m3').add('m4');
|
||||
assert.equal(s.size, 3);
|
||||
assert.equal(s.peek('m1'), false);
|
||||
assert.deepEqual([...s.values()], ['m2', 'm3', 'm4']);
|
||||
assert.equal(s.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 的 has 刷新活跃度', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('a').add('b').add('c');
|
||||
assert.equal(s.has('a'), true);
|
||||
s.add('d');
|
||||
assert.equal(s.peek('a'), true, '刚被去重挡下的那封应当留得更久');
|
||||
assert.equal(s.peek('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedSet 重复 add 不占额外位置', () => {
|
||||
const s = new BoundedSet(2);
|
||||
s.add('a').add('a').add('a');
|
||||
assert.equal(s.size, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 支持 delete / clear / 迭代,且能喂给 new Set()', () => {
|
||||
const s = new BoundedSet(5);
|
||||
s.add('a').add('b');
|
||||
assert.equal(s.delete('a'), true);
|
||||
assert.deepEqual([...s], ['b']);
|
||||
// pool.mailDrivenIDs() 会 `new Set(retired)` —— 少了 Symbol.iterator 就炸
|
||||
assert.deepEqual([...new Set(s)], ['b']);
|
||||
s.clear();
|
||||
assert.equal(s.size, 0);
|
||||
});
|
||||
|
||||
// ─── 负向对照:非法上限必须当场报错 ───
|
||||
|
||||
test('上限为 0 时抛错,而不是静默变成一张永远空着的表', () => {
|
||||
// 0 的后果最隐蔽:每次 set 之后立刻把自己淘汰掉,于是去重全部失效,
|
||||
// 邮件被反复投递,而代码里一行错误都不打。
|
||||
assert.throws(() => new BoundedMap(0), RangeError);
|
||||
assert.throws(() => new BoundedSet(0), RangeError);
|
||||
});
|
||||
|
||||
test('上限为 NaN / 负数 / 非数字时抛错,而不是退化成无界', () => {
|
||||
for (const bad of [NaN, -1, 'abc', undefined, null]) {
|
||||
assert.throws(() => new BoundedMap(bad), RangeError, `BoundedMap(${String(bad)}) 应当抛错`);
|
||||
assert.throws(() => new BoundedSet(bad), RangeError, `BoundedSet(${String(bad)}) 应当抛错`);
|
||||
}
|
||||
});
|
||||
|
||||
test('小数上限向下取整', () => {
|
||||
const m = new BoundedMap(2.9);
|
||||
assert.equal(m.limit, 2);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
assert.equal(m.size, 2);
|
||||
});
|
||||
|
||||
// ─── 压力:确认 size 真的封顶(这条是「不泄露」的直接断言)───
|
||||
|
||||
test('灌一万条之后 size 仍等于上限', () => {
|
||||
const s = new BoundedSet(100);
|
||||
for (let i = 0; i < 10_000; i++) s.add(`mail-${i}`);
|
||||
assert.equal(s.size, 100);
|
||||
assert.equal(s.evicted, 9900);
|
||||
assert.equal(s.peek('mail-9999'), true, '最新的必须还在');
|
||||
assert.equal(s.peek('mail-0'), false);
|
||||
|
||||
const m = new BoundedMap(100);
|
||||
for (let i = 0; i < 10_000; i++) m.set(`s-${i}`, { n: i });
|
||||
assert.equal(m.size, 100);
|
||||
});
|
||||
192
plugins/dsh-mail-bridge/test/denial-reason.test.mjs
Normal file
192
plugins/dsh-mail-bridge/test/denial-reason.test.mjs
Normal file
@ -0,0 +1,192 @@
|
||||
// 拒绝原因递给模型的语义验证。
|
||||
//
|
||||
// noteDenial / takeDenial / DENIED_REASON_TTL_MS 是 apply() 外的模块级私有量,
|
||||
// 但入口文件 import 不进来(导入 src/index.ts 会拉起整个 Cordis 插件),
|
||||
// 所以把结构原样复刻在这里验语义 —— 与 turnwait.test.mjs 同一手法。
|
||||
//
|
||||
// ## 为什么要有这一层
|
||||
//
|
||||
// DSH 把 approval/request 的 'rejected' 翻译成 dsh-tools 里写死的一句:
|
||||
//
|
||||
// case "rejected": reason = `the user rejected tool "${exec.name}"`
|
||||
//
|
||||
// 插件因为「这条链上没有人类可问」或「转发遇 4xx 永久失败」主动拒绝时,
|
||||
// **没有任何用户拒绝过它**。模型看到一句不存在的拒绝,只会以为人不同意,
|
||||
// 不会去换一条路;服务端给的 suggestion 则只进了 journalctl。
|
||||
//
|
||||
// pi({block:true, reason})与 opencode(output.reason)的 reason 直达模型,
|
||||
// 只有 DSH 需要 tools/post-execute 这道绕行。
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const DENIED_REASON_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function makeDenialStore(now = () => Date.now()) {
|
||||
const deniedReasons = new Map();
|
||||
|
||||
const denialKey = (agentId, callId) => `${agentId}:${String(callId ?? 'nocall')}`;
|
||||
|
||||
function noteDenial(agentId, callId, text) {
|
||||
const t = now();
|
||||
for (const [k, v] of deniedReasons) {
|
||||
if (t - v.at > DENIED_REASON_TTL_MS) deniedReasons.delete(k);
|
||||
}
|
||||
deniedReasons.set(denialKey(agentId, callId), { text, at: t });
|
||||
}
|
||||
|
||||
function takeDenial(agentId, callId) {
|
||||
const key = denialKey(agentId, callId);
|
||||
const hit = deniedReasons.get(key);
|
||||
if (!hit) return undefined;
|
||||
deniedReasons.delete(key);
|
||||
if (now() - hit.at > DENIED_REASON_TTL_MS) return undefined;
|
||||
return hit.text;
|
||||
}
|
||||
|
||||
return { noteDenial, takeDenial, size: () => deniedReasons.size };
|
||||
}
|
||||
|
||||
// post-execute 处理器的结构复刻(与 src/index.ts 中的判据一致)。
|
||||
function makeHandler(store, mailDrivenSessions) {
|
||||
return async function postExecute(exec, result, next) {
|
||||
const agentId = String(exec?.agent?.id ?? '');
|
||||
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
|
||||
if (!result?.isError) return next();
|
||||
|
||||
const reason = store.takeDenial(agentId, exec?.callId);
|
||||
if (!reason) return next();
|
||||
|
||||
return {
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: `无法执行 ${exec?.name}:${reason}` }],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const NEXT = { kind: 'accept' };
|
||||
const next = async () => NEXT;
|
||||
|
||||
test('记下的原因会替换掉平台写死的文案', async () => {
|
||||
const store = makeDenialStore();
|
||||
const handler = makeHandler(store, new Set(['a1']));
|
||||
|
||||
store.noteDenial('a1', 'call-1', '权限询问无法送达:这条任务链上没有人类用户\n请改用不需要授权的方式完成。');
|
||||
|
||||
const out = await handler(
|
||||
{ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' },
|
||||
{ isError: true },
|
||||
next,
|
||||
);
|
||||
|
||||
assert.equal(out.kind, 'block');
|
||||
assert.match(out.feedback[0].text, /没有人类用户/);
|
||||
assert.match(out.feedback[0].text, /请改用不需要授权的方式/);
|
||||
// 必须提到工具名,否则模型不知道是哪一次调用被挡了
|
||||
assert.match(out.feedback[0].text, /bash/);
|
||||
});
|
||||
|
||||
test('没记原因时放过,不干扰别的工具失败', async () => {
|
||||
const store = makeDenialStore();
|
||||
const handler = makeHandler(store, new Set(['a1']));
|
||||
|
||||
const out = await handler(
|
||||
{ agent: { id: 'a1' }, callId: 'call-x', name: 'bash' },
|
||||
{ isError: true, content: [{ type: 'text', text: 'Error: command not found' }] },
|
||||
next,
|
||||
);
|
||||
|
||||
assert.equal(out, NEXT);
|
||||
});
|
||||
|
||||
test('成功的结果一律放过 —— 被拒绝的调用不可能成功', async () => {
|
||||
const store = makeDenialStore();
|
||||
const handler = makeHandler(store, new Set(['a1']));
|
||||
|
||||
store.noteDenial('a1', 'call-1', '不该被用到');
|
||||
|
||||
const out = await handler(
|
||||
{ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' },
|
||||
{ isError: false },
|
||||
next,
|
||||
);
|
||||
|
||||
assert.equal(out, NEXT, '成功结果不该被 block');
|
||||
});
|
||||
|
||||
test('非邮件驱动的会话不接管(人坐在 TUI 前面,平台文案没问题)', async () => {
|
||||
const store = makeDenialStore();
|
||||
const handler = makeHandler(store, new Set(['a1']));
|
||||
|
||||
store.noteDenial('other', 'call-1', '不该被用到');
|
||||
|
||||
const out = await handler(
|
||||
{ agent: { id: 'other' }, callId: 'call-1', name: 'bash' },
|
||||
{ isError: true },
|
||||
next,
|
||||
);
|
||||
|
||||
assert.equal(out, NEXT);
|
||||
});
|
||||
|
||||
test('一次性:同一次调用只替换一次', async () => {
|
||||
const store = makeDenialStore();
|
||||
const handler = makeHandler(store, new Set(['a1']));
|
||||
|
||||
store.noteDenial('a1', 'call-1', '原因文本');
|
||||
|
||||
const first = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
|
||||
assert.equal(first.kind, 'block');
|
||||
|
||||
const second = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
|
||||
assert.equal(second, NEXT, '第二次不该再 block(否则一个原因会污染后续同 callId 的失败)');
|
||||
});
|
||||
|
||||
test('按 (会话, callId) 隔离:别的会话拿不到这条原因', async () => {
|
||||
const store = makeDenialStore();
|
||||
const handler = makeHandler(store, new Set(['a1', 'a2']));
|
||||
|
||||
store.noteDenial('a1', 'call-1', 'a1 的原因');
|
||||
|
||||
const other = await handler({ agent: { id: 'a2' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
|
||||
assert.equal(other, NEXT, '同 callId 但不同会话不该命中');
|
||||
|
||||
const mine = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
|
||||
assert.equal(mine.kind, 'block');
|
||||
});
|
||||
|
||||
test('callId 缺失时也能对上(DSH 允许 callId 为空)', async () => {
|
||||
const store = makeDenialStore();
|
||||
const handler = makeHandler(store, new Set(['a1']));
|
||||
|
||||
store.noteDenial('a1', undefined, '没有 callId 的拒绝');
|
||||
|
||||
const out = await handler({ agent: { id: 'a1' }, name: 'bash' }, { isError: true }, next);
|
||||
assert.equal(out.kind, 'block');
|
||||
assert.match(out.feedback[0].text, /没有 callId 的拒绝/);
|
||||
});
|
||||
|
||||
test('过期的原因不再使用(避免把十分钟前的拒绝贴到新失败上)', async () => {
|
||||
let clock = 1_000_000;
|
||||
const store = makeDenialStore(() => clock);
|
||||
const handler = makeHandler(store, new Set(['a1']));
|
||||
|
||||
store.noteDenial('a1', 'call-1', '很久以前的原因');
|
||||
clock += DENIED_REASON_TTL_MS + 1;
|
||||
|
||||
const out = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
|
||||
assert.equal(out, NEXT, '过期条目应当被忽略');
|
||||
});
|
||||
|
||||
test('写入时顺带清理过期条目,表不会无限增长', () => {
|
||||
let clock = 1_000_000;
|
||||
const store = makeDenialStore(() => clock);
|
||||
|
||||
for (let i = 0; i < 5; i++) store.noteDenial('a1', `old-${i}`, 'x');
|
||||
assert.equal(store.size(), 5);
|
||||
|
||||
clock += DENIED_REASON_TTL_MS + 1;
|
||||
store.noteDenial('a1', 'fresh', 'y');
|
||||
|
||||
assert.equal(store.size(), 1, '过期的 5 条应当在写入时被清掉,只剩新的那条');
|
||||
});
|
||||
84
plugins/homeagent-mail-bridge/attach.go
Normal file
84
plugins/homeagent-mail-bridge/attach.go
Normal file
@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
// 附件相关的两个小助手。
|
||||
//
|
||||
// 单独一个文件而不是塞进 plugin.go:它们是纯函数、有独立测试,
|
||||
// 而 plugin.go 已经是这个插件里最长的文件。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// stringList 把工具参数里的数组取成 []string。
|
||||
//
|
||||
// # 为什么不能直接断言 []string
|
||||
//
|
||||
// SDK 把工具参数当 JSON 解出来交给插件,所以数组一律是 `[]interface{}`,
|
||||
// 元素一律是 `interface{}`(字符串元素的动态类型是 `string`)。
|
||||
// 写 `args["attachment_ids"].([]string)` 永远失败 —— 而失败是静默的:
|
||||
// 类型断言的第二个返回值一丢,附件字段就消失了,发出去的邮件没有附件,
|
||||
// HTTP 仍是 200。这正是 `attachments` 字段名写错时发生过的事故形状。
|
||||
//
|
||||
// # 逐项校验而非整体放弃
|
||||
//
|
||||
// 模型偶尔会混进 null 或数字。丢掉坏元素、保留好元素,比整批丢弃好:
|
||||
// 后者会让「三个附件里有一个写错」变成「一个附件都没发出」。
|
||||
// 空字符串一并丢掉 —— 服务端的 parseAttachmentIDs 也跳过空串,
|
||||
// 与它保持一致,免得插件放过去的东西在服务端换个形状再失败一次。
|
||||
//
|
||||
// 也接受单个字符串(不带数组):那是模型常见的偷懒写法,
|
||||
// 拒绝它只会换来一次重试,而意图毫无歧义。
|
||||
func stringList(v interface{}) []string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{s}
|
||||
case []string:
|
||||
// 单测里手写参数时会走到这一支;运行时走不到(见上)
|
||||
out := make([]string, 0, len(t))
|
||||
for _, s := range t {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// formatSize 把字节数写成人类可读的大小。
|
||||
//
|
||||
// 与三个 Node 插件的 formatSize 同形(B / KB / MB,一位小数)。
|
||||
// 原先这里直接写 `size/1024` 加 "KB":一个 800 字节的附件显示成 `0KB`,
|
||||
// 而模型会据此认为上传失败了。
|
||||
func formatSize(n int64) string {
|
||||
switch {
|
||||
case n < 0:
|
||||
return "0 B"
|
||||
case n < 1024:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
case n < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||||
}
|
||||
}
|
||||
131
plugins/homeagent-mail-bridge/attach_test.go
Normal file
131
plugins/homeagent-mail-bridge/attach_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
// attach.go 的测试。
|
||||
//
|
||||
// 事故背景(本轮实测,生产环境):homeagent 的 upload_attachment 按
|
||||
// **顶层** `attachment_id` 解析服务端响应,而服务端返回的是
|
||||
// `{"attachment":{"attachment_id":…}}` —— 三个字段全解成零值。
|
||||
//
|
||||
// 那是最坏的一种失败形状:上传其实成功了(HTTP 200、文件已落盘、库里已登记),
|
||||
// 没有任何一层报错,但模型看到的是 `id= filename= size=0KB`。拿着空 id 它
|
||||
// 发不出这个附件,24 小时后 GC 把那个没人引用的文件清掉,现场不留痕迹。
|
||||
//
|
||||
// 同时 send_mail 根本没声明 attachment_ids 参数,且服务端解请求体时没开
|
||||
// DisallowUnknownFields —— 实测传 `attachments:[{...}]` 返回 200,
|
||||
// 而那封邮件的附件数是 0。两个 bug 叠起来,homeagent 的附件发送链路
|
||||
// **从来没成功过一次**。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── stringList ───
|
||||
|
||||
func TestStringList_JSONShape(t *testing.T) {
|
||||
// 运行时的真实形状:SDK 把工具参数当 JSON 解出来,
|
||||
// 数组是 []interface{},元素是 interface{}(string)
|
||||
got := stringList([]interface{}{"a", "b"})
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("want [a b], got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringList_NotTypedSlice(t *testing.T) {
|
||||
// 这条是整个文件存在的理由:写 args[...].([]string) 会拿到 nil。
|
||||
// 若哪天有人把 stringList 换回类型断言,这个用例会失败。
|
||||
var v interface{} = []interface{}{"id-1"}
|
||||
if _, ok := v.([]string); ok {
|
||||
t.Fatal("[]interface{} 不该能断言成 []string —— 前提变了,附件字段会静默消失")
|
||||
}
|
||||
if got := stringList(v); len(got) != 1 || got[0] != "id-1" {
|
||||
t.Fatalf("stringList 必须能吃下 []interface{},got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringList_SingleString(t *testing.T) {
|
||||
// 模型常见的偷懒写法。意图无歧义,拒绝它只换来一次重试。
|
||||
got := stringList("only-one")
|
||||
if len(got) != 1 || got[0] != "only-one" {
|
||||
t.Fatalf("want [only-one], got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringList_DropsBadElementsKeepsGood(t *testing.T) {
|
||||
// 逐项校验而非整批放弃:否则「三个附件里有一个写错」会变成
|
||||
// 「一个附件都没发出」。
|
||||
got := stringList([]interface{}{"good-1", nil, 42, "", " ", "good-2", map[string]interface{}{}})
|
||||
if len(got) != 2 || got[0] != "good-1" || got[1] != "good-2" {
|
||||
t.Fatalf("want [good-1 good-2], got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringList_TrimsSpace(t *testing.T) {
|
||||
got := stringList([]interface{}{" padded-id "})
|
||||
if len(got) != 1 || got[0] != "padded-id" {
|
||||
t.Fatalf("want [padded-id], got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringList_EmptyAndNil(t *testing.T) {
|
||||
// nil / 空数组 / 全是坏元素都要返回空,让调用方省略 payload 字段而不是传 []
|
||||
for name, in := range map[string]interface{}{
|
||||
"nil": nil,
|
||||
"空数组": []interface{}{},
|
||||
"空字符串": "",
|
||||
"只有空白": " ",
|
||||
"全是坏元素": []interface{}{nil, 1, false},
|
||||
"不认识的类型": map[string]interface{}{"a": 1},
|
||||
"数字": 42,
|
||||
"全是空串元素": []interface{}{"", " "},
|
||||
} {
|
||||
if got := stringList(in); len(got) != 0 {
|
||||
t.Fatalf("%s 应返回空,got %#v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringList_TypedSliceAlsoWorks(t *testing.T) {
|
||||
// 单测里手写参数会走到这一支
|
||||
got := stringList([]string{"x", "", "y"})
|
||||
if len(got) != 2 || got[0] != "x" || got[1] != "y" {
|
||||
t.Fatalf("want [x y], got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── formatSize ───
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
in int64
|
||||
want string
|
||||
}{
|
||||
// 关键用例:小文件不能显示成 0KB。原先写的是 size/1024 + "KB",
|
||||
// 一个 800 字节的附件显示成 `0KB`,模型会据此认为上传失败。
|
||||
{0, "0 B"},
|
||||
{1, "1 B"},
|
||||
{800, "800 B"},
|
||||
{1023, "1023 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{28, "28 B"}, // 本轮探针的真实大小
|
||||
{1024 * 1024, "1.0 MB"},
|
||||
{25 << 20, "25.0 MB"}, // 附件上限
|
||||
{-1, "0 B"}, // 不该出现,但不能打印成 -1 B
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatSize(c.in); got != c.want {
|
||||
t.Errorf("formatSize(%d) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSizeNeverZeroKBForNonEmpty(t *testing.T) {
|
||||
// 负向对照的固化:任何非空文件都不该显示成「0」开头的大小
|
||||
for _, n := range []int64{1, 10, 100, 800, 1023} {
|
||||
got := formatSize(n)
|
||||
if strings.HasPrefix(got, "0 ") || got == "0KB" || strings.HasPrefix(got, "0.0") {
|
||||
t.Errorf("formatSize(%d) = %q —— 非空文件不能显示成零", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
208
plugins/opencode-mail-bridge/lib/bounded.js
Normal file
208
plugins/opencode-mail-bridge/lib/bounded.js
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
|
||||
*
|
||||
* # 为什么需要它
|
||||
*
|
||||
* 桥是**常驻进程**(pi 的守护进程能跑几十天,opencode/DSH 的插件跟着平台一起活)。
|
||||
* 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部
|
||||
* 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。
|
||||
*
|
||||
* 单条成本很小(uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆
|
||||
* 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目,
|
||||
* 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现,
|
||||
* 只在生产上跑了几周后表现为「重启一下就好了」。
|
||||
*
|
||||
* # 淘汰策略:丢最久没被访问的
|
||||
*
|
||||
* JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。
|
||||
* 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 ——
|
||||
* 被淘汰的总是「很久没人问过」的那些。
|
||||
*
|
||||
* # 上限分表定义,因为丢一条的后果差别很大
|
||||
*
|
||||
* - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种
|
||||
* 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级,
|
||||
* 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。
|
||||
*
|
||||
* - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。
|
||||
* 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived`
|
||||
* 主动清理,让上限只当兜底。
|
||||
*
|
||||
* # 不要用它装「还在等结果的东西」
|
||||
*
|
||||
* 待决权限询问(opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`)
|
||||
* 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** ——
|
||||
* 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件
|
||||
* 时 fail closed),不该套上界。上界只适合「记录已经发生过的事实」的表。
|
||||
*
|
||||
* 三平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 已投递邮件 id 的记忆上限。
|
||||
*
|
||||
* 2000 覆盖的是去重真正需要的时间窗:SSE 重放最多回放服务端环形缓冲的 500 条
|
||||
* 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。
|
||||
*/
|
||||
export const MAX_TRACKED_MAILS = 2000;
|
||||
|
||||
/**
|
||||
* 会话级映射的条目上限。
|
||||
*
|
||||
* 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的
|
||||
* 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。
|
||||
*
|
||||
* 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性
|
||||
* 时机;上限兜的是「一直不归档」。
|
||||
*/
|
||||
export const MAX_TRACKED_SESSIONS = 500;
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
const n = Number(limit);
|
||||
// 上限必须是正整数:0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部
|
||||
// 失效且不报错),NaN 会让 while 条件恒假(退化成无界)。两种都是静默的
|
||||
// 错误行为,不如当场拒绝。
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`);
|
||||
}
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Map,超过上限时丢弃最久未访问的条目。
|
||||
*
|
||||
* 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖
|
||||
* 「这张表是有界的」这个必须被看见的事实。
|
||||
*/
|
||||
export class BoundedMap {
|
||||
/** @param {number} limit 条目上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Map<any, any>} */
|
||||
this.map = new Map();
|
||||
/** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取值并把该键移到队尾。
|
||||
*
|
||||
* 读也算访问:一条会话只要还在收信就会被反复 get,不刷新的话它会因为
|
||||
* 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。
|
||||
*/
|
||||
get(key) {
|
||||
if (!this.map.has(key)) return undefined;
|
||||
const value = this.map.get(key);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */
|
||||
peek(key) {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
// 已存在时先删:Map 的 set 不改变已有键的位置,不删就刷不了活跃度。
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
while (this.map.size > this.limit) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
this.map.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
return this.map.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.map.keys();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.map.values();
|
||||
}
|
||||
|
||||
entries() {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.map[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Set,超过上限时丢弃最久未访问的成员。
|
||||
*
|
||||
* `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着
|
||||
* 「刚被去重挡下的那封」会留得更久,正合语义。
|
||||
*/
|
||||
export class BoundedSet {
|
||||
/** @param {number} limit 成员上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Set<any>} */
|
||||
this.set = new Set();
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.set.size;
|
||||
}
|
||||
|
||||
has(value) {
|
||||
if (!this.set.has(value)) return false;
|
||||
this.set.delete(value);
|
||||
this.set.add(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 判断存在但**不**刷新顺序。 */
|
||||
peek(value) {
|
||||
return this.set.has(value);
|
||||
}
|
||||
|
||||
add(value) {
|
||||
if (this.set.has(value)) this.set.delete(value);
|
||||
this.set.add(value);
|
||||
while (this.set.size > this.limit) {
|
||||
const oldest = this.set.values().next().value;
|
||||
this.set.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(value) {
|
||||
return this.set.delete(value);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set.clear();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.set.values();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.set[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@
|
||||
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
|
||||
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
|
||||
|
||||
import { BoundedMap, MAX_TRACKED_SESSIONS } from './bounded.js';
|
||||
|
||||
/** 取三维地址的名字段:admin@root.alias -> admin */
|
||||
export function addrName(addr) {
|
||||
return String(addr || "").split("@")[0].trim();
|
||||
@ -25,8 +27,18 @@ export function addrName(addr) {
|
||||
*
|
||||
* 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始),
|
||||
* relaySummary 用完即清。
|
||||
*
|
||||
* # 为什么仍要有界
|
||||
*
|
||||
* 上面那些清理路径都要求「这个会话之后还有事发生」。一条只发过一次信、
|
||||
* 之后既没有新邮件也没有 idle 的会话(模型自己发完就没动静了,或者插件在
|
||||
* 那一轮之后重连),条目就永久留下。桥是常驻进程,这种残留会一直累积。
|
||||
*
|
||||
* 淘汰是安全的:条目的语义是「本轮已经亲手回过」,而「本轮」是分钟级的。
|
||||
* 丢掉一条很久以前的记录最坏结果是那条会话下一次 idle 时多转一封总结,
|
||||
* 而服务端的 relay_key 幂等还会兜一层。
|
||||
*/
|
||||
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
|
||||
export const explicitSends = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> { names:Set, replyTos:Set }
|
||||
|
||||
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
|
||||
export function noteExplicitSend(sessionID, to, replyTo) {
|
||||
|
||||
152
plugins/opencode-mail-bridge/test/bounded.test.mjs
Normal file
152
plugins/opencode-mail-bridge/test/bounded.test.mjs
Normal file
@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
|
||||
// ─── 上限常量 ───
|
||||
|
||||
test('两个上限的相对大小编码了「丢一条的后果」', () => {
|
||||
// 会话级映射丢一条会让那条会话失去平台侧上下文(真的行为退化),
|
||||
// 而 deliveredMails 丢一条只是理论上可能重复投递一封几千封之前的邮件。
|
||||
// 所以邮件窗口可以给得比会话映射宽。
|
||||
assert.ok(MAX_TRACKED_MAILS >= MAX_TRACKED_SESSIONS,
|
||||
'已投递邮件的窗口应当比会话映射更宽(它的淘汰代价更小)');
|
||||
assert.ok(MAX_TRACKED_SESSIONS > 0);
|
||||
});
|
||||
|
||||
// ─── BoundedMap ───
|
||||
|
||||
test('BoundedMap 未达上限时与普通 Map 行为一致', () => {
|
||||
const m = new BoundedMap(10);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 1);
|
||||
assert.equal(m.get('b'), 2);
|
||||
assert.equal(m.has('a'), true);
|
||||
assert.equal(m.has('zzz'), false);
|
||||
assert.equal(m.get('zzz'), undefined);
|
||||
assert.equal(m.evicted, 0);
|
||||
});
|
||||
|
||||
test('BoundedMap 超过上限时丢最老的,size 不再增长', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3).set('d', 4);
|
||||
assert.equal(m.size, 3, '上限之后 size 必须封顶 —— 这正是泄露的反面');
|
||||
assert.equal(m.has('a'), false, 'a 是最老的,应当被淘汰');
|
||||
assert.deepEqual([...m.keys()], ['b', 'c', 'd']);
|
||||
assert.equal(m.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 get 刷新活跃度,长期被读的键不会被淘汰', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.get('a'); // a 变成最新
|
||||
m.set('d', 4); // 淘汰最老的 —— 现在是 b,不是 a
|
||||
assert.equal(m.has('a'), true, '读也算访问:还在收信的会话不该因为建得早被丢');
|
||||
assert.equal(m.has('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 peek 不刷新活跃度', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.peek('a');
|
||||
m.set('d', 4);
|
||||
assert.equal(m.has('a'), false, 'peek 是「只看一眼」,不该改变淘汰顺序');
|
||||
});
|
||||
|
||||
test('BoundedMap 重复 set 同一个键只占一个位置且刷新顺序', () => {
|
||||
const m = new BoundedMap(2);
|
||||
m.set('a', 1).set('b', 2).set('a', 9);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 9);
|
||||
m.set('c', 3);
|
||||
assert.equal(m.has('b'), false, 'a 被重新 set 过,b 才是最老的');
|
||||
assert.equal(m.has('a'), true);
|
||||
});
|
||||
|
||||
test('BoundedMap 支持 delete / clear / 迭代', () => {
|
||||
const m = new BoundedMap(5);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.delete('a'), true);
|
||||
assert.equal(m.delete('a'), false);
|
||||
assert.deepEqual([...m.entries()], [['b', 2]]);
|
||||
assert.deepEqual([...m.values()], [2]);
|
||||
assert.deepEqual([...m], [['b', 2]]);
|
||||
m.clear();
|
||||
assert.equal(m.size, 0);
|
||||
});
|
||||
|
||||
// ─── BoundedSet ───
|
||||
|
||||
test('BoundedSet 超过上限时丢最老的成员', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('m1').add('m2').add('m3').add('m4');
|
||||
assert.equal(s.size, 3);
|
||||
assert.equal(s.peek('m1'), false);
|
||||
assert.deepEqual([...s.values()], ['m2', 'm3', 'm4']);
|
||||
assert.equal(s.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 的 has 刷新活跃度', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('a').add('b').add('c');
|
||||
assert.equal(s.has('a'), true);
|
||||
s.add('d');
|
||||
assert.equal(s.peek('a'), true, '刚被去重挡下的那封应当留得更久');
|
||||
assert.equal(s.peek('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedSet 重复 add 不占额外位置', () => {
|
||||
const s = new BoundedSet(2);
|
||||
s.add('a').add('a').add('a');
|
||||
assert.equal(s.size, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 支持 delete / clear / 迭代,且能喂给 new Set()', () => {
|
||||
const s = new BoundedSet(5);
|
||||
s.add('a').add('b');
|
||||
assert.equal(s.delete('a'), true);
|
||||
assert.deepEqual([...s], ['b']);
|
||||
// pool.mailDrivenIDs() 会 `new Set(retired)` —— 少了 Symbol.iterator 就炸
|
||||
assert.deepEqual([...new Set(s)], ['b']);
|
||||
s.clear();
|
||||
assert.equal(s.size, 0);
|
||||
});
|
||||
|
||||
// ─── 负向对照:非法上限必须当场报错 ───
|
||||
|
||||
test('上限为 0 时抛错,而不是静默变成一张永远空着的表', () => {
|
||||
// 0 的后果最隐蔽:每次 set 之后立刻把自己淘汰掉,于是去重全部失效,
|
||||
// 邮件被反复投递,而代码里一行错误都不打。
|
||||
assert.throws(() => new BoundedMap(0), RangeError);
|
||||
assert.throws(() => new BoundedSet(0), RangeError);
|
||||
});
|
||||
|
||||
test('上限为 NaN / 负数 / 非数字时抛错,而不是退化成无界', () => {
|
||||
for (const bad of [NaN, -1, 'abc', undefined, null]) {
|
||||
assert.throws(() => new BoundedMap(bad), RangeError, `BoundedMap(${String(bad)}) 应当抛错`);
|
||||
assert.throws(() => new BoundedSet(bad), RangeError, `BoundedSet(${String(bad)}) 应当抛错`);
|
||||
}
|
||||
});
|
||||
|
||||
test('小数上限向下取整', () => {
|
||||
const m = new BoundedMap(2.9);
|
||||
assert.equal(m.limit, 2);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
assert.equal(m.size, 2);
|
||||
});
|
||||
|
||||
// ─── 压力:确认 size 真的封顶(这条是「不泄露」的直接断言)───
|
||||
|
||||
test('灌一万条之后 size 仍等于上限', () => {
|
||||
const s = new BoundedSet(100);
|
||||
for (let i = 0; i < 10_000; i++) s.add(`mail-${i}`);
|
||||
assert.equal(s.size, 100);
|
||||
assert.equal(s.evicted, 9900);
|
||||
assert.equal(s.peek('mail-9999'), true, '最新的必须还在');
|
||||
assert.equal(s.peek('mail-0'), false);
|
||||
|
||||
const m = new BoundedMap(100);
|
||||
for (let i = 0; i < 10_000; i++) m.set(`s-${i}`, { n: i });
|
||||
assert.equal(m.size, 100);
|
||||
});
|
||||
208
plugins/pi-mail-bridge/lib/bounded.js
Normal file
208
plugins/pi-mail-bridge/lib/bounded.js
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
|
||||
*
|
||||
* # 为什么需要它
|
||||
*
|
||||
* 桥是**常驻进程**(pi 的守护进程能跑几十天,opencode/DSH 的插件跟着平台一起活)。
|
||||
* 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部
|
||||
* 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。
|
||||
*
|
||||
* 单条成本很小(uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆
|
||||
* 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目,
|
||||
* 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现,
|
||||
* 只在生产上跑了几周后表现为「重启一下就好了」。
|
||||
*
|
||||
* # 淘汰策略:丢最久没被访问的
|
||||
*
|
||||
* JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。
|
||||
* 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 ——
|
||||
* 被淘汰的总是「很久没人问过」的那些。
|
||||
*
|
||||
* # 上限分表定义,因为丢一条的后果差别很大
|
||||
*
|
||||
* - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种
|
||||
* 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级,
|
||||
* 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。
|
||||
*
|
||||
* - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。
|
||||
* 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived`
|
||||
* 主动清理,让上限只当兜底。
|
||||
*
|
||||
* # 不要用它装「还在等结果的东西」
|
||||
*
|
||||
* 待决权限询问(opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`)
|
||||
* 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** ——
|
||||
* 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件
|
||||
* 时 fail closed),不该套上界。上界只适合「记录已经发生过的事实」的表。
|
||||
*
|
||||
* 三平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 已投递邮件 id 的记忆上限。
|
||||
*
|
||||
* 2000 覆盖的是去重真正需要的时间窗:SSE 重放最多回放服务端环形缓冲的 500 条
|
||||
* 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。
|
||||
*/
|
||||
export const MAX_TRACKED_MAILS = 2000;
|
||||
|
||||
/**
|
||||
* 会话级映射的条目上限。
|
||||
*
|
||||
* 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的
|
||||
* 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。
|
||||
*
|
||||
* 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性
|
||||
* 时机;上限兜的是「一直不归档」。
|
||||
*/
|
||||
export const MAX_TRACKED_SESSIONS = 500;
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
const n = Number(limit);
|
||||
// 上限必须是正整数:0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部
|
||||
// 失效且不报错),NaN 会让 while 条件恒假(退化成无界)。两种都是静默的
|
||||
// 错误行为,不如当场拒绝。
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`);
|
||||
}
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Map,超过上限时丢弃最久未访问的条目。
|
||||
*
|
||||
* 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖
|
||||
* 「这张表是有界的」这个必须被看见的事实。
|
||||
*/
|
||||
export class BoundedMap {
|
||||
/** @param {number} limit 条目上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Map<any, any>} */
|
||||
this.map = new Map();
|
||||
/** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取值并把该键移到队尾。
|
||||
*
|
||||
* 读也算访问:一条会话只要还在收信就会被反复 get,不刷新的话它会因为
|
||||
* 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。
|
||||
*/
|
||||
get(key) {
|
||||
if (!this.map.has(key)) return undefined;
|
||||
const value = this.map.get(key);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */
|
||||
peek(key) {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
// 已存在时先删:Map 的 set 不改变已有键的位置,不删就刷不了活跃度。
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
while (this.map.size > this.limit) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
this.map.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
return this.map.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.map.keys();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.map.values();
|
||||
}
|
||||
|
||||
entries() {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.map[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Set,超过上限时丢弃最久未访问的成员。
|
||||
*
|
||||
* `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着
|
||||
* 「刚被去重挡下的那封」会留得更久,正合语义。
|
||||
*/
|
||||
export class BoundedSet {
|
||||
/** @param {number} limit 成员上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Set<any>} */
|
||||
this.set = new Set();
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.set.size;
|
||||
}
|
||||
|
||||
has(value) {
|
||||
if (!this.set.has(value)) return false;
|
||||
this.set.delete(value);
|
||||
this.set.add(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 判断存在但**不**刷新顺序。 */
|
||||
peek(value) {
|
||||
return this.set.has(value);
|
||||
}
|
||||
|
||||
add(value) {
|
||||
if (this.set.has(value)) this.set.delete(value);
|
||||
this.set.add(value);
|
||||
while (this.set.size > this.limit) {
|
||||
const oldest = this.set.values().next().value;
|
||||
this.set.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(value) {
|
||||
return this.set.delete(value);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set.clear();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.set.values();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.set[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@
|
||||
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
|
||||
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
|
||||
|
||||
import { BoundedMap, MAX_TRACKED_SESSIONS } from './bounded.js';
|
||||
|
||||
/** 取三维地址的名字段:admin@root.alias -> admin */
|
||||
export function addrName(addr) {
|
||||
return String(addr || "").split("@")[0].trim();
|
||||
@ -25,8 +27,18 @@ export function addrName(addr) {
|
||||
*
|
||||
* 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始),
|
||||
* relaySummary 用完即清。
|
||||
*
|
||||
* # 为什么仍要有界
|
||||
*
|
||||
* 上面那些清理路径都要求「这个会话之后还有事发生」。一条只发过一次信、
|
||||
* 之后既没有新邮件也没有 idle 的会话(模型自己发完就没动静了,或者插件在
|
||||
* 那一轮之后重连),条目就永久留下。桥是常驻进程,这种残留会一直累积。
|
||||
*
|
||||
* 淘汰是安全的:条目的语义是「本轮已经亲手回过」,而「本轮」是分钟级的。
|
||||
* 丢掉一条很久以前的记录最坏结果是那条会话下一次 idle 时多转一封总结,
|
||||
* 而服务端的 relay_key 幂等还会兜一层。
|
||||
*/
|
||||
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
|
||||
export const explicitSends = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> { names:Set, replyTos:Set }
|
||||
|
||||
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
|
||||
export function noteExplicitSend(sessionID, to, replyTo) {
|
||||
|
||||
321
plugins/pi-mail-bridge/src/session-scan.mjs
Normal file
321
plugins/pi-mail-bridge/src/session-scan.mjs
Normal file
@ -0,0 +1,321 @@
|
||||
/**
|
||||
* pi 会话目录的增量扫描器 —— 替换心跳路径上的 `SessionManager.listAll()`。
|
||||
*
|
||||
* # 为什么不能用 listAll
|
||||
*
|
||||
* 心跳每 30 秒要上报一次平台会话快照(`snapshotPiSessions`),而它只用到四个
|
||||
* 字段:`id` / `cwd` / `name` / `modified`。`SessionManager.listAll()` 为了拿到
|
||||
* 这四个字段,把 `~/.pi/agent/sessions` 下**每个 .jsonl 的每一行**都读进来并
|
||||
* `JSON.parse`,还顺手把所有消息正文拼成一个 `allMessagesText` 大字符串。
|
||||
*
|
||||
* 本机实测(115 个文件 / 145MB,其中单个会话文件 29MB、单行最长 2.6MB):
|
||||
*
|
||||
* listAll() 1431ms RSS 41 → 323MB(heapUsed 141MB)
|
||||
* 仅读 header 3ms RSS 41 → 46MB
|
||||
* 本模块(冷启动) 516ms RSS 41 → 131MB
|
||||
* 本模块(稳态) 3ms 重扫 0 字节
|
||||
*
|
||||
* 那 282MB 每 30 秒分配一次、随即变成垃圾。GC 收得掉(所以 RSS 呈锯齿而不是
|
||||
* 单调上升),但代价是:常驻内存被垃圾撑到 300MB 上下,且每拍有 1.4 秒的同步
|
||||
* 解析跑在事件循环上 —— 那期间 SSE 读循环停着,新邮件事件在 TCP 缓冲区排队。
|
||||
*
|
||||
* # 三条省法
|
||||
*
|
||||
* 1. **id / cwd / created 只在首行**。header 是第一行,读 4KB 就够,不必读全文。
|
||||
* 2. **name 来自 `session_info` 行**,而那种行只有几百字节。按行扫描时长度超过
|
||||
* 上限的行**直接跳过、不materialize**,于是 2.6MB 的 message 行不进内存。
|
||||
* 3. **文件是 append-only 的**。缓存 `size`,下一拍只扫 `[上次 size, 现 size)`
|
||||
* 这段尾巴 —— 没有新消息的会话一个字节都不读。
|
||||
*
|
||||
* `modified` 改用 `stat.mtime`:listAll 是从最后一条消息的活动时间算的,
|
||||
* 而快照只拿它排序(「最近在谈的排前面」),mtime 表达的正是这件事,且免费。
|
||||
*
|
||||
* # 为什么这个模块不进 lib/(不与另两个平台共用)
|
||||
*
|
||||
* 它读的是 pi 自己的磁盘格式。opencode 的 `session.list()` 是进程内调用,
|
||||
* DSH 走 `sessionQuery` 的语料观测 —— 两者都没有「扫目录读文件」这一步,
|
||||
* 强行抽象成共用模块只会得到一个谁都不合身的接口。
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, stat, open } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
|
||||
/**
|
||||
* 单行长度上限(字符)。超过这个长度的行不参与解析。
|
||||
*
|
||||
* `session_info` 行的构成是固定的:type + 两个 8 字节 id + ISO 时间戳 + name,
|
||||
* 而 pi-web 的标题生成器把 name 截到 60 字符。几百字节封顶,留 4096 是十倍余量。
|
||||
*
|
||||
* 这个上限同时是**内存上界**:扫描时跨块累积的未完成行一旦超过它就被丢弃,
|
||||
* 因此无论会话里有多大的一条 message(实测见过 2.6MB),扫描峰值都不受影响。
|
||||
*/
|
||||
const MAX_LINE_CHARS = 4096;
|
||||
|
||||
/** header 只读这么多字节。第一行是 `{"type":"session",...}`,远不到 4KB。 */
|
||||
const HEADER_READ_BYTES = 4096;
|
||||
|
||||
/** `session_info` 行的判别串。先做子串命中再 JSON.parse,省掉绝大多数解析。 */
|
||||
const SESSION_INFO_NEEDLE = '"type":"session_info"';
|
||||
|
||||
/**
|
||||
* 读会话文件的 header(第一行)。
|
||||
*
|
||||
* @param {string} file
|
||||
* @returns {Promise<{id: string, cwd: string, created: Date} | null>}
|
||||
* 不是合法会话文件时返回 null(首行不是 session 类型、空文件、读不动)。
|
||||
*/
|
||||
async function readHeader(file) {
|
||||
let fh;
|
||||
try {
|
||||
fh = await open(file, 'r');
|
||||
const buf = Buffer.allocUnsafe(HEADER_READ_BYTES);
|
||||
const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0);
|
||||
if (bytesRead === 0) return null;
|
||||
const text = buf.subarray(0, bytesRead).toString('utf8');
|
||||
const nl = text.indexOf('\n');
|
||||
// 没有换行说明首行比 4KB 还长 —— 那不是 header(header 是固定几个字段)。
|
||||
if (nl < 0) return null;
|
||||
const entry = JSON.parse(text.slice(0, nl));
|
||||
if (entry?.type !== 'session' || typeof entry.id !== 'string') return null;
|
||||
const ts = typeof entry.timestamp === 'string' ? new Date(entry.timestamp) : null;
|
||||
return {
|
||||
id: entry.id,
|
||||
// 老会话的 cwd 是空串(pi 的 SessionInfo 注释写明了),照实传下去 ——
|
||||
// snapshotPiSessions 会按空 workspace 上报,不该拿桥自己的 cwd 冒充。
|
||||
cwd: typeof entry.cwd === 'string' ? entry.cwd : '',
|
||||
created: ts && !Number.isNaN(ts.getTime()) ? ts : new Date(0),
|
||||
};
|
||||
} catch {
|
||||
// 读不动、JSON 坏了、文件正好被删 —— 一律当「不是会话」。
|
||||
// 单个坏文件不该让整份快照失败(listAll 也是这个策略)。
|
||||
return null;
|
||||
} finally {
|
||||
await fh?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫一段字节区间,返回其中**最后一个** `session_info` 的 name。
|
||||
*
|
||||
* 「最后一个」而不是第一个:会话可以被改名多次,也可以显式清名
|
||||
* (`session_info` 不带 name = 清掉)。语义与 SDK 的 buildSessionInfo 一致 ——
|
||||
* 最新的那条生效。
|
||||
*
|
||||
* @param {string} file
|
||||
* @param {number} from 起始字节(含)。append-only 文件里它一定落在行首。
|
||||
* @param {number} to 结束字节(不含)
|
||||
* @returns {Promise<{name: string | undefined, found: boolean}>}
|
||||
* `found` 为假表示这段里没有任何 session_info —— 调用方应保留上一次的 name,
|
||||
* 而不是把它当成「被清空了」。
|
||||
*/
|
||||
async function scanRangeForName(file, from, to) {
|
||||
if (to <= from) return { name: undefined, found: false };
|
||||
|
||||
let name;
|
||||
let found = false;
|
||||
const decoder = new StringDecoder('utf8');
|
||||
let pending = '';
|
||||
// 当前这一行已经超过上限 → 丢弃它剩下的部分,直到下一个换行。
|
||||
// 这是内存上界的实现:巨大的 message 行永远不会被拼出来。
|
||||
let skipping = false;
|
||||
|
||||
const consider = (line) => {
|
||||
if (line.length > MAX_LINE_CHARS) return;
|
||||
if (!line.includes(SESSION_INFO_NEEDLE)) return;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry?.type !== 'session_info') return;
|
||||
found = true;
|
||||
const n = typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||
name = n || undefined;
|
||||
} catch {
|
||||
// 半截行(起点没对齐、文件正在被写)解析失败 —— 忽略即可,
|
||||
// 下一拍尾巴长出来之后会重新看到完整的那一行。
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createReadStream(file, { start: from, end: to - 1 });
|
||||
for await (const chunk of stream) {
|
||||
const text = decoder.write(chunk);
|
||||
let start = 0;
|
||||
for (;;) {
|
||||
const nl = text.indexOf('\n', start);
|
||||
if (nl < 0) break;
|
||||
if (!skipping) consider(pending + text.slice(start, nl));
|
||||
pending = '';
|
||||
skipping = false;
|
||||
start = nl + 1;
|
||||
}
|
||||
if (skipping) continue;
|
||||
pending += text.slice(start);
|
||||
if (pending.length > MAX_LINE_CHARS) {
|
||||
pending = '';
|
||||
skipping = true;
|
||||
}
|
||||
}
|
||||
const tail = decoder.end();
|
||||
if (!skipping) {
|
||||
pending += tail;
|
||||
// 末行没有换行符时也要看一眼(正在被写入的会话就是这种状态)
|
||||
if (pending) consider(pending);
|
||||
}
|
||||
|
||||
return { name, found };
|
||||
}
|
||||
|
||||
/**
|
||||
* 建一个扫描器。缓存跨拍存活,因此要在插件启动时建一次、之后复用。
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.sessionsDir] 会话根目录。默认 `~/.pi/agent/sessions`
|
||||
* (由调用方传 `join(getAgentDir(), 'sessions')`,这里不 import SDK
|
||||
* —— 让这个模块可以脱离 SDK 单测)。
|
||||
* @returns {{scan: () => Promise<object[]>, stats: () => object}}
|
||||
*/
|
||||
export function createSessionScanner({ sessionsDir } = {}) {
|
||||
if (!sessionsDir) throw new Error('createSessionScanner 需要 sessionsDir');
|
||||
|
||||
/**
|
||||
* file -> { size, id, cwd, created, name, hasName }
|
||||
*
|
||||
* 这张表的键是磁盘上真实存在的文件,每次 scan 都会把消失的文件删掉 ——
|
||||
* 所以它不需要额外的上界:会话文件被删(pi 侧清理历史)时条目跟着走。
|
||||
*
|
||||
* `hasName` 与 `name === undefined` 不同:前者是「曾经见过 session_info」,
|
||||
* 后者可能是「见过但被清名了」。区分它们才能让增量扫描保留上一次的结论。
|
||||
*/
|
||||
const cache = new Map();
|
||||
let fullScans = 0;
|
||||
let tailScans = 0;
|
||||
let tailBytes = 0;
|
||||
|
||||
async function collectFiles() {
|
||||
const out = [];
|
||||
let dirs;
|
||||
try {
|
||||
dirs = await readdir(sessionsDir, { withFileTypes: true });
|
||||
} catch (e) {
|
||||
// 目录不存在(pi 从没跑过)= 确实一条会话都没有,空列表是正确答案。
|
||||
//
|
||||
// 其他错误(权限、I/O)必须**抛出去**:调用方据此省略 platform_sessions
|
||||
// 字段,保留服务端镜像。返回空数组的语义是「平台确实没有会话」,
|
||||
// 会把镜像抹掉(W-3 / N-7)—— 一次 EACCES 就能清空别人的补全候选。
|
||||
if (e?.code === 'ENOENT') return out;
|
||||
throw e;
|
||||
}
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory() && !d.isSymbolicLink()) continue;
|
||||
const dir = join(sessionsDir, d.name);
|
||||
try {
|
||||
for (const f of await readdir(dir)) {
|
||||
if (f.endsWith('.jsonl')) out.push(join(dir, f));
|
||||
}
|
||||
} catch {
|
||||
// 单个子目录读不动(权限、正被删)不该拖垮整轮
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫一遍,返回与 `SessionManager.listAll()` 同形的条目
|
||||
* (`snapshotPiSessions` 用到的那四个字段 + created)。
|
||||
*
|
||||
* 只在这里做 I/O;调用方拿到的是纯数据。
|
||||
*/
|
||||
async function scan() {
|
||||
const files = await collectFiles();
|
||||
const alive = new Set(files);
|
||||
// 会话文件被删掉之后缓存里的条目必须走,否则这张表就是下一个泄露源。
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (!alive.has(key)) cache.delete(key);
|
||||
}
|
||||
|
||||
const out = [];
|
||||
// 并发 16:这些都是小 I/O(稳态下多数只有一次 stat),并发高一点能盖住
|
||||
// 磁盘延迟;再高就只是给事件循环添堵。
|
||||
for (let i = 0; i < files.length; i += 16) {
|
||||
const batch = await Promise.all(
|
||||
files.slice(i, i + 16).map((f) => scanOne(f).catch(() => null)),
|
||||
);
|
||||
for (const item of batch) if (item) out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function scanOne(file) {
|
||||
let st;
|
||||
try {
|
||||
st = await stat(file);
|
||||
} catch {
|
||||
cache.delete(file);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = cache.get(file);
|
||||
|
||||
// 文件变小 = 被重写/截断(pi 只 append,所以这不该发生 —— 但真发生时
|
||||
// 缓存里的 size 会让我们从一个越界的偏移开始读)。整份重扫最保险。
|
||||
const shrank = cached && st.size < cached.size;
|
||||
|
||||
if (!cached || shrank) {
|
||||
const header = await readHeader(file);
|
||||
if (!header) {
|
||||
// 首行不是 header:不是会话文件。记一条空壳避免每拍都重读它。
|
||||
cache.set(file, { size: st.size, id: '', cwd: '', created: new Date(0), name: undefined, hasName: false });
|
||||
return null;
|
||||
}
|
||||
fullScans++;
|
||||
tailBytes += st.size;
|
||||
const { name, found } = await scanRangeForName(file, 0, st.size);
|
||||
const entry = { size: st.size, id: header.id, cwd: header.cwd, created: header.created, name, hasName: found };
|
||||
cache.set(file, entry);
|
||||
return toInfo(file, entry, st);
|
||||
}
|
||||
|
||||
// 空壳(已知不是会话文件):文件长了也不用管,它不会突然变成会话。
|
||||
if (!cached.id) {
|
||||
cached.size = st.size;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (st.size > cached.size) {
|
||||
tailScans++;
|
||||
tailBytes += st.size - cached.size;
|
||||
const { name, found } = await scanRangeForName(file, cached.size, st.size);
|
||||
cached.size = st.size;
|
||||
// 尾巴里没有 session_info 时**保留**旧 name。写成 `cached.name = name`
|
||||
// 会让每次有新消息的会话都丢掉名字 —— 而没有 name 的会话不上报
|
||||
// (S-1),于是活跃会话会从补全候选里消失。
|
||||
if (found) {
|
||||
cached.name = name;
|
||||
cached.hasName = true;
|
||||
}
|
||||
}
|
||||
return toInfo(file, cached, st);
|
||||
}
|
||||
|
||||
function toInfo(file, entry, st) {
|
||||
return {
|
||||
path: file,
|
||||
id: entry.id,
|
||||
cwd: entry.cwd,
|
||||
name: entry.name,
|
||||
created: entry.created,
|
||||
// 排序用「最近活跃」,mtime 就是它,且已经在手上(stat 已经做过了)
|
||||
modified: st.mtime,
|
||||
};
|
||||
}
|
||||
|
||||
/** 观测用:稳态下 fullScans 应当不再增长,tailBytes 每拍只涨一点。 */
|
||||
const stats = () => ({
|
||||
tracked: cache.size,
|
||||
fullScans,
|
||||
tailScans,
|
||||
tailBytes,
|
||||
});
|
||||
|
||||
return { scan, stats };
|
||||
}
|
||||
@ -107,6 +107,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) {
|
||||
reply_to: params.reply_to || '',
|
||||
session_alias: params.session_alias || '',
|
||||
attachment_ids: params.attachment_ids || [],
|
||||
from_session_id: ctx?.sessionManager?.getSessionId?.() || '',
|
||||
// 这里**不带 relay**(N-5):模型的自主发信要计配额,
|
||||
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。
|
||||
});
|
||||
|
||||
@ -54,6 +54,7 @@ import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
|
||||
import { autoRelayDecision } from '../lib/relay-policy.js';
|
||||
import { adoptedSessionID, adoptMissingMessage } from '../lib/adopt.js';
|
||||
import { isApproval, isAlwaysDecision } from '../lib/permission-grants.js';
|
||||
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
|
||||
|
||||
// ─── 与主进程的通道 ───
|
||||
|
||||
@ -121,7 +122,12 @@ function permissionExtension() {
|
||||
if (grants.has(event.toolName)) return;
|
||||
|
||||
// relay_key 用 pi 给的 toolCallId(B-8.1):服务端随决策事件回传它。
|
||||
const relayKey = `${sid || piSessionId}:${event.toolCallId}`;
|
||||
//
|
||||
// clampRelayKey 不是防御性冗余:启用 extended thinking 时 Bedrock 把
|
||||
// 思考签名拼进 toolCallId,实测长到 437 ~ 13601 字节,键直接超服务端
|
||||
// 160 字节列宽 → 400。同一条会话里长短两种形态混着出现,于是权限询问
|
||||
// 随机成功随机失败(生产日志 21:54:02 失败、21:54:24 同会话成功)。
|
||||
const relayKey = clampRelayKey(`${sid || piSessionId}:${event.toolCallId}`);
|
||||
|
||||
try {
|
||||
// 不传 `to`:决策人由服务端按 会话 owner → 线索里最近的人类 → 409
|
||||
@ -151,8 +157,31 @@ function permissionExtension() {
|
||||
log(`权限询问无人可投,当场拒绝 ${relayKey}:${b.error || ''}`);
|
||||
return { block: true, reason };
|
||||
}
|
||||
// 其余失败是暂时的 → 让位给 pi 本地决策(B-8.2)。
|
||||
log(`权限转发失败,让位给本地决策: ${describeError(e)}`);
|
||||
|
||||
// 其余 4xx(400 / 401 / 403 / 404 / 422…)同样永远不会因重试成功。
|
||||
//
|
||||
// 这里原来一律「让位给本地决策」,而邮件驱动的 worker 没有 TUI ——
|
||||
// 让位等于守卫消失。生产实测:relay_key 过长报 400 被当暂时失败,
|
||||
// 那次 bash 在无人批准的情况下执行了(21:54:02 让位,同会话
|
||||
// 21:54:24 另一次 key 正常,于是权限询问被随机吞掉)。
|
||||
//
|
||||
// fail closed:宁可让模型看到「权限系统坏了」并自己改道,
|
||||
// 也不能悄悄放行一条没人看过的命令。
|
||||
if (isPermanentFailure(e)) {
|
||||
const detail = describeError(e);
|
||||
log(`权限转发遇到永久失败(HTTP ${e?.status}),当场拒绝:${detail}`);
|
||||
return {
|
||||
block: true,
|
||||
reason: [
|
||||
`无法把 ${event.toolName} 的授权请求送达给人类:${detail}`,
|
||||
'这是一个不会因重试而改变的失败(请求本身被服务端拒绝)。',
|
||||
'请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// 暂时失败(5xx / 408 / 429 / 网络层)→ 让位给 pi 本地决策(B-8.2)。
|
||||
log(`权限转发暂时失败,让位给本地决策: ${describeError(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -231,9 +260,18 @@ async function loadSession(mailTools) {
|
||||
|
||||
const adoptID = adoptedSessionID(data);
|
||||
if (adoptID) {
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
|
||||
// listAll 而不是 list(cwd):worker 的进程 cwd 与会话 cwd 无关。
|
||||
const all = await SessionManager.listAll();
|
||||
// 用 sessionScanner 而不是 `SessionManager.listAll()`:这里只要 id → path,
|
||||
// 而那两个字段全在会话文件的**首行** header 里。listAll 为了拿它们会把
|
||||
// 每个 .jsonl 的每一行读进来并 JSON.parse,还把所有消息正文拼成一个大字符串
|
||||
// (本机 115 个文件 / 145MB 实测:1431ms、堆里瞬时 240MB)。
|
||||
//
|
||||
// worker 是短命进程,拿不到跨拍缓存的好处,但冷启动也依然便宜得多:
|
||||
// 没有任何一行巨大的 message 被 materialize(实测单行最长 2.63MB)。
|
||||
const { createSessionScanner } = await import('./session-scan.mjs');
|
||||
const { getAgentDir } = await import('@earendil-works/pi-coding-agent');
|
||||
const all = await createSessionScanner({
|
||||
sessionsDir: join(getAgentDir(), 'sessions'),
|
||||
}).scan();
|
||||
const info = all.find((e) => e?.id === adoptID);
|
||||
if (!info?.path) {
|
||||
// 镜像是快照,可以过期。**不能**退回「新建一条」—— 那会让人在 TUI 里
|
||||
|
||||
152
plugins/pi-mail-bridge/test/bounded.test.mjs
Normal file
152
plugins/pi-mail-bridge/test/bounded.test.mjs
Normal file
@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
|
||||
// ─── 上限常量 ───
|
||||
|
||||
test('两个上限的相对大小编码了「丢一条的后果」', () => {
|
||||
// 会话级映射丢一条会让那条会话失去平台侧上下文(真的行为退化),
|
||||
// 而 deliveredMails 丢一条只是理论上可能重复投递一封几千封之前的邮件。
|
||||
// 所以邮件窗口可以给得比会话映射宽。
|
||||
assert.ok(MAX_TRACKED_MAILS >= MAX_TRACKED_SESSIONS,
|
||||
'已投递邮件的窗口应当比会话映射更宽(它的淘汰代价更小)');
|
||||
assert.ok(MAX_TRACKED_SESSIONS > 0);
|
||||
});
|
||||
|
||||
// ─── BoundedMap ───
|
||||
|
||||
test('BoundedMap 未达上限时与普通 Map 行为一致', () => {
|
||||
const m = new BoundedMap(10);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 1);
|
||||
assert.equal(m.get('b'), 2);
|
||||
assert.equal(m.has('a'), true);
|
||||
assert.equal(m.has('zzz'), false);
|
||||
assert.equal(m.get('zzz'), undefined);
|
||||
assert.equal(m.evicted, 0);
|
||||
});
|
||||
|
||||
test('BoundedMap 超过上限时丢最老的,size 不再增长', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3).set('d', 4);
|
||||
assert.equal(m.size, 3, '上限之后 size 必须封顶 —— 这正是泄露的反面');
|
||||
assert.equal(m.has('a'), false, 'a 是最老的,应当被淘汰');
|
||||
assert.deepEqual([...m.keys()], ['b', 'c', 'd']);
|
||||
assert.equal(m.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 get 刷新活跃度,长期被读的键不会被淘汰', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.get('a'); // a 变成最新
|
||||
m.set('d', 4); // 淘汰最老的 —— 现在是 b,不是 a
|
||||
assert.equal(m.has('a'), true, '读也算访问:还在收信的会话不该因为建得早被丢');
|
||||
assert.equal(m.has('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 peek 不刷新活跃度', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.peek('a');
|
||||
m.set('d', 4);
|
||||
assert.equal(m.has('a'), false, 'peek 是「只看一眼」,不该改变淘汰顺序');
|
||||
});
|
||||
|
||||
test('BoundedMap 重复 set 同一个键只占一个位置且刷新顺序', () => {
|
||||
const m = new BoundedMap(2);
|
||||
m.set('a', 1).set('b', 2).set('a', 9);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 9);
|
||||
m.set('c', 3);
|
||||
assert.equal(m.has('b'), false, 'a 被重新 set 过,b 才是最老的');
|
||||
assert.equal(m.has('a'), true);
|
||||
});
|
||||
|
||||
test('BoundedMap 支持 delete / clear / 迭代', () => {
|
||||
const m = new BoundedMap(5);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.delete('a'), true);
|
||||
assert.equal(m.delete('a'), false);
|
||||
assert.deepEqual([...m.entries()], [['b', 2]]);
|
||||
assert.deepEqual([...m.values()], [2]);
|
||||
assert.deepEqual([...m], [['b', 2]]);
|
||||
m.clear();
|
||||
assert.equal(m.size, 0);
|
||||
});
|
||||
|
||||
// ─── BoundedSet ───
|
||||
|
||||
test('BoundedSet 超过上限时丢最老的成员', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('m1').add('m2').add('m3').add('m4');
|
||||
assert.equal(s.size, 3);
|
||||
assert.equal(s.peek('m1'), false);
|
||||
assert.deepEqual([...s.values()], ['m2', 'm3', 'm4']);
|
||||
assert.equal(s.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 的 has 刷新活跃度', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('a').add('b').add('c');
|
||||
assert.equal(s.has('a'), true);
|
||||
s.add('d');
|
||||
assert.equal(s.peek('a'), true, '刚被去重挡下的那封应当留得更久');
|
||||
assert.equal(s.peek('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedSet 重复 add 不占额外位置', () => {
|
||||
const s = new BoundedSet(2);
|
||||
s.add('a').add('a').add('a');
|
||||
assert.equal(s.size, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 支持 delete / clear / 迭代,且能喂给 new Set()', () => {
|
||||
const s = new BoundedSet(5);
|
||||
s.add('a').add('b');
|
||||
assert.equal(s.delete('a'), true);
|
||||
assert.deepEqual([...s], ['b']);
|
||||
// pool.mailDrivenIDs() 会 `new Set(retired)` —— 少了 Symbol.iterator 就炸
|
||||
assert.deepEqual([...new Set(s)], ['b']);
|
||||
s.clear();
|
||||
assert.equal(s.size, 0);
|
||||
});
|
||||
|
||||
// ─── 负向对照:非法上限必须当场报错 ───
|
||||
|
||||
test('上限为 0 时抛错,而不是静默变成一张永远空着的表', () => {
|
||||
// 0 的后果最隐蔽:每次 set 之后立刻把自己淘汰掉,于是去重全部失效,
|
||||
// 邮件被反复投递,而代码里一行错误都不打。
|
||||
assert.throws(() => new BoundedMap(0), RangeError);
|
||||
assert.throws(() => new BoundedSet(0), RangeError);
|
||||
});
|
||||
|
||||
test('上限为 NaN / 负数 / 非数字时抛错,而不是退化成无界', () => {
|
||||
for (const bad of [NaN, -1, 'abc', undefined, null]) {
|
||||
assert.throws(() => new BoundedMap(bad), RangeError, `BoundedMap(${String(bad)}) 应当抛错`);
|
||||
assert.throws(() => new BoundedSet(bad), RangeError, `BoundedSet(${String(bad)}) 应当抛错`);
|
||||
}
|
||||
});
|
||||
|
||||
test('小数上限向下取整', () => {
|
||||
const m = new BoundedMap(2.9);
|
||||
assert.equal(m.limit, 2);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
assert.equal(m.size, 2);
|
||||
});
|
||||
|
||||
// ─── 压力:确认 size 真的封顶(这条是「不泄露」的直接断言)───
|
||||
|
||||
test('灌一万条之后 size 仍等于上限', () => {
|
||||
const s = new BoundedSet(100);
|
||||
for (let i = 0; i < 10_000; i++) s.add(`mail-${i}`);
|
||||
assert.equal(s.size, 100);
|
||||
assert.equal(s.evicted, 9900);
|
||||
assert.equal(s.peek('mail-9999'), true, '最新的必须还在');
|
||||
assert.equal(s.peek('mail-0'), false);
|
||||
|
||||
const m = new BoundedMap(100);
|
||||
for (let i = 0; i < 10_000; i++) m.set(`s-${i}`, { n: i });
|
||||
assert.equal(m.size, 100);
|
||||
});
|
||||
@ -137,11 +137,13 @@ function SessionGroup({
|
||||
// 列表行只显示「跟谁在通信」,**不带会话位**:分组头下面已经单独显示了
|
||||
// 会话别名,再拼一遍会让长别名(实测 92 字节)把这一行挤没。
|
||||
//
|
||||
// workspace 从会话取:from_workspace 对 Agent 存的是 Agent 名而非路径。
|
||||
const peerWs = showTo ? g.latest.to_workspace : g.latest.from_workspace;
|
||||
// 人还是 Agent 走显式布尔;workspace 从会话取(from_workspace 对 Agent
|
||||
// 存的是 Agent 名而非路径)。
|
||||
const isPeerHuman = showTo ? g.latest.to_human : g.latest.from_human;
|
||||
const peer = participantAddress(
|
||||
showTo ? g.latest.to_name : g.latest.from_name,
|
||||
peerWs ? g.latest.session_workspace || '' : ''
|
||||
isPeerHuman,
|
||||
isPeerHuman ? '' : g.latest.session_workspace || ''
|
||||
);
|
||||
|
||||
// 组内含选中邮件时给个边框,否则展开一个组再滚下去会找不到自己在看哪封
|
||||
@ -240,10 +242,11 @@ function MailItem({
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const rowWs = showTo ? mail.to_workspace : mail.from_workspace;
|
||||
const isRowHuman = showTo ? mail.to_human : mail.from_human;
|
||||
const peer = participantAddress(
|
||||
showTo ? mail.to_name : mail.from_name,
|
||||
rowWs ? mail.session_workspace || '' : ''
|
||||
isRowHuman,
|
||||
isRowHuman ? '' : mail.session_workspace || ''
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -383,12 +383,14 @@ function Header({
|
||||
const ws = mail.session_workspace || '';
|
||||
const from = participantAddress(
|
||||
mail.from_name,
|
||||
mail.from_workspace ? ws : '',
|
||||
mail.from_human,
|
||||
mail.from_human ? '' : ws,
|
||||
mail.session_alias
|
||||
);
|
||||
const to = participantAddress(
|
||||
mail.to_name,
|
||||
mail.to_workspace ? ws : '',
|
||||
mail.to_human,
|
||||
mail.to_human ? '' : ws,
|
||||
mail.session_alias
|
||||
);
|
||||
|
||||
|
||||
@ -240,11 +240,13 @@ function Node({
|
||||
)}
|
||||
{/* 树节点一行里塞了 from → to、转发标记与时间,不带会话位:
|
||||
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
|
||||
workspace 从会话取(from_workspace 对 Agent 存的是 Agent 名)。 */}
|
||||
人还是 Agent 走显式布尔;workspace 从会话取(from_workspace
|
||||
对 Agent 存的是 Agent 名)。 */}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{participantAddress(
|
||||
node.from_name,
|
||||
node.from_workspace ? node.session_workspace || '' : ''
|
||||
node.from_human,
|
||||
node.from_human ? '' : node.session_workspace || ''
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">→</span>
|
||||
|
||||
@ -26,6 +26,8 @@ export interface Counterpart {
|
||||
name: string;
|
||||
/** 工作目录,可能为空(人类没有工作目录) */
|
||||
path: string;
|
||||
/** 这一方是人类用户还是 Agent —— 决定地址拼几段 */
|
||||
isHuman: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -69,23 +71,23 @@ export function formatAddress(name: string, path: string, session?: string | nul
|
||||
* **人只要名字**:人没有工作目录,也不需要指定会话(发给人就是进他的收件箱)。
|
||||
* 给人拼 `jianf@.某会话` 或 `jianf.某会话` 都是把 Agent 的维度硬套在人身上。
|
||||
*
|
||||
* 判据是有没有 workspace:Agent 一定带工作目录,人一定不带。
|
||||
* 判据从「workspace 是否为空」的启发式改成**显式布尔**:
|
||||
* `mails.from_workspace` 对 Agent 存的是 Agent 名而不是路径(历史遗留),
|
||||
* 拿它当「是不是 Agent」的代理变量会在边界上猜错。
|
||||
* 服务端用 `EXISTS (SELECT 1 FROM users …)` 判人/Agent,那条布尔才是权威。
|
||||
*
|
||||
* # workspace 必须从**会话**取,不能用 from_workspace
|
||||
*
|
||||
* `mails.from_workspace` 对 Agent 存的是**Agent 名而不是路径**(历史遗留,
|
||||
* 见后端 db/migrate.go 里 sessions.workspace 的注释)。拿它当路径拼会得到
|
||||
* `dsh@dsh` —— 界面上真出现过。会话的 `workspace` 才是权威来源。
|
||||
* workspace 必须从**会话**取(`session_workspace`),不能用 from_workspace:
|
||||
* 后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`。
|
||||
*/
|
||||
export function participantAddress(
|
||||
name: string,
|
||||
isHuman: boolean,
|
||||
workspace?: string | null,
|
||||
sessionAlias?: string | null
|
||||
): string {
|
||||
const path = (workspace || '').trim();
|
||||
// 人(无工作目录):只有名字,不带 path 也不带会话位
|
||||
if (!path) return formatAddress(name, '');
|
||||
return formatAddress(name, path, sessionAlias || null);
|
||||
if (isHuman) return formatAddress(name, '');
|
||||
return formatAddress(name, (workspace || '').trim(), sessionAlias || null);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -96,9 +98,11 @@ export function participantAddress(
|
||||
*/
|
||||
export function mailCounterpart(mail: Mail, me: string): Counterpart {
|
||||
const iSent = !!me && mail.from_name === me;
|
||||
return iSent
|
||||
? { name: mail.to_name, path: mail.to_workspace || '' }
|
||||
: { name: mail.from_name, path: mail.from_workspace || '' };
|
||||
const ws = mail.session_workspace || '';
|
||||
if (iSent) {
|
||||
return { name: mail.to_name, path: mail.to_human ? '' : ws, isHuman: mail.to_human };
|
||||
}
|
||||
return { name: mail.from_name, path: mail.from_human ? '' : ws, isHuman: mail.from_human };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -124,13 +128,14 @@ export function sessionCounterpart(mails: Mail[], me: string): Counterpart | nul
|
||||
return na !== nb ? na - nb : a.mail_id.localeCompare(b.mail_id);
|
||||
});
|
||||
|
||||
const ws = (m: Mail) => m.session_workspace || '';
|
||||
let found: Counterpart | null = null;
|
||||
for (const m of sorted) {
|
||||
// 收件人优先于发件人:会话首封多是「我 → Agent」,
|
||||
// 收件人优先于发件人:会话首封多是「我 → Agent」,
|
||||
// 那个 to_name 就是这次任务派给了谁
|
||||
const candidates: Counterpart[] = [
|
||||
{ name: m.to_name, path: m.to_workspace || '' },
|
||||
{ name: m.from_name, path: m.from_workspace || '' }
|
||||
{ name: m.to_name, path: m.to_human ? '' : ws(m), isHuman: m.to_human },
|
||||
{ name: m.from_name, path: m.from_human ? '' : ws(m), isHuman: m.from_human }
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (!c.name || c.name === me) continue;
|
||||
@ -173,15 +178,20 @@ export function mailReplyTarget(mail: Mail, me: string): string {
|
||||
*
|
||||
* 原先用 `!a.startsWith('human')` 去掉自己 —— 同一个遗留判据,
|
||||
* 结果是点「回复全部」会把自己抄送进去。
|
||||
*
|
||||
* 地址拼法与 participantAddress 一致:人只有名字,Agent 拼 `name@path.session`。
|
||||
* path 从 session_workspace 取,不能从 from_workspace/to_workspace 取
|
||||
* (后者对 Agent 存的是 Agent 名)。
|
||||
*/
|
||||
export function replyAllCC(
|
||||
mail: Mail,
|
||||
me: string,
|
||||
primaryName: string
|
||||
): string[] {
|
||||
const ws = mail.session_workspace || '';
|
||||
const raw = [
|
||||
formatAddress(mail.from_name, mail.from_workspace || ''),
|
||||
formatAddress(mail.to_name, mail.to_workspace || ''),
|
||||
formatAddress(mail.from_name, mail.from_human ? '' : ws),
|
||||
formatAddress(mail.to_name, mail.to_human ? '' : ws),
|
||||
// cc_list 使用 raw 字段(用户输入的原文,保留 .new 等原始意图)
|
||||
...(mail.cc_list ?? []).map(c => c.raw || formatAddress(c.name, c.path || '', c.session || null))
|
||||
];
|
||||
|
||||
@ -114,6 +114,10 @@ export interface Mail {
|
||||
session_workspace?: string;
|
||||
body_preview?: string;
|
||||
attachments?: Attachment[];
|
||||
/** 发件方是人类用户而不是 Agent(服务端 EXISTS users 判的) */
|
||||
from_human: boolean;
|
||||
/** 收件方是人类用户而不是 Agent */
|
||||
to_human: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -36,6 +36,8 @@ function mail(over: Partial<Mail> = {}): Mail {
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
@ -41,6 +41,11 @@ function mail(over: Partial<Mail> = {}): Mail {
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
// 默认值:会话工作目录、发件方是 Agent、收件方是人 ——
|
||||
// 对应「pi 在 /home/program/llmsproxy 干活 → jianf」这条最常见的信
|
||||
session_workspace: '/home/program/llmsproxy',
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
@ -79,18 +84,20 @@ describe('formatAddress', () => {
|
||||
describe('mailCounterpart', () => {
|
||||
it('别人发来的 → 回给发件人', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('我发出的 → 回给收件人(不是回给自己)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home/program/llmsproxy'
|
||||
});
|
||||
// 这一条就是生产 bug:原代码在这里返回 jianf
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' });
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('登录名未知时退化为回给发件人,不会回给自己', () => {
|
||||
@ -104,22 +111,37 @@ describe('mailCounterpart', () => {
|
||||
expect(mailCounterpart(m, 'jianf').name).toBe('human');
|
||||
expect(mailCounterpart(m, 'human').name).toBe('pi');
|
||||
});
|
||||
|
||||
it('path 从 session_workspace 取,不用 from_workspace(后者存的是 Agent 名)', () => {
|
||||
// from_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||||
// 从 session_workspace 取路径才不会拼出 dsh@dsh
|
||||
const m = mail({
|
||||
from_name: 'dsh',
|
||||
from_workspace: 'dsh', // Agent 名,不是路径
|
||||
to_name: 'jianf',
|
||||
to_human: true,
|
||||
session_workspace: '/home/program/webui4frpc'
|
||||
});
|
||||
const peer = mailCounterpart(m, 'jianf');
|
||||
expect(peer.path).toBe('/home/program/webui4frpc');
|
||||
expect(peer.path).not.toBe('dsh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionCounterpart', () => {
|
||||
it('按会话定对端,不受最后一封是谁发的影响', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
|
||||
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:00:00Z' })
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
});
|
||||
|
||||
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
@ -127,27 +149,28 @@ describe('sessionCounterpart', () => {
|
||||
|
||||
it('补上首次出现时缺失的 path', () => {
|
||||
const mails = [
|
||||
// 首封的 to_workspace 是空的
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
|
||||
// 首封的 session_workspace 是空的
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
|
||||
// 后一封才带上目录
|
||||
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
|
||||
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', session_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
// 同名 Agent 在不同目录是不同的活,path 不能丢
|
||||
expect(sessionCounterpart(mails, 'jianf')).toEqual({
|
||||
name: 'pi',
|
||||
path: '/home/program/llmsproxy'
|
||||
path: '/home/program/llmsproxy',
|
||||
isHuman: false
|
||||
});
|
||||
});
|
||||
|
||||
it('同刻邮件用 mail_id 定序,结果稳定', () => {
|
||||
const ts = '2026-09-03T09:00:00Z';
|
||||
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: ts });
|
||||
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: ts });
|
||||
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
|
||||
});
|
||||
|
||||
it('全是自己的会话返回 null 而不是自己', () => {
|
||||
const mails = [mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf' })];
|
||||
const mails = [mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true })];
|
||||
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
|
||||
});
|
||||
|
||||
@ -156,8 +179,8 @@ describe('sessionCounterpart', () => {
|
||||
});
|
||||
|
||||
it('created_at 解析失败不影响确定性', () => {
|
||||
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: '2026-09-03T09:00:00Z' });
|
||||
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: '2026-09-03T09:00:00Z' });
|
||||
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
|
||||
sessionCounterpart([good, bad], 'jianf')?.name
|
||||
);
|
||||
@ -167,7 +190,7 @@ describe('sessionCounterpart', () => {
|
||||
describe('sessionReplyTarget', () => {
|
||||
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy' })
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
|
||||
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
|
||||
@ -176,7 +199,7 @@ describe('sessionReplyTarget', () => {
|
||||
|
||||
it('会话未命名时省略会话段', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home' })
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '/home' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
|
||||
});
|
||||
@ -184,9 +207,9 @@ describe('sessionReplyTarget', () => {
|
||||
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
|
||||
mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:56:34Z' })
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:56:34Z' })
|
||||
];
|
||||
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
|
||||
expect(target.startsWith('pi@')).toBe(true);
|
||||
@ -239,7 +262,9 @@ describe('replyAllCC', () => {
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'dsh',
|
||||
to_human: false,
|
||||
to_workspace: '/opt',
|
||||
session_workspace: '/opt',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
@ -302,33 +327,36 @@ describe('participantAddress', () => {
|
||||
* 人只要名字(没有工作目录,也不需要指定会话)
|
||||
*/
|
||||
it('Agent 带完整三段 —— 少任何一段都不是可投递地址', () => {
|
||||
expect(participantAddress('pi', '/home/program/agentmail', '我的任务'))
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', '我的任务'))
|
||||
.toBe('pi@/home/program/agentmail.我的任务');
|
||||
});
|
||||
|
||||
it('Agent 无会话别名时退到 name@path(默认会话)', () => {
|
||||
expect(participantAddress('pi', '/home/program/agentmail', null))
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', null))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
expect(participantAddress('pi', '/home/program/agentmail'))
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail'))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
});
|
||||
|
||||
it('人只显示名字,即便传了会话别名也不拼', () => {
|
||||
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
|
||||
expect(participantAddress('jianf', '', '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', null, '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true, '', '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true, null, '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true)).toBe('jianf');
|
||||
});
|
||||
|
||||
it('人的地址里不含 @ 也不含 .', () => {
|
||||
const addr = participantAddress('jianf', '', '邮件驱动·多智能体协作平台-完整设计文档');
|
||||
const addr = participantAddress('jianf', true, '', '邮件驱动·多智能体协作平台-完整设计文档');
|
||||
expect(addr).toBe('jianf');
|
||||
expect(addr).not.toContain('@');
|
||||
expect(addr).not.toContain('.');
|
||||
});
|
||||
|
||||
it('workspace 只有空白时按人处理', () => {
|
||||
expect(participantAddress('jianf', ' ', '某会话')).toBe('jianf');
|
||||
it('人是 Agent 时 workspace 为空也不会拼出裸 @', () => {
|
||||
// 边界:Agent 名在库里但 session_workspace 为空(数据不完整),
|
||||
// 仍然要留 @ 而不是裸名字 —— 否则按最后一个 . 切分会错
|
||||
expect(participantAddress('pi', false, '', 'sess'))
|
||||
.toBe('pi@.sess');
|
||||
});
|
||||
});
|
||||
|
||||
@ -378,8 +406,10 @@ describe('replyAllCC 保留 cc_list 的原始意图', () => {
|
||||
it('人类发件人(无 workspace)在抄送里是裸名字,不带会话位', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home',
|
||||
session_alias: '某个很长的会话别名'
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user