-- 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), 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.),必须全局唯一。 -- 部分唯一索引:未命名会话(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 '[]'; -- Agent 在正文里提议改会话别名()。 -- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。 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;