fix(归档): 两表判据分叉的成因收口 —— TouchSession 不再写 status,建邮件一律拒归档会话
pi 2026-09-28 裁定 §1/§2 认可「判据分叉」这个定性,§4 要求做 1+2,
并把 permission/request 点为第三个复活入口。本轮做 1+2,并补上第四个。
# 缺陷:归档后不可见,判据挂在两张表上
unreadFor / readStateFor 判邮件行 (repo.go:unreadFor)
ListInbox / UnreadWorkspaces 判会话行 (repo.go:ListInbox)
两边对同一条已归档线索给出不同答案,而每一边单独看都「是对的」。
分叉由 `TouchSession` 的 `SET status='active'` 与建邮件 INSERT 只写
邮件行共同造成 ⇒ 只要有一个写路径碰会话行而不碰邮件行,半活会话就能被造出来。
# 改法:让不变量由构造保证,而不是逐个入口堵
· TouchSession 只剩 updated_at —— 它是全库唯一能解除归档的入口
· EnsureSessionOpen 是 CreateMail / CreatePermissionMail / CreateDecisionMail
的共同前置(集中一处,新增建邮件函数必须经过它)
· ErrSessionArchived 与 ErrSessionNotFound 分列:调用方要能分开回话
· resolveTarget 的 reply_to 分支恢复归档契约(此前绕过别名路径的 404)
· permission/request 补 SessionOpenFor:存在 + 未归档 + 参与方
· FindSessionByPlatformID 补 s.status(adopt 路径,pi 未列的第四个入口)
# 判据:写成不变量而不是单点
session_status_invariant_test.go:对任意 session_id,
sessions.status='archived' ⟹ 该会话全部邮件 archived。入口级回归单测仍在,
但它们是说明。已实测把 TouchSession 改回旧实现后该判据转红
(不是「改完就绿」的装饰)。
# 读侧清册
mail_status_readers_test.go 的清册仍为 repo.go=13 / thread.go=1 / migrate.go=4:
本轮新增的 5 处命中全在注释里(散文里拼了列名字面量),已改写措辞而不改数字
—— 让数字变化会给未来新增读取凭空送出 5 格余量,正是那张表要防的事。
# 遗留(pi 裁定本轮不做,已登记)
FindSessionByAddress 无 status 条件:补上会把重复归档从 200 变成 404,
属行为变更,不在 bugfix 里夹带。
This commit is contained in:
@ -371,12 +371,89 @@ func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// TouchSession 刷一条会话的 updated_at("这条线索刚刚有新活动")。
|
||||
//
|
||||
// ★★ 它**只**更新 updated_at,**绝不碰 status**(2026-09-28 bugfix)。
|
||||
//
|
||||
// 原实现是 `SET updated_at = NOW(), status = 'active'`。那一行 `status='active'`
|
||||
// 让**每一个**调用点都成了"复活入口":6 个调用点里 5 个的 id 都是服务端刚查出来的
|
||||
// (收件人确实参与过的那条会话),只有 reply_to 与 permission/request 是从请求里拿的
|
||||
// id。于是"归档后不可见"被这样绕过:
|
||||
//
|
||||
// sessions.status: archived → active(只被这一列改)
|
||||
// mails 那张表: archived(原地不动)⇒ **半活会话**
|
||||
//
|
||||
// 半活会话在库里有两处可观察的裂口:
|
||||
// - `ListInbox` 整体按 `s.status <> 'archived'` 过滤 ⇒ 归档邮件**重新出现在列表里**,
|
||||
// 只是标着"已归档"(判据读会话表);
|
||||
// - `unreadFor` 判的是邮件行上的 `<> 'archived'` ⇒ 收件箱 unread 一封都不给
|
||||
// (判据读邮件表)。同一个"归档"在两张表上给出**两个不同答案**。
|
||||
//
|
||||
// 而"不可见"的判据本来就不该由**任何**单点写入来恢复 —— 归档是全局的、
|
||||
// 单向的(前端只有归档入口,没有恢复入口)。所以正确的形状不是"在 reply_to 里
|
||||
// 拦一道"(那只是把一个入口堵上),而是让这个函数**没有能力**去改 status。
|
||||
// 判据见 session_status_invariant_test.go:不变量写在**两个写入口**(CreateMail 系
|
||||
// 与 ArchiveSession)上,而不是写成"某条路径返回 404"。
|
||||
func TouchSession(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET updated_at = NOW(), status = 'active' WHERE session_id = $1`, id)
|
||||
`UPDATE sessions SET updated_at = NOW() WHERE session_id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrSessionArchived 表示目标会话已归档。
|
||||
//
|
||||
// 它与 ErrSessionNotFound **刻意分列**,不是同一件事的两份写法:前者是"这条线索
|
||||
// 你知道存在、我也知道,只是不再接受邮件",后者是"没有这条线索"。
|
||||
// 两者对调用方是不同的可恢复动作(前者可以 `.new` 另起一条,后者要先确认地址),
|
||||
// 所以调用方要能 errors.Is 分开判。
|
||||
var ErrSessionArchived = errors.New("session is archived")
|
||||
|
||||
// SessionOpenFor 报告 agent 是否可以**往这条会话里发新邮件**。
|
||||
//
|
||||
// 三个判据,来自三张表,缺一不可:
|
||||
// - 会话存在(ErrSessionNotFound);
|
||||
// - 会话未归档(ErrSessionArchived)—— 归档是全局、单向的;
|
||||
// - 发起方是发起方:`s.from_agent = $1` **或**该 Agent 参与过这条线索
|
||||
// (from/to/cc 的 EXISTS 子句,与别名寻址 `FindNamedSessionFor` 同一形状)。
|
||||
//
|
||||
// 第三个判据补的是 `AgentCanAccessSession`(repo.go:AgentCanAccessSession)那条
|
||||
// 只在读路径被调用的判据:写路径此前**一个都不查**。permission/request 的
|
||||
// session_id 完全来自请求体,携带别人的 session_id 就能往那条线索里投一封邮件 ——
|
||||
// 而 mail.go 的 reply_to 分支同样不查参与方,于是"知道 id 就能往别人的线索里发信"。
|
||||
//
|
||||
// 为什么不复用 AgentCanAccessSession:那个函数判的是"能否改这条线索的**别名**"
|
||||
// (别名是人记住的寻址入口),范围比"能否发信"窄(Agent 可以发信到一条自己
|
||||
// 只是被抄送的线索,别名却不该由它改)。两者是不同的能力,不能互相顶替。
|
||||
func SessionOpenFor(ctx context.Context, agentName string, sessionID uuid.UUID) error {
|
||||
var status string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT s.status FROM sessions s WHERE s.session_id = $1`, sessionID).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "archived" {
|
||||
return ErrSessionArchived
|
||||
}
|
||||
var n int
|
||||
if err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sessions s
|
||||
WHERE s.session_id = $1
|
||||
AND (s.from_agent = $2 OR EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = s.session_id
|
||||
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)))`,
|
||||
sessionID, agentName).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSessionAlias 手工改名(人显式指定)。
|
||||
//
|
||||
// 同时把 alias_source 标为 'manual':人的选择优先于平台自动命名。
|
||||
@ -394,6 +471,9 @@ func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error {
|
||||
|
||||
func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID,
|
||||
fromName, fromWorkspace, toName, toWorkspace, subject, body string, ccList []models.Address) (uuid.UUID, error) {
|
||||
if err := EnsureSessionOpen(ctx, sessionID); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if ccList == nil {
|
||||
ccList = []models.Address{}
|
||||
}
|
||||
@ -424,7 +504,42 @@ func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUI
|
||||
//
|
||||
// 取会话的 workspace 而不是传参:会话的工作目录在它建立时就定下了,
|
||||
// 而询问发起于那条会话里。
|
||||
//
|
||||
// ★ 这里的 EnsureSessionOpen 是本文件**所有**建邮件函数
|
||||
// (CreateMail / CreatePermissionMail / CreateDecisionMail)的共同前置。
|
||||
//
|
||||
// 为什么不把 WHERE 塞进每个 INSERT:分叉判据的成因就是「每个写路径各判一次,
|
||||
// 而它们判的列不同」(会话表 vs 邮件表)。守卫集中一处,
|
||||
// 新增一种建邮件的函数就必须经过它 —— 否则下一个新函数又会漏。
|
||||
//
|
||||
// 目的是让**不变量由构造保证**:会话一旦 archived,就再也长不出非归档的邮件行,
|
||||
// 而两表分叉正是「半活会话」的全部成因
|
||||
// (判据:session_status_invariant_test.go)。
|
||||
// EnsureSessionOpen 判「会话存在且未归档」,不判参与方。
|
||||
//
|
||||
// 需要它的调用点各自已经判过权限(人类的决策回执只允许收件人本人或管理员),
|
||||
// 所以这里**刻意不**叠参与方那一格 —— 叠上会让管理员被误挡,
|
||||
// 而管理员本来就可以给任何线索做决策,那不是越权。
|
||||
func EnsureSessionOpen(ctx context.Context, sessionID uuid.UUID) error {
|
||||
var status string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT status FROM sessions WHERE session_id = $1`, sessionID).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "archived" {
|
||||
return ErrSessionArchived
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, toUser, question, body string, options []string, kind string, multiSelect bool) (uuid.UUID, error) {
|
||||
if err := EnsureSessionOpen(ctx, sessionID); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
optsJSON, _ := json.Marshal(options)
|
||||
var multiSelectInt int
|
||||
if multiSelect {
|
||||
@ -447,6 +562,11 @@ func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, to
|
||||
|
||||
// CreateDecisionMail 创建人类决策邮件(fromUser → toAgent)
|
||||
func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID uuid.UUID, fromUser, toAgent, decision, note string) (uuid.UUID, error) {
|
||||
// 守卫对这一条尤其要紧:决策是人在授权页上点的,而被决策的权限邮件可能
|
||||
// 在等这一轮的过程中被归档。静默丢弃决策会让 Agent 那边永远等不到回执。
|
||||
if err := EnsureSessionOpen(ctx, sessionID); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
var id uuid.UUID
|
||||
body := decision
|
||||
if note != "" {
|
||||
|
||||
Reference in New Issue
Block a user