Files
MailUI4Agents/gateway/internal/db/migrations/init_sqlite.sql
JianFeeeee 069bf03ae2 feat(calendar): 日历后端 —— 事件/提醒/重复规则 + iCal + 多收件人
三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`from_name = "calendar"` 刻意既非人类名也非 Agent 名 —— 用创建者的名字
会让 Agent 以为人在实时找它,而人此刻可能在睡觉,模型据此判断
「要不要马上追问」,来源写错会让它问一个不在线的人。

日历提醒不扣会话预算:预算的语义是「这件事值得模型自主发多少封信」,
而提醒是人预先设定的定时任务,不是模型的自主行为。

**多收件人两种投递模式,都要**:
- separate(默认)= 各发一封、落各自会话、互相看不到
- together        = 首个为主收件人、其余进 cc_list、共享一条线索

「让三个 Agent 各自独立汇报」与「让 pi 主办、dsh 知情」是完全不同的任务
形态。默认 separate 因为失败模式更轻:together 用错会让本该独立判断的
Agent 互相看到回复而趋同,那种上下文污染事后无法分离。

抄送方也推 SSE。漏了这步的后果很隐蔽:cc_list 里有他们、查收件箱看得见,
但没有任何事件推给他们 —— 插件不会唤起会话,Agent 到下次补拉才发现。

recipients 存**原始地址串**而非结构化:session 位的 new/别名三态该在
触发那一刻解析,存结构化会让「.new」这种一次性语义在建事件时就被固化,
而重复事件每次触发都该重新决定落到哪条会话。

---

修掉的七个真问题:

**1. 默认提醒模板把时间烤成字面值。**
原来 Sprintf 出含字面时间的正文存进 reminder_text。对重复事件是错的:
AdvanceRecurrence 只推进 event_time,模板不动 —— 「每天 9 点」的提醒
从第二天起永远写着第一天的日期,且不报任何错。改为存变量形式。

**2. `{time}` 渲染成 UTC。**
DSN 带 _timezone=UTC,读回的 EventTime 是 UTC。直接 Format 会把人在
+0800 输入的 14:30 写成 06:30,而前端预览用本地时间 —— 两边差 8 小时
且都不报错。

**3. 同一提醒每 tick 重发一次(生产实测 4 封)。**
DueEvents 有 60 秒 lookahead(周期 30 秒,不提前看会迟到)。去重判据
原本是 `last_fired_at < event_time` —— 触发时刻本来就早于落在窗口内的
event_time,条件恒真。实测一条 12:53:17 的事件在 12:52:30 / 12:53:00 /
12:53:06 / 12:53:36 各发一封。新增 fired_for 列记录**已触发的
occurrence**,判据改为 `fired_for <> event_time`。

**4. 过期重复事件刷屏。**
AdvanceRecurrence 只推一步:一条 100 天前设的每日事件每轮都判定过期 →
发一封 → 只前进一天 → 下轮又过期。实测 30 轮触发 30 次,而周期是
30 秒。改成 advanceToFuture 一路推到越过当前时刻;跳过的 occurrence
不补发(三个月前那次站会提醒现在发出去毫无意义,只会淹掉该看的那封)。
带 maxAdvanceSteps=4000 上限:农历路径依赖外部库,没有上限就是个死循环
goroutine,而它跑在调度器里 —— 整个提醒系统会一起卡住。

**5. 越过 recurrence_end 不置 cancelled。**
留在 active 会变僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
但 fired_for 已等于 event_time 所以又不触发。

**6. 附件从未落盘。**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 Read
不保证填满缓冲(大文件必然短读,sha256 算的是半截内容),而且文件内容
压根没写进 blob 存储。结果是附件「上传成功」、清单里看得见、
发提醒时取不到任何字节。改走 Blobs.Put。

**7. iCal TRIGGER 往返是断的。**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。更糟的是
`-P15M` 在任何合规客户端里都是「提前 **15 个月**」—— iCal duration 的 M
在 T 之前是月、之后才是分钟。而且用 maxInt(RemindBefore,15) 兜底,把用户
明确设的「到点提醒」(0) 悄悄改成提前 15 分钟。

---

