# 3-24 Local-folder browser event bus v1

> 创建时间：2026-06-07
>
> 状态：`done`
>
> Owner：03-rust-web / 05-editor-mainline / 07-ai
>
> 来源：`design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` P2
>
> 上位依据：
> - `design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
> - `design/03-rust-web/process/3-23-sidebar-local-folder-resource-runtime-followup-v1.md`

## 1. 第一结论

当前 local-folder 浏览器侧有多条独立刷新链：

- `tree-live-controller.js` 对 local-folder 使用 `/api/local-folder/events` EventSource，并派发 `tree:local-folder-watch-batch`。
- `sidebar-tree-live-apply-runtime.js` 订阅 `tree:local-folder-watch-batch` 后刷新 filetree parent，必要时再刷新 sidebar projection。
- `document-session-runtime.js` 也直接打开 `/api/local-folder/events`，用于当前文档内容刷新。
- `document-resource-tab-runtime.js` 对资源 tab 直接打开 `/api/local-folder/events`，并在部分保存后合成 `tree:local-folder-watch-batch`。
- `sidebar-page-ai-runtime.js` 从 agent receipt 合成 `tree:local-folder-watch-batch` 与 `mnote:page-ai-tool-write-completed`。

这会造成同一 `workspaceId/rootUri` 页面内多个 EventSource、receipt + watcher echo 双刷新、sidebar projection 和 filetree parent 重复请求。P2 不改 Rust watcher 协议，先在浏览器侧增加单例 event bus，把连接、事件归一、订阅和刷新编排收口。

## 2. 非目标

- 不改变 `/api/local-folder/events`、`/api/realtime/ws`、`/api/tree/events` 的服务端协议。
- 不在本稿恢复轮询 fallback。
- 不把 event bus 变成新的树/文件/文档事实源；它只转发和归一事件。
- 不一次性重写 sidebar、document session、resource tab、Page AI runtime；按订阅方逐步切换。
- 不改变 Page AI agent 文件编辑的 audit / receipt 语义。

## 3. 输入源

统一入口：新增 `rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js`，挂到 `window.__mnoteLocalFolderEventBus`。

输入源：

- `watcher_sse`：`/api/local-folder/events?rootUri=...&treeLive=true`，当前 local-folder 主输入。
- `tree_ws`：`/api/realtime/ws`，只在非 local-folder tree live 或未来 local-folder WS 化时接入。
- `tree_sse`：`/api/tree/events`，作为 tree projection fallback，不直接替代 local-folder watcher。
- `synthetic_page_ai_receipt`：Page AI receipt 合成事件。
- `synthetic_resource_write`：resource tab 保存后合成事件。
- `explicit_resync`：命令结果或 runtime 主动要求重新取 sidebar/filetree/document。

约束：

- 同一 `workspaceId/rootUri` 页面内只允许一个 local-folder watcher 连接。
- bus 连接 key 以页面内 `rootUri` 为准；`workspaceId` 只作为事件元信息。原因是 tree live bootstrap、document session、resource tab 在 local-folder 页面可能分别拿到空 workspaceId / local-ws workspaceId，若把 workspaceId 放入连接 key 会在同一 rootUri 内重复建 watcher。
- 不同 rootUri 可以有不同 bus instance；默认页面只注册当前 rootUri。
- bus 输出事件必须带 `source`、`reason`、`rootUri`、`workspaceId`、`revision`、`changedPaths`、`affectedParents`。

## 4. 输出事件

bus 派发浏览器事件：

- `mnote:local-folder:event-bus-ready`
- `mnote:local-folder:watch-batch`
- `mnote:local-folder:document-changed`
- `mnote:local-folder:resource-changed`
- `mnote:local-folder:filetree-parent-changed`
- `mnote:local-folder:knowledge-rag-source-updated`
- `mnote:local-folder:resync-required`

兼容桥：

- 短期继续派发旧 `tree:local-folder-watch-batch`，但 detail 增加 `viaEventBus=true`。
- 短期继续派发旧 `mnote:page-ai-tool-write-completed`，document session 可逐步改订阅新事件。

## 5. 订阅方职责

- Sidebar / FileTree：
  - 订阅 `filetree-parent-changed` 刷新局部 parent。
  - 订阅 `resync-required` 刷新 sidebar projection。
  - 不直接拥有 watcher 连接。
- Document session：
  - 订阅 `document-changed`，仅当当前文档路径命中才刷新当前 buffer。
  - 不因任意 watch batch 刷新当前文档。
- Resource tab：
  - 订阅 `resource-changed`，仅当当前资源路径命中才刷新 stat/read。
  - resource save 完成后只向 bus 发 synthetic event，不直接广播全局 watch batch。
- Page AI：
  - receipt 只向 bus 发 `synthetic_page_ai_receipt`。
  - 由 bus 去重 watcher echo 与 receipt refresh，避免重复 sidebar projection。
- Knowledge RAG UI：
  - 订阅 `knowledge-rag-source-updated`，只刷新资料库来源状态，不触发文档正文刷新。

## 6. 实施 Checklist

Phase A：只加 bus，不切换行为。

- [x] 新增 `local-folder-event-bus-runtime.js`。
- [x] 在 SSR layout 中加载 bus runtime，保证早于 tree live controller；document session / resource tab 后续切订阅时继续复用该入口。
- [x] bus 可以接管 `tree-live-controller.js` local-folder EventSource 创建；同一 rootUri 二次 start 返回同一连接。
- [x] bus 继续兼容派发 `tree:local-folder-watch-batch`，并标记 `viaEventBus=true`。
- [x] DOM diagnostics：
  - `data-mnote-local-folder-event-bus="ready"`
  - `data-mnote-local-folder-event-bus-connections`
  - `data-mnote-local-folder-event-bus-last-source`
  - `data-mnote-local-folder-event-bus-last-reason`

Phase A 结果：

- 新增 `/api/mnote-browser-runtime/local-folder-event-bus-runtime.js` runtime asset。
- `tree-live-controller.js` 的 local-folder 分支优先调用 `window.__mnoteLocalFolderEventBus.startLocalFolderWatcher(...)`；bus 不可用时保留原 `/api/local-folder/events` 直连 fallback。
- bus 连接 key 已按 `rootUri` 收口，避免 tree live / resource tab 因 workspaceId 解析差异重复建连接。
- 当前只收敛 tree live controller 的 local-folder watcher；`document-session-runtime.js` 与 `document-resource-tab-runtime.js` 仍在 Phase C 切换，P2 总体验收尚未完成。

Phase B：刷新编排。

- [x] `refreshLocalFolderSidebarSnapshot` 外包到 bus orchestrator：同一 tick 合并 reason 和 parent 列表。
- [x] bus 维护 `pendingFileTreeParents`，同一 parent 在同一 tick 只刷新一次。
- [x] fallback / resync 仍允许刷新 sidebar projection，但必须记录 reason。
- [x] `sidebar-tree-live-apply-runtime.js` 由直接处理 watch batch 改为订阅 bus 输出。

Phase B 结果：

- bus 新增 `mnote:local-folder:sidebar-refresh-requested`，同一 tick 合并 `changedPaths`、`affectedParents`、`reasons` 和 `resyncRequired`。
- `sidebar-tree-live-apply-runtime.js` 订阅 `mnote:local-folder:sidebar-refresh-requested` 后复用 `applyLocalFolderWatchBatch(...)`，并跳过 `viaEventBus=true` 的旧 `tree:local-folder-watch-batch` 兼容事件，避免同一 bus 事件被 sidebar 处理两次。
- `task540` 会连续发两次同 tick `synthetic_page_ai_receipt`，验证最终只产生一次 filetree parent projection 请求，且 content-only receipt 不触发 sidebar projection。

Phase C：订阅方切换。

- [x] `document-session-runtime.js` 不再直接打开 `/api/local-folder/events`；改订阅 `document-changed`。
  - 正常 bus 可用路径已改订阅 `mnote:local-folder:document-changed` 并复用 `startLocalFolderWatcher(...)`；保留 bus 不可用时旧 EventSource fallback，作为退路而非主路径。
- [x] `document-resource-tab-runtime.js` 不再为每个 resource tab 直接打开 EventSource；改订阅 `resource-changed`。
  - passive resource tab 已在 bus 可用时订阅 `mnote:local-folder:resource-changed`，并以 `data-mnote-resource-watch-ready="event-bus"` 暴露诊断；保留 bus 不可用时旧 EventSource fallback。
- [x] resource write 完成后向 bus 发 `synthetic_resource_write`。
- [x] Page AI receipt 向 bus 发 `synthetic_page_ai_receipt`。

Phase D：smoke。

- [x] 新增 `task540-local-folder-event-bus-single-connection-smoke.js`。
- [x] 同一页面打开 sidebar + document + resource tab + Page AI 后，只存在一个 local-folder watcher EventSource。
  - Phase D-1：`task540` 当前覆盖 sidebar/tree live + document session 同页只创建一个 `/api/local-folder/events` EventSource，并验证 synthetic event 兼容派发带 `viaEventBus=true`；resource tab + Page AI 全组合请求计数仍待后续扩展。
  - Phase D-2：`task540` 已扩展到 passive resource tab，并验证 resource tab watch ready 走 `event-bus`、`synthetic_page_ai_receipt` 事件经 bus 派发、同页仍只有一个 `/api/local-folder/events` EventSource。尚未覆盖真实 Page AI 面板点击到 receipt 的完整 UI 链路。
- [x] 外部修改当前 `.md` 后，tiptap 可见刷新。
- [x] 外部新增 / 删除文件后，filetree 只刷新受影响 parent。
- [x] Page AI receipt 后，sidebar projection 不重复请求。
- [x] 复跑 `task446`、`task447`、`task448` 或相邻 tree realtime smoke。
  - `task446` / `task447` / `task448` 当前仍依赖无 workspace 的 `/api/tree/commands` cloud/Convex 旧入口，运行结果为 `convex_retired`，不适合作为 local-folder event bus 验收证据。
  - 已改用相邻 local-folder runtime smoke：`task435`、`task436`、`task535`、`task540`。

Phase C-1 / D-1 结果：

- `document-session-runtime.js` 在 event bus 可用时不再新建按 documentId/resourcePath 分裂的 EventSource，改为共享 root 级 bus 并按 documentId / relativePath 过滤。
- passive `document-resource-tab-runtime.js` 在 event bus 可用时改订阅 `resource-changed`，旧 per-resource EventSource 仅作为 fallback。
- `document-resource-tab-runtime.js` 的 local OCR sidecar refresh 改为优先 `synthetic_resource_write` 发给 bus，旧 `tree:local-folder-watch-batch` 只作为 bus 不可用 fallback。
- `sidebar-page-ai-runtime.js` 的 agent receipt refresh 改为优先 `emitSyntheticWatchBatch(...)`，兼容事件由 bus 统一派发。
- 扩展 `task540` 后发现同 rootUri 下 tree live 与 resource tab 的 workspaceId 解析差异会导致重复连接；已把 bus 连接 key 收口为 `rootUri`，`workspaceId` 只作为事件元信息。
- `task540` 在 3300 最新 mnote-web 通过，验证当前文档页 + passive resource tab 只创建一个 local-folder watcher EventSource、bus diagnostics ready、`synthetic_page_ai_receipt` source/reason 保留、兼容事件带 `viaEventBus=true`，且 resource-changed 触发当前资源 stat 刷新。
- `task535` 在 3300 最新 mnote-web 通过，验证真实 Page AI clean agent receipt 后当前文档和 filetree 均刷新，且不调用旧保存/写入工具。
- `task436` 在 3300 最新 mnote-web 通过，验证当前打开 local `.md` 外部修改后 tiptap 可见刷新，并覆盖 dirty 冲突保护。
- `task435` 在 3300 最新 mnote-web 通过，验证外部创建/重命名/删除 Markdown 与非 Markdown 资源后 page tree / filetree 原地更新且无 reload。

## 7. 验收与归档条件

- 页面内同一 `workspaceId/rootUri` 只有一个 local-folder watcher 连接。
- sidebar、document session、resource tab、Page AI receipt 都通过 event bus 收敛。
- `tree:local-folder-watch-batch` 兼容事件仍可用，但 detail 标明 `viaEventBus=true`。
- P2 smoke 全部通过，并记录请求计数证据。
- 本稿完成后移动到 `design/03-rust-web/done/`。
