Files
MailUI4Agents/gateway/internal/db/migrations/init.sql
JianFeeeee a44fd6949b feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
2026-09-06 15:16:49 +08:00

430 lines
19 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,
-- 接管的平台侧会话 id见 init_sqlite.sql 的说明)
platform_id VARCHAR(256) NOT NULL DEFAULT '',
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;
-- 会话级权限档位plan / workspace / full
--
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_mode TEXT NOT NULL DEFAULT 'workspace';
-- 接收平台实际做到的强制力native / advisory由插件心跳自报后落到会话上。
-- 旧库默认 'advisory':没自报过的插件,不能替它宣称「档位在这里是被强制的」。
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_enforcement TEXT NOT NULL DEFAULT 'advisory';
-- Agent 自报的档位强制能力native / advisory随心跳更新。
-- 与 sessions.permission_enforcement 的区别:这里是平台当下的能力,
-- 那里是某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
ALTER TABLE agents ADD COLUMN IF NOT EXISTS mode_enforcement TEXT NOT NULL DEFAULT 'advisory';
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);
-- ---------- 日历(见 init_sqlite.sql 里的设计说明) ----------
--
-- 三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
-- 这份 PG schema 曾经整块缺失 —— 后果是 DATABASE_URL 一旦非空,
-- 所有 /calendar/* 端点在 relation does not exist 上 500
-- 而 SQLite 下一切正常,于是问题只在切外部库时才暴露。
CREATE TABLE IF NOT EXISTS calendar_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(512) NOT NULL,
description TEXT NOT NULL DEFAULT '',
-- 提醒邮件正文模板,支持 {title} {time} {description}
reminder_text TEXT NOT NULL DEFAULT '',
-- 收件方agent_name 是兜底to_address 是权威(完整三维寻址)
agent_name VARCHAR(128) NOT NULL DEFAULT '',
to_address VARCHAR(512) NOT NULL DEFAULT '',
event_time TIMESTAMPTZ NOT NULL,
remind_before INTEGER NOT NULL DEFAULT 0,
recurrence VARCHAR(32) NOT NULL DEFAULT 'none',
recurrence_end TIMESTAMPTZ,
-- 收件人列表与投递方式(见 init_sqlite.sql 的说明)
recipients JSONB NOT NULL DEFAULT '[]'::jsonb,
delivery_mode VARCHAR(32) NOT NULL DEFAULT 'separate',
status VARCHAR(32) NOT NULL DEFAULT 'active',
last_fired_at TIMESTAMPTZ,
-- 已触发的 occurrence= 当时的 event_time。见 init_sqlite.sql 的说明。
fired_for TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(128) NOT NULL DEFAULT ''
);
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 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES calendar_events(event_id) ON DELETE CASCADE,
filename VARCHAR(512) NOT NULL,
sha256 VARCHAR(64) NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
ON calendar_attachments(event_id);
-- 接管平台会话时按 platform_id 反查本侧会话。
--
-- 先 ALTER 再建索引CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,
-- 而这个脚本在已部署的库上也要能跑。PG 支持 ADD COLUMN IF NOT EXISTS
-- 所以这里不需要像 SQLite 那样绕到代码里去(见 migrate.go 的 sqliteAddIndexes
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform_id VARCHAR(256) NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_sessions_platform
ON sessions(platform_id) WHERE platform_id <> '';