Document the VSCode-like local-first product shape, demote Convex to a control-plane role, and retire stale architecture drafts. Add local workspace migration/export references plus smoke coverage for no-Convex managed workspace startup, local markdown title/body/options persistence, asset upload behavior, and Convex fixture export. Verification: git diff --cached --check; node scripts/check-local-first-convex-guard.js --staged; node scripts/task444-convex-workspace-export-local-fixture-smoke.js; node scripts/task166-local-first-managed-workspace-no-convex-smoke.js; node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
89 KiB
2-2 [done] Local-First Workspace 与 Convex 控制面降级方案 v1
创建时间:2026-05-18
当前状态:
DONE本稿目的:
- 固定早期产品形态:10 人以内、用户笔记默认独立、可分享、AI 隔离且可共享。
- 将 mnote 从“Convex 与本地两套同等重量系统”收口为“本地文件夹默认主路径 + Convex / 服务端控制面”。
- 给出现有文件树、页面树、上传下载、编辑、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/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md/mnt/Data1T/mnote/design/03-rust-web/process/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/07-ai/process/7-14-online-local-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 是唯一语义真相;Convex 或服务端降级为账号、分享、同步、协作和 AI 隔离的控制面。
这不是否定 Convex 的价值,而是把 Convex 从默认主存储降级为可选增强能力。
2. 产品形态结论
2.1 默认形态
默认运行形态:
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 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面。
拆开看:
- VSCode 简化版工作区内核:本地文件夹、目录树、页面树、资源树、拖拽、重命名、移动、回收站、导入导出。
- tiptap markdown 编辑器:页面正文的前端显示与交互层,消费本地文件投影,不承担 agent 主编辑面职责。
- Hermes / Reasonix agent:在授权目录白名单内直接读写本地文件,尽量复用它们已有的权限和文件编辑能力。
- 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:
LocalFS / Convex / Future Sync Store
-> WorkspaceSource
-> Rust kernel
-> projection / command / page aggregate / AI tools
-> mnote-web UI
前端不判断“这是 Convex 树还是本地树”。前端只消费:
file_treepage_treepage_aggregatetree.*command resultmnote.doc.*/mnote.block.*AI tool result
2.4 VSCode-like Agent Runtime
本地优先场景下,agent 的运行形态应尽量贴近 VSCode / Codex:
用户打开页面
-> 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,不自动覆盖用户编辑。
- 如果当前 tiptap 没有 dirty 修改,自动重新读取
- AI 写入被视为“后台文件编辑器”写入;它不拥有高于用户手动编辑的优先级。
- 冲突处理默认提供三类选择:接受磁盘版本、保留当前编辑器版本、打开差异比较 / 合并。
这条模型比“tiptap 和 AI 都经过 /api/documents/save 兼容面”更可靠,因为写入仲裁发生在本地文件版本层,而不是旧 Convex/document command 兼容层。
3. 本地工作区数据形状
本机默认受管数据根固定为:
/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 内部建议默认结构:
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 文件。
标题优先级:
- frontmatter
title - 第一行 H1
- 文件名
页面稳定身份优先级:
- frontmatter
mnote_id .mnote/page-ids.json- path-derived 临时 id
path-derived id 只能用于未初始化只读模式,不能作为长期引用 id。
3.2 资源与附件
上传不应默认进入 Convex storage。
本地 .md 页面内的图片和附件上传,默认写入当前 Markdown 文件同目录下的 sibling assets 目录:
<md-dir>/<md-base>.assets/<safe-file-name>
示例:
pages/Inbox.md
pages/Inbox.assets/image.png
pages/Inbox.assets/spec.docx
编辑器正文必须写入相对当前 Markdown 文件目录的标准 Markdown 路径:

