feat: AgentMail —— 以邮件为统一范式的多智能体协作平台

Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
This commit is contained in:
2026-09-02 10:29:26 +08:00
commit 0e754617a4
95 changed files with 23219 additions and 0 deletions

193
gateway/internal/db/db.go Normal file
View File

@ -0,0 +1,193 @@
// Package db 提供数据库连接与方言适配。
//
// AgentMail 默认用 SQLite零依赖、单文件配合 go:embed 的前端就是「一个二进制 + 一个 .db」
// 用户显式给出 DATABASE_URL 时切换到外部 PostgreSQL。
//
// 两种方言的差异集中在本包处理repo 层只写一份 SQL
// - 占位符SQLite 也支持 $1/$2无需改写
// - NOW() / gen_random_uuid()SQLite 侧注册同名函数补齐
// - JSONB 包含判断:走 CCHas/CCArg 辅助函数(唯一必须分支的查询)
// - 唯一约束冲突IsUniqueViolation 统一识别
package db
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib" // database/sql 驱动pgx
sqlite "modernc.org/sqlite"
)
type Dialect string
const (
Postgres Dialect = "postgres"
SQLite Dialect = "sqlite"
)
var (
DB *sql.DB
// D 是当前生效的方言repo 层据此选择 SQL 片段
D Dialect
)
func init() {
// SQLite 没有 NOW() 与 gen_random_uuid(),注册同名函数使 repo 层 SQL 与 PG 保持一致。
// 函数名在 SQLite 中大小写不敏感,注册小写即可匹配 SQL 里的 NOW()。
sqlite.MustRegisterDeterministicScalarFunction("gen_random_uuid", 0,
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
return uuid.NewString(), nil
})
// NOW() 必须非确定性:同一语句内多次调用要各自取当前时刻,
// 且格式与 SQLite 的 CURRENT_TIMESTAMP 一致,才能统一扫进 time.Time。
sqlite.MustRegisterScalarFunction("now", 0,
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
return time.Now().UTC().Format("2006-01-02 15:04:05"), nil
})
}
// Connect 依据 DATABASE_URL 建立连接。空值时落到 SQLite。
func Connect(ctx context.Context, dsn string) error {
driverName, connStr, dialect, err := resolve(dsn)
if err != nil {
return err
}
pool, err := sql.Open(driverName, connStr)
if err != nil {
return fmt.Errorf("open %s: %w", dialect, err)
}
switch dialect {
case Postgres:
pool.SetMaxOpenConns(20)
pool.SetMaxIdleConns(4)
pool.SetConnMaxLifetime(30 * time.Minute)
pool.SetConnMaxIdleTime(5 * time.Minute)
case SQLite:
// SQLite 单写者:并发写靠 WAL + busy_timeout 排队,连接数放大只会加剧锁竞争。
pool.SetMaxOpenConns(1)
pool.SetMaxIdleConns(1)
pool.SetConnMaxLifetime(0)
}
if err := pool.PingContext(ctx); err != nil {
pool.Close()
return fmt.Errorf("ping %s: %w", dialect, err)
}
DB = pool
D = dialect
return nil
}
// resolve 把 DATABASE_URL 解析为 (驱动名, 连接串, 方言)。
func resolve(dsn string) (string, string, Dialect, error) {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return "sqlite", sqliteDSN(defaultDBPath()), SQLite, nil
}
switch {
case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"):
return "pgx", dsn, Postgres, nil
case strings.HasPrefix(dsn, "sqlite://"):
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite://")), SQLite, nil
case strings.HasPrefix(dsn, "sqlite:"):
return "sqlite", sqliteDSN(strings.TrimPrefix(dsn, "sqlite:")), SQLite, nil
case strings.HasPrefix(dsn, "file:"):
// 已是 SQLite URI原样透传调用方自带 pragma
return "sqlite", dsn, SQLite, nil
case strings.HasSuffix(dsn, ".db"), strings.HasSuffix(dsn, ".sqlite"), strings.HasSuffix(dsn, ".sqlite3"):
return "sqlite", sqliteDSN(dsn), SQLite, nil
}
return "", "", "", fmt.Errorf("无法识别的 DATABASE_URL %q期望 postgres://…、sqlite:///path/x.db 或 /path/x.db", dsn)
}
// defaultDBPath 返回默认 SQLite 文件位置AGENTMAIL_DATA_DIR 可覆盖)。
func defaultDBPath() string {
dir := os.Getenv("AGENTMAIL_DATA_DIR")
if dir == "" {
dir = "data"
}
return filepath.Join(dir, "agentmail.db")
}
// sqliteDSN 把文件路径包装为带 pragma 的 SQLite URI并确保父目录存在。
//
// - journal_mode=WAL读写不互斥SSE 长连接查询不会被写入阻塞
// - busy_timeout=5000并发写时排队 5s 而不是立刻 SQLITE_BUSY
// - foreign_keys=1SQLite 默认不校验外键,必须显式打开
func sqliteDSN(path string) string {
if dir := filepath.Dir(path); dir != "" && dir != "." {
os.MkdirAll(dir, 0o755)
}
return "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=busy_timeout(5000)" +
"&_pragma=foreign_keys(1)"
}
func Close() {
if DB != nil {
DB.Close()
}
}
// ---------- 方言差异 ----------
// CCHas 返回「cc_list 是否抄送了某人」的 SQL 片段argN 是该人名对应的占位符序号。
//
// 两个方言的实参都是【纯人名字符串】,不是 JSON 探针——因为多处查询把同一个
// 占位符同时用于 from_name/to_name 比较和抄送判断,两种实参约定必然出错。
// PG 侧在 SQL 里用 jsonb_build_* 现场构造探针SQLite 侧用 json_each 展开逐项比对。
func CCHas(col string, argN int) string {
if D == Postgres {
return fmt.Sprintf("%s @> jsonb_build_array(jsonb_build_object('name', $%d::text))", col, argN)
}
return fmt.Sprintf(
"EXISTS (SELECT 1 FROM json_each(%s) WHERE json_extract(value, '$.name') = $%d)",
col, argN)
}
// JSONCast 返回把占位符转成 JSONB 的后缀PG 需要 ::jsonbSQLite 存 TEXT 无需转换)。
func JSONCast() string {
if D == Postgres {
return "::jsonb"
}
return ""
}
// IsUniqueViolation 判断错误是否为唯一约束冲突(用于别名撞名重试)。
func IsUniqueViolation(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
var liteErr *sqlite.Error
if errors.As(err, &liteErr) {
// SQLITE_CONSTRAINT_UNIQUE = 2067、SQLITE_CONSTRAINT_PRIMARYKEY = 1555
code := liteErr.Code()
return code == 2067 || code == 1555
}
return false
}

