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:
2026-09-06 15:18:06 +08:00
parent a44fd6949b
commit 79c4171c9d
40 changed files with 3369 additions and 116 deletions

View File

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

View File

@ -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
);

View File

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

View File

@ -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 的维度硬套在人身上。
*
* 判据是有没有 workspaceAgent 一定带工作目录,人一定不带。
* 判据从「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))
];

View File

@ -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;
}
/**