Files
MailUI4Agents/gateway/internal/db/migrations/init.sql
JianFeeeee ca64d12057 feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。

## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」)

插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有
邮件会话既不属于任何项目、彼此也不同组。

而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload
只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在
SSE 事件里,插件即使想用也拿不到。

- SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**,
  不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区
- 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到
  兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活)
- 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`

## 2. 会话别名列不出工作区下的历史会话(无法选择)

workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须
JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace
填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p`
在只剩 Agent 回信可匹配时两边都对不上。

- `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入
- `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace
  为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失
- `FindOrCreateDefaultSession` 同步改用会话的 workspace

## 3. 平台侧会话在补全里根本不存在

人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。

新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。
**上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway,
Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。

- 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有
  本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断
  这条到底是不是真的本侧会话
- **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是
  三态语义,指向不存在的会话直接 404
- **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像),
  而不是传空数组把镜像抹掉
- **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是
  派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名;
  它们是父 agent 内部的工作单元,人往里发邮件毫无意义
- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里
  出现几个点哪个都不确定的候选
- DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着

补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与
第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是
「缓存选型」而不是 brisk-harbor 这种随机短名。

## 4. 对话树看不见抄送与转发产生的分支

旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是
它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到
另一个;挂在原件上的转发分支同理。

改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向,
因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点
为什么并列,唯一的解释就是父邮件抄送给了两个人。

## 5. DSH 插件(Phase 7.7)

卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是
`followup()` 的参数形状:DSH 要完整的 UserMessage(content + source),
而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。

- `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的
  session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道
- `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的
  permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall,
  可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回
- 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 ——
  留在别名里会让它自己被解析器切开)
- 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处
  炸一个无关错误」

## 其他

- `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup`
  而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的
  foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰
  systemd(演练时误停过一次生产服务)