[spec.docx](Inbox.assets/spec.docx)
当前已落地基线:
POST /api/local-folder/assets/upload已新增。sourceKind=local_folder时前端上传分流到本地上传入口。- 本地上传复制到
{mdBase}.assets/,返回sourcePath/file_url相对路径。 - 本地图片保存为
。 - 本地附件保存为
[name](relative/path),不生成 OnlyOffice assetId URL。 - 单测与 HTTP smoke 已通过,剩余真实浏览器 smoke 仍需补齐。
通用导入、跨页面共享素材或批量迁移资源可以放入工作区级资源目录:
assets/attachments/<yyyy>/<mm>/<safe-file-name>
写入后 Rust kernel 生成 resource identity,并更新 .mnote/resource-index.json 或等价本地索引。页面内上传优先保持 Markdown 可迁移性,工作区级资源目录只作为共享资源和迁移工具的目标,不是编辑器上传默认路径。
3.3 AI 会话
AI 会话默认写入:
ai-sessions/private/<session-id>.jsonl
共享会话写入:
ai-sessions/shared/<share-id>/<session-id>.jsonl
会话记录必须包含:
user_idworkspace_idsession_idscopeallowed_resource_idscreated_atmodeltool_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 都要计算:
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 专用编辑工具,而是:
前端传当前文件引用 / 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。
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页面上传图片后,落盘正文是。 - 本地
.md页面上传附件后,落盘正文是[name](relative/path)。 - 上传目标目录不越过 local root,冲突文件名递增,不覆盖已有文件。
- 文件树能显示上传后的资源。
- 刷新页面后图片 / 附件块能从相对路径恢复。
- 删除页面或附件进入
.mnote/trash/或系统回收策略。
Phase 5:AI 会话本地化与隔离
目标: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:口径冻结与依赖矩阵
- 新增本稿,固定 local-first workspace 作为早期产品默认形态。
ARCHITECTURE.md已加入 2026-05-18 local-first 口径。design/01-05-current-priority-overview.md已把 local-first workspace 提升为第一优先级。- 建立 Convex 依赖矩阵,至少覆盖:
- File Tree source / projection / command。
- Page Tree source / projection / command。
- Page Aggregate read / save / options。
- 编辑器图片 / 附件上传。
- 普通文件下载 / 页面导出。
- AI session / message / tool call 存储。
- share grants / membership / auth。
- 给新增设计和实现加检查规则:默认不得把新功能直接绑定到 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 projection;Convex 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 executor;Convex 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,子树导出目录与 assets;Convex 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;Convex 只同步必要 metadata / share metadata / quota policy。 |
未完成:当前会话主存储仍是 Convex aiSessions。 |
| 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/hermes_client.rs 和 routes/hermes_tools.rs 使用 ensure_authenticated;rust/crates/mnote-web/src/routes/tree.rs 仍调用 workspaces:ensureDefaultWorkspace。 |
Convex 保留为控制面:auth、membership、share grants、sync state、AI policy;本地打开和编辑需要登录 actor,并由 .mnote/workspace.json.owner_id 限定 root 归属;分享/同步继续要求控制面授权。 |
部分符合目标:auth 适合留在控制面,但当前仍有部分 AI / workspace 主路径待收紧。 |
| 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 workspace;Convex 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:本地工作区入口
- 登录后,
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。
- 证据:
- 登录后,没有最近 workspace 时展示“打开文件夹 / 创建工作区”,不要求先进入 Convex workspace。
- 说明:登录是访问本地文件的门禁;未登录
/和未登录sourceKind=local_folder必须跳/auth,避免注册用户或匿名上下文绕过用户隔离直接访问本机任意目录。
- 说明:登录是访问本地文件的门禁;未登录
.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 已存在时才能打开,不能被普通登录用户直接初始化或接管。
- 目录:默认写入
- 登录状态是本地文件访问、用户空间隔离、分享、同步、云端协作的共同门禁;Convex 不再是正文/附件主存储,但 auth 仍是本地 workspace 访问控制面。
- 证据:
sourceKind=local_folder根入口继续要求 auth;本地文件读写入口新增.mnote/workspace.json.ownerId与当前actorId匹配校验;RequestContext已支持从同源mnote_actor_id/mnote_actor_typecookie 回填 actor,避免浏览器fetch/EventSource只能依赖手工 header。 - 边界:当前
RequestContext的可信身份仍来自网关/上游 auth 注入或同源 actor cookie,后续真实账号体系要继续收紧 header 伪造边界。
- 证据:
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覆盖匿名根入口。
- 证据:
- 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、/treedebug 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收口,不计入本项完成范围。
- 要求:不能只靠前端隐藏入口;
- 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.jsonowner manifest。
- 证据:
- 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.contentpage aggregate。
- 证据:
8.3 P2:LocalFS File Tree / Page Tree 默认 source
- 既有
4-22已完成 Local Folder / Convex 统一树源执行基线。 - 将 LocalFS 明确设为默认
WorkspaceSource。- 证据:
root_entry在无显式sourceKind/workspaceId/pageId时渲染 local-first landing,不再先调用workspaces:ensureDefaultWorkspace;SIDEBAR_TREE_JS::currentSourceKind()在首页无显式 source 参数时默认返回local_folder。
- 证据:
- File Tree 默认从 local root 扫描目录、
.md、普通文件、mindmap、附件。- 证据:
load_local_folder_file_tree_snapshot通过scan_directory输出统一file_treeprojection;tree_shell_filetree_mode_can_open_local_folder_readonly_snapshot与task164-desktop-hot-local-folder-main-entry-smoke.js已通过。
- 证据:
- 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已通过。
- 证据:
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 创建链路。
- 证据:
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已通过。
- 证据:
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已通过。
- 证据:
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已通过。
- 证据:
- 文件树 / 页面树 UI 不新增 local-only 分支,只消费 projection。
- 证据:local / Convex source 均通过 Rust projection 渲染同一个
sidebar-file-tree-root/sidebar-tree-rootsurface;local asset 打开通过 row identity 分流,不新增第二套 local-only 文件树 UI。
- 证据:local / Convex source 均通过 Rust projection 渲染同一个
- 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 P3:Page Aggregate 本地 Markdown 读写闭环
page_aggregate已能从documents.content/ local markdown content 投影blockDocument。- local source 下
page_aggregateprovenance 标记为 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。
- 证据:
page.body.save对 local source 写回.md文件。- 证据:
documents.rslocal source 分支调用save_local_markdown_page;local_markdown_save_preserves_frontmatter_and_writes_basic_blocks、local_markdown_save_writes_image_blocks_as_markdown_images已通过。
- 证据:
- 页面设置写入
.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已通过。
- 证据:
- 标题修改规则固定为 frontmatter
title> H1 > 文件名,并避免三处长期分叉。- 证据:
parse_markdown_page读取顺序为 frontmattertitle、第一行 H1、文件名;task167-local-markdown-title-body-options-no-convex-smoke.js已验证 frontmatter 标题优先于正文 H1。
- 证据:
- 保存时检查 mtime / content hash,外部编辑冲突不得静默覆盖。
- 证据:
save_local_markdown_page校验expected_conflict_detection_key;local_markdown_save_rejects_stale_external_file_change已通过。
- 证据:
- 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。
- 证据:
- 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。
- 证据:
- 将
/api/documents/save从长期写入口降级为 compat adapter;新增或收口到 local-firstpage.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。
- 证据:
page.body.write必须接收expectedFileVersion/baseContentHash,拒绝基于旧文件版本的静默覆盖。- 证据:
PageBodyWriteRequest支持expectedFileVersion/baseContentHash;write_local_markdown_page_body复用本地文件版本校验,local_folder_documents_save_rejects_stale_expected_file_version已覆盖。
- 证据:
Page Aggregate写侧区分 canonical markdown 与 runtime cache:local-first 下.md是正文真相,EditorBlockDocument可以作为.mnote/cache或内存投影缓存,但不能成为第二正文真相。- 证据:local source 写入链只落盘
.md和.mnote/page-options.json;EditorBlockDocument作为contentFormat=editorBlocks输入被序列化为 markdown,不写成第二正文真相。
- 证据:local source 写入链只落盘
- tiptap 保存链不再直接依赖
documents/save兼容面决定 revision;它应提交基于当前baseFileVersion的 markdown 写入意图,由 LocalFS executor 做版本校验。- 证据:local source editor bootstrap / 动态 pane bootstrap 均指向
/api/page-body/write,payload 显式带expectedFileVersion、contentFormat=editorBlocks、editorSource=tiptap。
- 证据:local source editor bootstrap / 动态 pane bootstrap 均指向
- 文件 watcher 发现 AI / 外部编辑器写入后,若 tiptap clean 则自动刷新,若 tiptap dirty 则进入 conflict state。
- 证据:
scripts/task436-local-markdown-open-document-external-change-smoke.js覆盖 clean 自动刷新与 dirty 进入external-change-conflict。
- 证据:
- smoke 覆盖:tiptap dirty 时 AI 后台修改同一
.md,前台不得静默覆盖任一方,必须显示冲突或保留可合并状态。- 证据:
node scripts/task436-local-markdown-open-document-external-change-smoke.js已通过 dirty 文档 AI 后台写入冲突场景。
- 证据:
- smoke 覆盖:tiptap clean 时 AI 后台修改同一
.md,前台 page aggregate / tiptap 自动同步到新内容。- 证据:
node scripts/task436-local-markdown-open-document-external-change-smoke.js已通过 clean 文档外部写入自动刷新场景。
- 证据:
8.5 P4:本地上传 / 下载 / 资源
- 已新增
POST /api/local-folder/assets/upload。 - 本地
.md上传图片 / 附件已分流,不再调用/api/media/upload。 - 本地上传复制到
<md-dir>/<md-base>.assets/。 - 本地上传返回
sourcePath/file_url相对路径。 - 本地附件插入普通相对路径链接,不生成 OnlyOffice assetId URL。
- 本地图片保存为标准 Markdown 图片语法。
- 单测覆盖本地上传目标路径、冲突递增、local markdown 图片 / 附件 roundtrip。
- HTTP smoke 覆盖真实 multipart 上传与落盘。
- 浏览器 smoke 覆盖本地
.md页面上传图片、保存、刷新后恢复。- 证据:
node scripts/task443-local-markdown-asset-upload-smoke.js已验证图片上传到 siblingREADME.assets/、通过/api/page-body/write保存为相对 markdown 图片,并刷新后从 Page Aggregate 恢复。
- 证据:
- 浏览器 smoke 覆盖本地
.md页面上传附件、保存、刷新后恢复。- 证据:
node scripts/task443-local-markdown-asset-upload-smoke.js已验证附件上传到 siblingREADME.assets/、保存为相对 markdown 链接,并刷新后从 Page Aggregate 恢复。
- 证据:
- 浏览器 smoke 覆盖上传后的
{mdBase}.assets/资源在 File Tree 可见。- 证据:
scripts/task443-local-markdown-asset-upload-smoke.js已补强断言sidebar-file-tree-root中出现task443-image.png与task443-spec.pdf。
- 证据:
- 下载普通本地文件时直接读取 local root 内文件。
- 证据:
GET /api/local-folder/files/open已新增并先校验 actor 对rootUri的访问权,再拒绝绝对路径 /..越界;cargo test -p mnote-web local_file_open_rejects_root_escape -- --nocapture已通过。
- 证据:
- 下载页面时导出
.md,下载子树时打包目录与 assets。- 证据:单个本地
.md页面和普通文件通过/api/local-folder/files/open?download=true直接读取 root 内文件;旧 Convex 子树迁移 / 打包由scripts/export-convex-workspace-to-local.js输出目录与 sibling assets。
- 证据:单个本地
- 删除页面时同步处理 sibling assets 的策略明确:保留、移动到 trash 或提示用户。
- 策略:当前 local source 删除 Markdown 页面只移动
.md到.mnote/trash/,sibling{mdBase}.assets/默认保留,避免误删仍被其他页面相对引用的资源;后续若要联动移动 assets,必须先做引用扫描和确认 UI。
- 策略:当前 local source 删除 Markdown 页面只移动
8.6 P5:AI 会话本地化与权限隔离
P5.1 Agent 文件访问 scope 与兼容 manifest
- 定义
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已通过。
- 证据:
- 页面 AI / ACP run 创建前,把当前
rootUri + documentId + selection解析成受控文件引用,并把 agent cwd 限制到授权 workspace。- 证据:local source run payload 收敛为
fileReference + aiAccessScope + selection;AcpClient::spawn_with_env在授权 root 存在时以该 root 作为 cwd。
- 证据:local source run payload 收敛为
- 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已通过。
- 证据:local ACP run 注入
- 本地 agent runtime 优先使用自身成熟的 diff / apply_patch / 文件编辑能力;MNote 不再把
mnote.doc.markdown_edit描述为 local-first 唯一主路径。- 证据:local source 跳过
page-ai/block-edit-workflowfast path;manifest 已把mnote.doc.markdown_edit描述为兼容 / 远端代理 fallback。
- 证据:local source 跳过
- 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已通过。
- 证据:共享本地 AI 会话创建已写入
routes/hermes_tools.rs仍在兼容 tool dispatch 前解析并校验AiAccessScope。- 证据:兼容 tool dispatch 前已解析
aiAccessScope.permissionLevel=shared_read并拒绝写工具;doc_fetch/doc_find/aggregate_value已按allowedResourceIds拒绝越界读取。
- 证据:兼容 tool dispatch 前已解析
hermes_tools/manifest.rs为保留的兼容读工具标注readOnly=true或等价 annotations。- 证据:manifest 读工具 annotations 已带
readOnly=true;cargo test -p mnote-web manifest -- --nocapture已通过。
- 证据:manifest 读工具 annotations 已带
hermes_tools/manifest.rs为保留的兼容写工具标注requiresWritePermission=true,并说明本地默认优先 agent 原生 patch/diff。- 证据:manifest 写工具 annotations 已带
requiresWritePermission=true,mnote.doc.markdown_edit文案明确 local-first 优先 agent 原生 patch/diff。
- 证据:manifest 写工具 annotations 已带
- 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已通过。
- 证据:manifest 与 local markdown edit 测试均覆盖 local source 相对附件路径保持;
P5.2 文件级读取 / 编辑与 mnote.doc.* 兼容层
- 前端普通页面 AI 只传当前文件引用、可选 selection 和用户指令,不再构造完整 page context 作为默认输入。
- 证据:local source 下
build_run_upstream_body把 instructions 收敛为文件引用 scope,并裁掉pageText/pageXml/contextBlocks。
- 证据:local source 下
- 本地 runtime 接收真实授权
.md文件路径或等价 file handle,并在 allowed roots 内执行 patch/diff。- 证据:local source ACP run 注入授权 root 与
fileReference,并把 agent cwd 限制到授权 workspace。
- 证据:local source ACP run 注入授权 root 与
- 写入后 MNote 通过文件 watcher 或显式 refresh 重新生成 Page Aggregate / File Tree,不要求 agent 调
mnote.doc.markdown_edit才能刷新。- 证据:
task436已覆盖后台文件写入后 clean 自动刷新、dirty 冲突。
- 证据:
- tiptap island 作为显示层消费刷新后的 Page Aggregate;后台文件编辑后前台页面无需经过专用 MNote AI 工具回写。
- 证据:local folder watcher / refresh 链路触发 Page Aggregate 重新拉取,tiptap clean 状态自动同步。
- 兼容
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已通过。
- 证据:
- 兼容
doc_fetch对 local.md返回 markdown 时保留相对附件路径。- 证据:local
.md读取不走 Convex media rewrite,直接返回 markdown / block projection 中的相对路径;hermes_tools_markdown_edit_local_folder_writes_same_markdown_file覆盖相对附件路径不被改写。
- 证据:local
doc_fetch对 shared scope 只返回授权页面 / 子树 / selection,不扩展到整个 workspace。- 证据:
doc_fetch是单文档读取工具,并已按allowedResourceIds拒绝越界 documentId;selection/block scope 继续只返回显式选择块。
- 证据:
doc_find搜索范围受allowed_resource_ids限制。- 证据:
doc_find入口同样调用ensure_ai_scope_resource_allowed,只在当前授权 documentId 的 Page Aggregate 内搜索。
- 证据:
plan_update只做 dry-run,不产生任何本地文件或 Convex 写入。- 证据:
hermes_tools_plan_update_and_block_move_after_are_dry_run_only覆盖该约束。
- 证据:
- 兼容
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已通过。
- 证据:
- 兼容
doc_markdown_edit对 local source 写同一份.md文件,不经 Convex。 - 兼容
doc_markdown_edit保留{mdBase}.assets/相对链接,不把图片 / 附件改写为/api/media/*或/onlyoffice?...assetId=...。 - 兼容
doc_markdown_edit写入后回读,确认最终 markdown 与落盘文件一致。- 证据:
hermes_tools_markdown_edit_local_folder_writes_same_markdown_file。
- 证据:
P5.3 mnote.block.* 与 mnote.doc.apply_block_ops(延后 / 可选)
- 当前 local-first 普通 Markdown 编辑不再把
mnote.block.*或mnote.doc.apply_block_ops列为主路径验收项。 - 若后续继续开放
block_fetch,执行前校验读权限和目标 block 所属 resource。- 证据:块工具读取 Page Aggregate 前会经过
aggregate_value,该入口已按AiAccessScope.allowedResourceIds校验当前 documentId。
- 证据:块工具读取 Page Aggregate 前会经过
- 若后续继续开放
block_replace/block_insert_after/block_delete/block_move_after,执行前校验写权限。- 证据:
routes/hermes_tools.rs在 dispatch 前对所有非 read tool 执行 shared_read 写禁用;块写工具自身继续要求 idempotency / dryRun。
- 证据:
- 若后续继续开放
doc_apply_block_ops,执行前校验allowedTargetBlockIds与allowed_resource_ids。- 证据:
doc_apply_block_ops既经过aggregate_value的 resource scope 校验,也复用ensure_allowed_target/allowedTargetBlockIds约束。
- 证据:
- local source 下块级写入必须回到统一 page aggregate / markdown save 路径,不允许新增 Convex-only 写链。
- 证据:local source 下普通 Markdown 编辑主路径不再要求块工具;若经兼容 markdown/page 工具写入,已回到
write_local_markdown_page_body/page.body.write。
- 证据:local source 下普通 Markdown 编辑主路径不再要求块工具;若经兼容 markdown/page 工具写入,已回到
shared_read下所有块级写工具返回禁止写错误。- 证据:写工具 dispatch 前的 shared_read 守卫覆盖
mnote.block.*与mnote.doc.apply_block_ops等所有非 read tool。
- 证据:写工具 dispatch 前的 shared_read 守卫覆盖
shared_write下所有块级写工具产生 audit 记录。- 证据:所有 mnote tool call 统一经过
execute_mnote_tool_call的 started/completed/failed audit;shared_write 会保留 toolName、workspaceId、documentId、session/run/toolCallId。
- 证据:所有 mnote tool call 统一经过
- 复杂块、resource 块、mindmap 块、table 块继续默认不可由 AI 精确写入,除非后续单独开放工具合同。
- 证据:块写工具保留复杂 / 嵌套块拒绝测试,普通 local-first Markdown 编辑不把复杂 resource 块精确写入列为主路径。
P5.4 mnote.page.* 与 artifact 工具
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已通过。
- 证据:
mnote.page.save降级为粗粒度兜底,不作为普通正文编辑默认入口。- 证据:manifest 已将
mnote.page.save标记为粗粒度兼容兜底;local-first 普通正文编辑主路径仍是 agent 文件 patch/diff 或/api/page-body/write。
- 证据:manifest 已将
- 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已通过。
- 证据:
update_title的 local source 写入规则与 Page Aggregate 标题规则一致。- 证据:local source
mnote.page.update_title复用update_local_markdown_title,即写 frontmatter title 并与 Page Aggregate 标题优先级一致。
- 证据:local source
update_options的 local source 写入.mnote/page-options.json或 frontmatter,不能出现第二套页面设置真相。- 证据:local source
mnote.page.update_options复用update_local_page_options,写.mnote/page-options.json。
- 证据:local source
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已通过。
- 证据:local source 下 artifact 工具写
P5.5 AI session 存储与 UI
- 私有 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已通过。
- 证据:local source session create / ACP run / event 均写 private JSONL;
- 共享 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已通过。
- 证据:
- AI session metadata 可以同步到控制面,但消息全文默认不写入 Convex。
- 证据:local source 分支返回
persistence=local_ai_session_jsonl并绕过 ConvexaiSessions:*;cloud/acp 非 local source 仍走convex_acp_runtime_store。
- 证据:local source 分支返回
- 页面 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已通过。
- 证据:
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覆盖该约束。
- 证据:
- AI 会话分享不得隐式分享整个 workspace。
- 证据:共享 session 创建只接收
shareId/permissionLevel并写入ai-sessions/shared/<share-id>/;写入 audit 记录 shareId,不扩展 workspace 全量授权。
- 证据:共享 session 创建只接收
P5.6 验收
- 单测覆盖 agent 文件引用解析在 local source 下不能越过
allowed_roots / allowed_file_paths。- 证据:
acp_runtime_env_limits_local_source_to_allowed_root覆盖普通用户 run 只包含自己的授权 root,不包含其他用户目录。
- 证据:
- 单测覆盖
shared_read下禁止 agent patch / 写工具 / 兼容mnote.doc.markdown_edit写入。- 证据:
cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture已通过。
- 证据:
- 单测覆盖兼容
mnote.doc.markdown_edit在 local source 下保留{mdBase}.assets/相对路径。- 证据:
hermes_tools_markdown_edit_local_folder_writes_same_markdown_file。
- 证据:
- 若继续开放块工具,再补
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 越界拒绝。
- 证据:
- 单测覆盖兼容 manifest 中写工具带
requiresWritePermission,且说明本地默认优先 agent 原生 patch/diff。- 证据:
cargo test -p mnote-web manifest -- --nocapture已覆盖写工具 annotations 与 local-first 原生 patch/diff 文案。
- 证据:
- 浏览器 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 拒绝。
- 证据:local workspace access guard 已在 route 层按 owner/admin/grant 校验;
- 浏览器 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 越界读取拒绝。
- 证据:
- 浏览器 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覆盖。
- 证据:当前先以 HTTP / route 单测覆盖 shared_write audit JSONL:
8.7 P6:Convex / 服务端控制面收口
- 控制面只保存 user、workspace membership、share grants、sync state、AI policy。
- 证据:G1 已冻结新增 Convex 边界;
scripts/check-local-first-convex-guard.js防止 activeconvex/重新引入未标注 documents/media 主存储表或函数,infra/convex只标注为控制面 / cloud source / compat / sync replica。
- 证据:G1 已冻结新增 Convex 边界;
- 页面正文只在启用同步时产生远端副本。
- 证据:local source 的正文保存主链写
.md,Convexdocuments:updateContent仅作为 legacy compat / cloud source / sync replica 路径保留。
- 证据:local source 的正文保存主链写
- 附件只在启用同步或分享时产生远端副本。
- 证据:local
.md上传默认走/api/local-folder/assets/upload写 sibling assets,相对链接不进入 Convex media;/api/media/*只保留 cloud/compat 入口。
- 证据:local
- 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。
- 证据:local source AI session create / run / event 写
- 分享页面先校验控制面授权,再决定本地缓存或同步拉取。
- 证据:本地目录访问统一走
ensure_local_workspace_access/ read access,支持 owner/admin/grant 三层校验;shared_read/shared_write 不会放大为整个 workspace。
- 证据:本地目录访问统一走
- 控制面不可用时不得扩大本地缓存权限。
- 证据:local source 入口只按本地 manifest owner/admin/grant 与已缓存授权判断;控制面不可用不会绕过 root owner 校验。
- 多人实时协作保持为后续能力,不进入早期默认路径。
- 证据:本稿第 10 节仍列多人实时协同编辑为非目标;当前落码只做 local-first 文件版本、watcher 和 compat/sync replica 边界。
8.8 P7:旧 Convex 主存储导出与退役
- 提供
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已通过。
- 证据:新增
- 导出 Convex 页面为
.md。- 证据:
task444断言pages/Project.md与pages/Project/Child Spec.md存在,并包含mnote_idfrontmatter。
- 证据:
- 导出 Convex 附件为本地 assets,并重写正文链接为相对路径。
- 证据:
task444断言asset_logo/asset_pdf导出到 sibling assets,正文链接重写为Project.assets/logo.png与Child Spec.assets/spec.pdf。
- 证据:
- 导出 tree 结构为目录、frontmatter、
.mnote/page-ids.json和.mnote/resource-index.json。- 证据:
task444断言父子页面目录、frontmatter、.mnote/page-ids.json、.mnote/page-options.json和.mnote/resource-index.json。
- 证据:
- 导出 AI 会话为
ai-sessions/,并保留权限元数据。- 证据:
task444断言ai-sessions/private/sess_1.jsonl存在并包含run.completed事件。
- 证据:
- 旧
documents.*route 降级为 compat adapter,不再作为新增功能入口。- 证据:local source
/api/documents/save只作为 compat adapter 调page.body.write;新增编辑保存入口为/api/page-body/write。
- 证据:local source
- Convex page body 写入改为 sync replica 写入。
- 证据:
transport/convex.rs已标注documents:updateContent是 legacy content substrate / sync replica;local-first 正文真相写.md,不以 Convex revision 作为 local source revision。
- 证据:
- 前端移除直接依赖 Convex documents / media / sessions 结构的主路径。
- 证据:local source 下页面保存、上传、文件打开和 AI session UI 均走 local-first 字段与 endpoint;
/api/media/*、aiSessions:*、documents:*只保留 cloud/compat 分支,并由 guard 阻止新增默认主链。
- 证据:local source 下页面保存、上传、文件打开和 AI session UI 均走 local-first 字段与 endpoint;
8.9 详细落码执行 Checklist(2026-05-19 起按此推进)
本节是后续落码的执行账本。每一项都必须能对应到具体代码、测试和验收证据;新增实现若偏离本节,需要先更新本节再编码。
A. 防走偏护栏与口径同步
- A1. 固定主路径口径:
MNote = VSCode 简化版工作区 + tiptap markdown 显示/编辑层 + Hermes/Reasonix agent + 插件 + Wolai web 壳 + 鉴权控制面。- 文档:本稿第 2.1、2.4、5、6 节。
- 验收:普通 local-first Markdown 编辑不再被描述为必须走
mnote.block.*、mnote.doc.markdown_edit或 Convex documents。
- 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 例外必须显式说明。
- 文件:
- 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 文件版本与冲突底座
- 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已通过。
- 文件:
- 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已通过。
- 文件:
- B3.
/api/documents/savelocal 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已通过。
- 文件:
- 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。
- 文件:
- 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_editlocal 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。
- 文件:
- 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。
- 文件:优先复用或补强
- 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
- 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与 legacyconflictDetectionKeyalias。
- 文件:本稿、
- 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 边界与文件版本校验。
- 文件:
- 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。
- 文件:
- 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并注册 route;local 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
- 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_foldersource、read_write权限、allowedRoots/allowedFilePaths/allowedResourceIds、shareContext,并拒绝空白必填字段 / 空白授权路径。cargo test -p core-protocol ai_access_scope -- --nocapture已通过。
- 文件:优先放
- 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-workflowfast 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已通过。
- 文件:
- 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已通过。
- 文件:
- 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-path,agent 原生文件写入可通过 local folder watcher 触发前台 refresh / conflict。
node scripts/task436-local-markdown-open-document-external-change-smoke.js已通过,覆盖 clean 外部写入自动刷新与 dirty 外部 / AI 后台写入进入冲突态。
- 文件:
- 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. 本地资源上传 / 下载 / 打开
- 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。
- 文件:
- E2. 浏览器 smoke 覆盖本地图片上传、保存、刷新恢复。
- 文件:新增或扩展
scripts/task443-local-markdown-asset-upload-smoke.js。 - 完成定义:落盘 markdown 是
,刷新后图片块仍可见。 - 完成证据:新增
scripts/task443-local-markdown-asset-upload-smoke.js,真实浏览器登录并打开 local.md,调用/api/local-folder/assets/upload上传图片到 siblingREADME.assets/,再通过/api/page-body/write保存相对图片引用;刷新后回读 Page Aggregate 确认README.assets/task443-image.png恢复,落盘 markdown 为,且无/api/media/或assetId=。node scripts/task443-local-markdown-asset-upload-smoke.js已通过。
- 文件:新增或扩展
- 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同时覆盖附件上传到 siblingREADME.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已通过。
- 文件:新增或复用
- 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 会话本地化
- 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,不再调用 ConvexaiSessions:*;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已通过。
- 文件:
- 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已通过。
- 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. 控制面收口与旧数据迁移
- G1. Convex 控制面字段边界冻结。
- 文件:
infra/convex/、rust/crates/mnote-web/src/transport/convex.rs。 - 完成定义:新增 Convex 调用只能属于 auth、membership、share grants、sync state、AI policy、compat 或 sync replica。
- 完成证据:
infra/convex/README.md与docker-compose.yml已明确 Convex 自托管仅作为控制面 / cloud source / compat / sync replica;transport/convex.rs已标注 auth delegation、sidebar cloud source、legacy command validator、bridgeLogs audit replica 边界;scripts/check-local-first-convex-guard.js --self-test已通过,并新增 activeconvex/目录防重新引入未标注 documents/media 主存储表或函数。
- 文件:
- 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已通过。
- 文件:新增迁移脚本,优先放
- 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. 每轮执行固定验证包
- H1. 改 Rust 协议 / 路由后至少运行:
cargo fmt --check --allgit 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已通过。
- H2. 改 local workspace / watcher / editor 后至少运行:
cargo test -p mnote-web local_folder -- --nocapture- 对应
scripts/task*.jsbrowser smoke。 - 完成证据:
cargo test -p mnote-web local_folder -- --nocapture已通过,48 个测试通过;node scripts/task443-local-markdown-asset-upload-smoke.js已通过,覆盖本地.mdsibling assets 上传、保存和刷新恢复;node scripts/task436-local-markdown-open-document-external-change-smoke.js已通过,覆盖 clean 自动刷新与 dirty 外部 / AI 后台写入冲突态。
- 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. 当前优先级建议
新的执行顺序应调整为:
- Local-first workspace 入口与 LocalFS 默认 source
- File Tree / Page Tree 默认消费 LocalFS projection
- Page Aggregate 本地 markdown 读写闭环
- 上传下载本地资源模型
- AI 会话本地化与权限隔离
- Convex 控制面收口
- 旧 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 不再是早期产品默认主数据层,而是控制面和可选同步协作层。