chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
226
server/internal/db/db.go
Normal file
226
server/internal/db/db.go
Normal file
@ -0,0 +1,226 @@
|
||||
// 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 只有秒,
|
||||
// 同一秒内插入的多封邮件排序就不确定 —— 「会话里最早那封」(决定联系人身份)
|
||||
// 与「最后那封」(决定最新进展)都会取错行。实测同秒插 5 封,
|
||||
// 按 created_at 排出来的顺序是乱的(由随机 UUID 决定)。
|
||||
//
|
||||
// 毫秒还不够:一次插入只要几十到几百微秒,循环里连插几封会落在同一毫秒。
|
||||
// 微秒是实测确认驱动能原样扫回 time.Time 的精度(纳秒也行,但没必要)。
|
||||
//
|
||||
// 格式仍是 SQLite 认得的 "YYYY-MM-DD HH:MM:SS.ffffff",因此:
|
||||
// - 驱动能扫进 time.Time(列声明为 DATETIME 时)
|
||||
// - 与老数据(秒精度)的文本比较依然正确:前缀相同时短的排前面,
|
||||
// 而 ":31" 确实早于 ":31.767000"
|
||||
sqlite.MustRegisterScalarFunction("now", 0,
|
||||
func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return time.Now().UTC().Format("2006-01-02 15:04:05.000000"), 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=1:SQLite 默认不校验外键,必须显式打开
|
||||
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)" +
|
||||
// _time_format / _timezone 决定 time.Time 参数怎么落成文本。
|
||||
//
|
||||
// 驱动的**默认行为是 Go 的 t.String()**,写出来是
|
||||
// 2026-09-10 15:10:36.122781994 +0800 HKT m=+607182.882153215
|
||||
// SQLite 的 datetime() 解析不了这种串(返回 NULL),而它跟我们注册的
|
||||
// NOW()("2006-01-02 15:04:05.000000" UTC)做的是**字符串**比较。
|
||||
// 后果实测有两条,都不是「显示不好看」级别的:
|
||||
// 1. 安全:`expires_at > NOW()` 比较两种格式且时区不同(+0800 vs UTC),
|
||||
// 用户会话永不过期,`DELETE ... WHERE expires_at < NOW()` 删 0 行。
|
||||
// 2. 功能:日历 `datetime(event_time,'-N minutes') <= NOW()` 恒为假,
|
||||
// 一条提醒都发不出去。
|
||||
//
|
||||
// _time_format=sqlite 给 "2006-01-02 15:04:05.999999999-07:00"(驱动
|
||||
// parseTimeFormats[0],读回时原样认得);_timezone=UTC 让偏移固定为
|
||||
// +00:00,与 NOW() 同一时间轴。
|
||||
//
|
||||
// 不用 _time_format=datetime("2006-01-02 15:04:05"):它把亚秒截断,
|
||||
// 会重新引入同秒插入多封邮件排序不确定的老问题 —— 「会话里最早那封」
|
||||
// (决定联系人身份)与「最后那封」(决定最新进展)都会取错行。
|
||||
"&_time_format=sqlite" +
|
||||
"&_timezone=UTC"
|
||||
}
|
||||
|
||||
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 需要 ::jsonb,SQLite 存 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
|
||||
}
|
||||
169
server/internal/db/migrate.go
Normal file
169
server/internal/db/migrate.go
Normal file
@ -0,0 +1,169 @@
|
||||
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"},
|
||||
// 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推
|
||||
// (Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会
|
||||
// 造出一批看起来有值实际是错的数据。
|
||||
{"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"},
|
||||
// 日历多收件人。旧库默认 '[]':读的时候由 EffectiveRecipients() 退回
|
||||
// to_address / agent_name,历史事件因此继续工作,不需要数据迁移。
|
||||
{"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"},
|
||||
{"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"},
|
||||
// 日历事件已触发的 occurrence。旧库为 NULL:等价于「从未触发」,
|
||||
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
|
||||
// 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。
|
||||
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
|
||||
// 日历事件的权限档位(plan / workspace / full)。事件触发时若新建会话,
|
||||
// 用这一列定死档位;复用已有会话则取「会话现档 与 事件档」中更严那个。
|
||||
//
|
||||
// 旧库默认 'workspace':历史事件补发提醒不该静默升到 full(提权路径
|
||||
// 会被 P1 calendar 投递接线堵住,但这里默认值也得守住)。
|
||||
// 与 sessions.permission_mode 的默认取向一致。
|
||||
{"calendar_events", "permission_mode", "ALTER TABLE calendar_events ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
|
||||
// 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」,
|
||||
// 与新建会话的语义一致,不需要数据迁移。
|
||||
{"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"},
|
||||
// 派给该 Agent 的新任务默认多少个来回。
|
||||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||||
// 不能直接搬过来当单任务预算。
|
||||
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
|
||||
// 会话级权限档位(plan / workspace / full)。
|
||||
//
|
||||
// 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||||
// 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||||
// 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||||
{"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
|
||||
// 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。
|
||||
//
|
||||
// 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||||
// 保守方向是承认做不到,而不是假装做到了。
|
||||
{"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||||
// Agent 自报的档位强制能力(native / advisory),随心跳更新。
|
||||
// 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是
|
||||
// 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
|
||||
{"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||||
// 待决请求类型(permission/question)与多选语义,供 ask_user_question 桥接使用。
|
||||
// 旧库默认 'permission'/0:历史请求是危险工具审批,语义不变。
|
||||
{"permission_requests", "kind", "ALTER TABLE permission_requests ADD COLUMN kind TEXT NOT NULL DEFAULT 'permission'"},
|
||||
{"permission_requests", "multi_select", "ALTER TABLE permission_requests ADD COLUMN multi_select INTEGER NOT NULL DEFAULT 0"},
|
||||
// 邮件上的请求类型与多选标记(与 permission_requests 表一致)。
|
||||
// 旧库默认 ''/0:历史权限邮件按单选审批渲染。
|
||||
{"mails", "permission_kind", "ALTER TABLE mails ADD COLUMN permission_kind TEXT NOT NULL DEFAULT ''"},
|
||||
{"mails", "permission_multi_select", "ALTER TABLE mails ADD COLUMN permission_multi_select INTEGER NOT NULL DEFAULT 0"},
|
||||
}
|
||||
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||||
var sqliteAddIndexes = []string{
|
||||
// 接管平台会话时按 platform_id 反查(依赖上面补的列)
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''",
|
||||
// 人类决策后要按 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
|
||||
}
|
||||
440
server/internal/db/migrations/init.sql
Normal file
440
server/internal/db/migrations/init.sql
Normal file
@ -0,0 +1,440 @@
|
||||
-- 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 '',
|
||||
-- permission = 危险操作审批;question = Agent 主动补充询问。
|
||||
kind VARCHAR(16) NOT NULL DEFAULT 'permission',
|
||||
-- ask_user_question 的多选语义;普通权限审批恒为 false。
|
||||
multi_select BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
result TEXT,
|
||||
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;
|
||||
|
||||
-- 已部署库补齐主动询问元数据,并解除旧 result VARCHAR(32) 对长文本回答的限制。
|
||||
ALTER TABLE permission_requests ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'permission';
|
||||
ALTER TABLE permission_requests ADD COLUMN IF NOT EXISTS multi_select BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE permission_requests ALTER COLUMN result TYPE TEXT;
|
||||
|
||||
-- 历史邮件里的字面量 '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,
|
||||
-- 权限档位(plan / workspace / full)。见 init_sqlite.sql 的说明。
|
||||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||||
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 <> '';
|
||||
479
server/internal/db/migrations/init_sqlite.sql
Normal file
479
server/internal/db/migrations/init_sqlite.sql
Normal file
@ -0,0 +1,479 @@
|
||||
-- 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,
|
||||
|
||||
-- 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);
|
||||
|
||||
-- 会话别名负责寻址(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,
|
||||
-- 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);
|
||||
|
||||
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。
|
||||
Reference in New Issue
Block a user