# 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 smoke：dirty 当前 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 更新重载与删除 missing；PDF 与 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 并发编辑同一 Markdown：clean 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 并发编辑同一 Markdown：clean tab 自动同步，dirty tab 保存 409。
- [x] 外部删除/移动打开文件：可 rekey 时保留 buffer，不可 rekey 时进入 Deleted。

## 验收

- 上传第一个/第二个附件后，主编辑区不显示 `mnote-editor-conflict-panel`。
- 新建页面后，旧页面冲突 UI 不带入新页面。
- 新建页面后直接向文件树拖入文件，不显示文件冲突。
- dirty 当前 Markdown 后由外部修改同一个 `.md` 文件，仍显示冲突并保留 accept disk / keep current / diff 流程。
