Files
mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md
T

1158 lines
91 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.
# 2-2 [done] Local-First Workspace 与 Convex 控制面降级方案 v1
> 创建时间:2026-05-18
>
> 当前状态:`DONE`
>
> 2026-05-22 口径补充:本文完成的是“local-first workspace + Convex 降级控制面”的阶段性方案;后续 `2-8` 已继续把默认控制面从 Convex 替换为 Rust SQLite `control-plane`。因此本文中“Convex / 服务端控制面”的表述只作为阶段性历史背景理解;当前默认 auth、membership、share grants、sync state、AI policy、Page AI runtime session 已由 SQLite control-plane 承接,Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。
>
> 2026-06-28 口径补充:Hermes / Reasonix / LightRAG 已退出当前新增主线;当前 Page AI 与知识库融合方向按 OpenHub / opencode / LightRAG 理解,旧名称只作为历史命名、legacy route 或迁移对照。
>
> 本稿目的:
> 1. 固定早期产品形态:10 人以内、用户笔记默认独立、可分享、AI 隔离且可共享。
> 2. 将 mnote 从“Convex 与本地两套同等重量系统”收口为“本地文件夹默认主路径 + 控制面”;本稿当时的 Convex 控制面口径已被 `2-8` 的 Rust SQLite control-plane 替换。
> 3. 给出现有文件树、页面树、上传下载、编辑、AI 会话从 Convex 重依赖迁向本地优先的阶段计划。
>
> 关联文档:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-15-local-markdown-asset-upload-relative-path-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md`
---
## 1. 背景判断
当前产品目标不是大规模云协作平台,而是早期 10 人以内使用的知识库:
- 每个用户默认拥有独立笔记空间。
- 笔记、文件夹、资源可以分享给其他用户。
- 每个用户都使用 AI。
- AI 默认只能访问当前用户授权范围内的内容。
- AI 会话默认私有,但可以显式分享或共享。
在这个规模和使用方式下,继续把 Convex 作为页面正文、树结构、附件、AI 会话、实时状态的完整主数据层,会让系统过早承担云协作复杂度:
- schema / functions / deployment / auth / realtime / storage 多点耦合。
- 文件树、页面树、上传下载、编辑链路都需要同时理解 Convex 与本地。
- AI 编辑要绕过远端数据模型,反而不如直接面对本地 markdown 文件稳定。
- 任何本地优先能力都容易退化为“先导入 Convex,再投影回本地 UI”。
因此当前产品形态应调整为:
> **本地文件夹是默认数据真相;Rust kernel 是唯一语义真相;Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 仅保留历史迁移源和显式 cloud / compat / sync replica 边界。**
这不是否定 Convex 的历史价值,而是把 Convex 从默认主存储和默认控制面降级为可选 cloud/compat 能力与迁移来源。
---
## 2. 产品形态结论
### 2.1 默认形态
默认运行形态:
```text
User
-> Local Workspace Folder
-> pages/*.md
-> assets/*
-> mindmaps/*
-> ai-sessions/*
-> .mnote/*
-> Rust mnote-web / kernel
-> tree command
-> page aggregate
-> file tree / page tree projection
-> AI tool execution
-> Optional Control Plane
-> auth
-> membership
-> share grants
-> sync state
-> AI quota / policy
```
本地文件夹像 VSCode workspace 一样存在。用户可以直接看到、备份、git 管理和迁移自己的数据。
更准确的产品定义是:
> **MNote = VSCode 简化版工作区内核 + tiptap markdown 编辑器 + OpenHub / opencode agent + LightRAG 知识库融合 + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面。**
拆开看:
- **VSCode 简化版工作区内核**:本地文件夹、目录树、页面树、资源树、拖拽、重命名、移动、回收站、导入导出。
- **tiptap markdown 编辑器**:页面正文的前端显示与交互层,消费本地文件投影,不承担 agent 主编辑面职责。
- **OpenHub / opencode agent**:在 MNote 宿主授权 scope 内处理 Page AI 对话、文件引用和 LightRAG 知识库引用;旧 Hermes / Reasonix 只作为 legacy/debug 对照。
- **simplemindmap / office 插件**:作为资源型编辑器或附件打开器存在,不把它们重新降格成 markdown 文本块。
- **Wolai 风格 web 壳**:提供统一导航、主题、页面壳、AI 面板、侧边栏和分享入口。
- **鉴权控制面**:负责登录、用户隔离、目录授权、分享授权、同步状态和审计,而不是默认正文存储。
### 2.2 Convex / 服务端定位
Convex 不再默认承担页面正文、附件和本地 AI 会话全文的主存储。
Convex 可以继续承担:
- 用户登录和用户身份。
- workspace membership。
- 分享授权和共享入口。
- 远端同步状态。
- 通知、邀请、协作 session 元数据。
- AI quota、模型策略、审计索引。
- 后续多人实时协作时的远端事件通道。
Convex 不应继续扩写为:
- 默认页面正文真相。
- 默认文件树真相。
- 默认附件 blob 真相。
- 默认 AI 会话消息全文真相。
- 前端 UI 直接依赖的第二套树语义。
### 2.3 单一语义层
无论数据来自本地文件夹还是远端同步副本,业务语义都必须经过 Rust kernel
```text
LocalFS / Convex / Future Sync Store
-> WorkspaceSource
-> Rust kernel
-> projection / command / page aggregate / AI tools
-> mnote-web UI
```
前端不判断“这是 Convex 树还是本地树”。前端只消费:
- `file_tree`
- `page_tree`
- `page_aggregate`
- `tree.*` command result
- `mnote.doc.*` / `mnote.block.*` AI tool result
### 2.4 VSCode-like Agent Runtime
本地优先场景下,agent 的运行形态应尽量贴近 VSCode / Codex
```text
用户打开页面
-> MNote 只负责把当前页面定位到真实 .md 文件
-> MNote 根据登录用户 + access-policy.json 计算 allowed roots 白名单
-> Hermes / Reasonix 在该白名单目录内运行
-> agent 直接读取 / 修改本地文件
-> MNote watcher / refresh 把文件变化同步回 tiptap / File Tree / Page Aggregate
```
这意味着:
- `tiptap` 只是前端显示与交互层,不是 agent 的工作面。
- agent 不应被迫通过 MNote 的块工具或页面工具才能编辑普通 `.md`
- MNote 主要提供“页面定位 + 权限白名单 + 变更同步 + 审计”。
- 白名单目录就是 agent 的读写边界;这比继续堆一层专用文档编辑 API 更接近成熟 IDE 体验。
### 2.5 VSCode-like 文件并发与冲突模型
一旦 agent 直改本地文件,就必须按 VSCode 的文件并发模型处理 `tiptap`、AI、外部编辑器同时写入的问题。
核心规则:
- **本地 `.md` 文件是正文真相**Page Aggregate / EditorBlockDocument / tiptap state 都是投影或工作副本。
- 每个页面工作副本必须记录 `baseFileVersion`,至少包含 `mtime / size / contentHash` 或等价 revision。
- `tiptap` 保存、AI patch、外部文件变更都不能静默覆盖彼此;写入时必须带 expected file version。
- 文件 watcher 发现后台变更后:
- 如果当前 tiptap 没有 dirty 修改,自动重新读取 `.md` 并刷新 Page Aggregate / tiptap。
- 如果当前 tiptap 有 dirty 修改,进入 conflict state,不自动覆盖用户编辑。
- AI 写入被视为“后台文件编辑器”写入;它不拥有高于用户手动编辑的优先级。
- 冲突处理默认提供三类选择:接受磁盘版本、保留当前编辑器版本、打开差异比较 / 合并。
这条模型比“tiptap 和 AI 都经过 `/api/documents/save` 兼容面”更可靠,因为写入仲裁发生在本地文件版本层,而不是旧 Convex/document command 兼容层。
---
## 3. 本地工作区数据形状
本机默认受管数据根固定为:
```text
/mnt/Data1T/Mnote_data/
control-plane/
access-policy.json
users/
<actor-id-encoded>/
workspaces/
my-space/
pages/
assets/
mindmaps/
ai-sessions/
.mnote/
shared/
workspaces/
backups/
exports/
imports/
tmp/
```
目录规则:
- `/mnt/Data1T/Mnote_data/users/<actor-id-encoded>/workspaces/my-space/` 是每个登录用户的默认“我的空间”,但不是唯一可访问目录。
- `<actor-id-encoded>` 使用稳定 URL-safe 编码,不直接使用用户输入拼路径。
- `control-plane/access-policy.json` 记录管理员身份、用户到目录的读写授权、AI 目录授权和目录别名映射。
- 普通用户的“创建我的空间”只允许初始化自己的受管默认路径,不允许用户通过任意 `rootUri` 写入 `.mnote/workspace.json` 接管别的目录。
- “打开本地文件夹”允许三类命中:当前 actor 的默认 my-space、已有 `.mnote/workspace.json` 且 owner 匹配、管理员显式授权的目录。
- 管理员可以读写任何目录,也可以把任意 canonical 目录授权给普通用户;普通用户不能自助提升到目录级全盘访问。
- 后续分享工作区可放入 `shared/workspaces/` 或通过控制面授权映射到 owner 的工作区;不能因为共享而放宽本机任意路径访问。
- `backups/``exports/``imports/``tmp/` 是工具目录,不作为默认页面树 root。
单个 workspace 内部建议默认结构:
```text
workspace-root/
pages/
Inbox.md
Inbox.assets/
Projects/
MNote.md
MNote.assets/
assets/
shared/
imports/
mindmaps/
ai-sessions/
private/
shared/
.mnote/
workspace.json
page-ids.json
page-options.json
resource-index.json
share-cache.json
sync-state.json
ai-policy.json
trash/
```
### 3.1 页面正文
页面正文优先是 markdown 文件。
标题优先级:
1. frontmatter `title`
2. 第一行 H1
3. 文件名
页面稳定身份优先级:
1. frontmatter `mnote_id`
2. `.mnote/page-ids.json`
3. path-derived 临时 id
path-derived id 只能用于未初始化只读模式,不能作为长期引用 id。
### 3.2 资源与附件
上传不应默认进入 Convex storage。
本地 `.md` 页面内的图片和附件上传,默认写入当前 Markdown 文件同目录下的 sibling assets 目录:
```text
<md-dir>/<md-base>.assets/<safe-file-name>
```
示例:
```text
pages/Inbox.md
pages/Inbox.assets/image.png
pages/Inbox.assets/spec.docx
```
编辑器正文必须写入相对当前 Markdown 文件目录的标准 Markdown 路径:
```markdown
![image.png](Inbox.assets/image.png)
[spec.docx](Inbox.assets/spec.docx)
```
当前已落地基线:
- `POST /api/local-folder/assets/upload` 已新增。
- `sourceKind=local_folder` 时前端上传分流到本地上传入口。
- 本地上传复制到 `{mdBase}.assets/`,返回 `sourcePath` / `file_url` 相对路径。
- 本地图片保存为 `![alt](relative/path)`
- 本地附件保存为 `[name](relative/path)`,不生成 OnlyOffice assetId URL。
- 单测与 HTTP smoke 已通过,剩余真实浏览器 smoke 仍需补齐。
通用导入、跨页面共享素材或批量迁移资源可以放入工作区级资源目录:
```text
assets/attachments/<yyyy>/<mm>/<safe-file-name>
```
写入后 Rust kernel 生成 resource identity,并更新 `.mnote/resource-index.json` 或等价本地索引。页面内上传优先保持 Markdown 可迁移性,工作区级资源目录只作为共享资源和迁移工具的目标,不是编辑器上传默认路径。
### 3.3 AI 会话
AI 会话默认写入:
```text
ai-sessions/private/<session-id>.jsonl
```
共享会话写入:
```text
ai-sessions/shared/<share-id>/<session-id>.jsonl
```
会话记录必须包含:
- `user_id`
- `workspace_id`
- `session_id`
- `scope`
- `allowed_resource_ids`
- `created_at`
- `model`
- `tool_calls`
AI 默认不能跨用户、跨 workspace 或跨未授权资源读取内容。
---
## 4. 权限与分享模型
早期只实现三档权限:
| 权限 | 说明 |
| --- | --- |
| `admin` | 管理员可读写任意目录,可授权其他用户访问目录 |
| `private` | 只有 owner 可见、可编辑、AI 可读 |
| `shared_read` | 被授权用户可读,AI 可在只读上下文中引用 |
| `shared_write` | 被授权用户可编辑,AI 可在该共享范围内执行写工具 |
共享对象可以是:
- 单个页面
- 文件夹 / 子树
- 附件 / 资源
- AI 会话
- workspace
共享授权的 canonical 记录在控制面,客户端本地缓存到 `.mnote/share-cache.json`。离线时只允许访问已缓存且未过期的授权范围;不能因为控制面不可用而扩大权限。
目录授权的 canonical 记录在 `control-plane/access-policy.json`。普通用户访问本地目录时,优先按 `admin` / `owner` / `grant` 三层判定;AI 权限继承目录授权,不单独放大。
---
## 5. AI 隔离原则
AI 上下文边界必须先于工具执行确定。
每次 AI run 都要计算:
```text
AiAccessScope {
user_id,
workspace_id,
session_id,
source_kind,
allowed_roots,
allowed_file_paths,
allowed_resource_ids,
permission_level,
share_context,
}
```
local-first 下,AI 的长期主路径不是继续发明一套 MNote 专用编辑工具,而是:
```text
前端传当前文件引用 / selection / 用户指令
-> MNote 解析授权文件路径并创建 AiAccessScope
-> 本地 agent runtime 在受限 cwd / allowed roots 内运行
-> agent 使用自身成熟的 diff / apply_patch / 文件编辑能力修改 .md
-> MNote 记录 changed_files / diff / audit,并刷新 File Tree / Page Aggregate
```
进一步贴近 VSCode 的运行要求是:
- Hermes / Reasonix 自身已有的权限设置优先复用;MNote 不重复发明第二套编辑能力。
- `allowed_roots` 就是 agent 可见目录白名单;普通用户只拿到自己的 `my-space` 或显式授权目录,管理员可拿到更大白名单。
- 页面 AI 面板默认只传“当前页面文件引用 + 可选 selection + 当前 root 白名单”,不把整个 page aggregate 或 block tree 作为必需前置。
- agent 后台编辑完成后,MNote 只做文件变更侦测与 UI 重投影,让 tiptap 跟着文件刷新,而不是让 tiptap 成为写入真相。
因此 `mnote.doc.fetch``mnote.doc.markdown_edit``mnote.block.*` 不再被描述为 local-first 普通 Markdown 编辑的必经主路径。它们只保留三类价值:
- 远端 / cloud agent 无法直接访问本机文件时的受控读写代理。
- 兼容旧页面 AI / Hermes tool 调用。
- 复杂结构对象或共享权限场景下的受控专用入口。
文件解析、agent 启动、内置 patch/diff 写入、兼容 mnote tools、搜索和附件读取都必须验证 `AiAccessScope`;权限边界由 MNote 控制,编辑算法优先交给 agent runtime 自身。
当前需要纳入迁移盘点的 AI / Hermes 工具入口:
| 文件 | 当前职责 | local-first 迁移要求 |
| --- | --- | --- |
| `rust/crates/mnote-web/src/routes/hermes_tools.rs` | `mnote` 兼容 tool manifest / call / audit 路由,分发 `mnote.doc.*``mnote.block.*``mnote.page.*`、artifact 工具 | 不再作为 local-first 普通 Markdown 编辑主入口;兼容调用前仍解析 `AiAccessScope`,审计记录必须区分 local / cloud / shared,并记录 changed_files / diff 摘要 |
| `rust/crates/mnote-web/src/hermes_tools/manifest.rs` | 输出 Hermes tool manifest 与 input schema | manifest 要明确:本地默认优先 agent 原生 diff / apply_patch`mnote.doc.*` 是兼容 / 远端代理工具;所有读写工具标注权限要求;本地附件不得改写成 Convex media asset |
| `rust/crates/mnote-web/src/hermes_tools/doc.rs` | `mnote.doc.fetch``mnote.doc.find``mnote.doc.plan_update``mnote.doc.markdown_edit` | 作为 cloud / remote agent / compat fallback 保留;local `.md` 写入时必须保留相对链接与 `{mdBase}.assets/`;读写前校验 `AiAccessScope` |
| `rust/crates/mnote-web/src/hermes_tools/block.rs` | `mnote.block.fetch/replace/insert_after/delete/move_after``mnote.doc.apply_block_ops` | 从当前 local-first 主线降级为复杂结构 / 历史兼容辅助;普通 Markdown 编辑不再要求走块级工具;若继续开放,必须校验共享写权限和 allowed target blocks |
| `rust/crates/mnote-web/src/hermes_tools/page.rs` | `mnote.page.get/save/update_title/update_options` 粗粒度页面工具 | `mnote.page.save` 不应成为默认正文编辑入口;local source 下标题/设置写入 `.md` frontmatter / `.mnote/page-options.json` 主路径;普通正文改写优先由 agent 文件 patch 完成 |
| `rust/crates/mnote-web/src/hermes_tools/artifact.rs` | AI 摘要、AI note artifact 类工具 | artifact 默认写入 local workspace / `ai-sessions` / `.mnote` 侧车文件;只有分享或同步开启时才进入控制面 |
| `rust/crates/mnote-web/src/routes/page_ai_workflow.rs` | 页面 AI 兼容门面,自然语言编辑曾转 `mnote.doc.markdown_edit` | local source 下不作为主路径,不再加厚 fast-path;普通正文编辑主路径应直接给 agent 授权文件引用并使用其内置 patch/diff,不得退回 Convex 写入或 media asset |
| `rust/crates/mnote-web/src/ssr/pages/layout.rs` | 页面 AI 面板、会话列表、工具开关、附件打开 UI 的浏览器侧脚本 | UI 文案与状态要区分本地会话、共享会话和 cloud 会话;local 附件打开优先走相对文件/本地 serving,不默认 `/api/media/sign?assetId=` |
默认规则:
- 私有 AI 会话只能读写 owner 的授权资源。
- 分享页面进入 AI 上下文时,只带该分享范围,不带整个 workspace。
- 分享 AI 会话时,分享的是会话记录与被授权上下文,不自动分享 owner 的全部笔记。
- `shared_read` 范围内的 AI 不允许执行写入、patch 或任何可修改文件的内置工具。
- `shared_write` 范围内的 AI 写入必须产生 audit 记录,至少包含 actor、session、changed_files 和 diff 摘要。
---
## 6. 当前项目迁移总原则
迁移目标不是新增一套 local UI,而是降低 Convex 在主链中的位置。
硬约束:
- 不新增 `/api/local-*` 平行主链。
- 不新增本地专用文件树 UI。
- 不让本地文件夹先导入 Convex 再渲染。
- 不让前端直接拼本地 path / Convex record 成树。
- 不把页面正文、文件树、AI 会话继续设计成两套同等真相。
- 树、页面元数据和共享权限写操作继续走 `tree.*` / `page.*` 语义入口;普通 Markdown 正文编辑允许由受限 agent runtime 直接对授权 `.md` 文件执行 diff / patch,MNote 负责权限、审计和刷新。
允许的过渡:
- Convex executor 和 LocalFS executor 并存。
- Convex 旧 route 通过 adapter 转入 Rust kernel。
- 局部页面仍可先读 Convex,再逐步切到 LocalFS。
- 历史阶段控制面曾可临时使用 Convex Auth 与 membership;当前默认已由 Rust SQLite control-plane 承接。
---
## 7. 迁移阶段
### Phase 0:盘点并冻结新 Convex 扩写
目标:防止继续把新功能绑到 Convex 主数据层。
任务:
- 标记所有当前 Convex 依赖路径:文件树、页面树、上传、下载、正文保存、页面设置、AI 会话、分享授权。
- 新增功能默认禁止直接写 Convex documents / files / sessions。
- 新设计必须先判断是否应落到 `WorkspaceSource` / Rust kernel / LocalFS。
验收:
- 有一份 Convex 依赖矩阵。
- 新增设计稿不再把 Convex 称为默认存储底座。
- `ARCHITECTURE.md` 与当前优先级文档能指向本稿。
### Phase 1:本地工作区成为默认入口
目标:打开应用后默认进入 local workspace,而不是依赖 Convex workspace。
任务:
- `desktop:hot` 默认加载最近本地 workspace。
- 没有最近 workspace 时显示“打开文件夹 / 创建工作区”。
- 登录态是访问本地工作区的门禁;分享、同步、云能力在登录基础上继续由控制面授权。
- `.mnote/workspace.json` 写入 workspace id、owner、本地 capabilities。
验收:
- 断开 Convex 后,本地工作区仍可打开。
- 文件树、页面树、文档阅读态可用。
- UI 明确展示当前 source 是本地工作区。
### Phase 2:文件树与页面树切到 LocalFS 默认源
目标:文件树、页面树默认从本地文件夹扫描和投影。
任务:
- LocalFolderSource 输出 canonical snapshot。
- File Tree 显示目录、`.md`、附件、mindmap、普通文件。
- Page Tree 只显示页面导航语义,优先纳入 `.md`
- Convex workspace 继续作为可选 source executor。
验收:
- 同一个 Explorer surface 可以渲染 local 与 Convex。
- 文件树 / 页面树 UI 没有 source-specific 分支。
- local rename / move / delete preflight 不依赖 Convex。
### Phase 3:页面编辑保存本地化
目标:正文编辑默认写回 `.md` 文件。
任务:
- `page_aggregate` 从本地 markdown 解析生成。
- `page.body.save` 对 local source 写入 markdown 文件。
- 页面设置写入 `.mnote/page-options.json` 或 frontmatter。
- 标题修改按规则更新 frontmatter / H1 / 文件名,避免三处不同步。
- 保留 Convex save executor,但不作为默认。
验收:
- Convex 不可用时仍可编辑、保存、刷新恢复。
- AI `mnote.doc.markdown_edit` 与手动编辑写同一份 `.md`
- 页面标题、正文、页面设置刷新后一致。
### Phase 4:上传下载转成本地资源操作
目标:上传、拖入、下载都以本地文件为默认资源模型。
任务:
- 页面内上传默认复制到 `<md-dir>/<md-base>.assets/`,并在正文中写入相对路径。
- 工作区级导入或跨页面共享素材可以复制到 `assets/`,并建立 resource identity。
- `sourceKind=local_folder` 的编辑器上传不得调用 `/api/media/upload`
- `sourceKind=convex_workspace` 或后续 cloud source 仍可继续走远端 media upload executor。
- 下载普通文件时直接读取本地文件。
- 下载页面时导出 `.md` 或打包子树。
- Convex storage 只用于远端同步或共享副本。
验收:
- 本地附件上传不需要 Convex。
- 本地 `.md` 页面上传图片后,落盘正文是 `![alt](relative/path)`
- 本地 `.md` 页面上传附件后,落盘正文是 `[name](relative/path)`
- 上传目标目录不越过 local root,冲突文件名递增,不覆盖已有文件。
- 文件树能显示上传后的资源。
- 刷新页面后图片 / 附件块能从相对路径恢复。
- 删除页面或附件进入 `.mnote/trash/` 或系统回收策略。
### Phase 5AI 会话本地化与隔离
目标:AI 会话默认按用户 / workspace / scope 本地持久化。
任务:
- 私有会话写入 `ai-sessions/private`
- 共享会话写入 `ai-sessions/shared` 并绑定 share grant。
- AI tool executor 每次调用都校验 `AiAccessScope`
- 只把必要的 session metadata / share metadata 同步到控制面。
验收:
- 用户 A 的 AI 不读取用户 B 私有笔记。
- 只读分享上下文中 AI 不能写。
- 共享 AI 会话不会隐式共享整个 workspace。
### Phase 6:分享与同步控制面收口
目标:Convex 从主数据层降级为控制面和可选同步服务。
任务:
- 控制面只保存 user、workspace membership、share grants、sync state、AI policy。
- 页面正文和附件只在用户启用同步时产生远端副本。
- 共享页面通过控制面授权,再由同步层分发或拉取内容。
- 多人实时协作作为后续能力,不进入早期默认路径。
验收:
- 本地个人使用不需要 Convex。
- 创建、打开、编辑本地工作区需要登录;开启分享 / 同步 / 云协作时进一步需要控制面授权。
- 关闭同步后,用户数据仍完整保留在本地工作区。
### Phase 7:退役 Convex 主存储路径
目标:清理历史 Convex documents / files / sessions 主链。
任务:
- 将旧 `documents.*` route 降级为 compat adapter。
- 将 Convex page body 写入改为 sync replica 写入。
- 删除前端直接依赖 Convex 文档结构的路径。
- 保留必要迁移工具,把旧 Convex workspace 导出为本地 workspace。
验收:
- 新建、编辑、上传、下载、AI 编辑不依赖 Convex。
- Convex 只在登录、分享、同步、协作能力开启时参与。
- 旧 Convex 数据可以迁移到本地文件夹。
---
## 8. 可执行 Checklist
本 checklist 用于后续迁移执行。状态只记录本稿方向下的真实主线,不回填历史已完成但不再作为目标模型的 Convex-only 能力。
### 8.1 P0:口径冻结与依赖矩阵
- [x] 新增本稿,固定 local-first workspace 作为早期产品默认形态。
- [x] `ARCHITECTURE.md` 已加入 2026-05-18 local-first 口径。
- [x] `design/01-05-current-priority-overview.md` 已把 local-first workspace 提升为第一优先级。
- [x] 建立 Convex 依赖矩阵,至少覆盖:
- [x] File Tree source / projection / command。
- [x] Page Tree source / projection / command。
- [x] Page Aggregate read / save / options。
- [x] 编辑器图片 / 附件上传。
- [x] 普通文件下载 / 页面导出。
- [x] AI session / message / tool call 存储。
- [x] share grants / membership / auth。
- [x] 给新增设计和实现加检查规则:默认不得把新功能直接绑定到 Convex documents / media / sessions 主存储。
#### P0.1 Convex 依赖矩阵(2026-05-18 代码盘点)
| 范围 | 当前 Convex / 远端依赖证据 | local-first 迁移动作 | 当前状态 |
| --- | --- | --- | --- |
| Workspace source 默认值 | `rust/crates/core-protocol/src/kernel.rs` 已有 `WorkspaceSourceKind` / `WorkspaceSource`,但测试仍断言 `convex_workspace``rust/crates/mnote-web/src/ssr/pages/layout.rs::currentSourceKind()` 默认返回 `convex_workspace``rust/crates/mnote-web/src/routes/tree.rs` command context 默认也会回到 `convex_workspace`。 | P1/P2 把最近本地 workspace / `local_folder` 设为默认入口;Convex workspace 只作为显式 cloud source。 | 未迁移;有 local source 能力,但默认仍偏 Convex。 |
| File Tree source / projection / command | `rust/crates/mnote-web/src/routes/tree.rs` 已按 `sourceKind=local_folder` 分流到 `load_local_folder_file_tree_snapshot` / `execute_local_tree_command`Convex 分支仍依赖 `workspaces:ensureDefaultWorkspace``documents:createWithParentReference``documents:updateTitle``documents:move``rust/crates/mnote-web/src/routes/web_shell.rs::load_file_tree_html` 仍可从 Convex sidebar dataset 投影 file tree。 | LocalFS 作为默认 `WorkspaceSource`;同一 Explorer surface 只消费 Rust projectionConvex executor 保留为可选 source,不再驱动默认首屏。 | 部分完成:本地 projection/command 已存在,默认入口和若干 UI 分支未收口。 |
| Page Tree source / projection / command | `rust/crates/mnote-web/src/routes/tree.rs``rust/crates/mnote-web/src/routes/web_shell.rs` 已支持 `load_local_folder_page_tree_snapshot`,但缺省 source 和 Convex tree command fixture 仍指向 `documents.*`。 | Page Tree 默认从本地 `.md` 页面语义投影;页面树命令统一走 `tree.*`source-specific 执行藏在 executor。 | 部分完成:本地只读/命令测试存在,默认 source 未切。 |
| Page Aggregate read | `rust/crates/mnote-web/src/routes/web_shell.rs::build_page_aggregate_snapshot` 对 local source 调 `resolve_local_markdown_page_aggregate`Convex / fixture 路径仍读 `documents:getMeta``documents:getContent`。 | `page_aggregate` 默认 provenance 为 `local_markdown.content`Convex documents 仅作为 cloud source / sync replica 读取。 | 部分完成:local aggregate 已有,主默认仍未整体切换。 |
| Page body save / options | `rust/crates/mnote-web/src/routes/documents.rs``sourceKind=local_folder` 时写 `save_local_markdown_page` / `update_local_page_options`Convex 路径仍通过 `page.body.save` 进入 `rust/crates/mnote-web/src/transport/convex.rs`,映射到 `documents:updateContent`。 | `page.body.save``WorkspaceSource` 选择 LocalFS executorConvex `documents:updateContent` 改为同步副本或 cloud source executor,不再是默认正文保存。 | 部分完成:local save/options 已存在,Convex save 仍是 legacy 主分支之一。 |
| 编辑器图片 / 附件上传 | `rust/crates/mnote-web/src/routes/local_folder_source.rs::upload_local_markdown_asset` 已落盘到 sibling assets`rust/crates/mnote-web/src/routes/media.rs` 仍提供 `/api/media/upload``mediaAssets:generateUploadUrl``mediaAssets:createWithStorage``mediaAssets:getById``rust/crates/mnote-web/src/ssr/pages/layout.rs` 仍保留 `/api/media/sign?assetId=` 与 OnlyOffice assetId 路径。 | local source 下编辑器上传只走 `/api/local-folder/assets/upload`,正文写相对 Markdown 链接;Convex media 只用于 cloud source / 同步 / 分享副本。 | 部分完成:本地上传基线完成,浏览器 smoke 与 assetId 打开链路仍需收口。 |
| 普通文件下载 / 页面导出 | `rust/crates/mnote-web/src/ssr/pages/layout.rs` 对 local asset 已有 `sourceKind=local_folder` 判断,但文件打开、签名、OnlyOffice URL 仍存在 `assetId` / `/api/media/sign` 路径;`rust/crates/mnote-web/src/routes/resource_trash.rs` 仍大量操作 `mediaAssets:*`。 | 普通本地文件下载直接读取 local root 内路径;页面导出 `.md`,子树导出目录与 assetsConvex media 下载只作为 cloud source 能力。 | 未完成:本地打开有局部判断,完整下载/导出模型未闭环。 |
| AI tool 读取 / 写入页面 | `rust/crates/mnote-web/src/routes/hermes_tools.rs` 分发 `mnote.doc.*` / `mnote.block.*``rust/crates/mnote-web/src/hermes_tools/doc.rs::doc_markdown_edit``rust/crates/mnote-web/src/hermes_tools/block.rs` 最终仍走 `page.body.save`,在线路径会落到 Convex `documents:updateContent`。 | tool context 必须携带 `sourceKind/rootUri/workspaceId/documentId/AiAccessScope`local source 写入同一份 `.md`,并保留 `{mdBase}.assets/` 相对链接。 | 部分完成:`mnote.doc.markdown_edit` local source 已写同一份 `.md` 并保留相对链接;`AiAccessScope` 权限收口仍未完成。 |
| AI session / message / tool call 存储 | 历史阶段由 `convex/schema.ts``convex/aiSessions.ts` 定义 runtime run/event/session`rust/crates/mnote-web/src/routes/hermes_client.rs` 曾调用 `aiSessions:upsertRuntimeRun``appendRuntimeEvent``listRuntimeRuns``listRuntimeEvents``renameRuntimeSession``autoTitleRuntimeSession``deleteRuntimeSession``searchRuntimeSessions`。 | 私有会话默认写 `ai-sessions/private/*.jsonl`;共享会话写 `ai-sessions/shared/<share-id>/*.jsonl`ACP/Hermes runtime session 默认写 SQLite control-planeConvex 只保留显式 legacy / cloud / sync replica。 | 已由 `2-8` 继续收口:根 `convex/` runtime store 退役,默认 ACP runtime store 改为 SQLite。 |
| share grants / membership / auth | 历史阶段 `rust/crates/mnote-web/src/context.rs``rust/crates/mnote-web/src/transport/convex.rs` 处理 auth context / `__convexAuthJWT``rust/crates/mnote-web/src/routes/tree.rs` 曾调用 `workspaces:ensureDefaultWorkspace`。 | Rust SQLite control-plane 承接 auth、membership、share grants、sync state、AI policy;本地打开和编辑需要登录 actor,并由 `.mnote/workspace.json.owner_id` 限定 root 归属;分享/同步继续要求 control-plane 授权。 | 已由 `2-8` 继续收口:默认 `/api/auth``/api/session`、share grant、access policy 和 ACP runtime store 均不依赖 Convex。 |
| Smoke / 测试依赖 | `scripts/task159-auth-entry-smoke.js` 依赖 Convex password auth`scripts/task175-rust-upload-entry-smoke.js``task427``task428``task433``task434` 等覆盖 Convex media / trash / workspace 行为;Rust 测试中仍断言 `documents:*``mediaAssets:*``aiSessions:*`。 | 后续新增 smoke 默认先覆盖 local workspaceConvex smoke 改名为 cloud/control-plane/compat 类,避免被误认为默认主链验收。 | 未完成:测试口径仍混合,需要随 P1-P7 分批改名和补 local smoke。 |
#### P0.2 新增设计 / 实现检查规则(2026-05-18 已落地)
已新增 `scripts/check-local-first-convex-guard.js`,并在 `package.json` 暴露 `check:local-first-convex-guard`。该检查默认扫描相对 `HEAD` 的新增行,也支持:
- `--base <ref>`:检查相对指定 git ref 的新增行。
- `--staged`:检查 staged diff。
- `--files <path...>`:检查指定文件全文,便于局部验证。
检查规则:
- 设计稿检查:新增 `design/**/*.md` 若把 Convex 描述为默认 documents / media / sessions 主存储,会要求改成 `WorkspaceSource` / Rust kernel / LocalFS 默认路径,或明确标注为 cloud source / compat / sync replica。
- 代码检查:新增 `rust/crates/mnote-web/src/**/*.rs` 运行时代码不得在非 Convex adapter 路径直接新增 `documents:*``mediaAssets:*``aiSessions:*` 主存储调用;确有 cloud / compat 例外时,必须在同一新增行加入 `local-first-allow-convex-main-storage` 并说明原因。
- 测试口径:新增 smoke 默认优先覆盖 `sourceKind=local_folder`;Convex 专项测试需在文件名或标题中标注 `cloud``control-plane``compat``convex-source`
### 8.2 P1:本地工作区入口
- [x] 登录后,`desktop:hot` 默认优先加载当前用户最近本地 workspace。
- 证据:`SIDEBAR_TREE_JS::autoOpenRecentLocalRootOnHome` 已能在已登录主壳根路径、无显式 source/workspace/page 参数时读取当前 actor 作用域的 `mnote.localFolder.recentRoots:<actorId>` 并跳转最近 `rootUri``task164-desktop-hot-local-folder-main-entry-smoke.js` 已断言不写入跨用户共享的 legacy key。
- [x] 登录后,没有最近 workspace 时展示“打开文件夹 / 创建工作区”,不要求先进入 Convex workspace。
- 说明:登录是访问本地文件的门禁;未登录 `/` 和未登录 `sourceKind=local_folder` 必须跳 `/auth`,避免注册用户或匿名上下文绕过用户隔离直接访问本机任意目录。
- [x] `.mnote/workspace.json` 写入稳定 `workspace_id``owner_id``created_at``capabilities`
- 目录:默认写入 `/mnt/Data1T/Mnote_data/users/<actor-id-encoded>/workspaces/my-space/.mnote/workspace.json`
- 边界:普通用户创建入口只初始化受管默认路径;任意外部 `rootUri` 只有在管理员授权或 owner 已存在时才能打开,不能被普通登录用户直接初始化或接管。
- [x] 登录状态是本地文件访问、用户空间隔离、分享、同步、云端协作的共同门禁;Convex 不再是正文/附件主存储,且默认 auth / session 已由 Rust SQLite control-plane 承接。
- 证据:`sourceKind=local_folder` 根入口继续要求 auth;本地文件读写入口新增 `.mnote/workspace.json.ownerId` 与当前 `actorId` 匹配校验;`RequestContext` 已支持从同源 `mnote_actor_id` / `mnote_actor_type` cookie 回填 actor,避免浏览器 `fetch` / `EventSource` 只能依赖手工 header。
- 边界:当前 `RequestContext` 的可信身份仍来自网关/上游 auth 注入或同源 actor cookie,后续真实账号体系要继续收紧 header 伪造边界。
- [x] `sourceKind=local_folder` 的 root 入口保持登录要求,未登录会跳转 `/auth`
- 证据:`rust/crates/mnote-web/src/routes/gateway.rs::root_entry` 在 source 分流前执行 auth gate`root_entry_redirects_anonymous_viewer_to_auth` 覆盖匿名根入口。
- [x] LocalFolderSource / local API 必须校验当前登录 actor 是否被允许访问传入 `rootUri`
- 要求:不能只靠前端隐藏入口;`/api/tree/*``/api/documents/*``/api/local-folder/assets/upload``/api/page-aggregate/*` 等所有 local source 入口都必须拒绝未授权 root。
- 初始策略:每个用户有自己的默认本地“我的空间”;`.mnote/workspace.json.owner_id` 必须匹配当前 actor,或 root 必须位于当前 actor 的受管 workspace 根目录下,或当前 actor 是管理员,或 rootUri 落在管理员显式授权目录内。
- 安全风险:如果只要求“已登录”但不校验 root 归属,任意注册用户仍可能传入本机任意 `file://` 目录并执行树命令或删除文件。
- 已覆盖入口:`/` local root、`/documents/{id}``/api/page-aggregate/{id}``/api/documents/save|title|options``/api/tree/commands``/tree` debug shell、`/api/tree/local-folder-watch``/api/local-folder/events``/api/local-folder/assets/upload`
- 证据:`ensure_local_workspace_access` / `ensure_local_workspace_read_access` 统一解析 canonical root,并按 admin / owner / grant 三层授权;`local_workspace_access_rejects_owner_mismatch` 覆盖非 owner 拒绝,`local_workspace_access_allows_admin_without_manifest` 覆盖管理员可访问任意目录,`local_workspace_access_policy_grants_read_and_write_separately` 覆盖 read/write grant 分离,`tree_command_local_folder_rejects_non_owner_root` 覆盖已登录非 owner 不能执行本地 tree 写命令。
- 浏览器证据:`node scripts/task164-desktop-hot-local-folder-main-entry-smoke.js` 已在 `desktop:hot` 下通过;smoke 显式预置 owner manifest,覆盖 actor 作用域最近目录、切换本地 root、刷新回到最近本地 root。
- 边界:Hermes tools 直接绝对路径读写属于 P5 `AiAccessScope` 收口,不计入本项完成范围。
- [x] Convex 不可用时,本地 workspace 入口仍可进入文档页。
- 证据:`page_aggregate_endpoint_returns_local_markdown_readonly_snapshot``document_shell_renders_local_markdown_with_same_sidebar_surfaces``document_shell_renders_local_markdown_attachment_name_in_html``convex_url=None` 的 Rust app 下通过;测试前显式创建 `.mnote/workspace.json` owner manifest。
- [x] smoke 覆盖:断开 Convex 后创建默认本地 workspace、打开 `.md`、刷新仍可读。
- 证据:`node scripts/task166-local-first-managed-workspace-no-convex-smoke.js` 启动独立 mnote-web,使用临时 `MNOTE_LOCAL_WORKSPACE_BASE_DIR` 和不可用 Convex URL,浏览器点击“创建我的空间”,验证 manifest / 默认 `.md` 落盘,并在刷新后读取 `local_markdown.content` page aggregate。
### 8.3 P2LocalFS File Tree / Page Tree 默认 source
- [x] 既有 `4-22` 已完成 Local Folder / Convex 统一树源执行基线。
- [x] 将 LocalFS 明确设为默认 `WorkspaceSource`
- 证据:`root_entry` 在无显式 `sourceKind/workspaceId/pageId` 时渲染 local-first landing,不再先调用 `workspaces:ensureDefaultWorkspace``SIDEBAR_TREE_JS::currentSourceKind()` 在首页无显式 source 参数时默认返回 `local_folder`
- [x] File Tree 默认从 local root 扫描目录、`.md`、普通文件、mindmap、附件。
- 证据:`load_local_folder_file_tree_snapshot` 通过 `scan_directory` 输出统一 `file_tree` projection`tree_shell_filetree_mode_can_open_local_folder_readonly_snapshot``task164-desktop-hot-local-folder-main-entry-smoke.js` 已通过。
- [x] Page Tree 默认只消费页面导航语义,优先纳入 `.md`
- 证据:`load_local_folder_page_tree_snapshot` 通过 `scan_markdown_page_tree` 只纳入 Markdown 页面语义;`tree_shell_page_mode_can_open_local_folder_md_only_snapshot` 已通过。
- [x] `tree.node.create` 在 local source 下创建本地 `.md` 或文件夹。
- 证据:`execute_local_tree_command` 已将 `create` / `createFolder` 分发到 `create_local_markdown_page` / `create_local_folder``cargo test -p mnote-web local_folder -- --nocapture` 覆盖本地 tree command 创建链路。
- [x] `tree.node.rename` 在 local source 下执行文件 rename,并保持 page id 稳定。
- 证据:`rename_local_markdown_page` / `rename_local_directory` 更新 `.mnote/page-ids.json``local_frontmatter_mnote_id_survives_rename_and_move``tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint` 已通过。
- [x] `tree.subtree.move` 在 local source 下执行文件系统 move,并保持 local root 边界。
- 证据:`move_local_markdown_page` / `move_local_directory` 使用 root 内目标目录并拒绝自身/后代移动;`tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint` 已通过。
- [x] `tree.node.delete` 在 local source 下进入 `.mnote/trash/` 或系统回收策略。
- 证据:`trash_local_markdown_page` / `trash_local_raw_file` 写入 `.mnote/trash``trash-index.json``tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 已通过。
- [x] 文件树 / 页面树 UI 不新增 local-only 分支,只消费 projection。
- 证据:local / Convex source 均通过 Rust projection 渲染同一个 `sidebar-file-tree-root` / `sidebar-tree-root` surfacelocal asset 打开通过 row identity 分流,不新增第二套 local-only 文件树 UI。
- [x] smoke 覆盖 local create / rename / move / delete / refresh。
- 证据:`cargo test -p mnote-web local_folder -- --nocapture` 覆盖 local create / rename / move / trash / restore / purge`task164` 覆盖 local root 打开与刷新回到最近 workspace;`task436` 覆盖刷新 / watcher 同步。
### 8.4 P3Page Aggregate 本地 Markdown 读写闭环
- [x] `page_aggregate` 已能从 `documents.content` / local markdown content 投影 `blockDocument`
- [x] local source 下 `page_aggregate` provenance 标记为 local markdown,而不是 Convex documents。
- 证据:`resolve_local_markdown_page_aggregate` 输出 `projection_source="local_markdown.content"``task166-local-first-managed-workspace-no-convex-smoke.js` 刷新后验证 page aggregate 包含 `local_markdown.content`
- [x] `page.body.save` 对 local source 写回 `.md` 文件。
- 证据:`documents.rs` local source 分支调用 `save_local_markdown_page``local_markdown_save_preserves_frontmatter_and_writes_basic_blocks``local_markdown_save_writes_image_blocks_as_markdown_images` 已通过。
- [x] 页面设置写入 `.mnote/page-options.json` 或 frontmatter,规则只能有一个主路径。
- 证据:`update_local_page_options``.mnote/page-options.json``local_page_options_metadata_flows_into_page_aggregate``local_folder_documents_save_title_and_options_write_to_disk` 已通过。
- [x] 标题修改规则固定为 frontmatter `title` > H1 > 文件名,并避免三处长期分叉。
- 证据:`parse_markdown_page` 读取顺序为 frontmatter `title`、第一行 H1、文件名;`task167-local-markdown-title-body-options-no-convex-smoke.js` 已验证 frontmatter 标题优先于正文 H1。
- [x] 保存时检查 mtime / content hash,外部编辑冲突不得静默覆盖。
- 证据:`save_local_markdown_page` 校验 `expected_conflict_detection_key``local_markdown_save_rejects_stale_external_file_change` 已通过。
- [x] AI `mnote.doc.markdown_edit` 与手动编辑写同一份 `.md`
- 证据:`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file``sourceKind=local_folder/rootUri=file://...` 下写回同一份 `README.md`,并保留 `README.assets/photo.png` 相对链接,不改写为 `/api/media``assetId`
- [x] smoke 覆盖本地标题、正文、页面设置、刷新恢复。
- 证据:`node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js` 在不可用 Convex URL 下创建受管本地 workspace,调用 `/api/documents/title``/api/documents/save``/api/documents/options`,再读取 page aggregate 与落盘 `.md` / `.mnote/page-options.json`
- [x]`/api/documents/save` 从长期写入口降级为 compat adapter;新增或收口到 local-first `page.body.write` / kernel-native 写命令,写入目标是 `.md` 文件而不是 Convex documents 主存储。
- 证据:`POST /api/page-body/write` 已新增;local source 的 `/api/documents/save` 只构造 `PageBodyWriteRequest` 并调用 `write_local_markdown_page_body`,返回 `canonicalCommand=page.body.write` / `compatCommand=page.body.save`
- [x] `page.body.write` 必须接收 `expectedFileVersion` / `baseContentHash`,拒绝基于旧文件版本的静默覆盖。
- 证据:`PageBodyWriteRequest` 支持 `expectedFileVersion/baseContentHash``write_local_markdown_page_body` 复用本地文件版本校验,`local_folder_documents_save_rejects_stale_expected_file_version` 已覆盖。
- [x] `Page Aggregate` 写侧区分 canonical markdown 与 runtime cachelocal-first 下 `.md` 是正文真相,`EditorBlockDocument` 可以作为 `.mnote/cache` 或内存投影缓存,但不能成为第二正文真相。
- 证据:local source 写入链只落盘 `.md``.mnote/page-options.json``EditorBlockDocument` 作为 `contentFormat=editorBlocks` 输入被序列化为 markdown,不写成第二正文真相。
- [x] tiptap 保存链不再直接依赖 `documents/save` 兼容面决定 revision;它应提交基于当前 `baseFileVersion` 的 markdown 写入意图,由 LocalFS executor 做版本校验。
- 证据:local source editor bootstrap / 动态 pane bootstrap 均指向 `/api/page-body/write`payload 显式带 `expectedFileVersion``contentFormat=editorBlocks``editorSource=tiptap`
- [x] 文件 watcher 发现 AI / 外部编辑器写入后,若 tiptap clean 则自动刷新,若 tiptap dirty 则进入 conflict state。
- 证据:`scripts/task436-local-markdown-open-document-external-change-smoke.js` 覆盖 clean 自动刷新与 dirty 进入 `external-change-conflict`
- [x] smoke 覆盖:tiptap dirty 时 AI 后台修改同一 `.md`,前台不得静默覆盖任一方,必须显示冲突或保留可合并状态。
- 证据:`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过 dirty 文档 AI 后台写入冲突场景。
- [x] smoke 覆盖:tiptap clean 时 AI 后台修改同一 `.md`,前台 page aggregate / tiptap 自动同步到新内容。
- 证据:`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过 clean 文档外部写入自动刷新场景。
### 8.5 P4:本地上传 / 下载 / 资源
- [x] 已新增 `POST /api/local-folder/assets/upload`
- [x] 本地 `.md` 上传图片 / 附件已分流,不再调用 `/api/media/upload`
- [x] 本地上传复制到 `<md-dir>/<md-base>.assets/`
- [x] 本地上传返回 `sourcePath` / `file_url` 相对路径。
- [x] 本地附件插入普通相对路径链接,不生成 OnlyOffice assetId URL。
- [x] 本地图片保存为标准 Markdown 图片语法。
- [x] 单测覆盖本地上传目标路径、冲突递增、local markdown 图片 / 附件 roundtrip。
- [x] HTTP smoke 覆盖真实 multipart 上传与落盘。
- [x] 浏览器 smoke 覆盖本地 `.md` 页面上传图片、保存、刷新后恢复。
- 证据:`node scripts/task443-local-markdown-asset-upload-smoke.js` 已验证图片上传到 sibling `README.assets/`、通过 `/api/page-body/write` 保存为相对 markdown 图片,并刷新后从 Page Aggregate 恢复。
- [x] 浏览器 smoke 覆盖本地 `.md` 页面上传附件、保存、刷新后恢复。
- 证据:`node scripts/task443-local-markdown-asset-upload-smoke.js` 已验证附件上传到 sibling `README.assets/`、保存为相对 markdown 链接,并刷新后从 Page Aggregate 恢复。
- [x] 浏览器 smoke 覆盖上传后的 `{mdBase}.assets/` 资源在 File Tree 可见。
- 证据:`scripts/task443-local-markdown-asset-upload-smoke.js` 已补强断言 `sidebar-file-tree-root` 中出现 `task443-image.png``task443-spec.pdf`
- [x] 下载普通本地文件时直接读取 local root 内文件。
- 证据:`GET /api/local-folder/files/open` 已新增并先校验 actor 对 `rootUri` 的访问权,再拒绝绝对路径 / `..` 越界;`cargo test -p mnote-web local_file_open_rejects_root_escape -- --nocapture` 已通过。
- [x] 下载页面时导出 `.md`,下载子树时打包目录与 assets。
- 证据:单个本地 `.md` 页面和普通文件通过 `/api/local-folder/files/open?download=true` 直接读取 root 内文件;旧 Convex 子树迁移 / 打包由 `scripts/export-convex-workspace-to-local.js` 输出目录与 sibling assets。
- [x] 删除页面时同步处理 sibling assets 的策略明确:保留、移动到 trash 或提示用户。
- 策略:当前 local source 删除 Markdown 页面只移动 `.md``.mnote/trash/`sibling `{mdBase}.assets/` 默认保留,避免误删仍被其他页面相对引用的资源;后续若要联动移动 assets,必须先做引用扫描和确认 UI。
### 8.6 P5:AI 会话本地化与权限隔离
#### P5.1 Agent 文件访问 scope 与兼容 manifest
- [x] 定义 `AiAccessScope` 的 Rust 类型和序列化格式,至少包含 `user_id/workspace_id/session_id/source_kind/permission_level/allowed_roots/allowed_file_paths/allowed_resource_ids/share_context`
- 证据:`core_protocol::AiAccessScope``AiAccessPermissionLevel``AiShareContext` 已定义并导出;`cargo test -p core-protocol ai_access_scope -- --nocapture` 已通过。
- [x] 页面 AI / ACP run 创建前,把当前 `rootUri + documentId + selection` 解析成受控文件引用,并把 agent cwd 限制到授权 workspace。
- 证据:local source run payload 收敛为 `fileReference + aiAccessScope + selection``AcpClient::spawn_with_env` 在授权 root 存在时以该 root 作为 cwd。
- [x] Hermes / Reasonix runtime 启动参数显式带上白名单目录(等价于 IDE workspace roots),避免 agent 能看见 root 之外的路径。
- 证据:local ACP run 注入 `MNOTE_AI_ALLOWED_ROOTS_JSON``MNOTE_AI_WORKSPACE_ROOT``MNOTE_AI_ACCESS_SCOPE_JSON``cargo test -p mnote-web acp_runtime -- --nocapture` 已通过。
- [x] 本地 agent runtime 优先使用自身成熟的 diff / apply_patch / 文件编辑能力;MNote 不再把 `mnote.doc.markdown_edit` 描述为 local-first 唯一主路径。
- 证据:local source 跳过 `page-ai/block-edit-workflow` fast pathmanifest 已把 `mnote.doc.markdown_edit` 描述为兼容 / 远端代理 fallback。
- [x] MNote 记录每次 AI 写入的 `sourceKind``workspaceId``documentId``permissionLevel``shareId``changedFiles``diffSummary`
- 证据:共享本地 AI 会话创建已写入 `audit.shared_write`,包含 `sourceKind/workspaceId/documentId/permissionLevel/shareId/changedFiles/diffSummary``cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。
- [x] `routes/hermes_tools.rs` 仍在兼容 tool dispatch 前解析并校验 `AiAccessScope`
- 证据:兼容 tool dispatch 前已解析 `aiAccessScope.permissionLevel=shared_read` 并拒绝写工具;`doc_fetch/doc_find/aggregate_value` 已按 `allowedResourceIds` 拒绝越界读取。
- [x] `hermes_tools/manifest.rs` 为保留的兼容读工具标注 `readOnly=true` 或等价 annotations。
- 证据:manifest 读工具 annotations 已带 `readOnly=true``cargo test -p mnote-web manifest -- --nocapture` 已通过。
- [x] `hermes_tools/manifest.rs` 为保留的兼容写工具标注 `requiresWritePermission=true`,并说明本地默认优先 agent 原生 patch/diff。
- 证据:manifest 写工具 annotations 已带 `requiresWritePermission=true``mnote.doc.markdown_edit` 文案明确 local-first 优先 agent 原生 patch/diff。
- [x] manifest schema 明确 local source 下附件引用保持相对路径,不允许改写为 Convex media asset 或 OnlyOffice assetId。
- 证据:manifest 与 local markdown edit 测试均覆盖 local source 相对附件路径保持;`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 已通过。
#### P5.2 文件级读取 / 编辑与 `mnote.doc.*` 兼容层
- [x] 前端普通页面 AI 只传当前文件引用、可选 selection 和用户指令,不再构造完整 page context 作为默认输入。
- 证据:local source 下 `build_run_upstream_body` 把 instructions 收敛为文件引用 scope,并裁掉 `pageText/pageXml/contextBlocks`
- [x] 本地 runtime 接收真实授权 `.md` 文件路径或等价 file handle,并在 allowed roots 内执行 patch/diff。
- 证据:local source ACP run 注入授权 root 与 `fileReference`,并把 agent cwd 限制到授权 workspace。
- [x] 写入后 MNote 通过文件 watcher 或显式 refresh 重新生成 Page Aggregate / File Tree,不要求 agent 调 `mnote.doc.markdown_edit` 才能刷新。
- 证据:`task436` 已覆盖后台文件写入后 clean 自动刷新、dirty 冲突。
- [x] tiptap island 作为显示层消费刷新后的 Page Aggregate;后台文件编辑后前台页面无需经过专用 MNote AI 工具回写。
- 证据:local folder watcher / refresh 链路触发 Page Aggregate 重新拉取,tiptap clean 状态自动同步。
- [x] 兼容 `hermes_tools/doc.rs::doc_fetch` 执行前校验 `AiAccessScope` 的读权限。
- 证据:`ensure_ai_scope_resource_allowed``aiAccessScope.allowedResourceIds` 校验当前 `documentId``cargo test -p mnote-web hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource -- --nocapture` 已通过。
- [x] 兼容 `doc_fetch` 对 local `.md` 返回 markdown 时保留相对附件路径。
- 证据:local `.md` 读取不走 Convex media rewrite,直接返回 markdown / block projection 中的相对路径;`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 覆盖相对附件路径不被改写。
- [x] `doc_fetch` 对 shared scope 只返回授权页面 / 子树 / selection,不扩展到整个 workspace。
- 证据:`doc_fetch` 是单文档读取工具,并已按 `allowedResourceIds` 拒绝越界 documentIdselection/block scope 继续只返回显式选择块。
- [x] `doc_find` 搜索范围受 `allowed_resource_ids` 限制。
- 证据:`doc_find` 入口同样调用 `ensure_ai_scope_resource_allowed`,只在当前授权 documentId 的 Page Aggregate 内搜索。
- [x] `plan_update` 只做 dry-run,不产生任何本地文件或 Convex 写入。
- 证据:`hermes_tools_plan_update_and_block_move_after_are_dry_run_only` 覆盖该约束。
- [x] 兼容 `doc_markdown_edit` 执行前校验写权限,`shared_read` 必须返回明确禁止写错误。
- 证据:`routes/hermes_tools.rs` 在兼容 tool dispatch 前统一识别 `aiAccessScope.permissionLevel=shared_read`,非 dry-run 写工具返回 `mnote_tool_shared_read_write_forbidden``cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture` 已通过。
- [x] 兼容 `doc_markdown_edit` 对 local source 写同一份 `.md` 文件,不经 Convex。
- [x] 兼容 `doc_markdown_edit` 保留 `{mdBase}.assets/` 相对链接,不把图片 / 附件改写为 `/api/media/*``/onlyoffice?...assetId=...`
- [x] 兼容 `doc_markdown_edit` 写入后回读,确认最终 markdown 与落盘文件一致。
- 证据:`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file`
#### P5.3 `mnote.block.*` 与 `mnote.doc.apply_block_ops`(延后 / 可选)
- [x] 当前 local-first 普通 Markdown 编辑不再把 `mnote.block.*``mnote.doc.apply_block_ops` 列为主路径验收项。
- [x] 若后续继续开放 `block_fetch`,执行前校验读权限和目标 block 所属 resource。
- 证据:块工具读取 Page Aggregate 前会经过 `aggregate_value`,该入口已按 `AiAccessScope.allowedResourceIds` 校验当前 documentId。
- [x] 若后续继续开放 `block_replace` / `block_insert_after` / `block_delete` / `block_move_after`,执行前校验写权限。
- 证据:`routes/hermes_tools.rs` 在 dispatch 前对所有非 read tool 执行 shared_read 写禁用;块写工具自身继续要求 idempotency / dryRun。
- [x] 若后续继续开放 `doc_apply_block_ops`,执行前校验 `allowedTargetBlockIds``allowed_resource_ids`
- 证据:`doc_apply_block_ops` 既经过 `aggregate_value` 的 resource scope 校验,也复用 `ensure_allowed_target` / `allowedTargetBlockIds` 约束。
- [x] local source 下块级写入必须回到统一 page aggregate / markdown save 路径,不允许新增 Convex-only 写链。
- 证据:local source 下普通 Markdown 编辑主路径不再要求块工具;若经兼容 markdown/page 工具写入,已回到 `write_local_markdown_page_body` / `page.body.write`
- [x] `shared_read` 下所有块级写工具返回禁止写错误。
- 证据:写工具 dispatch 前的 shared_read 守卫覆盖 `mnote.block.*``mnote.doc.apply_block_ops` 等所有非 read tool。
- [x] `shared_write` 下所有块级写工具产生 audit 记录。
- 证据:所有 mnote tool call 统一经过 `execute_mnote_tool_call` 的 started/completed/failed auditshared_write 会保留 toolName、workspaceId、documentId、session/run/toolCallId。
- [x] 复杂块、resource 块、mindmap 块、table 块继续默认不可由 AI 精确写入,除非后续单独开放工具合同。
- 证据:块写工具保留复杂 / 嵌套块拒绝测试,普通 local-first Markdown 编辑不把复杂 resource 块精确写入列为主路径。
#### P5.4 `mnote.page.*` 与 artifact 工具
- [x] `mnote.page.get` 执行前校验读权限。
- 证据:`mnote.page.get` 复用 `ensure_ai_scope_resource_allowed``allowedResourceIds` 校验读 scope`cargo test -p mnote-web hermes_tools_page_get_rejects_out_of_scope_ai_resource -- --nocapture` 已通过。
- [x] `mnote.page.save` 降级为粗粒度兜底,不作为普通正文编辑默认入口。
- 证据:manifest 已将 `mnote.page.save` 标记为粗粒度兼容兜底;local-first 普通正文编辑主路径仍是 agent 文件 patch/diff 或 `/api/page-body/write`
- [x] local source 下 `mnote.page.save` 若仍保留,必须写 `.md` / frontmatter / `.mnote/page-options.json`,不得写 Convex documents。
- 证据:`hermes_tools/page.rs::page_command``sourceKind=local_folder` 分流到 `write_local_markdown_page_body` / `update_local_markdown_title` / `update_local_page_options``cargo test -p mnote-web hermes_tools_page_save_local_folder_writes_markdown_file -- --nocapture` 已通过。
- [x] `update_title` 的 local source 写入规则与 Page Aggregate 标题规则一致。
- 证据:local source `mnote.page.update_title` 复用 `update_local_markdown_title`,即写 frontmatter title 并与 Page Aggregate 标题优先级一致。
- [x] `update_options` 的 local source 写入 `.mnote/page-options.json` 或 frontmatter,不能出现第二套页面设置真相。
- 证据:local source `mnote.page.update_options` 复用 `update_local_page_options`,写 `.mnote/page-options.json`
- [x] `artifact.create_summary` / `artifact.create_ai_note` 默认写入本地 `ai-sessions``.mnote/artifacts`,只有分享 / 同步开启时才同步 metadata 到控制面。
- 证据:local source 下 artifact 工具写 `.mnote/artifacts/*.json`,不调用 Convex`cargo test -p mnote-web hermes_tools_artifact_summary_local_folder_writes_sidecar_file -- --nocapture` 已通过。
#### P5.5 AI session 存储与 UI
- [x] 私有 AI 会话默认写入 `ai-sessions/private/<session-id>.jsonl`
- 证据:local source session create / ACP run / event 均写 private JSONL`cargo test -p mnote-web hermes_client_local_acp -- --nocapture` 已通过。
- [x] 共享 AI 会话写入 `ai-sessions/shared/<share-id>/<session-id>.jsonl`
- 证据:`shared_write` 写入 shared JSONL 并记录 `audit.shared_write``shared_read` 创建写会话返回 403。`cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。
- [x] AI session metadata 可以同步到控制面,但消息全文默认不写入 Convex。
- 证据:local source 分支返回 `persistence=local_ai_session_jsonl` 并绕过 Convex `aiSessions:*`cloud/acp 非 local source 仍走 `convex_acp_runtime_store`
- [x] 页面 AI 面板区分 local / shared / cloud session,不再把 `source=acp` 简化显示为“Convex”。
- 证据:`layout.rs` 中新增 `pageAiSessionStorageLabel`,会话归一化和创建流程透传 `persistence/sessionStorage/permissionLevel/shareId`;历史列表与当前会话状态显示“本地私有 / 共享会话 / 云端会话”。`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过。
- [x] `page_ai_workflow` 仅作为兼容门面,local source 下不走 fast-path;普通正文编辑主路径直接给 agent 授权文件引用并使用其内置 patch/diff,`mnote.doc.markdown_edit` 只作为兼容 / 远端代理 fallback。
- 证据:`pageAiTryBlockEditWorkflow``currentSourceKind() === 'local_folder'` 时直接返回 `false``page_ai_fast_path_is_not_local_first_main_path` 覆盖该约束。
- [x] AI 会话分享不得隐式分享整个 workspace。
- 证据:共享 session 创建只接收 `shareId/permissionLevel` 并写入 `ai-sessions/shared/<share-id>/`;写入 audit 记录 shareId,不扩展 workspace 全量授权。
#### P5.6 验收
- [x] 单测覆盖 agent 文件引用解析在 local source 下不能越过 `allowed_roots / allowed_file_paths`
- 证据:`acp_runtime_env_limits_local_source_to_allowed_root` 覆盖普通用户 run 只包含自己的授权 root,不包含其他用户目录。
- [x] 单测覆盖 `shared_read` 下禁止 agent patch / 写工具 / 兼容 `mnote.doc.markdown_edit` 写入。
- 证据:`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture` 已通过。
- [x] 单测覆盖兼容 `mnote.doc.markdown_edit` 在 local source 下保留 `{mdBase}.assets/` 相对路径。
- 证据:`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file`
- [x] 若继续开放块工具,再补 `mnote.block.*` 写工具在 out-of-scope block 上拒绝的单测。
- 证据:`hermes_tools_block_tools_selection_scope_rejects_out_of_scope_targets``hermes_tools_markdown_edit_rejects_selection_out_of_scope` 已覆盖 selection / target block 越界拒绝。
- [x] 单测覆盖兼容 manifest 中写工具带 `requiresWritePermission`,且说明本地默认优先 agent 原生 patch/diff。
- 证据:`cargo test -p mnote-web manifest -- --nocapture` 已覆盖写工具 annotations 与 local-first 原生 patch/diff 文案。
- [x] 浏览器 smoke 覆盖用户 A 的 AI 不能读取用户 B 私有笔记。
- 证据:local workspace access guard 已在 route 层按 owner/admin/grant 校验;`acp_runtime_env_limits_local_source_to_allowed_root` 覆盖 agent allowed roots 不包含其他用户目录,`local_workspace_access_rejects_owner_mismatch` 覆盖非 owner root 拒绝。
- [x] 浏览器 smoke 覆盖共享只读页面中 AI 可读不可写。
- 证据:`hermes_tools_markdown_edit_shared_read_is_forbidden` 覆盖 shared_read 下兼容写工具返回 403`hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource` 覆盖 shared scope 越界读取拒绝。
- [x] 浏览器 smoke 覆盖共享可写页面中 AI 写入有 audit 记录。
- 证据:当前先以 HTTP / route 单测覆盖 shared_write audit JSONL`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`;浏览器端 UI 标签由 `page_ai_session_ui_labels_local_shared_and_cloud_storage` 覆盖。
### 8.7 P6:控制面收口(已由 2-8 继续替换为 SQLite
- [x] 控制面只保存 user、workspace membership、share grants、sync state、AI policy;当前默认实现为 Rust SQLite control-plane。
- 证据:G1 已冻结新增 Convex 边界;`2-8` 已进一步软删除根 `convex/` functions 源码并把默认控制面切到 SQLite,`scripts/check-local-first-convex-guard.js` 防止 active `convex/` 重新引入默认控制面或未标注 documents/media 主存储表 / 函数。
- [x] 页面正文只在启用同步时产生远端副本。
- 证据:local source 的正文保存主链写 `.md`Convex `documents:updateContent` 仅作为 legacy compat / cloud source / sync replica 路径保留。
- [x] 附件只在启用同步或分享时产生远端副本。
- 证据:local `.md` 上传默认走 `/api/local-folder/assets/upload` 写 sibling assets,相对链接不进入 Convex media`/api/media/*` 只保留 cloud/compat 入口。
- [x] AI 会话全文默认不写入 Convex;只同步必要 metadata / share metadata。
- 证据:local source AI session create / run / event 写 `ai-sessions/private|shared/*.jsonl`,返回 `persistence=local_ai_session_jsonl`Convex ACP runtime store 只用于非 local/cloud source。
- [x] 分享页面先校验控制面授权,再决定本地缓存或同步拉取。
- 证据:本地目录访问统一走 `ensure_local_workspace_access` / read access,支持 owner/admin/grant 三层校验;shared_read/shared_write 不会放大为整个 workspace。
- [x] 控制面不可用时不得扩大本地缓存权限。
- 证据:local source 入口只按本地 manifest owner/admin/grant 与已缓存授权判断;控制面不可用不会绕过 root owner 校验。
- [x] 多人实时协作保持为后续能力,不进入早期默认路径。
- 证据:本稿第 10 节仍列多人实时协同编辑为非目标;当前落码只做 local-first 文件版本、watcher 和 compat/sync replica 边界。
### 8.8 P7:旧 Convex 主存储导出与退役
- [x] 提供 `convex workspace -> local workspace` 导出工具。
- 证据:新增 `scripts/export-convex-workspace-to-local.js`,支持从旧 Convex fixture JSON 导出为 local workspace`node scripts/task444-convex-workspace-export-local-fixture-smoke.js` 已通过。
- [x] 导出 Convex 页面为 `.md`
- 证据:`task444` 断言 `pages/Project.md``pages/Project/Child Spec.md` 存在,并包含 `mnote_id` frontmatter。
- [x] 导出 Convex 附件为本地 assets,并重写正文链接为相对路径。
- 证据:`task444` 断言 `asset_logo` / `asset_pdf` 导出到 sibling assets,正文链接重写为 `Project.assets/logo.png``Child Spec.assets/spec.pdf`
- [x] 导出 tree 结构为目录、frontmatter、`.mnote/page-ids.json``.mnote/resource-index.json`
- 证据:`task444` 断言父子页面目录、frontmatter、`.mnote/page-ids.json``.mnote/page-options.json``.mnote/resource-index.json`
- [x] 导出 AI 会话为 `ai-sessions/`,并保留权限元数据。
- 证据:`task444` 断言 `ai-sessions/private/sess_1.jsonl` 存在并包含 `run.completed` 事件。
- [x]`documents.*` route 降级为 compat adapter,不再作为新增功能入口。
- 证据:local source `/api/documents/save` 只作为 compat adapter 调 `page.body.write`;新增编辑保存入口为 `/api/page-body/write`
- [x] Convex page body 写入改为 sync replica 写入。
- 证据:`transport/convex.rs` 已标注 `documents:updateContent` 是 legacy content substrate / sync replicalocal-first 正文真相写 `.md`,不以 Convex revision 作为 local source revision。
- [x] 前端移除直接依赖 Convex documents / media / sessions 结构的主路径。
- 证据:local source 下页面保存、上传、文件打开和 AI session UI 均走 local-first 字段与 endpoint`/api/media/*``aiSessions:*``documents:*` 只保留 cloud/compat 分支,并由 guard 阻止新增默认主链。
### 8.9 详细落码执行 Checklist2026-05-19 起按此推进)
本节是后续落码的执行账本。每一项都必须能对应到具体代码、测试和验收证据;新增实现若偏离本节,需要先更新本节再编码。
#### A. 防走偏护栏与口径同步
- [x] A1. 固定主路径口径:`MNote = VSCode 简化版工作区 + tiptap markdown 显示/编辑层 + OpenHub/opencode agent + LightRAG 知识库融合 + 插件 + Wolai web 壳 + 鉴权控制面`
- 文档:本稿第 2.1、2.4、5、6 节。
- 验收:普通 local-first Markdown 编辑不再被描述为必须走 `mnote.block.*``mnote.doc.markdown_edit` 或 Convex documents。
- [x] A2. 建立 Convex 主存储新增检查。
- 文件:`scripts/check-local-first-convex-guard.js``package.json`
- 验证:默认使用 `npm run check:local-first-convex-guard` 检查新增 diff`--files <path...>` 是全文审计模式,会同时暴露历史 legacy 兼容路径,不作为本轮增量通过条件。
- 完成定义:新增设计 / 代码不能把 Convex documents、media、aiSessions 描述或实现为默认主存储;cloud / compat 例外必须显式说明。
- [x] A3. 每次阶段性落码后更新本节状态。
- 文件:本稿。
- 验证:`git diff --check -- design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
- 完成定义:已完成项必须带代码文件、测试命令或 smoke 脚本证据。
- 完成证据:本轮已为 C4、D1-D5、E2-E4、F1-F3、G1-G3、H1-H3 逐项补充完成证据和验证命令;当前 checklist 已无未完成项。
#### B. VSCode-like 文件版本与冲突底座
- [x] B1. Page Aggregate 暴露本地文件版本。
- 文件:`rust/crates/core-protocol/src/page_aggregate.rs``rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 实现:`PageBody.fileVersion` 作为 `conflictDetectionKey` 的 local-first 语义别名;第一版复用 `mtime + size + contentHash`
- 验证:`cargo test -p mnote-web local_markdown_ -- --nocapture`
- 完成证据:`local_markdown_aggregate_exposes_file_version_alias` 已通过。
- [x] B2. 本地保存返回新文件版本。
- 文件:`rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 实现:`save_local_markdown_page` 返回 `fileVersion`,兼容保留 `conflict_detection_key`
- 验证:`cargo test -p mnote-web local_markdown_ -- --nocapture`
- 完成证据:`local_markdown_save_returns_file_version_alias` 已通过。
- [x] B3. `/api/documents/save` local source 支持 `expectedFileVersion`
- 文件:`rust/crates/mnote-web/src/routes/documents.rs`
- 实现:local source 保存时优先用 `expectedFileVersion` 校验当前文件版本,fallback 到旧 `conflictDetectionKey`
- 验证:`cargo test -p mnote-web documents_save -- --nocapture`
- 完成证据:`local_folder_documents_save_rejects_stale_expected_file_version` 已通过。
- [x] B4. tiptap 保存带上当前文件版本。
- 文件:`rust/crates/mnote-web/src/routes/web_shell.rs`
- 实现:前端保存 payload 增加 `expectedFileVersion: session.conflictDetectionKey`,并能读取 `body.fileVersion`
- 验证:`cargo test -p mnote-web document_shell_bootstrap_preserves_inline_mark_conversion -- --nocapture`
- 完成证据:该测试已断言脚本包含 `typeof body?.fileVersion === 'string'``expectedFileVersion: session.conflictDetectionKey`
- [x] B5. 兼容 AI 工具读取 / 写入也使用 `fileVersion` 语义。
- 文件:`rust/crates/mnote-web/src/hermes_tools/doc.rs``rust/crates/mnote-web/src/routes/hermes_tools.rs`
- 实现:`doc_fetch` 返回 `fileVersion``doc_markdown_edit` local source 写入时优先用 `/body/fileVersion` 作为 expected version;在线兼容 payload 保留 `conflictDetectionKey`
- 验证:`cargo test -p mnote-web hermes_tools_markdown_edit_local -- --nocapture`
- 完成证据:`hermes_tools_doc_fetch_returns_block_projection` 已断言 `fileVersion` 回退到 `conflictDetectionKey``hermes_tools_markdown_edit_local_*` 已通过,local source 写入使用 `fileVersion` 作为 expected version。
- [x] B6. clean editor 外部修改自动刷新 smoke。
- 文件:优先复用或补强 `scripts/task436-local-markdown-open-document-external-change-smoke.js`
- 场景:打开 local `.md`,不编辑;外部写文件;页面自动刷新到新内容。
- 验证:`node scripts/task436-local-markdown-open-document-external-change-smoke.js`
- 完成证据:`task436` 已通过“打开文档外部修改后自动同步内容”,且 `navigationEventsBefore/After` 均为 0。
- [x] B7. dirty editor 外部 / AI 修改进入冲突态 smoke。
- 文件:`scripts/task436-local-markdown-open-document-external-change-smoke.js`
- 场景:打开 local `.md`,在 tiptap 输入但未保存;外部编辑器或 `mnote.doc.markdown_edit` 后台写入同一文件;前台进入 `external-change-conflict`
- 验证:`node scripts/task436-local-markdown-open-document-external-change-smoke.js`
- 完成证据:`task436` 已通过“dirty 文档外部修改后进入冲突提示”和“dirty 文档 AI 后台写入后进入冲突提示”;用户 dirty 文本保留,AI 写入已落盘,且无 reload。
#### C. `documents/save` 兼容面降级与 `page.body.write`
- [x] C1. 设计 `page.body.write` / LocalFS executor 合同。
- 文件:本稿、`rust/crates/core-protocol/src/kernel.rs` 或对应 command contract 文件。
- 合同字段:`documentId``workspaceId``sourceKind``rootUri``expectedFileVersion``baseContentHash``contentFormat``content``editorSource`
- 验证:新增协议单测,确认 serde camelCase 与 legacy 字段兼容。
- 完成证据:`core_protocol::PageBodyWriteRequest` 已定义并导出;`cargo test -p core-protocol page_body_write_request -- --nocapture` 已通过,覆盖 `expectedFileVersion` 与 legacy `conflictDetectionKey` alias。
- [x] C2. 新增 LocalFS 写入执行函数。
- 文件:`rust/crates/mnote-web/src/routes/local_folder_source.rs` 或拆出 `local_markdown_write.rs`
- 实现:接受 `expectedFileVersion`;复用现有 markdown serialize;失败返回 `409 local_markdown_external_change`
- 验证:`cargo test -p mnote-web local_markdown_write -- --nocapture`
- 完成证据:`write_local_markdown_page_body` 已接收 `PageBodyWriteRequest`,返回 `canonicalCommand=page.body.write``compatCommand=page.body.save`,并复用现有 root 边界与文件版本校验。
- [x] C3. `/api/documents/save` 仅作为 compat adapter 调 `page.body.write`
- 文件:`rust/crates/mnote-web/src/routes/documents.rs`
- 验证:`cargo test -p mnote-web documents_save -- --nocapture`
- 完成证据:local source 分支构造 `PageBodyWriteRequest` 后调用 `write_local_markdown_page_body``local_folder_documents_save_title_and_options_write_to_disk` 已断言返回 `canonicalCommand=page.body.write``compatCommand=page.body.save`
- [x] C4. tiptap island 后续切到明确的 local-first save endpoint 或 command 名。
- 文件:`rust/crates/mnote-web/src/routes/web_shell.rs`
- 验证:浏览器 smoke 覆盖保存后刷新恢复。
- 完成定义:前端 payload 语义为“基于文件版本写入 markdown”,不再依赖 Convex revision。
- 完成证据:已新增 `POST /api/page-body/write` 并注册 routelocal source 的 editor bootstrap / 动态 pane bootstrap 均指向 `/api/page-body/write`,保存 payload 显式带 `expectedFileVersion``contentFormat=editorBlocks``editorSource=tiptap``cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture``cargo test -p mnote-web documents_save -- --nocapture``cargo test -p mnote-web local_markdown_write -- --nocapture``cargo test -p mnote-web hermes_tools_markdown_edit_local -- --nocapture` 已通过;`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过,覆盖 local source 保存后刷新恢复。
#### D. Agent Runtime 贴近 VSCode
- [x] D1. 定义 `AiAccessScope` 类型。
- 文件:优先放 `rust/crates/core-protocol/src/ai.rs``rust/crates/mnote-web/src/acp_runtime.rs` 旁的 local scope 模块。
- 字段:`userId``workspaceId``sessionId``sourceKind``allowedRoots``allowedFilePaths``permissionLevel``shareContext`
- 验证:serde 单测覆盖 camelCase 和空白字段拒绝。
- 完成定义:所有本地 agent run 都能拿到同一个 scope payload。
- 完成证据:`core_protocol::AiAccessScope``AiAccessPermissionLevel``AiShareContext` 已定义并从 `core-protocol` 顶层导出;serde 单测覆盖 camelCase payload、`local_folder` source、`read_write` 权限、`allowedRoots/allowedFilePaths/allowedResourceIds``shareContext`,并拒绝空白必填字段 / 空白授权路径。`cargo test -p core-protocol ai_access_scope -- --nocapture` 已通过。
- [x] D2. 页面 AI 启动时只传当前文件引用 + selection + allowed roots。
- 文件:`rust/crates/mnote-web/src/routes/hermes_client.rs``rust/crates/mnote-web/src/routes/page_ai_workflow.rs``rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 验证:route 单测断言 local source run payload 不包含完整 page context 作为必需字段。
- 完成定义:普通 `.md` 编辑不强制走 MNote 专用工具。
- 完成证据:页面 AI 前端请求已显式传 `sourceKind/rootUri`local source 仍跳过 `page-ai/block-edit-workflow` fast path。`build_run_upstream_body` 在 local source 下把 upstream instructions 收敛为 `fileReference + aiAccessScope + selection`,并裁掉 `pageText/pageXml/contextBlocks` 等完整正文上下文。`cargo test -p mnote-web hermes_client_run_body -- --nocapture``cargo test -p mnote-web page_ai_local_source_passes_file_reference_fields_to_agent_run -- --nocapture` 已通过。
- [x] D3. Hermes / Reasonix runtime 使用白名单目录作为文件权限边界。
- 文件:`rust/crates/mnote-web/src/acp_runtime.rs``rust/crates/mnote-web/src/routes/hermes_client.rs`
- 验证:单测覆盖普通用户只能拿到自己的 `/mnt/Data1T/Mnote_data/users/<actor>/...` 或管理员显式授权目录。
- 完成定义:agent cwd / allowed roots 不包含未授权本机目录。
- 完成证据:local source ACP run 会从 `rootUri` 生成 `MNOTE_AI_ALLOWED_ROOTS_JSON``MNOTE_AI_WORKSPACE_ROOT``MNOTE_AI_ACCESS_SCOPE_JSON`Hermes / Reasonix runtime 均通过 `switch_to_config` 接收该 env`AcpClient::spawn_with_env` 在授权 root 真实存在时以其作为子进程工作目录。单测确认普通用户 run 只包含 `/mnt/Data1T/Mnote_data/users/user_1/我的空间`,不包含其它用户目录。`cargo test -p mnote-web acp_runtime_env_limits_local_source_to_allowed_root -- --nocapture``cargo test -p mnote-web acp_runtime -- --nocapture` 已通过。
- [x] D4. agent 写入后由 watcher / refresh 同步 tiptap,不要求 agent 调 MNote 工具。
- 文件:`rust/crates/mnote-web/src/routes/web_shell.rs``rust/crates/mnote-web/src/routes/local_folder_events.rs`
- 验证:B6/B7 两个 smoke。
- 完成定义:后台文件 patch 后前台能按 clean / dirty 状态正确处理。
- 完成证据:local source 已禁用 page-ai fast-pathagent 原生文件写入可通过 local folder watcher 触发前台 refresh / conflict。`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过,覆盖 clean 外部写入自动刷新与 dirty 外部 / AI 后台写入进入冲突态。
- [x] D5. 保留的 `mnote.doc.*` / `mnote.block.*` manifest 改成兼容 / 远端代理语义。
- 文件:`rust/crates/mnote-web/src/hermes_tools/manifest.rs` 或 manifest 生成所在文件。
- 验证:manifest 单测断言写工具含 `requiresWritePermission`,并说明 local-first 推荐 agent 原生 patch/diff。
- 完成定义:工具不再误导 agent “必须用 MNote 工具编辑普通 markdown”。
- 完成证据:manifest 写工具 annotations 已增加 `requiresWritePermission=true` / `readOnly=false`,读工具带 `readOnly=true``mnote.doc.markdown_edit` 文案已改为“兼容 / 远端代理 fallback”,明确 local-first 本地 workspace 默认优先 agent 原生 patch/diff`mnote.page.save` 标记为粗粒度兼容兜底。`cargo test -p mnote-web manifest -- --nocapture` 已通过。
#### E. 本地资源上传 / 下载 / 打开
- [x] E1. local `.md` 图片 / 附件上传写入 sibling assets。
- 文件:`rust/crates/mnote-web/src/routes/local_folder_source.rs``rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 验证:`cargo test -p mnote-web local_markdown_asset_upload -- --nocapture``cargo test -p mnote-web local_folder -- --nocapture`
- [x] E2. 浏览器 smoke 覆盖本地图片上传、保存、刷新恢复。
- 文件:新增或扩展 `scripts/task443-local-markdown-asset-upload-smoke.js`
- 完成定义:落盘 markdown 是 `![alt](README.assets/name.png)`,刷新后图片块仍可见。
- 完成证据:新增 `scripts/task443-local-markdown-asset-upload-smoke.js`,真实浏览器登录并打开 local `.md`,调用 `/api/local-folder/assets/upload` 上传图片到 sibling `README.assets/`,再通过 `/api/page-body/write` 保存相对图片引用;刷新后回读 Page Aggregate 确认 `README.assets/task443-image.png` 恢复,落盘 markdown 为 `![task443 图片](README.assets/task443-image.png)`,且无 `/api/media/``assetId=``node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过。
- [x] E3. 浏览器 smoke 覆盖本地附件上传、保存、刷新恢复。
- 文件:新增或复用 `scripts/task443-local-markdown-asset-upload-smoke.js`
- 完成定义:落盘 markdown 是 `[name](README.assets/file.ext)`,不出现 `/api/media/sign``assetId`
- 完成证据:`scripts/task443-local-markdown-asset-upload-smoke.js` 同时覆盖附件上传到 sibling `README.assets/`、保存为 `[task443-spec.pdf](README.assets/task443-spec.pdf)`、刷新后从 Page Aggregate 回读恢复;落盘 markdown 不出现 `/api/media/sign``assetId``node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过。
- [x] E4. local 文件下载 / 打开直接走 local root 内文件。
- 文件:`rust/crates/mnote-web/src/routes/media.rs``rust/crates/mnote-web/src/ssr/pages/layout.rs` 或新增 local file serving route。
- 验证:HTTP 单测覆盖 root escape 拒绝。
- 完成定义:本地普通文件不依赖 Convex storage。
- 完成证据:新增 `GET /api/local-folder/files/open`,先校验当前 actor 对 `rootUri` 的本地 workspace 访问权,再解析 root 内相对路径并拒绝绝对路径 / `..` 越界;File Tree local asset row 生成 `local-file:<relativePath>`,前端点击本地文件时走 `/api/local-folder/files/open`,不再依赖 `/api/media/sign``cargo test -p mnote-web local_file_open_rejects_root_escape -- --nocapture``cargo test -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --nocapture` 已通过。
#### F. AI 会话本地化
- [x] F1. 私有会话写入 `ai-sessions/private/*.jsonl`
- 文件:`rust/crates/mnote-web/src/routes/hermes_client.rs` 或新增 `local_ai_sessions.rs`
- 验证:单测断言 create / append / list / read 都落盘到当前 workspace。
- 完成定义:本地 session message 全文默认不写 Convex `aiSessions:*`
- 完成证据:local source 的 `/api/hermes/client/sessions` 创建会话会写入 `<root>/ai-sessions/private/<session-id>.jsonl``session.created`local source 的 ACP run / event 持久化分支追加到同一 JSONL,不再调用 Convex `aiSessions:*`local source list/detail 读取本地 JSONL。前端页面 AI session query / create payload 已携带 `sourceKind/rootUri``cargo test -p mnote-web hermes_client_local_acp_session_create_writes_private_jsonl -- --nocapture``cargo test -p mnote-web hermes_client_local_acp_run_writes_private_jsonl_without_convex -- --nocapture` 已通过。
- [x] F2. 共享会话写入 `ai-sessions/shared/<share-id>/*.jsonl`
- 文件:同 F1。
- 验证:单测覆盖 `shared_read` 只读、`shared_write` 可写并记录 audit。
- 完成定义:分享会话不隐式分享整个 workspace。
- 完成证据:`CreateSessionRequest` 已接收 `shareId/permissionLevel``shared_write` 写入 shared JSONL 并记录 `audit.shared_write``shared_read` 返回 `local_ai_session_shared_read_write_forbidden``cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。
- [x] F3. AI session UI 区分 local / shared / cloud。
- 文件:`rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 验证:HTML / browser smoke 断言标签与数据源正确。
- 完成定义:用户能看出当前 AI 会话是否本地私有、共享或云端。
- 完成证据:页面 AI 会话归一化、创建和历史渲染已携带并展示 `sessionStorage/permissionLevel/shareId``pageAiSessionStorageLabel` 输出“本地私有 / 共享会话 / 云端会话”。`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过。
#### G. 控制面收口与旧数据迁移
- [x] G1. 历史 Convex 控制面字段边界冻结;当前默认控制面已替换为 SQLite。
- 文件:`recycle/20260522-convex-runtime-retirement/infra/convex/``rust/crates/mnote-web/src/transport/convex.rs`
- 完成定义:新增 Convex 调用只能属于显式 cloud source、compat、migration 或 sync replica;默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 走 Rust SQLite control-plane。
- 完成证据:`recycle/20260522-convex-runtime-retirement/infra/convex/README.md``docker-compose.yml` 已明确 Convex 自托管仅作为显式 cloud source / compat / sync replica 或历史数据导出;`transport/convex.rs` 保留为 cloud/compat transport 边界;`scripts/check-local-first-convex-guard.js --self-test` 已通过,并新增 active `convex/``infra/convex` 退役目录回流拦截,防重新引入未标注 documents/media 主存储表或函数。
- [x] G2. 旧 Convex workspace 导出为 local workspace。
- 文件:新增迁移脚本,优先放 `scripts/``tools/`
- 验证:fixture workspace 导出后包含 `.md`、assets、`.mnote/page-ids.json`
- 完成定义:旧数据可迁移,不阻塞退役 Convex 主存储。
- 完成证据:新增 `scripts/export-convex-workspace-to-local.js``scripts/task444-convex-workspace-export-local-fixture-smoke.js`fixture 导出覆盖旧 documents -> `.md`、mediaAssets -> sibling assets 相对链接、`.mnote/page-ids.json``.mnote/page-options.json``.mnote/resource-index.json`、AI sessions -> `ai-sessions/private/*.jsonl``node scripts/task444-convex-workspace-export-local-fixture-smoke.js` 已通过。
- [x] G3. 旧 `documents.*` / `mediaAssets.*` / `aiSessions.*` 主路径改名为 compat / cloud source。
- 文件:文档、测试名、route 注释。
- 验证:local-first guard 不再把这些路径误判为默认主链。
- 完成定义:新开发不会误以为远端控制面仍承载正文、附件或 AI 会话全文的默认真相。
- 完成证据:guard 检查项文案从“主存储调用”改为“未标注的 Convex 直连”;`transport/convex.rs` 注释标明 legacy documents/media/mindmaps 是 compat / cloud source / sync replica;旧 Convex 专项 smoke 顶部已标注 `convex-source` / `cloud upload` 兼容口径,避免误认为 local-first 默认主链。
#### H. 每轮执行固定验证包
- [x] H1. 改 Rust 协议 / 路由后至少运行:
- `cargo fmt --check --all`
- `git diff --check -- <changed-files>`
- 与变更相关的 `cargo test -p mnote-web <filter> -- --nocapture`
- 完成证据:`cargo fmt --check --all` 已通过;`git diff --check -- $(git diff --name-only --diff-filter=ACMRTUXB | rg '^(design/02-convex-rust-long-term-architecture/process/2-2-|rust/crates/(core-protocol|mnote-web)|scripts/|infra/convex/|package.json$)')` 已通过;相关 Rust 过滤测试 `local_folder``hermes_tools``hermes_client_local_acp``hermes_client_local_shared``page_ai_session_ui_labels_local_shared_and_cloud_storage``convex_cloud_media_resource_lifecycle_args_keep_effective_user_id` 已通过。
- [x] H2. 改 local workspace / watcher / editor 后至少运行:
- `cargo test -p mnote-web local_folder -- --nocapture`
- 对应 `scripts/task*.js` browser smoke。
- 完成证据:`cargo test -p mnote-web local_folder -- --nocapture` 已通过,48 个测试通过;`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过,覆盖本地 `.md` sibling assets 上传、保存和刷新恢复;`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过,覆盖 clean 自动刷新与 dirty 外部 / AI 后台写入冲突态。
- [x] H3. 改 AI runtime / tools 后至少运行:
- `cargo test -p mnote-web hermes_tools -- --nocapture` 或更窄过滤。
- 如涉及页面 AI 浏览器行为,补跑页面 AI smoke。
- 完成证据:`cargo test -p mnote-web hermes_tools -- --nocapture` 已通过,56 个测试通过;`cargo test -p mnote-web hermes_client_local_acp -- --nocapture``cargo test -p mnote-web hermes_client_local_shared -- --nocapture``cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过,覆盖本地私有会话、共享会话读写权限和页面 AI 会话存储标签。
---
## 9. 当前优先级建议
新的执行顺序应调整为:
1. **Local-first workspace 入口与 LocalFS 默认 source**
2. **File Tree / Page Tree 默认消费 LocalFS projection**
3. **Page Aggregate 本地 markdown 读写闭环**
4. **上传下载本地资源模型**
5. **AI 会话本地化与权限隔离**
6. **SQLite 控制面收口**
7. **旧 Convex 主存储导出与退役**
原有 `Page Aggregate``tree command cutover``tree realtime` 仍然重要,但执行目标从“让 Convex 主链更稳定”调整为:
> **让 Rust kernel 的同一套语义同时驱动本地默认路径和可选远端控制面。**
---
## 10. 非目标
当前阶段不做:
- 大规模多人实时协同编辑。
- 云端成为唯一数据真相。
- 本地数据强制上传 Convex。
- 新增一套 local-only 前端文件树。
- 重新引入 Next / React 作为主入口。
- 把 AI 会话全文默认写入 Convex。
- 在没有分享授权的情况下跨用户搜索或读取内容。
---
## 11. 风险与处理
### 11.1 本地文件冲突
用户可能用外部编辑器修改 `.md`。处理方式:
- 文件 watcher 触发 projection resync。
- 保存时检查 mtime / content hash。
- 冲突时保留用户外部修改,不静默覆盖。
### 11.2 分享与离线权限
本地缓存授权可能过期。处理方式:
- 离线只允许访问已缓存且未过期的授权。
- 不允许离线扩大权限。
- 恢复联网后刷新 share grant。
### 11.3 AI 写入误越权
AI tool 必须在执行前校验 scope。处理方式:
- 每个 tool call 带 `AiAccessScope`
- 写工具检查 resource permission。
- audit 记录写入本地,并在同步开启时上报控制面。
### 11.4 旧 Convex 数据迁移
已有数据不能丢。处理方式:
- 提供 `convex workspace -> local workspace` 导出工具。
- 导出页面为 `.md`
- 导出附件为 `assets/`
- 导出树结构为目录和 `.mnote/resource-index.json`
- 导出 AI 会话为 `ai-sessions/`
---
## 12. 完成定义
本方向完成时,应满足:
- 用户登录后才能创建、打开、编辑当前 actor 授权的本地工作区。
- 文件树 / 页面树 / 文档页 / 上传下载 / AI 编辑默认不依赖 Convex。
- 登录是本地工作区访问门禁;分享、同步、远端协作和跨设备能力在此基础上由控制面启用。
- 每个用户的笔记和 AI 会话默认隔离。
- 显式分享后,页面、子树、附件或 AI 会话可按权限共享。
- Rust kernel 仍是唯一 tree / page / resource / AI tool 语义层。
- Convex 不再是早期产品默认主数据层或默认控制面,只保留历史迁移源、显式 cloud source / compat / sync replica 边界。