其他修正:
- **PG schema 整块缺失日历两张表** —— DATABASE_URL 非空时所有 /calendar/*
  在 relation does not exist 上 500,而 SQLite 下一切正常,问题只在切外部库
  时才暴露
- DeleteCalendarAttachment 曾返回 501,让人「删整个事件来清附件」
- 导出忽略 from/to;导入只接受 multipart(命令行调用者收到含糊的
  「Missing file field」)
- 上传附件不校验事件存在 —— 会攒孤儿记录,而 ON DELETE CASCADE 清不掉
  它们(SQLite 的 foreign_keys 默认关)
- 农历规则 RRULE 表达不了,走 X-AGENTMAIL-RECURRENCE 扩展属性 + 公历近似
  兜底。**RRULE 分支不能覆盖已读到的农历值** —— X- 出现在 RRULE 之前时
  无条件赋值会把 lunar_monthly 打回 monthly,往返一圈农历规则悄悄退化
- 抽出 calendarCols 常量:原先四处手抄同一串列名,加一列漏改任何一处
  不会编译报错,只会运行时列错位(ListSessionsFor 上真的发生过)

测试:repo 20+ 例(到期判定/幂等/lookahead 不重发/过期不刷屏/农历推进/
多收件人/兜底链)、handler 20 例 iCal、scheduler 7 例模板渲染。
生产端到端验证并清理了数据。
2026-09-04 06:28:03 +08:00

430 lines
21 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 Schema — SQLite默认后端
--
-- 与 init.sqlPostgreSQL保持同一套表结构与语义差异仅在方言
-- UUID → TEXTGo 侧 uuid 或 gen_random_uuid() 注册函数生成)
-- TIMESTAMPTZ → DATETIME必须写 DATETIMEdatabase/sql 才能扫进 time.Time
-- 默认值不用 CURRENT_TIMESTAMP它只有【秒】精度同一秒内插入的多行
-- 按 created_at 排序结果不确定,
-- 「会话里最早/最后那封邮件」都会取错行
-- (实测同秒插 5 封,排出来的顺序是乱的)。
-- 改用 strftime 的毫秒精度。mails 表另在 repo 层的
-- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要
-- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序
-- 直接决定 UI 上「最新进展」显示哪一封。
-- 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 (strftime('%Y-%m-%d %H:%M:%f','now')),
last_login DATETIME,
-- 权限边界:空数组 = 不限
allowed_agents TEXT NOT NULL DEFAULT '[]',
allowed_paths TEXT NOT NULL DEFAULT '[]',
agent_aliases TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS user_sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
expires_at DATETIME NOT NULL,
user_agent TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
CREATE TABLE IF NOT EXISTS agents (
agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
agent_name TEXT NOT NULL UNIQUE,
secret TEXT NOT NULL,
host_url TEXT NOT NULL DEFAULT '',
workspaces TEXT NOT NULL DEFAULT '[]',
platform TEXT NOT NULL DEFAULT 'pi',
status TEXT NOT NULL DEFAULT 'offline',
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
-- 配额是任务的属性,所以真正的约束在 sessions.max_rounds 上;
-- 这里只提供默认值 —— 不同 Agent 能力不同,默认值分开设才合理。
default_rounds INTEGER NOT NULL DEFAULT 20,
-- max_rounds / used_rounds 是历史遗留的「终身额度」。
-- 终身额度是错的工具:跑满就得管理员手工重置才能再干活,
-- 而 Agent 是长期在线的。现已降级为纯统计used_rounds 只累加、不拦请求),
-- max_rounds 保留列但不再参与判断。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0,
last_seen DATETIME,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
session_alias TEXT,
-- 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')),
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
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 (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 '',
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 派生),数据库只存元数据。
-- 不塞 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 (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 持密钥主动连 GatewayGateway 从不外呼)。让 Gateway 去调平台接口
-- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。
--
-- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间,
-- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处
-- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
agent_name TEXT NOT NULL,
-- 平台侧的会话 idopencode 的 ses_xxx / DSH 的 session id
platform_id TEXT NOT NULL,
-- 平台侧 cwd即三维地址的 path 位
workspace TEXT NOT NULL DEFAULT '',
-- 平台自己的可寻址短名opencode 的 slugDSH 由模型标题派生)
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,
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);