-- AgentMail Schema — SQLite(默认后端) -- -- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言: -- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成) -- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time) -- 默认值不用 CURRENT_TIMESTAMP:它只有【秒】精度,同一秒内插入的多行 -- 按 created_at 排序结果不确定, -- 「会话里最早/最后那封邮件」都会取错行 -- (实测同秒插 5 封,排出来的顺序是乱的)。 -- 改用 strftime 的毫秒精度。mails 表另在 repo 层的 -- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要 -- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序 -- 直接决定 UI 上「最新进展」显示哪一封。 -- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索) -- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责) -- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致 -- -- 本文件只建表建索引,不含数据迁移:SQLite 是新引入的默认后端,不存在历史库。 CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', status TEXT NOT NULL DEFAULT 'active', created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), last_login DATETIME, -- 权限边界:空数组 = 不限 allowed_agents TEXT NOT NULL DEFAULT '[]', allowed_paths TEXT NOT NULL DEFAULT '[]', agent_aliases TEXT NOT NULL DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS user_sessions ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), expires_at DATETIME NOT NULL, user_agent TEXT 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); CREATE TABLE IF NOT EXISTS agents ( agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), agent_name TEXT NOT NULL UNIQUE, secret TEXT NOT NULL, host_url TEXT NOT NULL DEFAULT '', workspaces TEXT NOT NULL DEFAULT '[]', platform TEXT NOT NULL DEFAULT 'pi', status TEXT NOT NULL DEFAULT 'offline', -- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。 -- 配额是任务的属性,所以真正的约束在 sessions.max_rounds 上; -- 这里只提供默认值 —— 不同 Agent 能力不同,默认值分开设才合理。 default_rounds INTEGER NOT NULL DEFAULT 20, -- max_rounds / used_rounds 是历史遗留的「终身额度」。 -- 终身额度是错的工具:跑满就得管理员手工重置才能再干活, -- 而 Agent 是长期在线的。现已降级为纯统计(used_rounds 只累加、不拦请求), -- max_rounds 保留列但不再参与判断。 max_rounds INTEGER NOT NULL DEFAULT 0, used_rounds INTEGER NOT NULL DEFAULT 0, last_seen DATETIME, created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')) ); CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), session_alias TEXT, from_agent TEXT NOT NULL, subject TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', owner_user_id TEXT REFERENCES users(user_id), created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), -- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。 rename_dismissed TEXT, -- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖) -- 或 'manual'(人显式指定,平台同步不得覆盖)。 -- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉, -- 人上一秒记住的寻址地址下一秒失效。 alias_source TEXT NOT NULL DEFAULT 'platform', -- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。 -- -- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性: -- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度, -- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。 -- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。 -- -- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话, -- 每条都是全新预算,全局上限就形同虚设。 max_rounds INTEGER NOT NULL DEFAULT 0, used_rounds INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias); CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id); -- 会话别名负责寻址(name@path.),必须全局唯一。 -- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。 CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq ON sessions(session_alias) WHERE session_alias IS NOT NULL; CREATE TABLE IF NOT EXISTS mails ( mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), session_id TEXT NOT NULL REFERENCES sessions(session_id), parent_mail_id TEXT REFERENCES mails(mail_id), from_name TEXT NOT NULL, from_workspace TEXT DEFAULT '', to_name TEXT NOT NULL, to_workspace TEXT DEFAULT '', subject TEXT NOT NULL, body TEXT NOT NULL, -- 抄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}] cc_list TEXT NOT NULL DEFAULT '[]', mail_type TEXT NOT NULL DEFAULT 'normal', permission_options TEXT, permission_result TEXT, status TEXT NOT NULL DEFAULT 'unread', created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), hop_limit INTEGER DEFAULT 5, -- Agent 在正文里提议改会话别名()。 -- 存在邮件上而非会话上:邮件是不可篡改的历史记录, -- 「谁在哪一封里提了什么」应当留痕。 rename_alias TEXT, rename_reason TEXT ); 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); CREATE INDEX IF NOT EXISTS idx_mails_created ON mails(created_at); -- 抄送检索无对应索引:SQLite 侧走 json_each 展开。 -- 单机邮件量级(数千至数万)下全表展开是毫秒级,不值得为此加物化列。 CREATE TABLE IF NOT EXISTS permission_requests ( request_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), mail_id TEXT NOT NULL REFERENCES mails(mail_id), session_id TEXT NOT NULL REFERENCES sessions(session_id), agent_name TEXT NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL DEFAULT '["同意","拒绝"]', context TEXT DEFAULT '', result TEXT, decided_at DATETIME, created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','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; -- ---------- 密钥认证体系 ---------- -- -- 两类密钥,共享一个全局唯一的 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 TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), key_token TEXT NOT NULL UNIQUE, agent_name TEXT, -- NULL = 待绑定 key_type TEXT NOT NULL DEFAULT 'permanent', label TEXT NOT NULL DEFAULT '', expires_at DATETIME, used_at DATETIME, created_by TEXT REFERENCES users(user_id), created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','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 TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), key_token TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT '', key_type TEXT NOT NULL DEFAULT 'permanent', expires_at DATETIME, used_at DATETIME, created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','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 派生),数据库只存元数据。 -- 不塞 BLOB:SQLite 的 BLOB 会让 .db 文件膨胀并拖慢 WAL,而附件是只写一次多次读的冷数据。 -- -- 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 TEXT NOT NULL, relay_key TEXT NOT NULL, mail_id TEXT REFERENCES mails(mail_id), kind TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','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 TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), mail_id TEXT REFERENCES mails(mail_id) ON DELETE CASCADE, -- 上传者(Agent 名或用户名),用于「只能挂自己上传的附件」校验 uploader TEXT NOT NULL, filename TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'application/octet-stream', size_bytes INTEGER NOT NULL, -- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径 sha256 TEXT NOT NULL, created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')) ); CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id); CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256); -- GC 扫描待挂载附件用 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 DATETIME NOT NULL, expired INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);