Files
mnote/design/05-editor-mainline/done/5-27-local-markdown-working-copy-conflict-contract-v1.md

211 lines
16 KiB
Markdown
Raw Permalink 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.
# 5-27 本地 Markdown Working Copy 冲突合同 v1
## 背景
`bugs/0524.md` 第 2 条暴露的问题不是单个上传入口错误,而是本地文件夹 watcher、正文 session、文件树资源事件和冲突 UI 之间缺少清晰边界:
- 上传附件或向文件树拖入文件后,非 Markdown 文件变化会进入正文外部变更链路。
- 新建页面后,旧的冲突提示可能被同 root 的事件流带到新页面,刷新后消失。
- 真实冲突仍然需要保留:当前 Markdown 有未保存编辑,同时磁盘上的同一个 Markdown 文件被外部修改时,必须进入冲突处理。
## Sidex 对照
Sidex/VSCode 的核心模型是 `StoredFileWorkingCopy`
- 每个 working copy 绑定一个具体 `resource`
- 保存时以 `lastResolvedFileStat.etag/mtime` 做 dirty write prevention。
- 只有同一个 resource 的写入出现 `FILE_MODIFIED_SINCE` 时,才进入 `inConflictMode`
- 文件系统 watcher 的目录级事件不会直接把同目录其它文件变化升级成当前 working copy 冲突。
- 自身文件操作走 `onDidRunOperation` 一类的写入通道,外部变化走 `onDidFilesChange` watcher 通道。
- dirty working copy 收到 watcher update 时不自动 reload;真正冲突主要在保存时通过 mtime/etag 前置条件产生。
- 保存成功只在保存期间 working copy 版本没有再次变化时清 dirty,否则保留 dirty 并继续下一轮保存。
MNote 不需要照搬 VSCode 的实现,但应采用同一条合同:正文冲突只属于当前 Markdown 文件,不属于同 root 下任意附件、文件树资源或元数据文件。
## 当前根因
当前 `mnote-web` 的 document event channel 以 `rootUri` 共享:
- 前端订阅 `/api/local-folder/events?rootUri=...`,没有传 `documentId`
- 后端 `build_document_events_stream` 在没有 `documentId` 时不会按 Markdown 相对路径过滤。
- watcher 对非 Markdown 资源发出的 payload 中 `documentId` 为空。
- 前端收到空 `documentId` 后,仍会对同 root 下所有 document session 设置 `externalChangePending` 并调度正文刷新。
这会把附件上传、文件树拖入、资源创建等 root 级变化误投递到正文 session,形成文件冲突误报和跨页面状态污染。
另一个独立根因是冲突 UI 的 DOM 生命周期:
- `renderSessionConflictSurface` 会把冲突面板直接插入 `.document-pane`
- 页面切换时 `unmountEditorViewBinding` 只卸载 editor runtime 和事件监听,没有清理旧 session 的冲突面板。
- 因此旧页面已出现冲突时,新建/切换到新页面可能短暂看到旧冲突面板;刷新后整页 DOM 重建,问题消失。
2026-05-28 复发根因:
- 上传附件后,`local-upload-runtime` 会先写附件文件,再把附件链接插入编辑器并保存 Markdown。
- 保存 Markdown 成功后,后端返回新的 `fileVersion/conflictDetectionKey`,但后续用户输入的 autosave 仍可能使用旧 `expectedFileVersion`
- Local watcher 会看到 MNote 自己 `fs::write()` 产生的 Markdown 文件事件,并把它标成 `external-editor`
- 如果用户在 watcher 回声到达前后继续输入,`Dirty/saveTimer/recent input` 与 watcher 事件相遇,被误判成外部冲突。
- 进入冲突态后,第二个附件上传仍可能写入附件文件和插入链接,但正文保存链停止可靠提交,导致两个附件的编辑器链接、Markdown 正文和文件树资源状态分裂。
## 合同
1. Markdown 正文 session 只订阅自己的 Markdown 文件事件。
2. 非 Markdown 资源事件只用于文件树/资源投影刷新,不触发正文 `externalChangePending`
3. 真实正文冲突只在“同一个 Markdown documentId + 当前 session dirty/saving/saveTimer/recent input”时出现。
4. 同 root 下不同 Markdown 页面必须有独立 document event channel。
5. 文件树 live stream 继续承担 root 级变化刷新,不依赖正文冲突链路。
6. 保存冲突以 CAS 为准:`expectedFileVersion` 与当前磁盘 `fileVersion` 不一致时返回 409。
7. watcher 事件不能单独制造正文冲突;它只能触发 clean reload、标记 buffer 外部变化,或在不同版本且 dirty 时把 buffer 推到 Stale。
8. MNote 自身保存成功后的同版本 watcher 回声必须被识别为 self-write echo,不得把 Clean 变 ExternalModified,也不得把 Dirty 变 Stale。
9. 保存成功必须原子更新 Rust `BufferStore`、浏览器 `DocumentSession.conflictDetectionKey/fileVersion`、页面 aggregate script 中的版本字段。
10. dirty working copy 不自动 reload;如果磁盘版本未变化,当前编辑器内容与磁盘快照不同也不是冲突。
## 已执行修复切片
- 前端 document event channel key 从 `rootUri` 收窄为 `rootUri#documentId`
- 前端订阅本地文件夹 document events 时携带 `documentId`
- 前端收到无 `documentId` 的 change payload 时直接忽略,不再设置正文 `externalChangePending`
- editor view unmount 时清理旧 session 的冲突面板,避免手动插入的 DOM 跨页面残留。
- 后端补充 document event filter 测试,确保资源文件路径不会匹配 Markdown 正文路径。
## 后续系统性收口
- [x]`DocumentBufferStore` 作为正文冲突状态底座之一,前端 session 不再仅凭 watcher/dirty 差异制造冲突。
- [x] 保存成功后的同版本 watcher 回声由 Rust `BufferStore` 忽略。
- [x] 上传附件后继续输入、再上传第二个附件的 browser smoke 覆盖。
- [x] 引入显式 `writeIntentId/saveOperationId`,让 watcher 可精确关联 MNote 自身写入,而不只依赖 fileVersion。
- [x] 为资源 tab 增加独立 resource watch 合同:按 `resourcePath` 监听资源文件,而不是复用 Markdown document event。
- [x] 为新建页面增加创建者写入抑制或 bootstrap generation,避免未来新建空文件 watcher 与首次打开时序竞争。
- [x]`task451` 的真实冲突 smoke 与上传/新建页面无冲突 smoke 合并成一组 local Markdown conflict regression。
- [x] 补 AI 写入、多浏览器 tab、外部删除/移动的冲突矩阵。
## 执行 Checklist
### Batch 1 - 保存意图与自写回声审计
- [x] `PageBodyWriteRequest` / `/api/documents/save` 接受并回传 `writeIntentId``saveOperationId`
- [x] 浏览器 `DocumentSession` 普通保存与 `local-upload-runtime` 附件保存都生成并发送 `writeIntentId/saveOperationId`
- [x] `BufferStore.mark_saved` 记录最后一次 `writeIntentId/saveOperationId`
- [x] watcher 处理 Markdown 文件事件时在 payload 中带出 `observedFileVersion``bufferFileVersion``lastWriteIntentId``lastSaveOperationId``selfWriteEcho`
- [x] 同版本 watcher 回声继续保持 Clean/Dirty 不被升级为 ExternalModified/Stale。
- [x] 定点验证:core-protocol 反序列化测试、BufferStore self-write outcome 测试、`task503` 连续上传 smoke。
验证证据:
- `cargo test --manifest-path rust/Cargo.toml -p core-protocol page_body_write_request_uses_file_version_contract -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_records_save_operation_and_reports_self_write_echo -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_ignores_watcher_echo_for_last_saved_version -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_marks_stale_when_dirty_buffer_sees_new_version -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown_write_contract_returns_page_body_write_command -- --nocapture`
- `cargo check --manifest-path rust/Cargo.toml -p mnote-web`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3017 node scripts/task503-local-pptx-upload-filetree-open-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3017 node scripts/task491-local-md-attachment-icon-refresh-smoke.js`
- `node --check rust/crates/mnote-web/browser/document-session-runtime.js && node --check rust/crates/mnote-web/browser/local-upload-runtime.js && node --check scripts/task503-local-pptx-upload-filetree-open-smoke.js`
### Batch 2 - 真实外部编辑冲突恢复
- [x] 写 browser smokedirty 当前 Markdown 后由外部进程修改同一 `.md`,必须显示冲突面板。
- [x] `accept_disk`:接受磁盘版本后编辑器、DocumentSession、BufferStore 与 aggregate 版本一致。
- [x] `keep_editor`:保留当前编辑器内容后下一次保存使用最新磁盘版本 CAS,成功后清冲突。
- [x] `open_diff`:至少能打开稳定 diff/对照视图,不丢当前编辑器内容。
- [x] 保存过程中继续输入:只清理已保存版本,后续输入保持 Dirty。
验证证据:
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task504-local-md-external-conflict-recovery-smoke.js`
- `node --check scripts/task504-local-md-external-conflict-recovery-smoke.js`
- `task504``save-while-typing` 步骤断言保存 pending 时继续输入会触发第二轮保存:`saveRequestCountBeforeRace=1``saveRequestCountAfterRace=3`,最终磁盘同时包含第一段和第二段且无冲突面板。
### Batch 3 - Resource Tab 独立 Watch 合同
- [x] 定义 resource watch 合同:`resourcePath/resourceId` 级别监听资源文件,不复用 Markdown document event。
- [x] PDF/Office/image/resource markdown tab 只响应自身 resource 事件。
- [x] resource Markdown 文件更新时刷新资源 tab 或提示冲突,不影响正文 session 冲突态。
- [x] resource 文件删除/移动进入 resource tab 自己的 missing/rekey 状态。
- [x] browser smoke 覆盖 Office/PDF resource tab 更新与删除。
验证证据:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web resource_event_path_filters_to_its_own_relative_path -- --nocapture`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task505-resource-tab-watch-contract-smoke.js`
- `node --check rust/crates/mnote-web/browser/document-session-runtime.js && node --check rust/crates/mnote-web/browser/document-editor-adapter-runtime.js && node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js && node --check scripts/task505-resource-tab-watch-contract-smoke.js`
- `task505` 覆盖 Markdown resource clean 更新、Markdown resource dirty 冲突、Office passive resource 更新重载与删除 missingPDF 与 Office 共用 iframe passive resource watch 路径,image 走同一 resourcePath watch 并重载 `img.src`
### Batch 4 - 创建/删除/移动 Bootstrap 与生命周期
- [x] 新建页面首写使用 bootstrap generation 或 creator write suppression,避免空文件 watcher 抢跑。
- [x] 外部删除打开 Markdown:不可 rekey 时进入 `Deleted`,保留 buffer 内容。
- [x] 外部移动/重命名打开 Markdown:可 rekey 时保留 buffer 并更新 workspace path/document id。
- [x] 文件树删除/移动与外部删除/移动走同一 resource lifecycle 合同。
验证证据:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_marks_deleted_after_local_file_operation_archive -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_rekeys_after_local_file_operation_rename -- --nocapture`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task506-local-md-delete-move-lifecycle-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task458-local-create-page-no-conflict-smoke.js`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`
- `node --check scripts/task506-local-md-delete-move-lifecycle-smoke.js`
- 说明:内部 filetree rename/move 通过 tree command execution 的 previous/next resource 调 `BufferStore.rekey_local_folder_markdown`;外部原始 `fs.rename` 在 watcher 层没有稳定 previous/next 配对,当前按冲突/删除态保留 buffer,不静默重定向到猜测路径。
### Batch 5 - AI 与多 Tab 并发矩阵
- [x] AI 写入 clean 文档:当前 tab 自动同步,BufferStore 保持 Clean。
- [x] AI 写入 dirty 文档:显示 agent 来源冲突,冲突 envelope 带 actor/source。
- [x] 两个浏览器 tab 并发编辑同一 Markdownclean tab 自动同步,dirty tab 保存 409。
- [x] 附件文件已落盘但 Markdown 引用保存 409:提示重试/保留孤儿/清理策略明确,不静默丢失。
- [x] 合并 `task451` 真实冲突 smoke、上传无冲突 smoke、新建页面无冲突 smoke 为 local Markdown conflict regression 组。
验证证据:
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task436-local-markdown-open-document-external-change-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task508-local-md-multitab-conflict-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task509-local-upload-save-409-orphan-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task510-local-markdown-conflict-regression-group.js`
- `task508` 断言 clean tab 自动同步,dirty tab 延迟保存经 A tab 写入后返回 `gateStatus=409`,B tab 保留未保存内容并显示冲突面板。
- `task509` 断言 local-upload save 409 后附件文件保留、编辑器引用保留、`data-mnote-last-upload-orphaned-policy=asset-kept-reference-unsaved-retry-required`;session 已冲突时第二次附件上传在写文件前阻断。
## 分阶段 Checklist
### Phase A - Working Copy 合同冻结
- [x] Page Aggregate body 输出 `fileVersion/conflictDetectionKey`
- [x] DocumentSession 保存时携带 `expectedFileVersion`
- [x] BufferStore 持有 `Clean/Dirty/Stale/ExternalModified/Deleted`
- [x] 文档化 `fileVersion` 生成规则和 `baseContentHash/currentContentHash` 语义。
合同说明:
- `fileVersion/conflictDetectionKey` 是本地 Markdown 的 CAS 令牌,由当前 `documentId + mtime_ms + file_size + content_hash_prefix` 生成;它只用于“这次保存基于哪个磁盘版本”比较,不作为页面 ID、附件 ID 或排序真相。
- `baseContentHash` 表示 buffer 上一次确认的磁盘/保存内容 hash;`currentContentHash` 表示当前 working copy 内容 hash。两者不同是 dirty/stale 的依据,但只有保存 CAS 失败或删除态才进入用户可见冲突。
### Phase B - Self-write 回声消除
- [x] `write_local_markdown_page_body()` 保存成功后调用 `BufferStore.mark_saved()`
- [x] watcher 看到同版本 Markdown 事件时不调用外部冲突转换。
- [x] 保存请求分配 `writeIntentId`watcher payload 回传并落审计。
- [x] `local-upload-runtime` 在 session 已冲突时阻止“看似成功”的第二次正文保存。
### Phase C - 真实冲突闭环
- [x]`expectedFileVersion` 保存返回 409 conflict envelope。
- [x] dirty 当前 Markdown 后外部编辑同一 `.md` 的 browser smoke。
- [x] 接受磁盘版本、保留当前编辑器版本、打开 diff 后的版本恢复 smoke。
- [x] 保存过程中用户继续输入时,只清理已保存版本,不丢后续 dirty。
### Phase D - 附件/文件树
- [x] 编辑器附件上传后继续输入不触发冲突。
- [x] 连续上传同名 pptx 后两个编辑器附件链接可打开。
- [x] 文件树拖拽上传后投影刷新并可打开。
- [x] 正文保存 409 时,附件文件存在但引用未落盘的 UI 提示/重试/孤儿清理策略。
### Phase E - AI / 多 Tab / 外部文件
- [x] AI 写入 clean 文档时自动同步。
- [x] AI 写入 dirty 文档时显示 agent 来源冲突。
- [x] 两个浏览器 tab 并发编辑同一 Markdownclean tab 自动同步,dirty tab 保存 409。
- [x] 外部删除/移动打开文件:可 rekey 时保留 buffer,不可 rekey 时进入 Deleted。
## 验收
- 上传第一个/第二个附件后,主编辑区不显示 `mnote-editor-conflict-panel`
- 新建页面后,旧页面冲突 UI 不带入新页面。
- 新建页面后直接向文件树拖入文件,不显示文件冲突。
- dirty 当前 Markdown 后由外部修改同一个 `.md` 文件,仍显示冲突并保留 accept disk / keep current / diff 流程。