缺口:maxAgentPingPong=8 撞闸后**计数永不回落** —— 只有人类插话才归零。 旧文案把出路指向「请由人类插一句话」,而那条线索上常常**根本没有人类** (2026-10-01 报告:agent 一封都发不出,且那条线索无人在场)。 修法(两条要求分别落地): ① 2h 恢复机制:撞闸即写入 session_agent_locks(落库,内存态一重启就"恢复", 且多副本各算各的);到期自动放行,人类插话立刻解锁(优先于到期)。 ② 锁定期间的邮件不自动重投递:冷却期内 relay 直接 403 丢弃 —— **不排队、不占幂等键、不入库**。排队会在 2h 后一次性灌回去, 那等于把刚压住的回路换个更糟的形状放出来。 实测踩到的三个坑(都被判据抓住): - `VALUES ($1,$2,$2,...)` 让 until_at 复用 locked_at 的 $2 ⇒ 锁诞生即过期 - driver 以 UTC 扫回 DATETIME,而 time.Now() 是本地(HKT+8) ⇒ 差 8h > 2h 的一半 ⇒ 锁形同虚设 - 只查**收件方**是否人类,漏了**发件方** —— 而「人类插话解锁」的常态就是 人回信、收件方仍是 Agent ⇒ 人插话反被自己写的闸 403 拦下,那条出路根本不存在 - SQLite 没有 GREATEST;在 DATETIME 上按**字符串**比大小 ⇒ CASE 也会错。 改为「已有锁一律不碰 until_at」。 判据:repo 6 格(含"读失败不得读成未锁定"—— 最初 0 格能抓,变异测试补的) + handler 4 格。三个变异全部经得起(且每��都先确认变异编译通过再数红格 —— 本轮多次 grep 得 0 实际是 build failed,测试压根没跑)。
631 lines
33 KiB
SQL
631 lines
33 KiB
SQL
-- 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 '{}',
|
||
|
||
-- 个人签名(顶栏「一言」的另一个来源)。
|
||
--
|
||
-- 2026-09-24:用户要求顶栏能轮播「一言 + 签名」,签名是用户的,
|
||
-- 所以存在 users 上而不是外观表里(签名不属于外观)。
|
||
signature TEXT NOT NULL DEFAULT ''
|
||
);
|
||
|
||
-- 一言句库(服务端拉取 + 缓存;客户端再缓一层)。
|
||
--
|
||
-- 2026-09-24:用户要求顶栏轮播「一言」,并明确「在服务器集成一言」。
|
||
--
|
||
-- 为什么落库而不是每次转发外网:
|
||
-- 1 外网挂了不该让顶栏空着(本地已有句库就照旧发)
|
||
-- 2 每次启动都打外网是把一个装饰性文案变成新的失败点
|
||
-- 3 句库本身不会变 —— 缓下来就一直是有效数据
|
||
-- 所以这张表是句库缓存,不是业务数据:删了也不影响正确性,只会触发重拉。
|
||
--
|
||
-- 注意:本文件只用行注释,不要用 C 风格块注释。
|
||
-- 建库时按分号切语句,而切分器只跳过以双横线开头的行;
|
||
-- 块注释里的文字会被当成 SQL 执行(我第一版就是因此让新库初始化失败,
|
||
-- 报错还是一个不相干的地方)。同理注释里也不要写反引号。
|
||
CREATE TABLE IF NOT EXISTS quotes (
|
||
quote_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||
-- 正文与出处(出处可空:一言接口有些句子没作者)
|
||
text TEXT NOT NULL,
|
||
source TEXT NOT NULL DEFAULT '',
|
||
-- 来源标记:'hitokoto' = 外部拉取,'local' = 内置句库
|
||
origin TEXT NOT NULL DEFAULT 'hitokoto',
|
||
fetched_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_quotes_origin ON quotes(origin);
|
||
|
||
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,
|
||
|
||
-- mode_enforcement 是该平台插件自报的权限档位强制能力(native / advisory),
|
||
-- 随心跳上报(与模型目录同一条通道 —— I-1:平台自己说的才算)。
|
||
--
|
||
-- 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
|
||
-- homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
|
||
-- 把这个事实藏起来比做不到本身更危险。
|
||
--
|
||
-- 默认 advisory 而不是 native:没自报过的插件,我们不能替它宣称
|
||
-- 「档位在这里是被强制的」。
|
||
mode_enforcement TEXT NOT NULL DEFAULT 'advisory',
|
||
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,
|
||
-- workspace 是这条会话所属的工作目录(三维地址 name@path.session 的 path 位)。
|
||
--
|
||
-- 之前它只存在于 mails.to_workspace 上,于是「这个工作区下有哪些会话」
|
||
-- 必须 JOIN mails 再从收发双方的 workspace 里猜,而 Agent 回信时
|
||
-- from_workspace 填的是 Agent 名而不是路径 —— 猜出来的结果是错的,
|
||
-- 别名候选列表因此列不出本工作区的历史会话。
|
||
-- 会话归属哪个工作区是会话自己的属性,就该存在会话上。
|
||
workspace TEXT NOT NULL DEFAULT '',
|
||
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')),
|
||
|
||
-- 绑定到平台侧的哪条会话(agent_platform_sessions.platform_id)。
|
||
--
|
||
-- 空 = 这条会话由邮件创建,平台侧的会话是桥按邮件开的。
|
||
-- 非空 = 这条会话**接管**了一条平台上已经存在的会话(人在 TUI 里开的那种)。
|
||
--
|
||
-- 为什么需要它:TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。
|
||
-- 人在 TUI 里聊了一半想转到邮件上继续,或者想把一封邮件投进正在谈的那条
|
||
-- 会话 —— 补全早就把平台会话列为候选(agent_platform_sessions),
|
||
-- 但投递侧没有这一跳,选中后只能得到 404。这一列就是那一跳的落点。
|
||
platform_id TEXT NOT NULL DEFAULT '',
|
||
|
||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||
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,
|
||
|
||
-- 会话级权限档位(plan / workspace / full)。
|
||
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||
|
||
-- 接收平台实际做到的强制力(native / advisory)。
|
||
-- 旧库默认 'advisory':没自报过的插件,我们不能替它宣称
|
||
-- 「档位在这里是被强制的」。保守方向是承认做不到,而不是假装做到了。
|
||
permission_enforcement TEXT NOT NULL DEFAULT 'advisory'
|
||
);
|
||
|
||
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);
|
||
|
||
-- 会话身份 = (path, session) —— 用户 2026-09-15 订正:
|
||
-- 「agent 平台的 session 是和 path 绑定的,path+session 才能指定到准确的 agent,
|
||
-- 而授权也是对 session 授权,而不是整个 agent」。
|
||
-- 所以寻址名的唯一性也是**按 path** 的:不同工作目录下的同名会话是两条不同的会话
|
||
-- (各自的身份、各自的授权),而不是"同名冲突"。
|
||
--
|
||
-- 历史:这里原先是 ON sessions(session_alias) 的全局唯一索引。全局唯一让 path 在解析时
|
||
-- 成为冗余(于是被忽略),一条 /home 之类的错 path 就能让两条不相干的线索落进同一个
|
||
-- 会话、同一个 pi 会话文件(2026-09-15 实测:zcode 的"介绍请求"与 agentmail 的部署
|
||
-- 往来混在 --home-- 的一个 jsonl 里)。
|
||
-- 注意:这条索引引用 sessions.workspace,而那是**后补的列**(sqliteAddColumns)。
|
||
-- 老库的 sessions 没有这一列,所以索引必须等补完列再建 ——
|
||
-- 它现在住在 migrate.go 的 sqliteAddIndexes 里(那个列表在补列之后执行)。
|
||
--
|
||
-- 2026-09-25 实测:一份 9 月 2 日的老库启动时直接死在这里
|
||
-- (no such column: workspace),报错却指向这个索引名,看着像索引写错,
|
||
-- 实际是**时序**问题(列还没补上就建索引)。生产库没暴露是因为它早补过了。
|
||
-- 老索引必须显式丢弃:CREATE ... IF NOT EXISTS 不会删掉它,而它会继续把别名限制成全局唯一。
|
||
DROP INDEX IF EXISTS idx_sessions_alias_uniq;
|
||
|
||
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,
|
||
-- permission = 危险操作审批;question = Agent 主动补充询问。
|
||
permission_kind TEXT NOT NULL DEFAULT '',
|
||
permission_multi_select INTEGER NOT NULL DEFAULT 0,
|
||
|
||
status TEXT NOT NULL DEFAULT 'unread',
|
||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
|
||
hop_limit INTEGER DEFAULT 5,
|
||
|
||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||
-- 「谁在哪一封里提了什么」应当留痕。
|
||
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 '',
|
||
-- permission = 危险操作审批;question = Agent 主动补充询问。
|
||
kind TEXT NOT NULL DEFAULT 'permission',
|
||
-- ask_user_question 的多选语义;普通权限审批恒为 0。
|
||
multi_select INTEGER NOT NULL DEFAULT 0,
|
||
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);
|
||
|
||
-- Agent↔Agent 回路的**锁定期**(2026-10-01)
|
||
--
|
||
-- 为什么需要:maxAgentPingPong=8 拦下之后,**计数永不回落** ——
|
||
-- 无人参与时会话被永久锁死(2026-10-01 报告:agent 一封都发不出,
|
||
-- 而那道闸的文案只教人「请由人类插一句话」,可这条线索上根本没有人类)。
|
||
--
|
||
-- 锁定期给出**自恢复出口**:到期自动放行,不依赖任何人介入。
|
||
-- 落库而不是放内存:重启必须保留(内存态一重启就"恢复",
|
||
-- 而锁定期的意义正是"别在短时间内又炸一轮")。
|
||
CREATE TABLE IF NOT EXISTS session_agent_locks (
|
||
session_id TEXT PRIMARY KEY REFERENCES sessions(session_id),
|
||
locked_at DATETIME NOT NULL,
|
||
until_at DATETIME NOT NULL,
|
||
hops INTEGER NOT NULL DEFAULT 0,
|
||
reason TEXT NOT NULL DEFAULT ''
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_session_agent_locks_until ON session_agent_locks(until_at);
|
||
|
||
|
||
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);
|
||
|
||
-- ---------- 邮件场景下的可用模型 ----------
|
||
--
|
||
-- 拆成两张表,因为它们是两种不同的真相:
|
||
--
|
||
-- agent_model_catalog —— 平台**上报**它当前看得见哪些模型。每次注册整表替换。
|
||
-- agent_allowed_models —— 管理员**选定**其中哪些可以在邮件场景下用,rank 即优先级。
|
||
--
|
||
-- 不合成一张带 allowed 标记的表:那样一来模型从平台目录里消失(换了 provider 配置、
|
||
-- 上游下线了某个模型)就会连带把管理员的选择删掉,等模型回来还得重新配一遍。
|
||
-- 分开存之后,选择是持久的,目录只决定「这一项现在是否可用」。
|
||
CREATE TABLE IF NOT EXISTS agent_model_catalog (
|
||
agent_name TEXT NOT NULL,
|
||
provider TEXT NOT NULL,
|
||
model TEXT NOT NULL,
|
||
-- 人类可读名,平台给什么就存什么;为空时前端显示 model id
|
||
display_name TEXT NOT NULL DEFAULT '',
|
||
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
PRIMARY KEY (agent_name, provider, model)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS agent_allowed_models (
|
||
agent_name TEXT NOT NULL,
|
||
provider TEXT NOT NULL,
|
||
model TEXT NOT NULL,
|
||
-- rank 越小越先试。插件按它顺序降级,全部失败才回一封失败邮件。
|
||
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);
|
||
|
||
-- ---------- 平台会话镜像 ----------
|
||
--
|
||
-- Agent 平台(opencode / DSH)自己也在开会话:有些经由邮件驱动,有些是人直接
|
||
-- 在平台界面上开的。写信时想续谈某条会话,就得先知道那个工作区下有哪些会话
|
||
-- 可以续 —— 而 Gateway 只看得见邮件驱动的那部分。
|
||
--
|
||
-- **由插件在心跳里上报,而不是 Gateway 反向拉取**:当前架构是单向的
|
||
-- (Agent 持密钥主动连 Gateway,Gateway 从不外呼)。让 Gateway 去调平台接口
|
||
-- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。
|
||
--
|
||
-- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间,
|
||
-- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处
|
||
-- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。
|
||
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
|
||
agent_name TEXT NOT NULL,
|
||
-- 平台侧的会话 id(opencode 的 ses_xxx / DSH 的 session id)
|
||
platform_id TEXT NOT NULL,
|
||
-- 平台侧 cwd,即三维地址的 path 位
|
||
workspace TEXT NOT NULL DEFAULT '',
|
||
-- 平台自己的可寻址短名(opencode 的 slug;DSH 由模型标题派生)
|
||
slug TEXT NOT NULL DEFAULT '',
|
||
title TEXT NOT NULL DEFAULT '',
|
||
-- 该平台会话是否由 AgentMail 的邮件驱动。用来在候选列表里区分
|
||
-- 「续谈已有邮件线索」与「接入一条平台侧已经在跑的会话」。
|
||
mail_driven INTEGER NOT NULL DEFAULT 0,
|
||
updated_at DATETIME,
|
||
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
PRIMARY KEY (agent_name, platform_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
|
||
ON agent_platform_sessions(agent_name, workspace);
|
||
|
||
-- ─── 日历事件 ───
|
||
--
|
||
-- Outlook 风格:事件 → 提醒 → 邮件通知 Agent。
|
||
-- 事件本身是日历实体,提醒是定时触发器,邮件是投递通道。
|
||
-- 三者分离:同一条事件可以有多个提醒(提前提醒 + 当天提醒),
|
||
-- 同一条提醒只触发一封邮件(幂等由 fired_at 控制)。
|
||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||
event_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||
-- 事件标题(UI 显示 + 邮件主题前缀)
|
||
title TEXT NOT NULL,
|
||
-- 事件描述(UI 显示,可含 markdown)
|
||
description TEXT NOT NULL DEFAULT '',
|
||
-- 用户可编辑的提醒消息模板。支持变量:{title} {time} {description}
|
||
reminder_text TEXT NOT NULL DEFAULT '',
|
||
|
||
-- 通知目标
|
||
agent_name TEXT NOT NULL DEFAULT '',
|
||
-- 收件人三维地址(空 = 用 agent_name 默认地址)
|
||
to_address TEXT NOT NULL DEFAULT '',
|
||
|
||
-- 时间安排
|
||
event_time DATETIME NOT NULL,
|
||
-- 提前多少分钟提醒(0 = 事件触发时)
|
||
remind_before INTEGER NOT NULL DEFAULT 0,
|
||
-- 重复规则:none / daily / weekly / monthly
|
||
-- lunar_monthly(每农历月同一日)/ lunar_yearly(每农历年同月同日)
|
||
--
|
||
-- 农历规则必须经 internal/lunar 推进,不能加固定天数 ——
|
||
-- 农历月 29~30 天不定、农历年 353~385 天(闰年多一整月),
|
||
-- 近似推进一年能偏半个月。
|
||
recurrence TEXT NOT NULL DEFAULT 'none',
|
||
-- 重复结束(空 = 永久)
|
||
recurrence_end DATETIME,
|
||
|
||
-- 收件人列表(JSON 数组,每项是完整三维地址串)。
|
||
--
|
||
-- 存原始串而不是结构化地址:session 位的 new/别名三态该在**触发那一刻**
|
||
-- 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化,
|
||
-- 而重复事件每次触发都该重新决定落到哪条会话。
|
||
recipients TEXT NOT NULL DEFAULT '[]',
|
||
|
||
-- 多收件人的投递方式:
|
||
-- separate(默认)= 各发一封、落各自会话、互不可见
|
||
-- together = 首个为主收件人,其余进 cc_list、共享一条线索
|
||
--
|
||
-- 默认 separate 因为它的失败模式更轻:together 用错会让本该独立判断的
|
||
-- Agent 互相看到回复而趋同,那种污染事后无法分离。
|
||
delivery_mode TEXT NOT NULL DEFAULT 'separate',
|
||
|
||
-- 状态:active / paused / cancelled
|
||
status TEXT NOT NULL DEFAULT 'active',
|
||
-- 最后一次触发的墙上时钟(给 UI 显示「上次触发于」)
|
||
last_fired_at DATETIME,
|
||
-- 已触发的那个 occurrence,值 = 当时的 event_time。
|
||
--
|
||
-- 去重不能拿 last_fired_at 跟 event_time 比大小:DueEvents 有 60 秒
|
||
-- lookahead,落在窗口内的**未来**事件被触发后 last_fired_at(now) 仍然
|
||
-- 小于 event_time,于是每个 tick 重发一次,直到 event_time 真正过去。
|
||
-- 生产实测:一条 12:53:17 的事件在 12:52:30 / 12:53:00 / 12:53:06 /
|
||
-- 12:53:36 发了 4 封相同提醒。
|
||
-- 按 occurrence 比相等则精确:AdvanceRecurrence 改了 event_time 就再触发,
|
||
-- 没改就永不重发。
|
||
fired_for DATETIME,
|
||
-- 权限档位(plan / workspace / full)。事件触发时新建会话 → 用此档位定死;
|
||
-- 复用已有会话 → 取「会话现档 与 事件档」中更严那个(ModeAtMost),
|
||
-- 不允许通过重复事件提权(plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan)。
|
||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
|
||
-- 创建者(人类用户)
|
||
created_by TEXT NOT NULL DEFAULT ''
|
||
);
|
||
|
||
-- 调度器每分钟扫描 active 事件,按 event_time + remind_before 排序取下一个
|
||
CREATE INDEX IF NOT EXISTS idx_calendar_next_fire
|
||
ON calendar_events(status, event_time) WHERE status = 'active';
|
||
|
||
-- 按时间范围查(日历视图)
|
||
CREATE INDEX IF NOT EXISTS idx_calendar_time_range
|
||
ON calendar_events(event_time, status);
|
||
|
||
-- 附件:事件触发时随提醒邮件一起发出
|
||
CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||
event_id TEXT NOT NULL REFERENCES calendar_events(event_id) ON DELETE CASCADE,
|
||
filename TEXT NOT NULL,
|
||
sha256 TEXT NOT NULL DEFAULT '',
|
||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||
ON calendar_attachments(event_id);
|
||
|
||
-- 注意:idx_sessions_platform 不在这里。
|
||
-- 这个脚本在 addMissingColumns **之前**执行,而已部署的库里 sessions 表已经
|
||
-- 存在 —— CREATE TABLE IF NOT EXISTS 不会给它补 platform_id 列,于是这里建
|
||
-- 索引会以 "no such column" 失败,整个迁移中断(实测过一次)。
|
||
-- 依赖补出来的列的索引一律放 migrate.go 的 sqliteAddIndexes。
|
||
|
||
-- ─── 按人记录的已读状态(mail_reads)─────────────────────────────────
|
||
--
|
||
-- 为什么需要它(2026-09-13 实测缺陷):已读原先只存在 mails.status 这一列上,
|
||
-- 而它是**邮件级**的 —— 任何收件人读掉,对所有收件人(含抄送)都变成已读。
|
||
-- 后果三连:
|
||
-- ① Agent 的 read_inbox(默认 status=unread)拿不到信,只能靠提示词里的
|
||
-- mail_id 兜(实测 dsh 明确回报"收件箱列表未展示它,直接按 mail_id 读取成功");
|
||
-- ② 人类还没读,未读却被抄送的 Agent 读掉了(徽标一起变);
|
||
-- ③ 桥的补投判据 pending_mails = CountUnread(agent):被别人读掉后计数为 0,
|
||
-- 于是 SSE 漏过或进程重启时那封信**不会被补投** —— "以为送到了,其实没人看过"。
|
||
--
|
||
-- 语义:某封邮件对某个读者(用户名或 Agent 名)是否未读 = 这张表里没有对应行。
|
||
-- mails.status 保留为"有人读过/已归档"的冗余,不再作为未读判据。
|
||
CREATE TABLE IF NOT EXISTS mail_reads (
|
||
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
|
||
reader_name TEXT NOT NULL,
|
||
read_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
PRIMARY KEY (mail_id, reader_name)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_mail_reads_reader ON mail_reads(reader_name);
|
||
|
||
-- 键值元数据表:给**一次性**迁移/回填留标记。
|
||
--
|
||
-- 为什么不能只靠 INSERT ... IF NOT EXISTS 这类幂等语句:回填只能做一次。
|
||
-- 若每次启动都跑,它会把"某个抄送方读过"的邮件按主收件人身份写成已读 —— 正是
|
||
-- 这次要修的错。所以回填必须由标记守住。
|
||
CREATE TABLE IF NOT EXISTS app_meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL DEFAULT ''
|
||
);
|
||
|
||
-- ─── 用户外观(主题 + 壁纸)────────────────────────────────────────────
|
||
--
|
||
-- 为什么放服务端(2026-09-13 用户报的缺陷):「为什么背景是保存在本地而不是服务器!」
|
||
-- 原先主题与壁纸都只存在浏览器 localStorage 里:换设备/换浏览器就没了,而且**多账号
|
||
-- 共用一份**(键是全局常量 agentmail.background)—— 同一台机器换账号,背景不跟着走。
|
||
--
|
||
-- 用**列**而不是一坨 JSON:因为 blob GC 要一眼看出"这张图还有没有人用"
|
||
-- (见 repo.SweepUnreferencedBlobs 的引用源列表)。塞进 JSON 就得在 SQL 里解析 JSON,
|
||
-- 两种方言写法还不一样。
|
||
CREATE TABLE IF NOT EXISTS user_appearance (
|
||
user_id TEXT NOT NULL,
|
||
theme TEXT NOT NULL DEFAULT 'system',
|
||
bg_kind TEXT NOT NULL DEFAULT 'none',
|
||
bg_preset_id TEXT NOT NULL DEFAULT '',
|
||
bg_dim INTEGER NOT NULL DEFAULT 12,
|
||
bg_blur INTEGER NOT NULL DEFAULT 4,
|
||
-- 壁纸图片:内容寻址(sha256),文件本身在 blob 存储里
|
||
image_sha256 TEXT NOT NULL DEFAULT '',
|
||
image_type TEXT NOT NULL DEFAULT '',
|
||
image_bytes INTEGER NOT NULL DEFAULT 0,
|
||
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
PRIMARY KEY (user_id)
|
||
);
|
||
|
||
-- ─── 设备推送 token(可选通道)──────────────────────────────────────────
|
||
--
|
||
-- 为什么有 provider 列:**推送是自部署后端的可选项,不是内置依赖**
|
||
-- (2026-09-15 用户明确要求:「不能写死推送方式,因为我们是自部署后端」
|
||
-- 「即推送密钥应当是可选项」)。一个自部署实例可能一个推送渠道都没配
|
||
-- —— 这是常态而不是配置错误;也可能同时接华为 HMS 与别的通道。
|
||
-- 加通道不该动 schema、不该动端点形状。
|
||
--
|
||
-- owner_name 是**注册者**(登录用户)。收件判据与 SSE 同源:一封新邮件推给
|
||
-- 谁,推送就发给谁 —— 两条通道不该有两套「谁该收到」的定义。
|
||
CREATE TABLE IF NOT EXISTS push_tokens (
|
||
token_id TEXT NOT NULL,
|
||
provider TEXT NOT NULL,
|
||
token TEXT NOT NULL,
|
||
owner_name TEXT NOT NULL,
|
||
-- 注册时客户端所在的会话:点通知要回到那条会话里的那封信
|
||
session_id TEXT NOT NULL DEFAULT '',
|
||
device_name TEXT NOT NULL DEFAULT '',
|
||
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
||
PRIMARY KEY (token_id)
|
||
);
|
||
-- 一个 token 只能属于一个注册者:换人登录是**转移**,不是并存 —— 否则上一任
|
||
-- 用户的通知会继续推到同一台设备上(那是隐私事故,不只是脏数据)。
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_push_tokens_provider_token ON push_tokens (provider, token);
|
||
CREATE INDEX IF NOT EXISTS idx_push_tokens_owner ON push_tokens (owner_name);
|
||
|