Files
mnote/design/03-rust-web/done/3-26-local-folder-filechange-service-v1.md
T

322 lines
19 KiB
Markdown
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.
# 3-26 Local Folder FileChangeService 统一文件变化事件层 v1
> 创建时间:2026-06-29
> 状态:`done`
> Owner`03-rust-web` 主控,协同 `05-editor-mainline`、`07-ai`
> 来源:当前 Page AI / OpenHub / opencode 写入本地 `.md` 后,左侧当前文档页不会自动更新,整页刷新后才读取到新内容。
> 参考:`reference-code/sidex-main` 的 watcher / `EventThrottler` / reaction 模型;`reference-code/vscode` 的 `FileService.onDidFilesChange`、Explorer 延迟刷新、TextFile editor reload/focus 补偿模型。
## 1. 结论
不要新增 OpenHub / opencode 专属“写完刷新当前页”链路,也不要完整重建 VSCode FileService。
本轮应把 MNote 现有 local-folder watcher / browser event bus 收口成一个轻量 `FileChangeService`
- Rust watcher、OpenHub/opencode changedFiles、legacy Page AI tool receipt、MNote 自身保存回声都只能作为统一文件变化事件的输入源。
- 当前文档、filetree、page tree、knowledge source status 只消费统一 FileChange reaction,不直接解析 provider 事件。
- 当前打开文档只有在非 dirty / 非 saving / 非 recent input 时自动拉取最新 Page Aggregate;dirty 时进入外部冲突状态,不覆盖编辑器内容。
- filetree/page tree 继续走现有 projection / watch_batch / sidebar refresh 链路,但由统一事件层负责去抖、合并、self-write 抑制和最小刷新范围。
## 2. 背景与现状
当前已经存在文件变化监控链路,不应另起一套:
1. `rust/crates/mnote-web/src/local_folder_watcher_registry.rs`
- 使用 `notify` 监听 local folder root。
- 生成 `relativePath``documentId``eventKind``revision``observedFileVersion``bufferFileVersion``selfWriteEcho`
- 调用 `BufferStore.mark_external_modified_if_version_changed_with_outcome` 区分自写回声与外部修改。
- 同步触发本地搜索索引刷新;该职责保持在 Rust 侧,不迁到浏览器 runtime。
2. `rust/crates/mnote-web/src/routes/local_folder_events.rs`
- `/api/local-folder/events?treeLive=true` 复用同一个 watcher。
- 聚合 watcher payload,输出 `watch_batch`
3. `rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js`
- 复用单连接,转发 `watch_batch`
- 派发 `mnote:local-folder:watch-batch``tree:local-folder-watch-batch``mnote:local-folder:filetree-parent-changed``mnote:local-folder:document-changed``mnote:local-folder:resource-changed``mnote:local-folder:sidebar-refresh-requested`
4. `rust/crates/mnote-web/browser/document-session-runtime.js`
- 消费 `mnote:local-folder:document-changed`
- 命中当前 document session 后调用 `refreshSessionFromExternalChange`,再 fetch `/api/page-aggregate/...`
问题说明:整页刷新后能看到 agent 写入内容,说明 Page Aggregate 读盘链路有效;当前缺口在“真实文件变化 / agent 写入结果 -> 当前打开 document session reaction”。
## 3. 参考实现判断
### 3.1 Sidex 可移植部分
`reference-code/sidex-main` 可迁移的是模型,不是完整代码:
- `crates/sidex-workspace/src/watcher.rs`
- `notify` 事件包装。
- path 级别 pending map 去重。
- ignore pattern。
- `crates/sidex-workspace/src/file_watcher_events.rs`
- `EventThrottler.record_own_write(path)` 抑制自身写入回声。
- `ingest()` 合并短时间多事件。
- `flush()` 把文件事件映射成 reaction。
- dirty 文件 + external modified 进入冲突 reaction。
- `src/vs/workbench/contrib/files/browser/explorerService.ts`
- Explorer 用延迟合并处理文件事件。
- 编辑中不刷新树。
- 只在展开父节点受影响时刷新。
### 3.2 Sidex 复核补充
Sidex 的关键收益不是更换 watcher,而是在 watcher 与消费者之间增加稳定事件质量层:
| 能力 | Sidex 模型 | MNote 当前状态 | 本设计处理 |
|---|---|---|---|
| 两段去重 | watcher pending map + `EventThrottler` | 只有 `local_folder_events.rs` 的 120ms 连续 payload 聚合 | 增加 per-root FileChange throttler,跨批次合并同一路径 |
| own-write 处理 | `record_own_write` + 过期窗口 | Rust 已产出 `selfWriteEcho`,但 browser consumer 仍要各自判断 | 统一 reaction 层消费 `selfWriteEcho`,避免各消费方重复判断 |
| Reaction | `FileWatcherReaction` | 前端各 runtime 自己解析 JSON | 增加统一 reaction schema,再兼容输出旧事件 |
| dirty 冲突 | throttler/working copy 层决定 reload 或 conflict | `BufferStore` 有版本仲裁,前端也有 conflict panel 壳 | reaction 层统一把 dirty/saving/recent input 转成冲突 |
| ignore | watcher 层忽略构建产物 | 主要覆盖 `.mnote` / 索引相关路径 | 后续补 `.git``node_modules``target` 等 |
### 3.3 不直接移植部分
- 不迁 Tauri `app.emit("watch-batch")`MNote 使用 Rust Web SSE/WS/browser event bus。
- 不迁 VSCode extension host `FileSystemWatcher`
- 不让 watcher 携带文件内容;MNote 正文读取仍走 Page Aggregate。
- 不把 Sidex 的 settings/extensions reload 规则原样迁入;MNote 后续可补自己的偏好、插件、knowledge source reaction。
- 不新增第二条 broadcast/event stream。统一层必须复用现有 `/api/local-folder/events` 与 browser event bus。
## 4. 目标架构
### 4.1 事件输入
统一输入源:
| 来源 | 入口 | 说明 |
|---|---|---|
| Rust local-folder watcher | `/api/local-folder/events?treeLive=true` | 主来源,真实磁盘文件变化 |
| MNote 编辑器保存 | `BufferStore` / existing save completion event | 用于 self-write echo 抑制和保存态同步 |
| OpenHub / opencode | changedFiles adapter | 只能转成 FileChange input,不直接刷新 UI |
| legacy Page AI tool receipt | `agentRunReceipt.changedFiles` | 只能转成 FileChange input |
| local upload / resource save | existing local-upload event | 资源 tab 与附件场景 |
### 4.2 标准事件
统一标准事件命名为 `mnote.file_change.v1`
```json
{
"schema": "mnote.file_change.v1",
"rootUri": "file:///...",
"workspaceId": "local-ws:...",
"relativePath": "新页面145827/新页面145827.md",
"documentId": "local-md:...",
"resourceKind": "markdown",
"changeType": "modified",
"fileVersion": "local-md:...",
"source": "watcher|editor_save|openhub|opencode|agent_receipt|local_upload",
"selfWriteEcho": false,
"observedFileVersion": "...",
"bufferFileVersion": "...",
"createdAt": 1782738295000
}
```
批量事件命名为 `mnote.file_change_batch.v1`
```json
{
"schema": "mnote.file_change_batch.v1",
"rootUri": "file:///...",
"workspaceId": "local-ws:...",
"source": "watcher",
"changes": [],
"affectedParents": [],
"revision": "..."
}
```
### 4.3 Reaction
统一 reaction 命名为 `mnote.file_change_reaction.v1`
| Reaction | 触发条件 | 消费方 |
|---|---|---|
| `refresh_current_document` | 当前打开文档 clean 且命中 changed `.md` | `document-session-runtime.js` |
| `external_conflict_current_document` | 当前打开文档 dirty/saving/recent input 且同文件外部变化 | `document-conflict-panel-runtime.js` / session status |
| `refresh_filetree_parents` | 创建/删除/重命名/父目录变化 | `sidebar-tree-live-apply-runtime.js` / filetree |
| `refresh_page_tree_projection` | `.md` 页面新增/删除/重命名 | sidebar page tree |
| `refresh_resource_tab` | 当前资源文件变化 | `document-resource-tab-runtime.js` |
| `refresh_knowledge_source_status` | knowledge source 文件变化 | knowledge RAG status |
| `ignore_self_write_echo` | `selfWriteEcho=true` 且 session 已处于 saved/clean | 不触发 UI 刷新 |
### 4.4 Browser 侧 owner
当前最小实现放在 `rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js`,作为现有 event bus 的升级,不新增并行 runtime。
后续如果文件变大,再拆为:
- `filechange-event-runtime.js`:标准化、去抖、source adapter、diagnostics。
- `filechange-reaction-runtime.js`reaction 计算。
- `local-folder-event-bus-runtime.js`:只负责 transport 与连接复用。
Rust 侧后续可增加类型化结构,但不要求首批大改广播签名:
- Phase A 保持 SSE JSON 兼容,补齐字段透传与 browser 标准化。
- Phase B/C 再评估是否在 `mnote-web/src/file_change_service.rs` 引入 `FileChangeEvent` / `FileChangeReaction` Rust 类型,并由现有 `Value` 序列化出口兼容输出。
- throttler 必须是 per-root 实例,放在 `LocalFolderWatchChannel` 或等价 root-scoped state 中,避免不同 workspace 互相去重。
## 5. 核心流程
### 5.1 外部编辑器 / agent 直接写 `.md`
1. 文件系统变化进入 Rust watcher。
2. watcher 更新 BufferStore 并生成标准字段。
3. `/api/local-folder/events?treeLive=true` 输出 `watch_batch`
4. browser event bus 标准化为 `mnote.file_change_batch.v1`
5. reaction 层判断命中当前 document session。
6. 当前文档 clean:调用 `refreshSessionFromExternalChange` 拉 Page Aggregate 并派发 tiptap 内容。
7. 当前文档 dirty:设置 `external-change-conflict`,不覆盖内容。
### 5.2 OpenHub / opencode 写当前页面
1. provider 事件或 session changedFiles 进入 adapter。
2. adapter 将绝对路径解析为当前 `rootUri``relativePath`
3. 生成 `mnote.file_change_batch.v1`source 为 `openhub``opencode`
4. 后续与 watcher 事件走同一 reaction。
5. 如果真实 watcher 随后也到达,同一路径同版本事件被去抖合并,不重复刷新。
### 5.3 MNote 自己保存
1. 保存链记录 write intent / save operation / fileVersion。
2. watcher 回声到达时带出 `selfWriteEcho=true`
3. FileChangeService 产出 `ignore_self_write_echo` 或只同步 clean metadata,不触发外部冲突。
## 6. 非目标
- 不引入周期轮询或 `setInterval` 检测文件是否变化。
- 不把 OpenHub/opencode changedFiles 当作事实源;它只是 watcher 补偿输入。
- 不让前端直接读取本地文件正文;正文仍走 Page Aggregate。
- 不复活 BlockNote / Convex / Next 作为当前刷新主链。
- 不把 filetree/page tree 的 projection 真相搬到前端。
- 不在 AI runtime 中直接调用 document editor 私有刷新逻辑。
## 7. 实施 Checklist
### Batch A:现状断点复核
- [x] 复核 `LocalFolderWatcherRegistry` 产出的 `selfWriteEcho``observedFileVersion``bufferFileVersion` 是否完整进入 `watch_batch.changedPaths[]`
- [x] 复核 `/api/local-folder/events?treeLive=true` 是否对当前 workspace root 只有一个 watcher 连接。
- [x] 复核 `local-folder-event-bus-runtime.js` 是否把 `documentId/eventKind/selfWriteEcho/fileVersion` 保留给 `document-session-runtime.js`
- [x] 复核 `document-session-runtime.js` 的命中条件:`documentId``relativePath``rootUri``workspaceId`
- [x] 复核当前截图场景中 Page AI/OpenHub 写入后是否有真实 watcher 事件、synthetic event、或两者都没有。
- [x] 复核是否存在跨批次重复事件导致同一路径多次 refresh。
- [x] 复核 ignore 规则是否放过 `.git``node_modules``target``dist` 等高频目录。
验收:
- [x] 产出一份断点日志或 smoke 证据,能指出“事件未到达 / 字段丢失 / session 未命中 / dirty 拦截 / refresh 失败”的具体层级。
### Batch B:标准 FileChange batch
- [x] 在 browser event bus 增加标准化函数:从 watcher `watch_batch`、synthetic changedFiles、legacy receipt 转成 `mnote.file_change_batch.v1`
- [x] 统一绝对路径到 root-relative path 的解析规则,路径不在 rootUri 下时丢弃并记录 diagnostics。
- [x] 合并同一 `rootUri + relativePath` 的短时间重复事件。
- [x] 保留 `source``eventKind``selfWriteEcho``observedFileVersion``bufferFileVersion`
- [x] 暴露 diagnostics:最近 batch source、changed count、dropped count、last reaction。
- [x] 保持旧 `watch_batch.changedPaths/affectedParents/eventKinds` 字段兼容,新增标准 batch 不破坏旧消费者。
- [x] 如 Rust 侧改类型,保证最终 SSE/JS JSON 格式仍兼容旧 smoke。
验收:
- [x] `task540-local-folder-event-bus-single-connection-smoke.js` 仍通过。
- [x] 新增静态 smoke 确认 `mnote.file_change_batch.v1``selfWriteEcho``emitSyntheticWatchBatch` adapter 存在。
### Batch CReaction 分发
- [x] 新增 reaction 计算:当前文档 clean -> `refresh_current_document`
- [x] dirty/saving/recent input -> `external_conflict_current_document`
- [x] self-write echo -> `ignore_self_write_echo`
- [x] filetree affected parents -> `refresh_filetree_parents`
- [x] resource tab -> `refresh_resource_tab`
- [x] 保持旧事件兼容输出:`tree:local-folder-watch-batch``mnote:local-folder:document-changed``mnote:local-folder:resource-changed`
- [x] 不在 Rust watcher 回调中做新的重 I/O;reaction 计算应基于已有事件字段和 session state。
验收:
- [x] 外部写当前 `.md` 后,当前 tiptap 页面无需浏览器刷新显示新文本。
- [x] 当前页面 dirty 时,外部写同一 `.md` 不覆盖编辑器,显示 external conflict。
- [x] filetree/page tree 仍按局部 parent 刷新,不整页 reload。
### Batch DAI source adapter 收口
- [x] OpenHub/opencode changedFiles 只调用统一 FileChange input,不直接调用 `refreshPrimaryDocument`
- [x] legacy Page AI receipt 只调用统一 FileChange input。
- [x] 移除或降级旧的 provider 专属直接刷新路径;保留兼容时必须标注为 fallback diagnostics。
- [x] changedFiles 为空时不伪造 diff;只依赖真实 watcher 或 explicit changedFiles。
验收:
- [x] agent/OpenHub/opencode 写当前 `.md` 后,当前页面自动更新。
- [x] 若 provider 不报 changedFiles,真实 watcher 仍能驱动更新。
- [x] 若 watcher 漏报但 provider 报 changedFilessynthetic FileChange 可驱动同一 reaction。
### Batch E:回归与归档
- [x] 新增或更新 smoke
- [x] `task-local-folder-filechange-current-document-refresh-smoke.js`
- [x] `task-page-ai-filechange-current-document-refresh-smoke.js`
- [x] `task-local-folder-filechange-dirty-conflict-smoke.js`
- [x] 运行 `cargo test -p mnote-web` 中相关单测。
- [x] 运行 local-folder event bus、filetree lazy loading、page aggregate refresh 相关 smoke。
- [x] `codegraph sync .` 并确认状态。
- [x] 完成后把本设计从 `process/` 移到 `done/`,记录验证命令和剩余风险。
## 8. 风险与防线
- 事件重复:watcher 与 synthetic changedFiles 同时到达。防线:按 `rootUri + relativePath + fileVersion/source window` 去抖。
- 自写回声误判外部冲突。防线:保留并消费 `selfWriteEcho`、write intent、save operation。
- dirty 编辑器被覆盖。防线:dirty/saving/recent input 一律进入 conflict reaction。
- filetree/page tree 首屏被 debug/diagnostics 挤占。防线:diagnostics 只写 DOM data attribute 或折叠开发态。
- provider 事件格式不稳定。防线:OpenHub/opencode 只做 adapter,不作为系统事实源。
- 前端拼第二份页面真相。防线:正文刷新只拉 Page Aggregate,不从 changedFiles 读取内容。
- 第二条事件总线造成状态分裂。防线:只复用现有 `/api/local-folder/events` 与 browser event bus,不新增并行 broadcast。
- throttler 误合并不同 workspace 事件。防线:per-root/per-workspace scoped state。
## 9. 验收定义
本设计完成的最低验收:
1. 真实外部写当前 `.md`,当前页面自动刷新正文。
2. OpenHub/opencode/agent 写当前 `.md`,当前页面自动刷新正文。
3. dirty 当前页遇到外部写入时不覆盖,显示冲突状态。
4. 文件树/页面树仍使用同一 event bus 更新,没有新增并行轮询。
5. OpenHub/opencode 没有直接持有编辑器刷新逻辑,只作为统一 FileChange input。
## 10. 完成记录
完成时间:2026-06-29
实现摘要:
- Rust watcher batch 继续复用 `/api/local-folder/events?treeLive=true`,并在 `changedPaths[]` 保留 `documentId``eventKind``selfWriteEcho``observedFileVersion``bufferFileVersion``lastWriteIntentId``lastSaveOperationId`
- Browser `local-folder-event-bus-runtime.js` 升级为轻量 FileChangeService:标准化 `mnote.file_change_batch.v1`80ms 去抖合并,输出 `mnote:file-change-batch` / `mnote:file-change-reaction`,并兼容旧 `tree:local-folder-watch-batch` / `mnote:local-folder:document-changed` / `resource-changed`
- `document-session-runtime.js` 统一消费 `selfWriteEcho`clean 当前页走 Page Aggregate 自动刷新,dirty/saving/recent input 当前页进入 external conflict,不覆盖编辑器内容。
- OpenHub/opencode/legacy receipt changedFiles 收口到 `emitChangedFiles` / FileChange input,不再直接调用 `refreshPrimaryDocument` 作为 provider 专属刷新链路。
- watcher ignore 扩展到 `.git``node_modules``target``dist``build` 等高频目录。
验证命令:
- `node --check rust/crates/mnote-web/browser/local-folder-event-bus-runtime.js && node --check rust/crates/mnote-web/browser/document-session-runtime.js && node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js && node --check scripts/task540-local-folder-event-bus-single-connection-smoke.js && node --check scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js && node --check scripts/task-local-folder-filechange-current-document-refresh-smoke.js && node --check scripts/task-local-folder-filechange-dirty-conflict-smoke.js && node --check scripts/task-page-ai-filechange-current-document-refresh-smoke.js`
- `cargo fmt --check -p mnote-web`
- `cargo test -p mnote-web local_folder -- --nocapture`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3010 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task540-local-folder-event-bus-single-connection-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task-local-folder-filechange-current-document-refresh-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task-local-folder-filechange-dirty-conflict-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task-page-ai-filechange-current-document-refresh-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3010 node scripts/task776-openhub-changed-files-bridge-smoke.js`live probe 因缺少 task774 真实文件编辑结果跳过,静态检查通过)
剩余风险:
- `task-local-folder-filechange-current-document-refresh-smoke.js` 与 dirty conflict smoke 复用现有 task436 主流程,覆盖真实外部写入、AI 写入、dirty conflict、rename/delete conflict,但不是独立新实现的更细粒度脚本。
- FileChange reaction 当前通过 document session debug snapshot 判断当前页状态,并已暴露 `data-mnote-file-change-service-session-api` diagnostics;后续可把这层升级成正式 session registry API。
- `mnote:page-ai-tool-write-completed` 仍保留为 changedFiles 为空时的 legacy fallback,不作为 OpenHub/opencode/receipt changedFiles 主路径。