View File

@ -0,0 +1,118 @@
package db
import (
"context"
_ "embed"
"fmt"
"strings"
)
//go:embed migrations/init.sql
var initSQLPostgres string
//go:embed migrations/init_sqlite.sql
var initSQLSQLite string
// Migrate 建表建索引。两种方言各有一份 schema语义保持一致。
func Migrate(ctx context.Context) error {
switch D {
case Postgres:
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
return fmt.Errorf("migrate postgres: %w", err)
}
case SQLite:
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
for i, stmt := range splitStatements(initSQLSQLite) {
if _, err := DB.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
}
}
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
if err := addMissingColumns(ctx); err != nil {
return err
}
default:
return fmt.Errorf("migrate: 未初始化的方言")
}
fmt.Printf("数据库迁移完成(%s\n", D)
return nil
}
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
func splitStatements(script string) []string {
var out []string
for _, raw := range strings.Split(script, ";") {
var lines []string
for _, line := range strings.Split(raw, "\n") {
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
continue
}
lines = append(lines, line)
}
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
out = append(out, stmt)
}
}
return out
}
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
//
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够SQLite 没有这个语法,
// 只能先查 pragma 再决定加不加。
//
// 新增列时同时改两处init_sqlite.sql 的 CREATE TABLE给新库与这张表给老库
var sqliteAddColumns = []struct{ table, column, ddl string }{
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
// 会话级往返预算0 = 不限)。旧库默认 0引入预算不应该把已在进行的会话卡死。
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
}
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
var sqliteAddIndexes = []string{
// 人类决策后要按 mail_id 反查上游 permission id
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
}
func addMissingColumns(ctx context.Context) error {
for _, c := range sqliteAddColumns {
has, err := columnExists(ctx, c.table, c.column)
if err != nil {
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
}
if has {
continue
}
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
}
fmt.Printf("补列 %s.%s\n", c.table, c.column)
}
for _, ddl := range sqliteAddIndexes {
if _, err := DB.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
}
}
return nil
}
func columnExists(ctx context.Context, table, column string) (bool, error) {
// pragma_table_info 是表函数形式的 PRAGMA可以直接当表查比解析 PRAGMA 输出干净)。
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
var n int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
table, column).Scan(&n)
return n > 0, err
}

View File

@ -0,0 +1,297 @@
-- 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.<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 '[]';
-- 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;

View File

@ -0,0 +1,241 @@
-- AgentMail Schema — SQLite默认后端
--
-- 与 init.sqlPostgreSQL保持同一套表结构与语义差异仅在方言
-- UUID → TEXTGo 侧 uuid 或 gen_random_uuid() 注册函数生成)
-- TIMESTAMPTZ → DATETIME必须写 DATETIMEdatabase/sql 才能扫进 time.Time
-- JSONB → TEXT存 JSON 字符串,用 json_each/json_extract 检索)
-- VARCHAR(n) → TEXTSQLite 不强制长度,长度约束由应用层负责)
-- 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 CURRENT_TIMESTAMP,
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 CURRENT_TIMESTAMP,
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',
max_rounds INTEGER NOT NULL DEFAULT 10,
used_rounds INTEGER NOT NULL DEFAULT 0,
last_seen DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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 CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
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.<alias>),必须全局唯一。
-- 部分唯一索引未命名会话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 CURRENT_TIMESTAMP,
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 '',
result TEXT,
decided_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
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 CURRENT_TIMESTAMP
);
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 CURRENT_TIMESTAMP
);
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 派生),数据库只存元数据。
-- 不塞 BLOBSQLite 的 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 CURRENT_TIMESTAMP,
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 CURRENT_TIMESTAMP
);
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;