生命周期事件系统(Lifecycle Events)¶
状态:已落地(批次 1–4 + 生命期作用域)。目标:把散落在启动链、导入链、打开链里的硬编码副作用,统一为"语义事件 + 注册式 handler",并统一全项目事件命名规范。
背景(改造前的状态)¶
生命周期点在代码里已经存在,但全是硬编码顺序调用:
| 生命周期点 | 现状位置 | 问题 |
|---|---|---|
| 应用启动 | src/main.tsx boot() + src/hooks/use-app-bootstrap.ts |
串行硬编码,加新任务需改源头 |
| 导入论文后处理 | src/lib/paper/import-actions.ts(refreshTree → rebuildWiki → refreshLibrary → track → openPaper → enqueue jobs) |
最长的隐式生命周期链 |
| 打开论文 | src/lib/workspace/actions.ts openPaper/openTab |
无挂载点 |
| 事件机制 | 60+ 处 Tauri emit,前端 listen 分散在各 hooks | 只有单向"技术事件",缺语义完成事件(如 paper:imported) |
命名规范¶
- 格式:
domain:event-name,全 kebab-case,单冒号分隔;域名用单个单词(job:,不用job-center:)。(例外:进度事件agent-lifecycle:progress、layout-remote:progress属既有豁免,多词域名保留不改。) - 按语义分三类:
- 事实事件(生命周期核心,动词过去式):
paper:imported、vault:opened—— "某事已发生",唯一的 hook 挂载点 - 进度流:
xxx:progress—— 仅 UI 反馈,不是 hook 点 - 请求/指令:
xxx:request—— 需要响应方,不属于生命周期 - payload:统一 envelope
{ vaultId, timestamp, ...data };paper 相关必带paperId。 - Tauri wire 事件名 = lifecycle 事件名,不做两套映射。
- 前端 API:
lifecycle.on('paper:imported', handler);事件类型集中定义于LifecycleEventMap(单一事实来源)。 - 事件分两类(
events.ts在类型层面区分): - Scoped(有生命期):目前只有
vault:opened。handler 可返回 teardown;用lifecycle.emitScoped()开启作用域,它同步返回 release 函数,直接交给 React effect 的 cleanup。 - Fact(一次性事实):其余全部。用
lifecycle.emit();返回值被忽略(TS 对void返回类型的赋值规则宽松,编译器拦不住,靠约定与 bus 行为保证)。
历史违规与清理¶
- ✅
settings_window_closed/feature_window_closed(snake_case 无前缀)→window:closed,payload{ kind: "settings" | "feature", view? } - ✅ 菜单裸 id 事件(
open_vault、toggle_sidebar等)→menu:invoked,payload{ action } job:changed保留为内部状态机事件,对外暴露派生的job:completed/job:failed
事件清单¶
⭐ = 有明确消费者(现有硬编码链可迁移);○ = 预留/预埋,有需求再加。"预埋:emit 已就位、暂无消费者(无回放,晚挂载者会错过)" = 发射侧先落地,消费侧等第一个真实需求。
app / window¶
| 事件 | 时机 |
|---|---|
○ app:ready |
前端 bootstrap 完成(store 已在 boot() 种子化、restored vault 校验 settle 后)。预埋:emit 已就位、暂无消费者(无回放,晚挂载者会错过) |
⏸ app:will-quit |
延后。从 RunEvent::Exit 发前端事件修不了真实需求(退出前 flush debounce 写盘):那时 webview 已销毁,写盘是异步 invoke。正确做法是 WindowEvent::CloseRequested → api.prevent_close() → emit → 等前端 ack → window.destroy(),属独立特性。Rust 侧 teardown 已直接放在 run closure 里 |
⭐ window:closed |
子窗口销毁(Host on_window_event(Destroyed))。payload { kind: "settings" \| "feature", view? }。消费者:settings 回写 ui store、feature popout 清 featurePoppedOut(register.ts) |
vault¶
| 事件 | 时机 |
|---|---|
⭐ vault:opened |
scoped。打开/切换 vault(refreshTree、refreshLibrary、seedVaultSkills、job/ingest reconcile、arXiv 预热挂载点);setup 里 vault_hold 注册本窗口对该 vault 的持有,teardown 负责各 store 的 vault 级清理(library / wiki / agent / annotations / layout / ui)与 vault_release(remote vault 跳过)。Host 侧释放是 vault 全局语义,按窗口持有计数:仅最后一个持有该 vault 的窗口触发(详见下文「生命期作用域」) |
○ vault:created |
新建 vault |
✅ ~~vault:closed~~ |
不再需要,由 vault:opened 的作用域 teardown 承担 |
保留 vault:file-changed |
watcher 文件变更(已符合规范) |
paper(论文对象)¶
| 事件 | 时机 |
|---|---|
⭐ paper:imported |
paper_commit 成功(catalog 已写入、NOTES 已建)。四条导入路径(魔棒 / 本地 PDF / Zotero / Connector)统一发;DownloadAssets 后台任务为孤儿文件夹补建 catalog 行时也发。消费者:register.ts(树/库/双链刷新、tab 资源重解析) |
○ paper:assets-ready |
PDF 下载 / LaTeX 解压 / PAPER.md 生成完成(异步,与 imported 分离)。预埋:emit 已就位、暂无消费者(无回放,晚挂载者会错过) |
⭐ paper:renamed |
后台元数据识别落地:占位目录改名为规范 id(outcome=renamed,含 wiki 链接重写清单)或并入已有条目(outcome=merged)。前端 handler 抑制 watcher 外部 rename 修复、remap 打开 tab、定向刷新树/库 |
○ paper:deleted / paper:moved / paper:tags-changed / paper:metadata-updated |
对象变更 |
reader(阅读会话,前端本地事件)¶
| 事件 | 时机 |
|---|---|
○ paper:opened |
openPaper/openTab 完成(layout enqueue、activity 打点挂载点)。预埋:emit 已就位、暂无消费者(无回放,晚挂载者会错过) |
○ paper:closed |
关 tab |
○ mark:created / mark:deleted |
标注增删 |
○ translation:completed |
翻译完成 |
note / wiki¶
| 事件 | 时机 |
|---|---|
○ note:saved |
NOTES.md 持久化 |
○ wiki:rebuilt |
双链索引重建完成 |
job(横切汇聚点)¶
| 事件 | 时机 |
|---|---|
○ job:completed / job:failed |
由 job:changed 状态机单点派生(emit_job_terminal),payload 为共享的终端字段(jobId / kind / vaultId / paperId / timestamp / error,后两者缺省省略;vaultId = JobSnapshot.vault_id,发起方传入的 vault 路径串,拼写随来源而异 —— 见下节已知限制;供消费者按当前 vault 过滤),带生成的 JobKind 联合(16 种,见 src/lib/core/bindings.ts)。预埋:emit 已就位、暂无消费者(无回放,晚挂载者会错过) |
已知限制(vaultId 的拼写)¶
fact 事件的 vaultId 是发起方传入的 vault 路径串,拼写随来源而异:
- 前端直连发起的流程(以
getVaultPath()调用命令,如下载 / 补资源 / reconcile):与前端getVaultPath()逐字一致,可直接比较。 - 经 job-executor 中继或 Host 内部发起的流程:传入/持有的是 Host 形式(通常 canonical)—— Windows 上带
\\?\前缀,软链接根路径同理,与前端拾取串不逐字相等;个别后台流程沿用触发方原串。例:tasks.ts的 offer 回传(lookup 导入及其补发的 layout / DownloadAssets 子 job)、后台识别落地的paper:renamed。
消费端按当前 vault 过滤时需容忍上述差异(比较前两侧都做 best-effort 归一,如 canonicalize_best_effort 语义)。系统性统一(激活时 canonicalize vault 身份并全链路使用同一形式)留作后续工作。
已符合规范、直接纳入¶
settings:changed、connector:item-saved、agent:completed、agent:failed、agent:registry-changed(Host 在 probe / 安装 / 增删 / 改默认后广播,Agent 面板防抖刷新切换器)、sync:state(后续可补 sync:completed)、mcp:status / mcp:tunnel-status。
架构设计¶
Rust 关键节点 ──emit──▶ Tauri wire 事件 ──▶ tauri-bridge.ts(listen/forward)─┐
├──▶ typed bus ──▶ 注册 handler
前端本地动作(bootstrap / openTab)──lifecycle.emit / emitScoped──────────────┘
src/lib/lifecycle/:events.ts:LifecycleEventMap类型定义(事件名 → payload 类型),以及ScopedLifecycleEvent/FactLifecycleEvent的划分。被桥接事件的字段类型与 wire 对齐(可空字段、JobKind等),由tauri-bridge.ts的forward在编译期校验bus.ts:极简 typed emitter(on/emit/emitScoped,无第三方依赖)tauri-bridge.ts:集中listen()wire 事件并转发进 bus;在 bootstrap 初始化一次register.ts:跨模块 handler 的集中注册处;组件本地的消费在自己的 hook 里lifecycle.on(如 Agent 面板的agent:registry-changed)- Rust 端不做 observer 抽象:仅在关键节点(
paper_commit后、jobs 状态机单点、assets 完成后)直接 emit 语义事件;当前只有前端一个消费者,不过度设计。 - 顺序约束显式化:handler 按注册顺序串行 await,不引入优先级系统,靠注册文件内的显式顺序表达。(注:openPaper 直接用导入结果里的绝对路径打开,不依赖 refreshLibrary 完成。)
生命期作用域(scoped events)¶
vault:opened 的 handler 可以返回 teardown,"打开时做什么"和"关闭时撤销什么"因此写在同一个闭包里 —— 这正是 useEffect 的形状,也是此前 vault:closed 长期停在"预留"的根因:接口里没有地方写它。
关键决策:作用域由 emitter 持有,不由 bus 从"重复 emit"推断。
emitScoped() 同步返回 release 函数,use-app-bootstrap.ts 把它直接当作 effect cleanup 返回。于是 vault→vault 切换、vault→null 关闭、组件卸载三种情况都由 React 自身的语义免费覆盖。若改成"再次 emit 即销毁上一次",有三个必然缺口:
vaultPath为 null 的分支不 emit,切到"无 vault"时 teardown 永不执行validateRestoredVault校验失败时直接setVaultPath(null),同上- StrictMode 会用相同 payload 触发两次 effect,变成 setup→teardown→setup,而那次 teardown 打在当前活跃的 vault 上(会掐断正在用的 remote 会话)
其余语义:
- teardown 按注册逆序执行(栈式,与 React 一致)
- scoped 事件的 setup/teardown 按事件串行化:setup 是异步的,快速连切两次 vault 否则会让旧作用域的 teardown 与新作用域的 setup 交错
- 作用域在 setup 排到队之前就被 release(StrictMode 挂载即卸载)时直接跳过 setup;setup 循环中途 release 同理:已完成且返回 teardown 的立即撤销,尚未开始的 handler 直接跳过——而不是做完一轮再撤销
- 注册项以记录对象为身份,同一个 handler 注册两次可独立注销
- handler 与 teardown 抛错只记日志,不中断其余项
窗口级持有计数(vault_hold / vault_release)¶
Vault 在前端按窗口 session 隔离(两窗口可同时打开同一 vault),但 vault_release 的释放序列是 vault 全局语义(取消该 vault 所有在途 job、清 caps cache、驱逐 catalog 连接)。因此 Host 维护 VaultHolders(vault → 窗口 label 集合):
vault:openedsetup 调vault_hold(path)注册持有(窗口 label 由 Tauri 注入,前端签名只有path);teardown 调vault_release(path)解除。vault_release仅当该 vault 持有集变空时才执行全局释放序列;仍有其他窗口持有则不做任何全局释放(如窗口 A 切库不再取消窗口 B 该 vault 的在途 job)。- 无持有记录时仍执行释放:刻意保留的单窗口兼容回退(漏注册不回归旧行为)。hold 调用失败被吞时(前端 fire & forget,仅记日志)同样落在这条路径:
vault_release走「无 entry 回退」照常执行全局释放。 - 窗口销毁兜底:Host
on_window_event(Destroyed)清理该 label 全部持有,变为空的 vault 用tauri::async_runtime::spawn异步执行释放(不阻塞事件循环)。 - 同一窗口重复激活(StrictMode remount)因 Set 语义幂等;hold / release 的先后由前端单线程发送决定(命令按运行时调度,本身无 FIFO 保证),理论乱序留下的陈旧持有条目由窗口 Destroyed drain 兜底。
- 路径身份按
canonicalize_best_effort归一(与 job 去重 key / catalog 连接缓存同一身份;目录已删时回退原串,查不到 entry 则走上面的回退释放),Windows 上D:\v与\\?\D:\v归一到同一键;CapsCache::clear()保持全局语义,只在完整释放时执行(有意减少全局清频率,不做按 vault 清)。
订阅 Tauri 事件的约定¶
一律用类型化事件绑定(src/lib/core/bindings.ts 的 events.*):组件内 useTauriEvent(events.x, cb),非 UI 模块 listenEventSafe(events.x, cb)(src/lib/core/tauri-events.ts),不要手写 let off; void (async () => { off = await listen(...) })(); return () => off?.():listen() 需要一次 Host 往返,在它 resolve 前 dispose 会让 off 仍是 undefined,清理成为空操作、监听器永久泄漏 —— StrictMode 每次开发挂载都会命中。字符串事件名订阅仅限前端窗口间广播(workspace:* 等)与 iOS bridge client 事件;非 Tauri wire 的 promise 式订阅(bridge、workspace-broadcast)用 toSafeDisposer()。
落地批次¶
- ✅ 前端 lifecycle 模块:types + bus + bridge,bootstrap 接入
- ✅ Rust 语义事件:
paper:imported、paper:assets-ready、job:completed/failed派生 - ✅ 迁移硬编码链:
vault:opened(bootstrap)、paper:imported后处理(import-actions)、paper:opened(openTab) - ✅ 命名清理:
window:closed、menu:invoked - ✅ 生命期作用域与 teardown:
- bus 支持返回 cleanup 的 scoped handler +
emitScoped listenSafe/useTauriEvent,替掉全部 20 处 leaky 订阅- bootstrap:store 种子移入
boot()(不再在 render 期做副作用)、JobCenter 订阅补 disposer、app:ready等校验 settle、window:closed收进 bus - 各 store 的 vault 级 clear +
vault_release(驱逐 Host catalog 连接;窗口级持有计数vault_hold,多窗口同 vault 时仅最后持有者触发全局释放) - 退出时
BridgeController::stop() - ACP 退出清理已接入
ExitRequested/Exit:同步关闭 warm 池;warm setup 超时/替换 通过取消 token 回收。Windows 已回退 SDK 启动,完整进程树与崩溃清理仍为 已知限制 #774,等待评估 SDK 能力。 实现与边界见../backend/agent.md,无需新增前端退出事件。 - 后续(本稿不含):hook 表用户可配置(settings /
.agentero/),动作接 JobCenter 执行
非目标¶
- 不做 Rust 内部 plugin/observer trait
- 不做用户可配置 hook(等事件层稳定后另立设计)
- 不改
xxx:progress/xxx:request类事件的既有语义 - 不为"零消费者"而新增事件。
paper:moved/paper:deleted曾被考虑用来归一各处refreshTree → refreshLibrary → rebuildWiki尾巴,实测那些差异是有意的:调过syncMovedPaths的路径(move / rename / 外部重命名修复)已增量更新链接并 bump 了 revision,不需要全量重建;未调的路径(trash / restore / paste / drop)才需要。归一反而会加上多余的重建。 - 退出前 flush debounce 写盘不走事件层,见上文
app:will-quit