- 插件 dist/ 不进版本库,install.sh 负责构建
- `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话
2026-09-02 20:05:51 +08:00

348 lines
15 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- AgentMail MVP Schema
-- PostgreSQL 14+
-- Users table人类多用户username 与 agents.agent_name 共用命名空间)
CREATE TABLE IF NOT EXISTS users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(128) NOT NULL DEFAULT '',
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'user',
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS user_sessions (
token VARCHAR(64) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
user_agent VARCHAR(256) DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
-- 用户权限边界:可调用的 Agent 与可访问的工作区目录
-- allowed_agents["deepseekharness","pi"],空数组 = 不限(继承系统默认)
-- allowed_paths ["/program","/home/x"],空数组 = 不限;按前缀匹配
-- agent_aliases {"大龙":"deepseekharness","小派":"pi"},发信时自动解析真实名
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_agents JSONB NOT NULL DEFAULT '[]';
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_paths JSONB NOT NULL DEFAULT '[]';
ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_aliases JSONB NOT NULL DEFAULT '{}';
-- Agents table
CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_name VARCHAR(64) NOT NULL UNIQUE,
secret VARCHAR(128) NOT NULL,
host_url VARCHAR(256) NOT NULL DEFAULT '',
workspaces JSONB NOT NULL DEFAULT '[]',
platform VARCHAR(32) NOT NULL DEFAULT 'pi',
status VARCHAR(16) NOT NULL DEFAULT 'offline',
max_rounds INT NOT NULL DEFAULT 10,
used_rounds INT NOT NULL DEFAULT 0,
last_seen TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_alias VARCHAR(128),
-- 这条会话所属的工作目录(见 init_sqlite.sql 里的设计说明)
workspace VARCHAR(512) NOT NULL DEFAULT '',
from_agent VARCHAR(64) NOT NULL,
subject VARCHAR(512) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
owner_user_id UUID REFERENCES users(user_id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
-- 会话别名负责寻址name@path.<alias>),必须全局唯一。
-- 部分唯一索引未命名会话NULL不受约束可以有任意多个。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
-- 已存在的库补列(必须先于依赖该列的索引)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
-- 别名是谁定的:'platform'Agent 平台自动同步,可被后续同步覆盖)
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT 'platform';
-- 本次任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
--
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
--
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
-- 每条都是全新预算,全局上限就形同虚设。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
-- Mails table
CREATE TABLE IF NOT EXISTS mails (
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(session_id),
parent_mail_id UUID REFERENCES mails(mail_id),
from_name VARCHAR(64) NOT NULL,
from_workspace VARCHAR(128) DEFAULT '',
to_name VARCHAR(64) NOT NULL,
to_workspace VARCHAR(128) DEFAULT '',
subject VARCHAR(512) NOT NULL,
body TEXT NOT NULL,
-- 拄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
cc_list JSONB NOT NULL DEFAULT '[]',
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal',
permission_options JSONB,
permission_result VARCHAR(32),
status VARCHAR(16) NOT NULL DEFAULT 'unread',
created_at TIMESTAMPTZ DEFAULT NOW(),
hop_limit INT DEFAULT 5
);
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
-- 已存在的库补列(重复运行安全)
ALTER TABLE mails ADD COLUMN IF NOT EXISTS cc_list JSONB NOT NULL DEFAULT '[]';
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
-- 配额是任务的属性,真正的约束在 sessions.max_rounds 上;这里只提供默认值。
ALTER TABLE agents ADD COLUMN IF NOT EXISTS default_rounds INTEGER NOT NULL DEFAULT 20;
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT;
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_reason TEXT;
-- 抄送检索cc_list @> '[{"name":"pi"}]' 走 GIN
CREATE INDEX IF NOT EXISTS idx_mails_cc_list ON mails USING GIN (cc_list jsonb_path_ops);
-- 历史数据规整早期写入过首字母大写的键Raw/Name/Path/Session统一成小写
UPDATE mails
SET cc_list = (
SELECT COALESCE(jsonb_agg(
jsonb_strip_nulls(jsonb_build_object(
'name', COALESCE(e->>'name', e->>'Name'),
'path', COALESCE(e->>'path', e->>'Path'),
'session', COALESCE(e->>'session', e->>'Session'),
'raw', COALESCE(e->>'raw', e->>'Raw')
))
), '[]'::jsonb)
FROM jsonb_array_elements(cc_list) AS e
)
WHERE cc_list @? '$[*].Name';
-- Permission requests table
CREATE TABLE IF NOT EXISTS permission_requests (
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mail_id UUID NOT NULL REFERENCES mails(mail_id),
session_id UUID NOT NULL REFERENCES sessions(session_id),
agent_name VARCHAR(64) NOT NULL,
question TEXT NOT NULL,
options JSONB NOT NULL DEFAULT '["同意", "拒绝"]',
context TEXT DEFAULT '',
result VARCHAR(32),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
-- 历史邮件里的字面量 'human' 迁移到默认管理员账号
-- (管理员由 Go 侧 EnsureAdminUser 首启创建,此处只做数据重写)
DO $$
DECLARE
admin_name TEXT;
BEGIN
SELECT username INTO admin_name
FROM users WHERE role = 'admin' AND status = 'active'
ORDER BY created_at ASC LIMIT 1;
IF admin_name IS NULL THEN
RETURN; -- 还没有管理员,下次迁移再试
END IF;
UPDATE mails SET from_name = admin_name WHERE from_name = 'human';
UPDATE mails SET to_name = admin_name WHERE to_name = 'human';
UPDATE sessions SET from_agent = admin_name WHERE from_agent = 'human';
-- 拄送列表里的 human 一并重写
UPDATE mails
SET cc_list = (
SELECT COALESCE(jsonb_agg(
CASE WHEN e->>'name' = 'human'
THEN jsonb_set(
jsonb_set(e, '{name}', to_jsonb(admin_name)),
'{raw}',
to_jsonb(admin_name || '@' || COALESCE(e->>'path','') ||
CASE WHEN COALESCE(e->>'session','') = '' THEN ''
ELSE '.' || (e->>'session') END))
ELSE e END
), '[]'::jsonb)
FROM jsonb_array_elements(cc_list) AS e
)
WHERE cc_list @> '[{"name":"human"}]';
-- 人类发起的会话补上 owner
UPDATE sessions s
SET owner_user_id = u.user_id
FROM users u
WHERE u.username = admin_name
AND s.owner_user_id IS NULL
AND s.from_agent = admin_name;
END $$;
-- ---------- 密钥认证体系 ----------
--
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys
-- agent_keys管理员签发用于 Agent 注册/心跳/SSE
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
--
-- key_type
-- permanent — 永不过期,可重复使用
-- one_time — 首次验证后写 used_at再用即拒
-- timed — expires_at 之后失效
CREATE TABLE IF NOT EXISTS agent_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_token VARCHAR(128) NOT NULL UNIQUE,
agent_name VARCHAR(64), -- NULL = 待绑定
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
label VARCHAR(128) NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
created_by UUID REFERENCES users(user_id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
CREATE TABLE IF NOT EXISTS user_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_token VARCHAR(128) NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
label VARCHAR(128) NOT NULL DEFAULT '',
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
expires_at TIMESTAMPTZ,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
-- ---------- 附件 ----------
--
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
--
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
-- 上传与发信是两步Agent 工具是 JSON 接口,没法在发信时带 multipart
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
-- 插件自动转发的邮件登记表。
--
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
--
-- relay_key 是上游那条消息的稳定标识opencode 的 permission id / assistant message id
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
-- * 插件重试、SSE 重连后重放都不会产生第二封
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
CREATE TABLE IF NOT EXISTS relayed_mails (
agent_name VARCHAR(64) NOT NULL,
relay_key VARCHAR(160) NOT NULL,
mail_id UUID REFERENCES mails(mail_id),
kind VARCHAR(32) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (agent_name, relay_key)
);
-- 人类决策后要按 mail_id 反查上游 permission id
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
CREATE TABLE IF NOT EXISTS attachments (
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mail_id UUID REFERENCES mails(mail_id) ON DELETE CASCADE,
uploader VARCHAR(64) NOT NULL,
filename VARCHAR(512) NOT NULL,
content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
-- 速率限制(登录失败 + 新建会话)
CREATE TABLE IF NOT EXISTS rate_limits (
bucket TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
expired BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);
-- ---------- 邮件场景下的可用模型(见 init_sqlite.sql 里的设计说明) ----------
CREATE TABLE IF NOT EXISTS agent_model_catalog (
agent_name VARCHAR(128) NOT NULL,
provider VARCHAR(128) NOT NULL,
model VARCHAR(256) NOT NULL,
display_name VARCHAR(256) NOT NULL DEFAULT '',
reported_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (agent_name, provider, model)
);
CREATE TABLE IF NOT EXISTS agent_allowed_models (
agent_name VARCHAR(128) NOT NULL,
provider VARCHAR(128) NOT NULL,
model VARCHAR(256) NOT NULL,
rank INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (agent_name, provider, model)
);
CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank);
-- ---------- 平台会话镜像(见 init_sqlite.sql 里的设计说明) ----------
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
agent_name VARCHAR(128) NOT NULL,
platform_id VARCHAR(256) NOT NULL,
workspace VARCHAR(512) NOT NULL DEFAULT '',
slug VARCHAR(256) NOT NULL DEFAULT '',
title VARCHAR(512) NOT NULL DEFAULT '',
mail_driven BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ,
reported_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (agent_name, platform_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
ON agent_platform_sessions(agent_name, workspace);