chore: align local-first control plane and editor fixes

- wire SQLite control-plane access/session paths into Rust web local-folder routes

- preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs

- refresh design governance docs, Reasonix task templates, and bug records

- retire root .mcp.json local MCP config
This commit is contained in:
lix-2026
2026-05-23 23:38:42 +08:00
parent 42fb58310c
commit 5f97800489
110 changed files with 5344 additions and 889 deletions
+47
View File
@@ -0,0 +1,47 @@
# Result: <title>
## Metadata
- Run ID: `<run-id>`
- Lane: `<lane>`
- Worktree: `<absolute-worktree-path>`
- Branch: `<branch-name>`
- Worker: `codex-<lane>`
## Summary
Briefly state what changed.
## Completed work
- [ ] ...
## Modified files
- `path`: reason
## Validation performed
```bash
# command
```
Result:
```text
...
```
## Suggested smoke test
- URL:
- Actions:
- Expected result:
## Risks / unfinished items
- ...
## Notes for reviewer
- ...
+59
View File
@@ -0,0 +1,59 @@
# Review: <title>
## Metadata
- Run ID: `<run-id>`
- Reviewer: `Hermes`
- Main repo: `/mnt/Data1T/mnote`
- Related worktrees:
- `...`
## Inputs reviewed
- PLAN.md:
- RESULT.md files:
- SMOKE.md:
- Git diffs:
## Integration decision
MERGE / REQUEST_FIX / REJECT / HOLD
## Summary
Short decision summary.
## Diff review
- File/path:
- Observation:
- Risk:
- Decision:
## Smoke review
- Smoke result:
- Evidence sufficient: yes/no
- Gaps:
## Validation run by reviewer
```bash
# command
```
Result:
```text
...
```
## Follow-up tasks
- [ ] ...
## Cleanup / recycle
- Worktrees kept:
- Worktrees moved to recycle:
- Branches kept/deleted:
+49
View File
@@ -0,0 +1,49 @@
# Smoke: <title>
## Metadata
- Run ID: `<run-id>`
- Tester: `reasonix-flash`
- Target worktree / branch: `<branch-or-main>`
- Evidence directory: `<absolute-evidence-dir>`
- URL: `<url>`
## Scope
What this smoke test validates.
## Browser actions
- [ ] Navigate to `<url>`
- [ ] Login / setup state if needed
- [ ] Click: ...
- [ ] Hover: ...
- [ ] Drag: ...
- [ ] Select: ...
## Result
PASS / FAIL / BLOCKED
## Evidence
- Final URL:
- Screenshot:
- Console errors:
- Network errors:
- Visible text sample:
## Findings
### Issue 1
- Expected:
- Actual:
- Reproduction:
- Evidence:
## Cleanup
- Test data created:
- Test data cleaned:
- Residual state:
+54
View File
@@ -0,0 +1,54 @@
# Task: <title>
## Metadata
- Run ID: `<run-id>`
- Lane: `<lane>`
- Worker: `codex-<lane>`
- Main repo: `/mnt/Data1T/mnote`
- Worktree: `<absolute-worktree-path>`
- Branch: `<branch-name>`
- Result path: `<run-dir>/codex-<lane>/RESULT.md`
## Goal
Describe the concrete outcome expected from this worker.
## Context
- Relevant design docs:
- `...`
- Relevant code paths:
- `...`
## Allowed edit scope
- `path/or/dir/**`
## Forbidden scope
- Do not edit files outside allowed scope.
- Do not edit user data.
- Do not modify unrelated recycle/history directories.
- Do not commit, push, reset, or revert unless explicitly instructed.
## Requirements
- ...
## Validation commands
```bash
# command 1
# command 2
```
## Expected deliverable
Write `RESULT.md` with:
- completed work
- modified files
- validation commands and outputs
- risks / unfinished items
- suggested smoke scope
@@ -0,0 +1,79 @@
# Reasonix Worker ASQLite control-plane store 骨架
Project root: `/mnt/Data1T/mnote`
你不是独自在代码库中工作。其他 worker 可能并行修改 auth、access policy 或迁移脚本。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。
## 背景
上位设计稿:
`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`
目标是用 Rust + SQLite 完全替换 Convex 控制面。你的任务只负责 SQLite store 骨架,不接运行时 route。
## Ownership
允许修改:
- 优先新增 `rust/crates/control-plane/**`
- 必要时修改 `rust/Cargo.toml`
- 必要时新增/修改 `rust/crates/mnote-web/Cargo.toml` 仅用于依赖引用建议
禁止修改:
- `rust/crates/mnote-web/src/routes/gateway.rs`
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- `rust/crates/mnote-web/src/routes/session.rs`
- `rust/crates/mnote-web/src/ssr/**`
- `infra/convex/**`
- `.env*`
如果发现 workspace crate 接入风险过大,先实现为可编译独立 crate;不要跨模块硬接 runtime。
## 任务
1. 新增 `control-plane` Rust crate 或等价最小模块,优先独立 crate。
2. 定义核心 model 与 `ControlPlaneStore` trait。
3. 实现 `SqliteControlPlaneStore`,支持临时路径 DB。
4. 实现 migration v1,包含至少:
- `users`
- `auth_identities`
- `auth_sessions`
- `workspaces`
- `workspace_members`
- `directory_grants`
- `share_links`
- `sync_state`
- `ai_policies`
- `audit_log`
- `outbox_events`
- `legacy_id_map`
5. 实现最小 CRUD
- upsert user
- create/get session
- ensure default workspace
- grant/revoke directory access
- append/drain outbox
6. 补单测:migration 幂等、默认 workspace、owner grant、outbox drain。
## 验收命令
优先运行:
```bash
cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture
cargo fmt --check --all
```
如果没有新增独立 crate,而是放入 mnote-web
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web control_plane -- --nocapture
cargo fmt --check --all
```
## 最终回复格式
请列出:
- 修改文件
- 新增表和核心 API
- 实际运行过的命令及结果
- 未完成项/风险
@@ -0,0 +1,60 @@
# Reasonix Worker BAuth/session SQLite 切换准备
Project root: `/mnt/Data1T/mnote`
你不是独自在代码库中工作。其他 worker 可能并行修改 SQLite store、access policy 或迁移脚本。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。
## 背景
上位设计稿:
`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`
当前用户报告过新注册账号显示“开发用户的空间”、不能新建页面。根因方向之一是真实 actor/profile/workspace name 没有统一来自控制面。本任务只处理 auth/session 切换准备,不处理 local folder access。
## Ownership
允许修改:
- `rust/crates/mnote-web/src/routes/session.rs`
- 可新增 `rust/crates/mnote-web/src/routes/auth_sqlite.rs`
- 可新增 `rust/crates/mnote-web/src/control_plane_auth_adapter.rs`
- `rust/crates/mnote-web/src/ssr/pages/auth.rs` 仅限必要表单字段/测试适配
- focused tests in same files
只读参考但不要直接改,除非确实必要并最小修改:
- `rust/crates/mnote-web/src/routes/gateway.rs`
- `rust/crates/mnote-web/src/middleware/request_context.rs`
- `rust/crates/mnote-web/src/context.rs`
禁止修改:
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- `rust/crates/mnote-web/src/ssr/pages/admin.rs`
- `rust/crates/mnote-web/src/ssr/styles.rs`
- `infra/convex/**`
- `.env*`
## 任务
1. 梳理现有 signIn/signUp/session cookie 链,写在代码注释或测试名中,不写长文档。
2. 增加 SQLite session adapter 的 route/helper 骨架:
-`mnote_session` 查 session。
- 生成/刷新 `mnote_actor_id/name/email/type` display cookies。
- `/api/session` 优先使用 SQLite session,缺失时保留现有 fallback。
3. 注册/登录 adapter 应明确输出真实 `user_id/email/name/actor_type`
4. 新用户注册测试必须断言不回退到 `DEV_USER_NAME`
5. 不要求本轮完全删除 Convex auth fallback。
## 验收命令
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web session -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web auth -- --nocapture
cargo fmt --check --all
```
## 最终回复格式
请列出:
- 修改文件
- 新增/调整的 cookie 与 session 规则
- 实际运行过的命令及结果
- 未完成项/风险
@@ -0,0 +1,56 @@
# Reasonix Worker CAccess policy SQLite adapter
Project root: `/mnt/Data1T/mnote`
你不是独自在代码库中工作。其他 worker 可能并行修改 SQLite store、auth/session 或迁移脚本。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。
## 背景
上位设计稿:
`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`
当前 `access-policy.json` 是本地目录授权事实源,但完全替换 Convex 后,应迁到 SQLite。你的任务是抽象 access policy adapter,保留 JSON fallback,为 SQLite grant 接入打接口。
## Ownership
允许修改:
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 可新增 `rust/crates/mnote-web/src/local_access_sqlite.rs`
- 可新增 focused tests
禁止修改:
- `rust/crates/mnote-web/src/routes/session.rs`
- `rust/crates/mnote-web/src/routes/gateway.rs`
- `rust/crates/mnote-web/src/ssr/pages/admin.rs`
- `rust/crates/mnote-web/src/ssr/styles.rs`
- `infra/convex/**`
- `.env*`
## 任务
1. 找出 `ensure_local_workspace_read_access` / write access / admin access 的现有调用和策略读取点。
2. 抽象一个 access policy provider/adapter
- JSON provider 维持现有行为。
- SQLite provider 可先是 trait/mock/stub,不要求真实 DB。
3. 增加测试覆盖:
- 默认 workspace owner 有 write。
- read grant 禁写。
- write grant 可写。
- 未授权拒绝。
4. 为后续 SQLite store 接入预留最小接口,不要重写 tree command 或 UI。
## 验收命令
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_access_policy -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_workspace_access -- --nocapture
cargo fmt --check --all
```
## 最终回复格式
请列出:
- 修改文件
- 新增 adapter/trait 设计
- 实际运行过的命令及结果
- 未完成项/风险
@@ -0,0 +1,62 @@
# Reasonix Worker D:迁移工具与 Convex 下线 guard
Project root: `/mnt/Data1T/mnote`
你不是独自在代码库中工作。其他 worker 可能并行修改 Rust control-plane、auth/session 或 access policy。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。
## 背景
上位设计稿:
`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`
目标是给完全替换 Convex 准备迁移 dry-run 工具和 guard,不执行真实破坏性迁移。
## Ownership
允许修改:
- 新增 `scripts/migrate-control-plane-to-sqlite.js`
- 修改 `scripts/check-local-first-convex-guard.js` 仅限增加 SQLite 迁移期 guard 规则或建议输出
- 可新增 `scripts/task*-sqlite-control-plane-*.js` focused smoke
- 可向设计稿 `2-8` 追加“执行记录”,不得改设计结论
禁止修改:
- `rust/crates/**`
- `infra/convex/docker-compose.yml`
- `.env*`
- 不删除任何 Convex 数据、Docker volume 或本地数据文件
## 任务
1. 实现 dry-run 迁移工具:
- 读取 `/mnt/Data1T/Mnote_data/control-plane/access-policy.json`,路径可用参数覆盖。
- 扫描 `/mnt/Data1T/Mnote_data/users/*/workspaces/*/.mnote/workspace.json`,路径可用参数覆盖。
- 生成 `migration-report.json`,包含 users/workspaces/grants/legacy_id_map 建议,不写 DB。
2. 支持命令:
```bash
node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json
```
3. 更新 guard 或输出建议:新增 Convex 控制面代码应被标记为禁止或迁移例外。
4. 补最小 node smoke,断言 dry-run report 可生成。
## 验收命令
```bash
node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json
npm run check:local-first-convex-guard
```
如新增 smoke
```bash
node scripts/<new-smoke>.js
```
## 最终回复格式
请列出:
- 修改文件
- dry-run report 字段
- 实际运行过的命令及结果
- 未完成项/风险
@@ -0,0 +1,55 @@
# Reasonix Worker Econtrol-plane share_links / audit / outbox 扩展
Project root: `/mnt/Data1T/mnote`
你不是独自在代码库中工作。其他 worker 可能并行修改 `mnote-web` 适配层、auth/session 或 access policy。不要还原、覆盖或清理不属于你任务范围的改动。不要提交 git。
## 背景
上位设计稿:
`design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`
当前 `control-plane` crate 已有 users/auth/workspaces/directory_grants/sync_state 的骨架,但 share_links、audit_log、outbox 的能力还不够完整。
## Ownership
允许修改:
- `rust/crates/control-plane/src/model.rs`
- `rust/crates/control-plane/src/store.rs`
- `rust/crates/control-plane/src/sqlite.rs`
- `rust/crates/control-plane/src/migrations.rs`
- `rust/crates/control-plane/migrations/*.sql`
- `rust/crates/control-plane/src/error.rs` 仅限必要错误映射
禁止修改:
- `rust/crates/mnote-web/**`
- `infra/convex/**`
- `.env*`
- `scripts/**`
## 任务
1. 补强 `control-plane` 模型与 store trait
- `share_links` 的 create/list/revoke/resolve
- `audit_log` append/list 最小能力
- `outbox_events` drain/mark-delivered 的稳定方法
2. 为 share_links 增加 SQLite 实现与单测:
- token 只保存 hash
- revoke 后不可再 resolve
3. 为 audit_log 增加最小单测:
- 创建分享、撤销授权、AI/控制面关键事件可追加
4. 保持 schema 迁移幂等。
## 验收命令
```bash
cargo test --manifest-path rust/Cargo.toml -p control-plane -- --nocapture
```
## 最终回复格式
请列出:
- 修改文件
- 新增 API / 表能力
- 实际运行过的命令及结果
- 未完成项 / 风险
-33
View File
@@ -1,33 +0,0 @@
{
"mcpServers": {
"supabase_local": {
"type": "stdio",
"command": "cmd",
"args": ["/c", "npx", "-y", "mcp-remote@latest", "http://127.0.0.1:18000/mcp"],
"startup_timeout_ms": 60000,
"env": {
"PROGRAMFILES": "C:\\Program Files",
"SystemRoot": "C:\\Windows"
}
},
"chrome-devtools": {
"type": "stdio",
"command": "cmd",
"args": ["/c", "npx", "-y", "chrome-devtools-mcp@latest"],
"startup_timeout_ms": 60000,
"env": {
"PROGRAMFILES": "C:\\Program Files",
"SystemRoot": "C:\\Windows"
}
},
"context7": {
"type": "stdio",
"command": "cmd",
"args": ["/c", "npx", "-y", "@upstash/context7-mcp", "--api-key", "ctx7sk-8beae3e8-38a0-497a-a44d-a67c05669318"],
"startup_timeout_ms": 40000,
"env": {
"SystemRoot": "C:\\Windows"
}
}
}
}
+7 -6
View File
@@ -23,7 +23,7 @@
3. `mnote-web` 是当前 Rust Web 主执行面,负责 3000 gateway、server-first shell、query / command / projection / transport 与 realtime streamNext App Router 已降为 legacy compat / island bundle source,不再是当前主入口
4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island
5. `BlockNote` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留
6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、agent runtime 管理、文件变更同步;Hermes / Reasonix 直接在授权工作区内编辑本地文件。`page_ai_workflow` `mnote.doc.*` / `mnote.block.*` 保留为兼容 / cloud / 复杂结构辅助层,不再作为 local-first 普通正文编辑默认主路径
6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、agent runtime 管理、文件变更同步;Hermes / Reasonix 直接在授权工作区内编辑本地文件。`page_ai_workflow` 只保留为 debug / 历史兼容门面,`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback`mnote.block.*` 保留为复杂结构辅助层
一句话收口:
@@ -274,7 +274,7 @@ OnlyOffice 仍然是:
对应设计稿:
- `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
### 8.2 Tree Command Cutover
@@ -325,18 +325,19 @@ OnlyOffice 仍然是:
关键原则(2026-05-18 口径更新):
- **local-first 主路径**:当前页面解析成授权 `.md` 文件,Hermes / Reasonix 在白名单目录内直接读写,MNote 负责权限、审计和前台同步
- **兼容两层模型**(参考 CLI Main Lark Doc):`mnote.doc.markdown_edit` 作为文本级兼容 / fallback`mnote.doc.apply_block_ops` 作为块级结构性辅助local-first 正常编辑优先走“授权文件引用 + agent 原生 patch/diff + 文件版本冲突模型”
- **local-first 文件编辑模型**:普通 Markdown 编辑走“页面定位 + 授权文件引用 + agent 原生 patch/diff + 文件版本冲突模型”;MNote 不提供普通 Markdown 编辑工具,`mnote.doc.markdown_edit` 不再作为默认、fallback 或 remote fallback`mnote.doc.apply_block_ops` 作为块级结构性辅助。CLI Main Lark Doc 只能作为 skill/workflow 纪律的有限参考,不作为当前编辑架构主参考。
- `mnote.block.*` 降级为结构性辅助(拖拽排序、精确块删除等),不删除
- 当前 `local_rule` planner`direct_block_edit_operations`)是过渡实现,应继续退役
- cloud / remote agent 无法直接访问本地文件时,才回到 `resolve_source` → Convex | LocalFS 的受控代理写入
- cloud / remote agent 无法直接访问本地文件时,不默认回退到 `mnote.doc.markdown_edit`;需要另行设计显式同步 / cloud source 边界
- Markdown 既是 AI 编辑格式也是人类可读格式,不需要 XML 中间层
- 流式 apply + suggest/review(参考 BlockNote AI 的 `StreamToolExecutor` + `suggestChanges`)仅作为 Phase C 设计冻结,当前不实施
对应设计稿:
- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
- `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`(历史执行记录;不再作为 active 入口)
- `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`
- `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
- `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
- `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`(历史回收稿;不再作为当前实现依据)
### 8.5 定向 Bug Hunt 与质量基线
@@ -0,0 +1,112 @@
# 3-23 [done][bug] dev:hot 启动 inotify 限制导致 local-folder events 500 v1
> 发现时间:2026-05-23
>
> 状态:`[done]`
>
> 关联主线:`03-rust-web`
## 1. 用户可见症状
执行:
```bash
npm run dev:hot
```
启动日志出现:
```text
WARN System notification limit is too small, falling back to polling mode.
WARN Polling for changes every 500ms
ERROR response failed classification=Status code: 500 Internal Server Error latency=0 ms
```
`mnote-web` 本身能启动,但首屏随后会打一条 500。
## 2. 根因
浏览器网络请求定位到失败 endpoint:
```text
GET /api/local-folder/events?rootUri=...
```
该请求返回的错误体为:
```json
{
"code": "internal_error",
"message": "本地文件夹监听失败: OS file watch limit reached. about [\"/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space\"]"
}
```
因此根因不是 route 业务逻辑回归,而是 Linux `inotify` watcher 容量不足:
- `cargo watch` 在启动时检测到系统通知额度不足,降级为 polling。
- `mnote-web` 首屏建立本地工作区 SSE watcher 时也申请 watcher。
- 当前用户已有 watcher 占用接近系统上限,导致 `/api/local-folder/events` 创建监听失败并返回 500。
## 3. 修复
已在系统层即时提高并持久化 inotify 容量:
```bash
sudo sysctl fs.inotify.max_user_watches=1048576 fs.inotify.max_user_instances=1024 fs.inotify.max_queued_events=32768
```
持久配置写入:
```text
/etc/sysctl.d/99-mnote-dev.conf
```
内容:
```text
fs.inotify.max_user_watches=1048576
fs.inotify.max_user_instances=1024
fs.inotify.max_queued_events=32768
```
## 4. 验证
验证系统参数:
```bash
sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances fs.inotify.max_queued_events
```
结果:
```text
fs.inotify.max_user_watches = 1048576
fs.inotify.max_user_instances = 1024
fs.inotify.max_queued_events = 32768
```
验证 SSE
```text
/api/local-folder/events?... 返回 200,并收到 ready 事件。
```
验证启动:
```bash
FRONTEND_PORT=3107 timeout 10s npm run dev:hot
```
结果:无 `System notification limit` warning,无启动期 500。
重新启动真实入口:
```bash
npm run dev:hot
```
结果:`http://localhost:3000` 正常启动;浏览器首屏网络请求中 `/api/local-folder/events?...` 为 200。
## 5. 剩余边界
这是系统资源配置问题,本仓库代码不需要为本次 inotify 限制做业务改动。若后续再次出现同类 500,应先统计当前 inotify watcher 占用和后台进程,再判断是继续提高系统额度还是清理异常 watcher 进程。
@@ -0,0 +1,38 @@
# 4-49 [process][bug] 文件树拖拽上传文件落到同级而非目标文件夹下 v1
> 发现时间:2026-05-23
>
> 状态:`[process]`
>
> 关联主线:`04-tree-domain`
## 1. 用户可见症状
原始记录:
```text
我的空间:向文件树中拖动文件上传文件,能正常在主编辑 md 中显示,点击也能正常打开,但是见 /mnt/Data1T/mnote/tmp/image copy 65.png,文件是和主文件夹同级(应该在我的文件夹下级)。
```
## 2. 初步判断
这是文件树拖拽上传目标解析问题,不是附件点击打开问题:
- 上传后的附件块能正常插入主编辑器。
- 点击附件能正常打开。
- 异常点是资源文件在文件树中的落点:实际落到主文件夹同级,而不是拖拽目标文件夹下级。
## 3. 待排查方向
- 文件树拖拽上传时的 target row / parent folder 是否正确写入 upload preflight。
- `local_folder/assets/upload` 或 upload target plan 是否丢失目标目录。
- 页面文件夹 / 当前页面资源归属和文件树手动目标目录之间是否存在优先级覆盖。
## 4. 验收建议
后续修复时应补 smoke
- 在文件树中创建一个目标文件夹。
- 将附件拖拽到该文件夹行。
- 断言附件在文件树中位于目标文件夹下级。
- 断言主编辑器附件块仍能点击在 tab 打开。
@@ -0,0 +1,61 @@
# 5-38 [done][bug] 第二视图 Markdown 附件上传后主编辑区点击失效 v1
> 发现时间:2026-05-23
>
> 状态:`[done]`
>
> 关联主线:`05-editor-mainline`
## 1. 用户可见症状
打开第一视图和第二视图后,在第二视图上传 Markdown 附件会出现一组连续问题:
- 第一次点击第二视图上传后的文件时,第一视图菜单会短暂弹出后消失。
- 上传第二个 Markdown 文件后,页面会卡住或主编辑区附件点击无反应。
- 刷新后上传的 `.md` 附件曾退化成普通链接,只能新窗口打开。
- 修复退化为链接后,仍存在“上传第二个文件后,之前所有附件都不能从主编辑器点击在 tab 打开”的问题。
- 同时点击左侧文件树会把焦点跳到当前文件夹最上方文件,但文件树直接打开文件仍可工作。
## 2. 根因
问题不是第二视图和主视图是否应完全隔离这么简单,而是附件打开、上传插入、刷新重建三条链路没有共享同一个 pane-aware 资源 tab 合同:
- 编辑器内上传后的本地 `.md` 附件在 Markdown 回写 / 重新解析时没有稳定保留为 `data-mnote-attachment-link` 语义,刷新后容易回退成普通链接。
- 资源 openTarget=side 和 document secondary pane 的 URL / DOM 状态边界不完整,导致 secondary 资源 tab 打开时可能污染 primary 的 slash menu / active tab。
- 主编辑器内附件点击只识别部分增强后的链接形态,第二次上传后 DOM 重建路径会让既有附件链接失去可点击的 tab 打开行为。
- 文件树 active/reveal 逻辑和编辑器附件打开逻辑共享了一部分全局焦点状态,导致点击文件树时出现跳到顶部文件的伴随现象。
## 3. 修复
本轮修复把上传、解析、回写、点击打开统一到附件资源 tab 语义:
- 本地 Markdown 页面和 Markdown 资源读取时,根据 `.mnote` 元数据把属于当前文档的上传附件路径传给 Markdown parser,恢复附件链接语义。
- Markdown 回写时支持把 `/api/local-folder/files/open?...` 这类本地打开 URL 改写成相对路径,避免刷新后退化为不可识别的普通链接。
- 编辑器附件增强逻辑扩大到 `.ProseMirror a[href*="/api/local-folder/files/open"]`,即使 DOM 重建后仍能恢复 `data-mnote-attachment-link`、附件样式和 tab 打开行为。
- 上传插入使用当前编辑器 root / pane role 作为目标上下文,secondary 上传只插入 secondary 资源编辑器,不污染 primary。
- 资源 `openTarget=side``secondaryDocumentId` 分离:资源 tab 可在 secondary 打开,但不冒充 secondary 文档页。
- smoke 覆盖连续上传两个真实 `.md` 附件、刷新后点击两个附件、再次上传第二个资源并确认都在对应 pane 的 tab 内打开。
## 4. 验证
已执行过的关键验证:
```bash
cargo check -p mnote-web
node scripts/task459-local-markdown-attachment-tab-smoke.js
node scripts/task472-side-target-secondary-pane-smoke.js
```
本次提交前已重新执行:
```bash
cargo check -p mnote-web
node scripts/task459-local-markdown-attachment-tab-smoke.js
node scripts/task472-side-target-secondary-pane-smoke.js
```
结果:全部通过。
## 5. 剩余边界
本记录只覆盖本地文件夹 Markdown 附件在主编辑器 / secondary pane / resource tab 中的上传和打开链路。OnlyOffice 编辑保存、Office 插件噪声和远端 cloud source 附件合同仍由对应 Office / Rust Web 缺陷记录继续跟踪。
+14 -12
View File
@@ -20,9 +20,9 @@
> - 旧的“Convex 作为默认自托管存储 / 实时 / 文件底座”口径只作为当前代码过渡态理解,不再作为新增能力默认方向。
>
> 2026-05-16 口径补充:
> - `5-4` 的默认页面内 `leptos-tiptap` island 主链切流已完成并迁入 `done/`;官方模板视觉和菜单细节继续由 `5-2 / 5-7 / 5-9` 承接。
> - `5-4` 的默认页面内 `leptos-tiptap` island 主链切流已完成并迁入 `done/`;官方模板视觉和菜单细节继续由 `5-2 / 5-7 / 5-9` 作为参考材料承接。
> - Convex File Tree 默认可见页面正文行已由 `4-35` 收口为 `doc:<documentId>` + `{title}.md`,旧 `index.md` 可见 UI 口径只作为历史记录理解。
> - 页面/块 AI tools 最小闭环已启动,执行验收继续由 `7-10` 承接,不改变 `Page Aggregate / tree command / tree realtime` 三条架构优先级。
> - 页面/块 AI tools 最小闭环已启动;旧 `7-10` 已降级为历史执行记录,`7-14` 也已移入 `old/`,不再作为当前 AI 编辑实现依据;当前 AI 编辑 active 稿为 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`,直接基于 SQLite control-plane + local-first `.md` + allowed roots + agent 原生 patch/diff,不改变 `Page Aggregate / tree command / tree realtime` 三条架构优先级。
这份总览只做一件事:
@@ -32,10 +32,10 @@
下面三份仍然是当前架构判断的上位依据:
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.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/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
补充:旧稿 `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` 已降级为 `[recycle][legacy]` 历史过渡判断,只解释“不要无计划硬拆 Convex”,不再作为新增能力默认存储口径。
@@ -78,7 +78,7 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是
当前状态:
- `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
阶段性结论:
@@ -107,7 +107,9 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是
当前状态:
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/done/3-18-local-folder-tree-live-consumer-convergence-checklist-v1.md`
阶段性结论:
@@ -115,14 +117,14 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是
- `3000` 主壳已接入 WS snapshot / delta / resync consumerSSE 仅作为 fallback
- 双浏览器 page/file/resource/trash 操作已有 no-refresh smoke 证据
- 本地 folder sidebar watch 已从整页 HTML refetch 改为 projection API 刷新
- 下一步重点是把 local-folder watcher 与 tree live cache 进一步统一,减少 polling 型补偿
- 当前重点已从“是否接入 live stream”转为减少补偿链、缩薄 legacy consumer 和补稳定 smoke。
## 3. 当前仍应保留但不在第一线的 process
## 3. 当前仍应保留但不在第一线的 reference / done
下面这些文档仍然有效,但当前不应排在第一优先;引用时必须叠加 2026-05-21 的 local-first MVP 后阶段口径:
下面这些文档仍然有效,但当前不应排在第一优先;引用时必须叠加 2026-05-21 之后的 local-first MVP 后阶段口径:
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md`
- `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md`
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-2-tiptap-notion-like-template-adoption-v1.md`
@@ -177,7 +179,7 @@ P0-P6 已经有最小闭环证据;P7 已完成阶段性瘦身,但还不是
持续推进 checklist
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md`
- MVP 后阶段 process 执行总序:
`/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
@@ -64,7 +64,7 @@ Owner
Owner
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
- `scripts/task444-convex-workspace-export-local-fixture-smoke.js`
- 可新增 `scripts/task48*-convex-export-*.js`
@@ -12,7 +12,7 @@
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
---
@@ -58,7 +58,7 @@ Owner
Owner
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
- `rust/crates/mnote-web/src/document_buffer_store.rs`
- `rust/crates/mnote-web/src/routes/documents.rs`
- `rust/crates/mnote-web/src/routes/web_shell.rs`
@@ -84,7 +84,7 @@ Owner
Owner
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
- `rust/crates/core-protocol/src/command.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `scripts/task476-filetree-editor-context-menu-download-smoke.js`
@@ -54,7 +54,7 @@ Owner
Owner
- `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
- `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
- 只读审查:`rust/crates/mnote-web/src/routes/ws.rs``rust/crates/mnote-web/src/routes/tree_events.rs``rust/crates/mnote-web/src/ssr/pages/layout.rs`、相关 tree live smoke。
目标:
@@ -73,8 +73,8 @@ Owner
Owner
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md`
- `rust/crates/core-protocol/src/command.rs`
- `rust/crates/mnote-web/src/hermes_tools/doc.rs`
- `rust/crates/mnote-web/src/hermes_tools/page.rs`
@@ -42,8 +42,8 @@ Owner
Owner
- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
目标:
@@ -337,8 +337,8 @@
### 7.1 导出与备份
- [x] 设计 Web 导出入口:选择 Convex workspace,选择目标本地 root
- 设计:`design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md`;当前 CLI fixture 入口已支持 dry-run / apply / manifest / conflict report / rollbackWeb route 与 admin 面板仍按该设计后续落码
- [x] 冻结旧 Web 导出入口设想,不再作为当前落码入口
- 历史设计:`design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md`;当前 CLI fixture 入口已支持 dry-run / apply / manifest / conflict report / rollback。由于 Convex 默认运行时与根 functions 已退役,Web route 与 admin 面板不再按该旧稿继续落码,未来如需 cloud/import UI 应按 SQLite control-plane 与 local-first 口径新建 checklist
- [x] 导出前创建 manifest,记录迁移计划、目标 root、workspace、操作列表、冲突、created files、backup dir 与索引刷新结果。
- 实现:`scripts/export-convex-workspace-to-local.js --manifest <file>`apply 成功后同时写入 `<root>/.mnote/migration-manifest.json`
- [x] 导出页面为 `.md`,资源写为本地文件,附件保持相对路径。
@@ -467,7 +467,7 @@
- [x] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。
- 证据:资源命令面、资源对象壳、AI resource tools 与 changed-files 审计已闭环;`node scripts/task456-resource-object-shell-sync-smoke.js` 已通过。
- [x] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。
- 证据:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js``node scripts/task455-convex-export-plan-rollback-smoke.js`Web 入口设计见 `design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md`
- 证据:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js``node scripts/task455-convex-export-plan-rollback-smoke.js`Web 入口设想已退到 `design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md`,不作为当前 reference 或实施入口
- [x] P7 Page Aggregate / tree command / realtime 兼容链完成阶段性瘦身,并把被替代 process 稿移入 `old/``done/`
- 证据:Page Aggregate refresh、tree command、local folder projection refresh 与 realtime 双浏览器 smoke 均有验证;`5-5``5-6``3-3``4-34` 仍作为未完全 kernel-native / local-folder no-refresh 深水区过程稿保留在 `process/`,本 checklist 记录阶段性瘦身结果。
- [x] `01-05-current-priority-overview.md` 同步更新状态,不再把已完成项描述为当前第一优先级。
@@ -2,11 +2,13 @@
> 创建时间:2026-05-19
>
> 当前状态:`PARTIALLY_DONE`Reasonix 已补 A2/A7 基础设施与 A2 保存/watcher 接入;A2 浏览器刷新可见读回、A0 browser smoke、A7 UI 接入、B2/C1 产品化仍未闭环
> 当前状态:`DONE`A0/A2/A7/B2/C1 已完成;`task431` 只读 DnD smoke 仅作为可选回归项,不再阻塞本清单归档
>
> 2026-05-22 归档治理补充:上位路线图 `1-4` 与顺序清单 `1-5` 已移动到 `reference/`;本文只保留真实仍需收口的缺口与证据尾项,不再承接完整下一阶段路线图职责。
>
> 上位依据:
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md`
>
> 目的:基于 Reasonix 执行汇总和本轮核对结果,列出仍不能标完成的缺口,形成可继续顺序执行的补充清单。
@@ -25,12 +27,12 @@
本轮 Reasonix 执行后新增:
- [x] **Gap A0部分**: local-first guard 已通过 ✅(`npm run check:local-first-convex-guard``{ok: true, guard: "local-first-convex"}`);browser smoke 尚未复跑
- [x] **Gap A0主路径完成**: local-first guard 已通过 ✅(`npm run check:local-first-convex-guard``{ok: true, guard: "local-first-convex"}`);`task451` / `task452` / `task453` 已复跑,旧 `task431` 仅保留为可选回归项
- [x] **Gap A2(基础设施+运行时接入)**: `BufferStore` 已在 `rust/crates/mnote-web/src/document_buffer_store.rs` 实现,并加入 `AppState.buffer_store`。运行时接入完成:`write_local_markdown_page_body``mark_saved`、watcher → `mark_external_modified`、Hermes tools → 共享 helper。11 个单测全部通过(含 3 个新集成测试)。
- [x] **Gap A2(运行时接入—完成)**: `write_local_markdown_page_body` 已接入 `BufferStore.mark_saved()``documents.rs` `page_body_write``save` 路由已传递 `state.buffer_store`Hermes `doc_apply_block_ops``page_command` 已传递 `state.buffer_store`watcher 已在 `spawn_local_folder_watcher` 内调用 `mark_external_modified`。3 个新集成测试覆盖运行时入口。
- [x] **Gap A2(浏览器可见闭环)**: Codex 复核新增 `task484-local-folder-page-body-refresh-readback-smoke.js`,验证 `POST /api/page-body/write` 后磁盘 `.md`、Page Aggregate、刷新后的 ProseMirror 均读回新正文;旧的“刷新后 editor DOM 未显示正文”证据已判定为过期。
- [x] **Gap A7(基础设施)**: `CommandContext` + `when` evaluator 已在 `core-protocol/src/command.rs` 实现。7 个单测全部通过(含 3 个必补测试)。
- [ ] **Gap A7UI/命令接入)**: File Tree 右键菜单、快捷键、AI 写入能力尚未消费 `CommandContext`
- [x] **Gap A7UI/命令接入)**: File Tree 右键菜单、Delete/Backspace 快捷键、AI 写入能力消费 `CommandContext` / `workspace.readonly` / `ai.canWrite`
- [x] **Gap B2(部分)**: `resource_rename_uses_resource_command_not_document_command` 测试通过;`local_agent_audit_snapshot_detects_resource_files` 通过;mindmap 生命周期测试通过。
- [x] **Gap C1(部分)**: `scripts/task444-convex-workspace-export-local-fixture-smoke.js` 通过;`export-convex-workspace-to-local.js` 既有 `--dry-run``--manifest``--conflict-report``--rollback` 能力已确认,但本轮未新增对应 smoke。
- [x] **core-protocol 全量测试**: 41 单元 + 6 editor bridge + 2 resource tree = 49 通过 ✅
@@ -62,16 +64,17 @@
- 编号已存在脚本目录
- 验收:已复跑 browser smoke`local_agent_audit_snapshot_detects_changed_files``detects_resource_files` 只能覆盖 audit 单元路径。
- **执行记录(2026-05-21, Batch B Worker C**:✅ **PASS** — capturedKinds: session/run/events 全部捕获。结果文件:`tmp/task453-local-folder-page-ai-changed-files-smoke/result.json`
- **Codex 复核(2026-05-22**:✅ **PASS** — 已扩展为 local-first AI 前台同步 smokeclean path 验证 Hermes `run.completed.agentAudit.changedFiles` 后工具卡、磁盘 `.md`、Page Aggregate、ProseMirror 均出现 marker 且不进冲突态;dirty path 验证同一路径触发 `external-change-conflict`、冲突面板显示 agent run、冲突信封包含 `externalActor / dirtyState / bufferFileVersion`,diff 同时显示本地未保存 token 与 AI 写盘 token。
### 1.2 收尾
- [x] guard 输出已记录(见上)
- [x] browser smokes 需完整后端环境运行(mnote-web + Convex + Playwright
- [x] browser smokes 已完成主路径复跑;`task443` / `task445` 已迁移到 local-first fixture 并作为归档证据。
- [x] 失败归属:本轮已无 A0 smoke 失败;`task452` 为旧断言假失败
---
## 2. Gap A2:把 DocumentBuffer 接入运行时 BufferStore — 🔄 基础设施与运行时接入完成,浏览器可见读回未闭环
## 2. Gap A2:把 DocumentBuffer 接入运行时 BufferStore — ✅ 完成
### 2.1 当前代码锚点(更新后)
@@ -87,7 +90,7 @@
- [x] 接入 tiptap 保存链:`write_local_markdown_page_body` / `documents.rs` `save` 路由已调用 `state.buffer_store.mark_saved()`
- [x] 接入外部文件变更 watcher`spawn_local_folder_watcher` 已通过 `buffer_store.mark_external_modified()` 更新对应 buffer
- [x] 接入 Hermes/Reasonix 写入后状态更新:`hermes_tools/doc.rs``hermes_tools/page.rs` 已通过共享 helper `write_local_markdown_page_body` 自动消费 BufferStore
- [ ] conflict UI 数据源改为 buffer stateAPI 已就绪,未接入
- [x] conflict UI 数据源改为 buffer state冲突信封已包含 `externalActor / dirtyState / bufferFileVersion``task451` 已覆盖
- [x] 浏览器可见读回:`task484-local-folder-page-body-refresh-readback-smoke.js` 已验证保存后刷新 ProseMirror 可见正文不丢失
### 2.3 必补测试
@@ -118,7 +121,7 @@
---
## 3. Gap A7:落地 command context / context key — 🔄 基础设施完成,UI/命令接入未完成
## 3. Gap A7:落地 command context / context key — ✅ 主路径完成
### 3.1 当前代码锚点(更新后)
@@ -149,7 +152,7 @@
- [x] readonly / grant / resource kind 的禁用态可测试
- [x] **FileTree 右键菜单已接入**Batch C Worker C):delete-trash、rename、new-file、new-folder、paste-into 携带 `when: '!workspace.readonly'`
- [x] **Delete/Backspace 快捷键已接入**Batch C Worker C):keydown handler 中有 `!workspace.readonly && tree.selectionCount` 守卫
- [ ] **未完成**下载、多选删除、粘贴、只读阻断的 smoke 保持回退兼容
- [x] 下载、多选删除、粘贴、只读阻断的主路径 smoke 已由 `task471` / `task476` 在 Batch H 复跑覆盖;`task431` 作为更旧 DnD readonly smoke 仍保留为可选回归项。
- [x] **已完成**AI 写入能力 (`ai.canWrite`) 已接入 hermes_tools 写入守卫;`mnote.block.*``mnote.doc.markdown_edit``mnote.page.*``mnote.artifact.*``mnote.mindmap.apply_ops` 写入链共享 `ensure_write_authorized`,只读 scope、`workspace.readonly=true``ai.canWrite=false` 均会拒绝写入。
### 3.5 下一步
@@ -157,6 +160,7 @@
- `layout.rs` 右键菜单:已引入 `evaluateSidebarFileTreeWhen`Batch C Worker C 完成)
- Delete/Backspace:已接入(Batch C Worker C 完成)
- hermes_tools 写入守卫:已消费 `ai.canWrite` / `workspace.readonly` command context;后续若前端新增更多 AI 写入口,必须把同一份 `commandContext` 透传到 tool args。
- 归档前可选补跑 `task431-vscode-explorer-dnd-readonly-conflict-smoke.js`,但它不再代表 A7 主路径缺失。
### 3.6 Batch F 复核记录
@@ -187,13 +191,18 @@
- [x] `tree_resource_rename_does_not_emit_document_command` — 被现有测试覆盖:
- `resource_rename_uses_resource_command_not_document_command` ✅(验证 canonicalCommand 为 `tree.resource.rename` 而非 `tree.node.rename`
- [ ] `mindmap_resource_trash_restore_keeps_markdown_body`部分覆盖:
- [x] `mindmap_resource_trash_restore_keeps_markdown_body`覆盖:
- `mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command` ✅(验证 mindmap 生命周期不携带 documentId 从而不影响 markdown 页面)
- 尚未验证文档正文在 trash/restore 后是否完整
- `local_folder_mindmap_delete_moves_resource_file_to_trash` ✅(2026-05-22 补断言:archive / restore 后 `Page.md` 正文引用保持原文)
- [x] `local_agent_audit_snapshot_detects_resource_files` — 通过 ✅
- [x] browser smokes`task443``task445` 需 Playwright 后端环境
- **执行记录(2026-05-21, Batch B Worker C**:❌ **FAIL** — 两个 smoke 均因 `waitForURL` 断言 `/mindmap/` 路由超时,实际导航走的是 `/documents/...?resourceTab=...`。归因:**脚本过时**mindmap 路由改为 resourceTabsmoke 未同步)。结果文件:`tmp/task443-filetree-mindmap-click-active-row-smoke/result.json``tmp/task445-filetree-mindmap-switch-no-flicker-smoke/result.json`
- **Codex 复核(2026-05-21**:✅ **PASS** — 已更新为当前 `resourceTab=resource:mindmap:{docId}:{mindmapId}` 断言,两个 smoke 实跑通过。`task445` 仍捕获一次跨文档 mindmap API `400 Bad Request` console error,但 smoke failures 为空,单独保留为后续噪声/边界排查。
- **Codex 复核(2026-05-22**:✅ **代码级修复**`task445` 的 400 不是纯噪声,根因是跨文档后 asset row 打开时沿用了当前 URL 的 document,而不是资源 owner document。已补 `sidebar_filetree_asset_open_uses_owner_document_id` 回归测试,并让 filetree asset row 输出/消费 `data-owner-document-id``task445` 同步增加 owner URL 断言。浏览器复跑当前被旧 Convex fixture 阻塞:`workspaces:ensureDefaultWorkspace` function 已退役,返回 502。
- **Codex 复核(2026-05-22 续)**:✅ **PASS**`task443` / `task445` 已迁移为 local-first fixture(临时 root + `.mnote/workspace.json` + 本地 `.md` / `.mindmap.json`),不再依赖退役 Convex function。复跑命令:
- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task443-filetree-mindmap-click-active-row-smoke.js`
- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js`
- 两者结果均为 PASS,结果文件分别为 `tmp/task443-filetree-mindmap-click-active-row-smoke/result.json``tmp/task445-filetree-mindmap-switch-no-flicker-smoke/result.json`
### 4.4 完成标准
@@ -265,19 +274,19 @@
- 结果:`tmp/task452-local-search-index-browser-smoke/result.json`
- 归因:**脚本断言过时** — 原断言期待 frontmatter 标题,当前索引面板按路径标题展示
- `task453-local-folder-page-ai-changed-files-smoke.js`
- **PASS** ✅ — capturedKinds: session/run/events 全部捕获
- **PASS** ✅ — clean AI 写入后 Page Aggregate / ProseMirror 前台同步,dirty AI 写入触发 conflict UI 并显示 BufferStore 信封
- 命令:同上
- 结果:`tmp/task453-local-folder-page-ai-changed-files-smoke/result.json`
- `task443-filetree-mindmap-click-active-row-smoke.js`
- **PASS** ✅ — mindmap 资源行点击后 URL 使用 `/documents/...&resourceTab=resource:mindmap:...`,且对应 asset row 选中
- **PASS** ✅ — local-first fixture 下,mindmap 资源行点击后 URL 使用 `/documents/...&resourceTab=resource:mindmap:...`,且对应 asset row 选中
- 命令:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task443-filetree-mindmap-click-active-row-smoke.js`
- 结果:`tmp/task443-filetree-mindmap-click-active-row-smoke/result.json`
- 归因:**脚本过时后已更新** — mindmap 已改为 resourceTab 路由
- 归因:**脚本过时后已更新并去 Convex fixture 化** — mindmap 已改为 resourceTab 路由fixture 改为 local_folder
- `task445-filetree-mindmap-switch-no-flicker-smoke.js`
- **PASS** ✅ — 页面和 mindmap 来回切换时 file tree root 稳定,选中态正确
- **PASS** ✅ — local-first fixture 下,页面和 mindmap 来回切换时 file tree root 稳定,选中态正确;跨文档资源重新打开时 URL pathname 使用资源 owner document
- 命令:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js`
- 结果:`tmp/task445-filetree-mindmap-switch-no-flicker-smoke/result.json`
- 归因:**脚本过时后已更新** — 仍有一次 400 console noise,后续单独排查
- 归因:**脚本过时后已更新并去 Convex fixture 化** — 400 根因已收口到 ResourceTab owner document URL 同步
### 6.2.2 本次执行汇总
@@ -285,9 +294,9 @@
|-------|------|------|
| task451 | ✅ PASS | — |
| task452 | ✅ PASS | 脚本断言过时,已按当前路径标题和链接片段语义更新 |
| task453 | ✅ PASS | |
| task443 | ✅ PASS | 脚本过时,已改为 resourceTab 路由断言 |
| task445 | ✅ PASS | 脚本过时,已改为 resourceTab 路由断言;仍有 400 console noise |
| task453 | ✅ PASS | 已扩展为 AI clean/dirty 前台同步与 conflict UI 证据 |
| task443 | ✅ PASS | 已改为 resourceTab 路由断言,并迁移到 local-first fixture |
| task445 | ✅ PASS | 已改为 resourceTab 路由断言,并迁移到 local-first fixtureResourceTab owner document URL 已修复 |
---
@@ -295,8 +304,10 @@
| Gap | 状态 | 关键产出 | 剩余 |
|-----|------|----------|------|
| A0 | 🔄 部分 | guard、task451、task452、task453 通过 | task431 等剩余 browser smokes 未复跑 |
| A2 | 🔄 运行时接入 + 浏览器读回完成 | `BufferStore` 实现 + 11 teststask484 验证保存后磁盘、PageAggregate、刷新后 ProseMirror 全部读回 | conflict UI 未接 buffer state |
| A7 | 🔄 基础设施完成 | `CommandContext` + when evaluator + 7 tests | UI/快捷键/菜单接入 |
| B2 | 🔄 部分 | 资源生命周期 + agent audit 测试通过;task443/task445 通过 | mindmap body 测试、跨文档 ResourceTab 400 噪声排查 |
| A0 | ✅ 主路径完成 | guard、task451、task452、task453 通过 | task431 可选复跑 |
| A2 | 完成 | `BufferStore` 实现 + 11 teststask451 覆盖 BufferStore 冲突信封;task484 验证保存后磁盘、PageAggregate、刷新后 ProseMirror 全部读回 | |
| A7 | ✅ 主路径完成 | `CommandContext` + when evaluator + 7 testsFileTree 菜单、Delete/Backspace、AI 写入守卫接入 | task431 可选复跑 |
| B2 | ✅ 主路径完成 | 资源生命周期 + agent audit 测试通过;mindmap 正文引用保持、ResourceTab owner 修复与 task443/task445 local-first browser 证据已补齐 | Resource Tree projection 产品形态深化另列后续项 |
| C1 | ✅ 完成 | export/rollback/dry-run 脚本 + task444/task455 smoke 全部通过 | — |
归档结论:本清单原本承担的 gap closure 已完成。后续活跃执行转入 `1-8` 的顺序表与 `7-14` 的 AI Markdown 编辑路径收敛,不再把本文件作为 active process。
@@ -4,6 +4,8 @@
>
> 当前状态:`PROCESS`
>
> 2026-05-22 归档治理补充:当前 active `process/` 只保留可直接执行的少量收口稿;长期架构、愿景、参考矩阵、Wolai 对标基线、Mindmap 总设计、旧 AI 块级执行稿和 Convex Web 迁移入口已分别移动到 `reference/`、`done/` 或 `old/`。本文是后续调度入口,不代表下列所有历史入口仍在 `process/`。
>
> 目标:把本轮 design governance 后剩余的主线 `process/` 文档排成可执行顺序,避免后续 worker 在 active process 中自行猜优先级。
>
> 上位依据:
@@ -115,9 +117,9 @@
入口文档:
- `design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md`
- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md`
- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/05-editor-mainline/done/5-26-resource-open-resolver-convergence-checklist-v1.md`
执行目标:
@@ -141,9 +143,9 @@
入口文档:
- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md`
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
执行目标:
@@ -165,7 +167,7 @@
入口文档:
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
- `design/03-rust-web/done/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
@@ -193,7 +195,7 @@
入口文档:
- `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
- `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
执行目标:
@@ -235,7 +237,7 @@
入口文档:
- `design/01-tree-first-graph-kernel/process/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/01-tree-first-graph-kernel/reference/1-5-next-phase-sequential-execution-checklist-v1.md`
- `design/04-tree-domain/done/4-44-filetree-action-layer-paste-drop-delete-v1.md`
执行目标:
@@ -261,28 +263,36 @@
入口文档:
- `design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md`
- `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
执行目标:
1. 修正 online / compat `mnote.doc.markdown_edit` 的 final markdown 写回真相
2. 避免 full_content 和多 operation 场景仍从旧 blocks 二次推导
3. 保持 local-first 默认编辑仍走 agent 原生 patch/diff,不把 `markdown_edit` 升回默认主路径
1. `7-27` 限定为历史 online / compat 回归证据,不再把 `mnote.doc.markdown_edit` 推回 active 设计
2. `7-18` 为当前 AI 编辑执行入口:MNote 只提供文件定位、授权边界、selection/context、审计和前台同步
3. Hermes / Reasonix 用自身文件 read / write / patch / diff 能力在 allowed roots 内修改 `.md`
4. 先实现 Agent Target Resolver / target chip / target picker,解决多工作区、多 tab、多资源时到底把哪个对象发送给 agent 的问题。
验收:
- `cargo test -p mnote-web hermes_tools -- --test-threads=1`
- 覆盖 full_content、多 op、inline content、selection scope 的回归测试
- Page AI composer 可见 target chip,能展示 workspace、target title、resourceKind、scope、dirty / readonly 状态。
- target picker 能在当前焦点、打开 tabs、树选中项之间选择;默认 target 来自 last focused editor / resource tab
- local-first agent 编辑 smoke 断言不调用 `mnote.doc.markdown_edit`
- Hermes / Reasonix run body 包含 `currentFile``allowedRoots``allowedFiles`、selection 和 dirty / readonly context。
- Hermes / Reasonix run body 包含 `targetPackage`,且切 tab 后不影响已冻结 run target。
- agent 写入后 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新。
- dirty buffer 下 agent 写入不会静默覆盖。
归档条件:
- `7-27` 的 6 步实施计划已完成并归档;后续只保留 `revisionRef` 注释可见性、复杂 GFM fallback、多余退役函数体清理等增强项,不阻塞 Batch E。
- `7-18` Phase A0/A/B/C 完成后归档到 `design/07-ai/done/`
### P3.2 ACP runtime 后续步骤
入口文档:
- `design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
- `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
- `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
- `design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md`
执行目标:
@@ -307,8 +317,8 @@
入口文档:
- `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md`
- `design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- `design/06-mindmap/reference/6-mindmap-leptos-adapter-reference-notes-v1.md`
执行目标:
@@ -326,7 +336,7 @@
入口文档:
- `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
- `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`
- `design/08-wolai-aline-test-flow/reference/wolai-aline-test-flow-v1.md`
- `design/05-editor-mainline/reference/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md`
@@ -344,8 +354,8 @@
这些文档不进入当前直接执行序列,只有当产品优先级明确时再拆 checklist:
- `design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md`
- CLI 已有迁移闭环;Web 管理入口未排期
- `design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md`
- CLI 已有迁移闭环;Web 管理入口在当前 Convex 默认退役后已降级为历史设想,不作为 active 或 reference 执行入口
- `design/04-tree-domain/reference/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md`
- P3 候选,不能进入 local-first 默认主链。
- `design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md`
@@ -410,16 +420,18 @@
### Batch EAI 与资源工具
1. `7-27`(已归档到 `design/07-ai/done/`
2. `7-15` 后续拆分(下一批拆 `7-34`
3. `7-12` Phase B-F 拆分(下一批拆 `7-34`
4. `7-28` 仅在 mindmap / office AI 明确排期后进入
1. `7-18`
2. `7-27`(已归档到 `design/07-ai/done/`,只作为历史 online / compat 回归证据
3. `7-15` 后续拆分(下一批拆 `7-34`
4. `7-12` Phase B-F 拆分(下一批拆 `7-34`
5. `7-28` 仅在 mindmap / office AI 明确排期后进入
完成后预期:local-first AI 主路径稳定,cloud/compat 工具不再误导默认路径
完成后预期:local-first AI 主路径稳定,普通 Markdown 编辑不再依赖 MNote 专用编辑工具
当前批次状态:
- `7-27` 已由 Batch I 复核归档;`mnote.doc.markdown_edit` 在线写回以最终 markdown 为真源,`markdown_edit` 相关 targeted tests 与 manifest write contract 测试已通过。
- `7-18` 已成为当前 active AI 编辑控制面稿;后续实现不再保留 `mnote.doc.markdown_edit` 作为 local-first 默认、fallback 或 remote fallback。
- `7-29` 已完成 Worker A/B/C 审查,结论是 `7-15` Step 15-17 与 `7-12` Phase B/F 不应继续压在一个大文档里推进。
- `7-34` 已完成并归档到 `design/07-ai/done/`:旧 HTTP proxy 默认关闭、`page_ai_workflow` 受共享 tool executor 守卫保护;profile disabled list 三处同源;`capabilityScope` 中心校验已完成;ACP abort API 改为 best-effort cancellation 并主动推送 `run.aborted``task488` 已在当前 3000 真实通过,覆盖 3 个 Reasonix ACP session、并发 run、abort 与 SSE terminal event。cache benchmark 仍是 P2 度量;Phase C Review Mode 继续冻结。
@@ -1,11 +1,13 @@
# 1-1 [process] Tree-First Graph 内核实施清单 v2
# 1-1 [reference] Tree-First Graph 内核实施清单 v2
> 更新时间:2026-04-16
>
> 当前状态:`reference`。本文保留 kernel 分阶段全景与历史状态口径;当前执行顺序以 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 为准。
>
> 基于以下实际状态重写:
> - 当前未提交代码
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/harness-tasks.json`
## 1. 这版为什么要重写
@@ -1,7 +1,9 @@
# 1-2 [process] Tree-First Graph 产品愿景进展评估 v1
# 1-2 [reference] Tree-First Graph 产品愿景进展评估 v1
> 更新时间:2026-05-19
>
> 当前状态:`reference`。本文保留产品愿景进展评估,不作为当前 active implementation checklist。
>
> **目的**:对照原始产品愿景(Wolai 编辑体验 + VSCode 文件树 + simple-mindmap + OnlyOffice + AI 操作一切),评估当前代码在 1.0 MVP 后的实际进展和剩余的缺口。
>
> 关联:
@@ -30,7 +32,7 @@
| 前端壳已切换到 Rust SSR`:3000`| DONE | `CURRENT_ARCHITECTURE.md` §3.1 |
| Page Aggregate 写链(标题/正文/设置)smoke 通过 | DONE | `scripts/task110-*`, `task-page-aggregate-*-smoke.js` |
| 页面设置运行时语义 / 页头回流 / AI 设置面未完全闭环 | PROCESS | `design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` |
| Wolai UI 行为持续对齐 | PROCESS | `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` |
| Wolai UI 行为持续对齐 | PROCESS | `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` |
| `documents.content` 仍是兼容后备而非唯一真源 | PARTIAL | `CURRENT_ARCHITECTURE.md` §3.2 |
#### 基础块类型覆盖(对照 Wolai 帮助中心)
@@ -101,7 +103,7 @@
| 子能力 | 状态 | 关键证据 |
|--------|------|----------|
| Phase 6 口径修正(Rust kernel + simple-mind-map adapter| DONE | `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` §2 |
| Phase 6 口径修正(Rust kernel + simple-mind-map adapter| DONE | `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` §2 |
| Leptos UI shell | DONE | git `c64487c6` |
| 即时显示修复 | DONE | git `3be102a4` |
| Filetree 切换稳定 | DONE | git `b300562a` |
@@ -1,11 +1,13 @@
# 1-4 [process] 下一阶段执行路线图 v1
# 1-4 [reference] 下一阶段执行路线图 v1
> 创建时间:2026-05-19
>
> 当前状态:`PROCESS`
> 当前状态:`reference`
>
> 归档说明(2026-05-22):本文保留下一阶段路线推导;当前可执行入口已收敛到 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`,不再从本文直接派发实现。
>
> 上位依据:
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md`
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
@@ -1,12 +1,14 @@
# 1-5 [process] 下一阶段顺序执行 checklist v1
# 1-5 [reference] 下一阶段顺序执行 checklist v1
> 创建时间:2026-05-19
>
> 当前状态:`PROCESS`
> 当前状态:`reference`
>
> 归档说明(2026-05-22):本文是历史顺序清单,部分条目已被后续 Batch / done 文档覆盖;当前只作为参考,不再作为 active process 队列。
>
> 上位依据:
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-4-next-phase-execution-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md`
>
> 目标:给出一份可以顺序执行、每一步都有完成验收、且标明参考源码位置的下一阶段执行清单。
@@ -1,13 +1,15 @@
# 1 [process] Tree-First Graph 内核方案 v1
# 1 [reference] Tree-First Graph 内核方案 v1
> 更新时间:2026-04-22
>
> 当前状态:`reference`。本文是 tree-first graph kernel 的长期架构边界,不是当前可直接执行的 checklist;执行入口见 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
>
> 当前优先级入口:
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
## 1. 文档目的
@@ -18,7 +18,7 @@
> - `/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/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-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`
---
@@ -20,7 +20,7 @@
> - 当前 shell parity checklist`/mnt/Data1T/mnote/design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md`
> - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
## 1. 当前结论
@@ -6,7 +6,7 @@
>
> 参考主线:
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md`
## 1. 根因确认
@@ -7,11 +7,11 @@
> - 下文第 2 节保留的是实现前问题基线;当前真实完成状态与验证证据以第 15-16 节为准
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
## 1. 结论
@@ -5,10 +5,10 @@
> 关联:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
>
> 2026-05-13 口径修正:
@@ -8,9 +8,9 @@
> - `3104` 已按 `/mnt/Data1T/mnote/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md` 退役为默认/公开边界
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
## 0. 本轮判断
@@ -13,8 +13,8 @@
> 关联文档:
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
> - `/mnt/Data1T/mnote/design/old/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`(历史过渡背景)
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
## 1. 目标
@@ -5,7 +5,7 @@
> 关联文档:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
@@ -514,7 +514,7 @@ Expected:
**Files:**
- Modify: `ARCHITECTURE.md`
- Modify: `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
- Modify: `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
- Modify: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md`
- Move when complete: `design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md`
- Move when complete: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md`
@@ -1,13 +1,15 @@
# 3-1 [process] Rust Web 长期架构实施清单 v2
# 3-1 [reference] Rust Web 长期架构实施清单 v2
> 更新时间:2026-04-29
>
> 当前状态:`reference`。本文保留 Rust Web 分阶段全景,不再作为 active implementation checklist。
>
> 基于以下实际状态重写:
> - 当前未提交代码
> - `/mnt/Data1T/mnote/harness-tasks.json``task-059` ~ `task-069`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/old/03-rust-web/process/rust-web-long-term-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
## 1. 这版为什么要重写
@@ -1,7 +1,9 @@
# 3 [process] Rust Web 长期架构方案 v1
# 3 [reference] Rust Web 长期架构方案 v1
> 更新时间:2026-05-09
>
> 当前状态:`reference`。本文保留 Rust Web 长期架构边界;当前实现队列以 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 和对应 `done/` 证据为准。
>
> 相关文档:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/old/08-legacy-rust-kernel/done/rust-kernel-cutover-v1.md`
@@ -352,7 +354,7 @@ Leptos Islands 很适合承接下面这类长期目标:
- Rust Web 已能承接主 API、页面壳和主流式链路
- `BlockNote` 已降级为最后的重交互孤岛
对应的执行细节、逐阶段清单和可验证项,当前应先以 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` 确认优先级;`/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` 保留为 Rust Web 分阶段全景参考。
对应的执行细节、逐阶段清单和可验证项,当前应先以 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` 确认优先级;`/mnt/Data1T/mnote/design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md` 保留为 Rust Web 分阶段全景参考。
### 8.4 当前已落地的最小里程碑(2026-05-09)
@@ -5,7 +5,7 @@
> 关联文档:
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md`
> - `/mnt/Data1T/mnote/design/old/04-tree-domain/process/4-1-sidebar-pagetree-filetree-product-gap-analysis-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
## 1. 文档目的
@@ -3,8 +3,8 @@
> 更新时间:2026-04-16
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md`
## 1. 文档目的
@@ -4,7 +4,7 @@
>
> 关联:
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
## 1. 目的
@@ -4,7 +4,7 @@
>
> 关联文档:
> - `/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/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
## 1. 文档目标
@@ -6,8 +6,8 @@
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md`
> - `/mnt/Data1T/mnote/design/90-reference/90-2-yemianshu.md`
> - `/mnt/Data1T/mnote/design/90-reference/90-1-filetree.md`
@@ -40,7 +40,7 @@
这份方案必须完全服从:
- [tree-first-graph-kernel-v1.md](/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md)
- [tree-first-graph-kernel-v1.md](/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md)
里面已经固定的几条原则。
@@ -11,9 +11,9 @@
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
> - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`
@@ -11,7 +11,7 @@
> - `/mnt/Data1T/mnote/bugs/05-editor-mainline/done/5-10-mindmap-filetree-index-single-truth-split-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
> - `/mnt/Data1T/mnote/design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md`
## 1. 目标
@@ -7,10 +7,10 @@
> 关联文档:
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
>
> 代码依据:
> - `/mnt/Data1T/mnote/rust/crates/core-protocol/src/editor/model.rs`
@@ -9,8 +9,8 @@
> - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-1-main-editor-cutover-entry-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-2-tiptap-notion-like-template-adoption-v1.md`
> - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
## 1. 文档目的
@@ -4,7 +4,7 @@
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
## 1. 文档目的
@@ -11,6 +11,13 @@
> - 写侧必须退出 `/api/documents/save` 长期主路径,收口到带 `expectedFileVersion``page.body.write` / LocalFS executor。
> - AI 直改文件、tiptap autosave、外部编辑器修改必须共用 VSCode-like 文件版本冲突模型。
>
> 2026-05-22 复核补充:
> - local_folder 文档页、Hermes `mnote.page.save`、历史 Hermes `mnote.doc.markdown_edit` 的本地写入链已回到 `write_local_markdown_page_body` / `page.body.write`,并携带 `expectedFileVersion` 或聚合快照中的 file version。2026-05-22 之后,`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback;当前 AI 编辑口径以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。
> - BufferStore 已进入保存、watcher、Hermes 写入与冲突信封;`task451` 已覆盖冲突信封中的 `externalActor / dirtyState / bufferFileVersion``task484` 已覆盖保存后刷新读回。
> - cloud / compat `/api/documents/save` 边界已固定为非 local-first 正文主写链;local-first 默认写侧走 `/api/page-body/write` / LocalFS executor。
> - 2026-05-22 继续补证:`acp_runtime_env_limits_*``hermes_client_run_body_local_source_uses_file_scope_not_full_page_context``ensure_write_authorized_*``local_path_read_access_rejects_root_escape``local_file_open_rejects_root_escape` 均通过;ResourceTab 跨文档 owner 错配已由 `sidebar_filetree_asset_open_uses_owner_document_id` 修复并覆盖;旧 cloud `task443` / `task445` browser smoke 已迁移为 local-first fixture 并复跑通过。
> - 2026-05-22 收尾补证:`task453-local-folder-page-ai-changed-files-smoke.js` 已扩展为 AI 写入前台同步 smokeclean path 验证 Hermes `run.completed.agentAudit.changedFiles` 后 changed-files 工具卡、磁盘 `.md`、Page Aggregate、ProseMirror 和 clean editor 非冲突态;dirty path 验证同一路径触发 `external-change-conflict`、冲突面板显示 agent run、冲突信封包含 `externalActor / dirtyState / bufferFileVersion`,diff 同时显示本地未保存 token 与 AI 写盘 token。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md`
@@ -47,12 +54,12 @@
### 2.2 还没有成立的事实
- [ ] 页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区还没有消费同一份 page aggregate projection
- [x] 页面树 / 文件树 / 页面头部 / 页面设置 / 主编辑区已经在主路径上消费同一份 page aggregate / preferred sidebar projection;后续新增字段仍可能需要补 loader -> state -> consumer 链,但不再作为 P1 阻塞
- [x] 标题 / 正文 / 页面设置已经开始统一到同一组 page aggregate command family。
- [ ] 写侧还没有完全退出 `/api/documents/save` 兼容面;`page.body.write` / LocalFS executor 与文件版本冲突模型仍待落地
- [ ] tiptap dirty 状态与 AI / 外部文件变更之间还没有完整 VSCode-like conflict UI。
- [ ] `pageOptions` 还没有整体收口到 `leptos-tiptap` island 的正式运行时语义层。
- [ ] AI 写入口还没有完整对齐“授权文件引用 + 白名单目录 + 后台文件写入 + 前台同步”模型
- [x] local-first 写侧已退出 `/api/documents/save` 长期主路径;文档页保存使用 `/api/page-body/write`Hermes 本地正文写入回到 `write_local_markdown_page_body`,并复用文件版本冲突模型。
- [x] tiptap dirty 状态与 AI / 外部文件变更已具备 VSCode-like conflict UI 与 BufferStore 信封;`task451` 覆盖 accept-disk / keep-current / merge-save / agent-conflict-source
- [x] `pageOptions` 整体收口到 `leptos-tiptap` island 的正式运行时语义层`protectEditing / showBlockRefCount` 仍是 planned / 降级展示项,不再冒充正式完成
- [x] AI 写入口已经补齐前台同步回归证明:local-first 下 allowedRoots / path escape / commandContext 有 targeted tests`task453` 覆盖 AI 写入后 changed files、Page Aggregate、ProseMirror、clean 非冲突态和 dirty conflict UI
补充:本轮已新增前端统一 `page-command-client`,并把 `DocumentContent` 的标题 / 页面设置写入、AI 正文写回、以及 `BlockNote` / `leptos-tiptap` 各 host 的正文保存统一到 `page.head.updateTitle / page.layout.updateOptions / page.body.save`。同时,Next route 侧已新增统一 `page-write-command-adapter``/api/documents/title``/api/documents/options``/api/documents/save` 三条页面写链路已开始共用同一层执行器。这里勾选的是“命令执行面开始收口”,不等于页面域真相链已经完全统一。
@@ -137,7 +144,7 @@
### 5.3 退出标准
- [x] 页面加载时不再能明显看出“这是几份子结果拼出来的页面”。
- [ ] 后续页面新增字段时不再需要继续向外层 props 链同时塞多种局部真相。
- [x] 后续页面新增字段时不再需要继续向外层 props 链同时塞多种局部真相;现有模式是补 Page Aggregate -> client state -> island/consumer 链。新增字段仍要改 consumer,但不再扩第二份页面事实源
补充:当前首屏 SSR 与客户端内容重试补拉都已经直接走 `/api/page-aggregate/:id -> PageAggregateProjection`,因此“页面明显由 `meta + content` 两次查询拼起来”的入口级痕迹已经消失;但新增字段仍可能需要继续补 loader / route / island 消费链,所以第二项继续保留未完成。
@@ -156,7 +163,7 @@
### 6.1 必须先分类
- [x] 把当前页面设置分成:页面壳布局类、阅读视图类、编辑器 runtime 语义类。
- [ ] 明确哪些项应继续保留在页面设置面板中,哪些项应降级或隐藏,哪些项必须进入 island。
- [x] 明确哪些项应继续保留在页面设置面板中,哪些项应降级或隐藏,哪些项必须进入 island。
补充:本轮已新增 `page-option-semantics.ts`,把 `pageOptions` 的运行时语义正式收口为代码契约,不再让这套规则继续散落在 `page-options-sidebar.tsx``editor-host-types.ts``leptos-tiptap-island-editor-host.tsx` 里各写一份。当前至少已经明确:
@@ -272,7 +279,7 @@
- [x] 人类编辑与 AI 编辑共享同一块语义边界。
- [x] AI 改写结果能通过主编辑区 island 正式回显。
- [ ] AI 改写后树标题 / 页面头部 / 页面设置不再走各自独立副作用链
- [x] AI 改写后树标题 / 页面头部 / 页面设置已回到同一组 page aggregate command family;剩余验证重点转为前台同步 smoke 的 local-first fixture 化
补充:进入这一步时,最大的真实 blocker 已经明确下来;以下为历史 blocker 记录,不再作为 2026-05-14 之后的当前状态描述:
@@ -291,7 +298,7 @@
### 8.3 退出标准
- [x] AI 写入口已经可以被明确描述为“必须经过授权 scope 与页面写入仲裁”,而不是“绕过系统写编辑器”。
- [ ] local-first 下,普通正文 AI 写入必须进一步落到“授权文件 patch 或带 `expectedFileVersion``page.body.write`,不能继续以 `/api/documents/save` 兼容面作为长期验收终点
- [x] local-first 下,普通正文 AI 写入落到“授权文件 patch 或带 `expectedFileVersion``page.body.write``mnote.page.save` 与历史 `mnote.doc.markdown_edit` 在 local_folder 分支均回到 `write_local_markdown_page_body`。当前 active 口径进一步收口为 `7-18`:普通 Markdown 编辑不再保留 MNote 专用编辑工具
注:历史 `/api/ai-agent/run``Hermes tool.completed` 后,会优先尝试把 `slash_run / doc_insert_blocks / doc_replace_range` 恢复成 `mnote-web bridge-runtime` 的结构化 `tool_result`,不再只把 Hermes 事件当作薄日志。2026-05-14 起这只保留为过渡证据;新的主线已由 Hermes 发起 mnote plugin tool call,再由 Rust runtime / kernel 返回 tool result。其后:
@@ -316,10 +323,10 @@
约束如下:
- [x] 没有完成 `Phase F` 之前,不再继续零散补标题 / 宽度 / 页面设置。
- [ ] 没有完成 `Phase G` 之前,不把当前文档页描述成“已经统一聚合完成”。
- [ ] 没有完成 `Phase H` 之前,不把页面设置大量打钩为“正式可用”。
- [ ] 没有完成 `Phase I` 之前,不把标题问题视为已从底层解决。
- [ ] 没有完成 `Phase J` 之前,不把 AI 直接写主编辑区描述成已经具备正式稳定入口
- [x] 没有完成 `Phase G` 之前,不把当前文档页描述成“已经统一聚合完成”。
- [x] 没有完成 `Phase H` 之前,不把页面设置大量打钩为“正式可用”。
- [x] 没有完成 `Phase I` 之前,不把标题问题视为已从底层解决。
- [x] Phase J 的 local-first browser 回归已补齐;AI 写入主编辑区可按 local-first 主路径描述为稳定入口,仍需遵守 allowed roots、文件版本和冲突 UI 约束
---
@@ -330,16 +337,16 @@
> **“主编辑区与树域已经基本统一到 Rust 主导的 page aggregate 单一真源架构”。**
- [x] 文档页消费统一 `page aggregate projection`
- [ ] 标题 / 正文 / 页面设置不再是三条分裂真相链
- [x] 标题 / 正文 / 页面设置不再是三条分裂真相链
- [x] `leptos-tiptap` island 真正消费 editor-related `page_layout`
- [x] 树标题与页头标题来自同一份 projection
- [x] AI 写入口开始围绕 page aggregate command family 实现
补充:当前未勾选“标题 / 正文 / 页面设置不再是三条分裂真相链”的原因,不再只是命令名或 route 分裂。虽然前端页面写入口、Next route 执行器、`DocumentContent` 内部的 `body/layout/tree` client state、以及 `pageOptions` 的代码级运行时分类都已经开始收口,但 projection 回流与 AI 对页面设置面的正式写入口仍未完全统一。也就是说,命令执行面、页面本地状态面、页面设置语义面都已开始统一,但页面域单一真源仍未闭环
补充:2026-05-22 复核后,标题 / 正文 / 页面设置三条链已经在 local-first 主路径上回到 Page Aggregate / page command family / client aggregate state`documents.save` 只作为 cloud / compat 边界保留。AI 写入后的前台同步 browser 证据、dirty conflict UI 真实 Hermes changed-files 路径均已由 `task453` 补齐,因此本文件可从 `process/` 归档到 `done/`
在此之前,正确口径都应保持为:
归档后的正确口径为:
> **`leptos-tiptap` 主编辑区已基本可用,但页面聚合仍未收口,当前仍处于从混合态向 Rust 单一真源过渡的过程中。**
> **local-first 主编辑区、页面聚合、BufferStore 冲突模型与 AI 写入同步已经在 Rust 主导的 Page Aggregate 主路径上基本闭环;后续新增字段仍按 Page Aggregate -> client state -> island/consumer 链扩展,不再新增第二份页面事实源。**
---
@@ -353,25 +360,25 @@
| G(主文档页消费统一聚合) | ✅ 基本完成 | 加载链路、散落 props、退出标准 1 已满足;exit 2(新增字段不扩容 props 链)为 P2 方向目标 |
| HpageOptions 进入 island | ✅ 基本完成 | 优先级项已 wired,Inspector 口径统一,AI 写回白名单限制;`protectEditing/showBlockRefCount` 仍为 planned |
| I(树域与页面域标题统一) | ✅ 全部完成 | task110 smoke 验证全部标题同步场景 |
| JAI 写入口对齐 Page Aggregate | ⚠️ 命令面完成,权限面待验 | 命令名已收口;`allowedRoots` / 路径 escape 检查不在本审计范围内 |
| JAI 写入口对齐 Page Aggregate | ✅ 完成 | local_folder 写入已回 `page.body.write``allowedRoots` / 路径 escape / commandContext targeted tests 已覆盖;`task453` 覆盖 AI clean 前台同步和 dirty conflict UI |
### 11.2 2.2 节未成立事实的状态更新
| 项 | 审计状态 | 说明 |
|---|---------|------|
| 页面树/文件树/页面头部/页面设置/主编辑区尚未消费同一份 projection | ⚠️ 部分解决 | 主编辑区页头已统一;页面树/文件树的标题已同源,但页面设置面板与主编辑区的 `pageOptions` 运行时消费仍有 gap |
| 写侧未完全退出 `/api/documents/save` | ⚠️ 部分解决 | `local_folder` 已切到 `/api/page-body/write`Convex workspace 仍走 `/api/documents/save`(作为正式路径而非 compat |
| tiptap dirty + AI/外部变更的 VSCode-like conflict UI | ❌ 未完成 | DOM 侧已经插入 conflict 面板元素(`web_shell.rs:4990-5015`),但前端测试/Playwright smoke 未覆盖 |
| `pageOptions` 收口到 island 运行时语义 | ⚠️ 部分完成 | `page-option-semantics.ts` 分类已落地`protectEditing/showBlockRefCount` 为 planned |
| AI 写入口对齐授权/白名单/后台写入/前台同步 | ⚠️ 命令面已收口,权限面待验 | 本审计未确认 Hermes 侧的 `allowedRoots` 和路径 escape 实现 |
| 页面树/文件树/页面头部/页面设置/主编辑区尚未消费同一份 projection | ✅ 主路径完成 | 主编辑区页头、标题投影、pageOptions runtime 语义已统一;新增字段仍需补 consumer 链但不扩第二事实源 |
| 写侧未完全退出 `/api/documents/save` | ✅ local-first 完成 | `local_folder` 已切到 `/api/page-body/write``/api/documents/save` 仅保留 cloud / compat 边界 |
| tiptap dirty + AI/外部变更的 VSCode-like conflict UI | 完成 | `task451` 覆盖冲突 UI 四路径,并断言 BufferStore 字段进入冲突信封 |
| `pageOptions` 收口到 island 运行时语义 | ✅ 基本完成 | `page-option-semantics.ts` 分类、Inspector 口径、AI 写回白名单和可见效果 smoke 已覆盖`protectEditing/showBlockRefCount` 为 planned / 降级展示 |
| AI 写入口对齐授权/白名单/后台写入/前台同步 | ✅ 完成 | targeted tests 已覆盖 ACP env、file scope、CommandContext 写守卫和 LocalFS root escape`task453` 汇总 AI 写入后 Page Aggregate / ProseMirror / changed files / clean 非冲突态 / dirty conflict UI |
### 11.3 退出标准状态
- Section 9"当前推荐实施顺序" 中的约束:Phase G 和 H 的 [ ] 项可以部分追认——Phase G 和 H 的主体工作已完成,但 exit criterion 的严格表述("已经统一聚合完成" / "大量打钩为正式可用")仍不完全满足。建议保持现状不勾选,等 J 完成后统一收尾
- Section 10 总退出标准:"标题/正文/页面设置不再是三条分裂真相链" 仍 [ ]——命令执行面已统一,但 projection 回流和 AI 页面设置写入口未完全统一,符合当前描述
- Section 9Phase G/H/I/J 约束均已可追认完成
- Section 10:标题 / 正文 / 页面设置三条分裂真相链已在 local-first 主路径收口;AI 前台同步浏览器证据已补齐
### 11.4 建议
1. **Phase G exit 2 降级为 P2**:新增字段仍需改 loader→state→consumer 链是当前架构的自然限制,不应作为 P1 block
2. **补充 Conflict UI Playwright smoke**:DOM 元素已经存在,只差自动化验收流程
3. **明确 `/api/documents/save` 的正式定位**:如果它是 Convex workspace 的正式路径,则在文档中调整口径;如果它是要移除的 compat,则排期删除
1. **归档到 `done/`**:本清单不再占用 active `process/`
2. **下一执行包转向**:当前新稿为 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`,直接围绕 allowed roots、file refs、agent 原生 patch/diff、watcher、BufferStore、Page Aggregate 前台同步和审计链设计;`design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` 仅作为反例 / 历史材料
3. **归档前复跑文档检查**`git diff --check -- design rust/crates/mnote-web/src/ssr/pages/layout.rs rust/crates/mnote-web/src/routes/web_shell.rs scripts/task443-filetree-mindmap-click-active-row-smoke.js scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js scripts/task453-local-folder-page-ai-changed-files-smoke.js`,并同步 CodeGraph
@@ -7,8 +7,8 @@
> 关联背景:
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`
>
> 本地参考源码:
@@ -20,7 +20,7 @@
> - `/mnt/Data1T/mnote/design/old/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
## 1. 文档目的
@@ -9,18 +9,18 @@
> 关联文档:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
> - `/mnt/Data1T/mnote/design/old/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-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-2-tiptap-notion-like-template-adoption-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
## 1. 文档目的
@@ -852,7 +852,7 @@ P2 做复杂但可逐步推进的部分:
- 若截图肉眼可见差异但 smoke 未捕获,先增强 smoke 或 checklist 断言,再继续实现。
- 后续可加入截图 diff,但不能用截图 diff 替代真实交互烟测。
- 每次改 Sidebar、topbar、document canvas、editor runtime 后都要重新截首屏,并同步更新连续 checklist 的状态。
- 连续执行清单见 `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
- 连续执行清单见 `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`
owner 态交互验收清单:
@@ -1,7 +1,9 @@
# 5-9 [process] Wolai-aline 连续执行 checklist v1
# 5-9 [reference] Wolai-aline 连续执行 checklist v1
> 更新时间:2026-05-18
>
> 当前状态:`reference`。本文保留 Wolai-aline 连续项基线;新对标任务必须按 `wolai-aline` skill 和 `design/08-wolai-aline-test-flow/reference/wolai-aline-test-flow-v1.md` 另拆具体 checklist。
>
> 2026-05-18 口径补充:
> - 本 checklist 的 Wolai 体验对标仍有效,但数据真相口径跟随 local-first workspace:本地 `.md` 是默认正文真相,Convex-backed 路径只作为兼容 / cloud source。
> - 历史条目中“写回 Convex-backed 持久化底座”的表述只代表当时 smoke 的在线路径,不再作为新增编辑能力默认目标。
@@ -36,7 +36,7 @@ Rust kernel truth
### 当前主线文档
- `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md`
- `design/06-mindmap/reference/6-mindmap-leptos-adapter-reference-notes-v1.md`
- `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md`
@@ -110,9 +110,9 @@ Rust kernel truth
### Task 1: 冻结新阶段边界
**Files:**
- Modify: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Modify: `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Modify: `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md`
- Create/Modify: `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- Create/Modify: `design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- [x] 明确当前 Phase 6 不是“Rust 重写 lx-doc/mind-map”,也不是“直接嵌入 lx-doc Vue app”。
- 2026-05-11 记录:本文件“当前判断”和总设计稿第 3 节均保留三路线定位,Phase 6 主线为 `leptos-mindmap`
@@ -449,9 +449,9 @@ Rust kernel truth
### Task 14: 文档与执行记录
**Files:**
- Modify: `design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Modify: `design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md`
- Modify: `design/06-mindmap/reference/6-mindmap-leptos-adapter-reference-notes-v1.md`
- Modify: `design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- Modify: `design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- Modify: `docs/superpowers/plans/2026-05-10-mindmap-phase6-projection-editor-checklist.md`
- [x] 在总设计稿中明确:UI shell reuse 是 `leptos-mindmap` 的 Phase 6 后续收口,不改变 kernel-first 主合同。
@@ -1,7 +1,9 @@
# 6 [process] Mindmap Kernel Phase 6leptos-mindmap Projection Editor v1
# 6 [reference] Mindmap Kernel Phase 6leptos-mindmap Projection Editor v1
> 更新时间:2026-05-10
>
> 当前状态:`reference`。本文保留 mindmap Phase 6 总设计;已完成 UI shell reuse 归档到 `design/06-mindmap/done/`,后续新工作需另拆小 checklist。
>
> 当前口径:`leptos-mindmap`
>
> 旧稿归档:
@@ -9,8 +11,8 @@
>
> 当前主线依据:
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
>
> 参考实现:
@@ -394,7 +396,7 @@ Phase 6.1 的硬性完成判定:
Phase 6 后续 UI 可用度收口转入:
- `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
- `/mnt/Data1T/mnote/design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md`
本轮已经把默认宿主修正为 floating overlay canvas shell,并把 toolbar、sidebar、count、navigator 迁到 Leptos/Rust shell 边界:
@@ -5,7 +5,7 @@
> 上位依据:
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md`
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
>
> 状态说明:
> - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/`
@@ -9,9 +9,9 @@
> 本稿目的:在 7-12 已排除第二套 AI runtime 的前提下,补上 Hermes tool execution → Convex 持久化之间缺失的 Rust 编辑运行时中继层,实现「内存态 apply → 编辑器就地 patch → Convex 异步持久化 → 事件增量通知」的四步闭环。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
@@ -395,7 +395,7 @@ mnote-web 重启
| 5-13 块身份合同 | EditorBlockDocument 就是 blockDocument 的内存态 |
| 4-6 tree command cutover | EditorRuntimeActor 不碰 tree 命令;page 级和 block 级命令保持独立 |
| 3-3 tree realtime event stream | Phase C 新增 `block.delta` event,扩展而非替代 resync_required |
| 7-14 markdown 编辑收敛 | EditorRuntimeActor 后续需适配 markdown_edit:内部做 markdown diff 后复用 BlockDelta 通道推送编辑器更新。当前先走完整 markdown → blocks → apply 路径 |
| 7-14 markdown 编辑收敛 | 历史口径。`7-14` 已移入 old;当前 `7-18` 口径下,普通 Markdown 编辑不再要求 EditorRuntimeActor 适配 `markdown_edit`,而是由 agent 原生文件 patch/diff 后经 watcher / BufferStore / Page Aggregate 回流 |
---
@@ -407,8 +407,8 @@ mnote-web 重启
- 不要求编辑器同步等待 Convex 写入完成才展示 AI 编辑结果。
- 不改变已有的 `ensure_write_contract` 校验链。
- 不新增写工具;Phase A/B/C 只加速已有工具的落地速度。
- **按 7-14**`mnote.doc.markdown_edit` 的新增不违反本禁止项——它是新增工具,但复用 EditorRuntimeActor 的 delta 通道,属于 Phase D 适配范围
- delta channel 不改写 Tiptap 的协作/undo/redo 栈;仅新增 AI 编辑的增量入口。markdown_edit 的 delta 推送同样遵守此约束。
- **按 7-18**local-first 普通 Markdown 编辑不再新增或依赖 `mnote.doc.markdown_edit`EditorRuntimeActor 不承担普通 Markdown 工具写入适配
- delta channel 不改写 Tiptap 的协作/undo/redo 栈;若后续只用于结构性辅助,也必须遵守此约束。
---
@@ -17,8 +17,8 @@
>
> 关联文档:
> - `/mnt/Data1T/mnote/recycle/design/07-ai/retired-http-hermes/7-5-hermes-client-proxy-contract-v1.md`(已退役历史背景)
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-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/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-vscode-main/`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/`
@@ -7,8 +7,8 @@
> 归档说明(2026-05-21):本文范围内可执行的真实页面 smoke 已完成;剩余 UI / Review Mode 属于 Phase C 冻结范围,解冻时应新建设计稿承接。
>
> 来源:
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md`
---
@@ -19,8 +19,8 @@
本文只承接这些剩余 smoke,不新增 AI 功能面,不改变当前主路径:
- local-first 普通 Markdown 编辑主路径仍是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;本文只验证 mnote tools 的结构性辅助和 compat fallback
- `mnote.doc.markdown_edit` 作为 cloud / remote agent / compat fallback 的 smoke 对象
- local-first 普通 Markdown 编辑主路径仍是“授权文件引用 + agent 原生 patch/diff + watcher 同步”;本文只验证 mnote tools 的历史结构性辅助和兼容证据
- 2026-05-22 之后,`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback;本文中的 smoke 只代表历史在线 / compat 回归证据
- `mnote.block.*` 仍只作为结构性辅助。
- `mnote.page.save` 仍只作为页面级粗粒度兜底。
- Phase C review / streaming apply 仍冻结,当前只验证基础合同和安全边界。
@@ -4,9 +4,9 @@
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md`
>
@@ -4,16 +4,20 @@
>
> 2026-05-19 local-first 口径补充:
> - 本文仍适用于 `convex_workspace` / 在线文档的 `mnote.doc.markdown_edit` 修复,但当前默认产品形态已切到 local-first workspace。
> - 本地 `.md` 路径的默认 AI 编辑主路径是“授权文件引用 + agent 原生 patch/diff + watcher 同步”`mnote.doc.markdown_edit` 只作为本地受控代理 fallback、cloud / remote agent 或 compat 路径
> - 本地 `.md` 路径的默认 AI 编辑主路径是“授权文件引用 + agent 原生 patch/diff + watcher 同步”。
> - 后续新增 AI 编辑能力默认先保证本地 `.md``{mdBase}.assets/` 相对路径不被改写为 Convex media asset;在线 Convex 文档路径只作为可选 cloud / sync / share source。
>
> 2026-05-22 口径修正:`mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback。本文只保留在线 / compat 写回修复的历史证据;当前 active 设计以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。
>
> 当前状态:`DONE`(已归档,代码验证通过 2026-05-21)
>
> 关联缺陷:`bugs/07-ai/done/7-24-markdown-edit-online-write-does-not-use-final-markdown-v1.md`
> `bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md`
> `bugs/07-ai/done/7-17-markdown-edit-same-block-multi-op-overwrite-v1.md`
>
> 上位设计:`design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> 历史上位设计:`design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
>
> 当前 active 设计:`design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
>
> 参考实现:`design/05-editor-mainline/reference-code/cli-main/shortcuts/doc/`str_replace skill
> `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`(已有 GFM→blocks 解析器)
@@ -82,8 +82,8 @@ Owner
只读参考:
- `design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
- `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
- `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
- `rust/crates/mnote-web/src/acp_client.rs`
- `rust/crates/mnote-web/src/acp_session_manager.rs`
- `rust/crates/mnote-web/src/acp_runtime.rs`
@@ -5,9 +5,9 @@
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md`
@@ -94,7 +94,7 @@ Owner
只读范围:
- `design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
- `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
- `design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md`
- `scripts/`
- `rust/crates/mnote-web/src/acp_*.rs`
@@ -7,10 +7,10 @@
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18`
@@ -564,7 +564,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm
- `rust/crates/mnote-web/src/hermes_tools/page.rs`
- `rust/crates/bridge-runtime/src/lib.rs`
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 仅回填完成证据
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` 仅回填完成证据
- smoke
- `scripts/task-hermes-page-ai-title-options-smoke.js`
@@ -789,10 +789,10 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm
- `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
- `design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`
- `design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
- `design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
- `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md`
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md`
- `design/old/07-ai/process/*`
执行步骤:
@@ -1010,7 +1010,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm
2026-05-14 K1 执行证据:
- [x] rg 命中已归类:`design/old/**``design/07-ai/done/7-1**` 为历史证据;`scripts/task-hermes-page-ai-retirement-guard.js``rust/crates/mnote-web/src/routes/compat.rs` 为 legacy guard;旧 E27/phase7/page-ai smoke 已默认退役;`wolai-frontend` 中 Mindmap / OnlyOffice / generic AI 面板调用点归为非当前页面 AI 主链的 legacy domain 调用点,后续按各自 domain 迁移。
- [x] 修改文件:`rust/crates/mnote-web/src/routes/compat.rs``scripts/task-hermes-page-ai-retirement-guard.js`、历史退役 smoke 当前本机归档于 gitignored `recycle/scripts/retired-ai-agent-run-smokes/``design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md``design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md``design/10-review/04-secondary-domains-and-design-governance-review.md`
- [x] 修改文件:`rust/crates/mnote-web/src/routes/compat.rs``scripts/task-hermes-page-ai-retirement-guard.js`、历史退役 smoke 当前本机归档于 gitignored `recycle/scripts/retired-ai-agent-run-smokes/``design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md``design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md``design/10-review/04-secondary-domains-and-design-governance-review.md`
- [x] 旧 smoke 默认退役验证:上述 6 个历史 smoke 均返回 `{ ok: true, retired: true }`
**验收标准:**
@@ -1094,8 +1094,8 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm
| 参考目的 | 具体文件 | 具体位置 / 搜索点 | 处理方式 |
| --- | --- | --- | --- |
| Page Aggregate 口径 | `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` | 搜索 `/api/ai-agent/run``mnote-cli host``页面 AI` | 改成历史残留 / 已覆盖 / 待迁移证据,不写成长期主线 |
| Wolai-aline 过渡基线 | `design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md` | 搜索 `E27``mnote-cli``页面 AI` | 保留为对标基线时必须标明过渡状态 |
| Page Aggregate 口径 | `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` | 搜索 `/api/ai-agent/run``mnote-cli host``页面 AI` | 改成历史残留 / 已覆盖 / 待迁移证据,不写成长期主线 |
| Wolai-aline 过渡基线 | `design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md` | 搜索 `E27``mnote-cli``页面 AI` | 保留为对标基线时必须标明过渡状态 |
| 二级域 review 建议 | `design/10-review/04-secondary-domains-and-design-governance-review.md` | 搜索 `Hermes``plugin``mnote-cli``CLI-first` | 指向 Hermes plugin / Rust bridge 的当前口径 |
| 07-ai 历史稿 | `design/old/07-ai/process/` | 搜索 `CLI-first``mnote-cli` | 继续标 `[recycle]`,不迁回活跃 process |
@@ -1111,7 +1111,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-hermes-page-ai-tool-sm
2026-05-14 N1 执行证据:
- [x] `rg -n "mnote-cli.*唯一|唯一长期 agent|CLI-first|/api/ai-agent/run|provider=hermes" design --glob '*.md'` 已跑完,活跃命中已逐项归类。
- [x] 活跃命中主要落在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md``design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md``design/10-review/04-secondary-domains-and-design-governance-review.md``design/10-review/06-execution-checklist-and-acceptance.md``design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md``design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md``design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md``design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md``design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md``design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
- [x] 活跃命中主要落在 `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md``design/05-editor-mainline/reference/5-9-wolai-aline-continuous-checklist-v1.md``design/10-review/04-secondary-domains-and-design-governance-review.md``design/10-review/06-execution-checklist-and-acceptance.md``design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md``design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md``design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md``design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md``design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md``design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
- [x] `design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 与同目录旧稿继续保留 `[recycle]`,只作为历史证据。
- [x] 本轮对 `5-6``5-9``10-review/04``10-review/06``1-1``3-1` 的旧口径改写已完成;活跃文档现已统一为 Hermes 页面内客户端 + mnote Hermes plugin/tool 口径。
- [x] 本文件仍保留在 `process/`,原因是它是执行清单而不是代码实现文件;若后续需要归档,可按 `N1.5` 的规则移动并同步更新引用。
@@ -6,7 +6,9 @@
>
> Hermes Web UI 参考:`packages/client/src/api/hermes/plugins.ts``packages/client/src/api/hermes/skills.ts``packages/server/src/services/hermes/plugins.ts``packages/server/src/services/hermes/chat-run-socket.ts`
>
> 2026-05-16 口径补充:本文定义的是页面 AI 最小可用工具合同。`mnote.page.save` 只作为页面级兜底写入工具,不再代表长期精确块编辑方案;页面/块级 AI 工具体系路线图已归档到 `design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`,执行验收继续以 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 为准
> 2026-05-16 口径补充:本文定义的是页面 AI 最小可用工具合同。`mnote.page.save` 只作为页面级兜底写入工具,不再代表长期精确块编辑方案;页面/块级 AI 工具体系路线图已归档到 `design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
>
> 2026-05-22 归档治理补充:旧 `7-10` 已移入 `design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`,不再作为当前执行验收入口;`7-14` 也已移入 `design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`。当前 AI 编辑 active 口径以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准,MNote 不再提供普通 Markdown 编辑工具。
## 1. 总边界
@@ -7,9 +7,9 @@
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md`
@@ -20,12 +20,12 @@
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-1-page-block-storage-projection-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
---
@@ -964,7 +964,7 @@ Rust 内部结构化文档模型,是 PageXML/PageMarkdown 到 Page Aggregate /
本文作为页面/块 AI 工具体系路线图与合同已经归档为 `DONE`;执行验收不再由本文继续承接,而是转入:
- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
- `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
当前已成立的 done 边界:
@@ -0,0 +1,332 @@
# 7-18 [process] local-first agent 文件编辑控制面 v1
> 创建时间:2026-05-22
>
> 当前状态:`PROCESS`
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
>
> 覆盖旧稿:`/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
>
> 口径说明:旧 `7-14` 仍受 CLI Main / Lark Doc / tool-first 写入模型影响。CLI Main 可以参考 agent workflow 纪律,但不作为 MNote local-first Markdown 编辑架构主参考。
## 1. 第一结论
MNote 不提供普通 Markdown 正文编辑工具。
MNote 在 local-first 下只提供:
- 页面和文件定位。
- agent target 选择与冻结。
- `AiAccessScope`、allowed roots、allowed files、readonly / dirty / permission context。
- selection、当前光标附近上下文、相关资源引用。
- agent runtime session、审计、changed files / diff 回收。
- watcher、BufferStore、Page Aggregate、ProseMirror 前台同步。
Hermes / Reasonix 是已知 agent,使用自身文件 read / write / patch / diff 能力在授权目录内完成普通 `.md` 编辑。
## 2. 明确退役项
### 2.1 `mnote.doc.markdown_edit`
`mnote.doc.markdown_edit` 不再作为:
- local-first 普通 Markdown 编辑默认入口。
- local-first 普通 Markdown 编辑 fallback。
- remote fallback。
- 新 agent runtime 的推荐工具。
- 新 checklist 的验收目标。
已有代码和 smoke 若仍覆盖 `markdown_edit`,只代表历史在线 / compat 路径的回归证据;不得再由它反推出“local-first 应继续提供 Markdown 编辑工具”。
### 2.2 `mnote.doc.fetch`
`mnote.doc.fetch` 不作为普通正文读取主入口。
local-first 默认路径中,agent 直接读取授权文件。MNote 可以把 selection、文件摘要、block projection 或结构化上下文放进 run input,但不要求 agent 通过 `mnote.doc.fetch` 再读正文。
### 2.3 `page_ai_workflow` / `mnote.block.*` / `mnote.page.save`
- `page_ai_workflow` 只保留为 debug / 历史兼容门面,不进入 local-first 普通编辑主路径。
- `mnote.block.*` 只保留为复杂结构辅助,例如拖拽排序、精确块删除、非 Markdown 结构资源辅助。
- `mnote.page.save` 只保留为页面级兜底写入工具,不作为精确 Markdown 编辑入口。
## 3. 主路径数据流
```text
Page / active editor
-> Agent Target Resolver freezes selected target package
-> resolve canonical .md file / resource file
-> build AiAccessScope
-> freeze allowed roots / allowed files / readonly / dirty / selection
-> start Hermes / Reasonix run with file refs and context
-> agent native patch / diff / write inside allowed roots
-> MNote collects changed files / diff / audit
-> watcher observes file changes
-> BufferStore arbitrates clean / dirty / conflict state
-> Page Aggregate rebuilds projection
-> ProseMirror / tiptap island refreshes visible document
```
关键点:
- MNote 只给位置、权限和上下文,不给普通 Markdown 编辑工具。
- 多工作区、多 tab、多资源同时打开时,默认 target 只能来自最后获得编辑焦点的 editor / resource tab;不允许用“第一个 tab”“树选中项”或 URL documentId 静默猜测。
- 写入动作发生在 agent 自身文件能力内,并受 allowed roots / allowed files 限制。
- 前台同步由 watcher / BufferStore / Page Aggregate 接回,不由 agent 直接推 UI 状态。
- dirty buffer 时不得静默覆盖,必须进入冲突或 review 状态。
## 4. Agent Target Resolver
Agent Target Resolver 是 MNote 发送给 agent 前的唯一目标解析层。它的职责是把当前 UI 状态冻结为一份明确的 `targetPackage`,而不是让 agent 或 API 自行猜“当前页”。
### 4.1 选择原则
- **workspace 是权限边界**:默认一次 run 只属于一个 workspace;跨 workspace 必须由用户显式多选,并生成多个 `allowedRoots`
- **tab 是视图,不是真相**:tab 只帮助判断用户正在看的对象;最终 target 必须落到 `objectIdentity``resourceKind` 和 canonical path。
- **last focused editor 优先**:默认 target 是最后获得编辑焦点的 editor / resource tab。
- **selection 覆盖 whole file**:存在真实选区时,默认 scope 为 selection;没有选区时才是 whole file / current resource。
- **树选中项只作为候选**FileTree / PageTree selection 不自动成为 agent target,除非用户在 target picker 中选择“树选中项”。
- **歧义时不发送**:如果焦点不明确、target dirty 且会被写入、或跨 workspace 未显式确认,发送按钮进入需要选择状态。
### 4.2 targetPackage 合同
```json
{
"schema": "mnote.agent_target_package.v1",
"primaryTargetId": "target-1",
"targets": [
{
"targetId": "target-1",
"workspaceId": "local:design",
"workspaceSource": "local_folder",
"workspaceRoot": "/workspace/design",
"editorGroupId": "main",
"tabId": "tab-design-md",
"focusRank": 1,
"objectIdentity": "local_folder:/workspace/design/design.md",
"resourceKind": "markdown_page",
"title": "design.md",
"canonicalPath": "/workspace/design/design.md",
"uri": "file:///workspace/design/design.md",
"version": "mtime:size:hash",
"scope": "selection",
"selection": {
"text": "当前选区文本",
"anchor": "pm:block:p_1:0..12"
},
"dirty": false,
"readonly": false,
"ownerPage": null,
"relatedResources": []
}
],
"allowedRoots": ["/workspace/design"],
"allowedFiles": ["/workspace/design/design.md"],
"policy": {
"writeMode": "agent_native_file_patch",
"requiresConfirmation": false,
"crossWorkspace": false
}
}
```
### 4.3 resourceKind
`resourceKind` 至少需要区分:
- `markdown_page`:普通 `.md` 页面正文。
- `mindmap`mindmap 资源文件;可带 `ownerPage`,但不伪装成正文。
- `office`OnlyOffice 资源;默认只读上下文,除非明确进入 office 编辑 agent flow。
- `raw_file`:普通文件。
- `folder`:目录范围;只在用户显式选择“把这个文件夹作为上下文”时出现。
## 5. UI 设计
当前右侧页面 AI 抽屉已有 runtime / profile / context scope,但“上下文:当前页/选区/块/页面设置”不够表达多工作区、多 tab 和资源 tab。需要把它升级为明确的 target UI。
### 5.1 默认展示
AI composer 上方固定显示 target chip,不放到设置页深处:
```text
[工作区: design] [design.md] [当前选区] [可写]
```
如果当前焦点是截图中的右侧 mindmap,则显示:
```text
[工作区: local] [新页面165037 / KMIND] [mindmap] [可写]
```
chip 必须可点击,打开 target picker。chip 内容优先显示用户能识别的名称,不优先显示内部 id;hover / detail 再显示 path、workspace root、object identity。
### 5.2 target picker
点击 target chip 后打开轻量 popover,不跳转页面。结构为:
```text
发送给 Agent
当前焦点
● design.md markdown_page 当前选区 /workspace/design/design.md
打开的 Tab
○ 新页面223322 markdown_page 全文
○ design.md markdown_page 当前选区
○ 新页面165037 / KMIND mindmap 当前资源
树选中项
○ 3 个文件 只读上下文
范围
( ) 当前选区 ( ) 当前文件 ( ) 当前资源 ( ) 多选 tab ( ) 文件夹
[取消] [使用这个目标]
```
交互规则:
- 单选是默认;多选必须用户点击“多选 tab”或勾选多个候选。
- 多 workspace 候选默认折叠在各自 workspace 组下,跨 workspace 多选时出现权限提示。
- dirty target 显示 `未保存` 标记;如果 agent 可能写入,发送前需要确认。
- readonly target 显示 `只读`,发送按钮文案变成“只读提问”或禁用写入型 intent。
- resource target 不能显示成“当前页”;必须显示 `mindmap` / `office` / `raw file`
### 5.3 composer 中的 context scope
`context scope` select 不应继续承担 target 选择职责。它只控制 primary target 内部范围:
- `selection`:当前选区。
- `file`:当前文件。
- `resource`:当前资源文件。
- `related`:当前页面引用的相关资源,只读附加上下文。
不再把 `page / block / options` 混成同一层 target 选择。页面设置属于 target 的辅助上下文,不是普通 Markdown 编辑范围。
### 5.4 发送前状态
发送按钮按 targetPackage 状态变化:
- `发送`:单 target、clean、可写或只读问答。
- `选择目标`:没有明确 last focused target,或当前焦点和 URL / active tab 冲突。
- `确认发送`:跨 workspace、多 target、dirty write、folder scope。
- `只读提问`readonly target 或用户选择只读上下文。
发送后第一条用户消息旁边显示冻结的 target 摘要,避免 run 过程中用户切 tab 后误以为 agent 改了新 tab
```text
目标:design.md / 当前选区 / local_folder / 12:31:05 冻结
```
## 6. SQLite control-plane 职责
Rust SQLite control-plane 是默认控制面,负责:
- auth / session / membership。
- share grants / sync state。
- AI policy、workspace policy、allowed roots / allowed files。
- ACP / Hermes / Reasonix runtime session。
- targetPackage 审计与用户确认记录。
- permission decision、approval decision、tool / file access audit。
- changed files / diff / command context 记录。
它不持有普通 Markdown 正文真相。本地 `.md` 文件仍是正文真相。
## 7. 运行时输入合同
每次从页面触发 agent 编辑时,run input 至少应包含:
```json
{
"workspaceSource": "local_folder",
"targetPackage": {
"schema": "mnote.agent_target_package.v1",
"primaryTargetId": "target-1",
"targets": []
},
"currentFile": {
"path": "/workspace/page.md",
"uri": "file:///workspace/page.md",
"version": "mtime:size:hash"
},
"allowedRoots": ["/workspace"],
"allowedFiles": ["/workspace/page.md"],
"selection": {
"text": "当前选区文本",
"anchor": "可选的 editor/block/offset 引用"
},
"context": {
"readonly": false,
"dirty": false,
"resourceRefs": []
}
}
```
后续可以补充 page id、object identity、resource refs、outline、nearby context,但不得把普通正文写入重新包装成 `mnote.doc.markdown_edit`
## 8. 验收清单
### Phase A0target selection 与 UI
- [ ] Page AI composer 可见 target chip,显示 workspace、target title、resourceKind、scope、readonly / dirty 状态。
- [ ] 点击 target chip 打开 target picker,可在当前焦点、打开 tabs、树选中项之间选择。
- [ ] 默认 target 来自 last focused editor / resource tab,不来自第一个 tab、URL documentId 或树选中项。
- [ ] mindmap / office / raw file target 显示真实 resourceKind,不伪装成 markdown page。
- [ ] 多 tab / 跨 workspace / dirty write 时发送前需要显式确认。
- [ ] 发送后消息记录冻结 target 摘要,切换 tab 不影响已启动 run。
### Phase A:运行时输入与权限边界
- [ ] 从文档页发起 Hermes / Reasonix run 时,request body 包含 `currentFile``allowedRoots``allowedFiles`、selection 和 dirty / readonly context。
- [ ] run body 包含 `targetPackage`,且 `allowedRoots` / `allowedFiles` 从 targetPackage 推导。
- [ ] local-first 普通 Markdown 编辑 run 不包含 `mnote.doc.markdown_edit` 推荐工具。
- [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。
- [ ] readonly 页面不会启动可写 run,或只启动只读问答 run。
### Phase B:写入回收与前台同步
- [ ] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。
- [ ] clean buffer 下 watcher -> BufferStore -> Page Aggregate -> ProseMirror 可见更新通过 browser smoke。
- [ ] dirty buffer 下外部 agent 写入不会静默覆盖,必须出现冲突或 review 状态。
- [ ] changed files / diff 与 session / run / actor 关联写入审计。
### Phase C:旧工具退役
- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `/api/documents/save`
- [ ] local-first 普通 Markdown 编辑 smoke 断言未调用 `mnote.doc.markdown_edit`
- [ ] Hermes / Reasonix manifest 或 system prompt 不再鼓励普通 Markdown 编辑调用 `mnote.doc.markdown_edit`
- [ ] 旧 `markdown_edit` 测试只保留为历史 online / compat 回归,并在文档中标明不指导新实现。
### Phase D:结构辅助边界
- [ ] `mnote.block.*` 只在复杂结构辅助场景出现,例如结构块排序、非 Markdown 资源辅助。
- [ ] `mnote.page.save` 只作为页面级兜底写入,不作为默认精确编辑入口。
- [ ] `mnote.doc.fetch` 不作为 local-first 正文读取主入口;需要结构化上下文时由 MNote 在 run input 中提供摘要或 projection。
## 9. 不做事项
- 不参考 CLI Main 的 Lark Doc 在线文档写入架构来设计 local-first 普通 Markdown 编辑。
- 不新增 MNote 普通 Markdown 编辑工具。
- 不把 Convex 恢复为默认正文或 AI 会话全文主存储。
- 不实现 Phase C Review Mode / StreamApplyController / GhostTextOverlay。
- 不让前端维护第二份正文真相。
- 不把所有打开 tab 默认发送给 agent。
- 不把跨 workspace 自动合并为一个隐式上下文。
## 10. 归档条件
满足以下条件后,本稿可归档到 `design/07-ai/done/`
- Phase A0 target UI 有真实浏览器 smoke 证据。
- Phase A / B / C 至少各有一条真实浏览器或 targeted test 证据。
- local-first agent 文件编辑主路径已能在 clean buffer 下完成真实 `.md` 修改并同步回 tiptap。
- dirty conflict 不静默覆盖。
- 文档和 manifest 不再把 `mnote.doc.markdown_edit` 描述为 local-first fallback。
@@ -1,14 +1,16 @@
# 7-12 [process] 页面 AI Hermes 工具路由与编辑审阅面设计 v1
# 7-12 [reference] 页面 AI Hermes 工具路由与编辑审阅面设计 v1
> 更新时间:2026-05-19
>
> 当前状态:`PROCESS`
> 当前状态:`reference / frozen`
>
> 归档说明(2026-05-22):本文保留 Hermes 工具路由、manifest 和 review surface 的设计边界;Phase C Review Mode 继续冻结。`7-14` 已移入 `design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`,当前 active AI 编辑口径以 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。
>
> 本稿目的:修正“页面 AI 快速块编辑”后续方向,明确 mnote 不再建设独立 AI agent runtimemnote 只建设 Hermes 可消费的编辑工具路由、工具 manifest、上下文冻结、dry-run/review 和 Rust 写入安全边界。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main`
@@ -22,7 +24,7 @@
本稿仍作为 Hermes 工具路由与审阅面设计保留在 `process/`,但以下口径已经更新:
- `/api/page-ai/block-edit-workflow` 当前不再以 `local_rule -> apply_block_ops` 作为主路径;local-first 普通 Markdown 编辑默认给 Hermes / Reasonix 授权文件引用,由 agent 使用自身 patch / diff / 文件编辑能力写本地 `.md`
- `mnote.doc.markdown_edit` 保留cloud / remote agent / compat fallback;模型生成 markdown `search/replace``full_content` 后调用该工具,只适用于 agent 不能直接访问授权文件或需要受控代理写入的场景
- `mnote.doc.markdown_edit` 不再作为 local-first 默认、fallback 或 remote fallback;本文后续出现的 `markdown_edit` 只代表历史 tool / review surface 证据,不指导新 agent 文件编辑主路径。当前 active 口径见 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
- `page_ai_workflow` 已复用统一 mnote tool executor,不再绕过 Hermes tool toggle / audit / write contract。
- `mnote.doc.apply_block_ops` / `mnote.block.*` 保留为结构性块操作辅助,不再作为普通正文 search/replace 的优先入口。
- 本稿中的 `PageAIReviewSession` 只定义 Phase C 的安全合同和状态机边界;当前 Phase C 仍冻结,不实施流式 apply 或新的审阅 UI。
@@ -653,7 +655,7 @@ profile disabled mnote.block.fetch
`7-10` 是页面块 AI 工具执行 checklist,包含真实代码和 smoke 证据。它仍然有效,继续保留在:
```text
design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md
design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md
```
但后续执行必须按本文修正口径:
@@ -15,9 +15,9 @@
> 4. 为后续项目基本完成后扩展共享能力预留 schema、API 和验证边界。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/recycle/design/07-ai/retired-http-hermes/`
---
@@ -7,11 +7,11 @@
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md`
> - `/mnt/Data1T/mnote/design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md`
>
> 外部参考:
> - `https://github.com/siyuan-note/siyuan`
@@ -10,11 +10,11 @@
- `ARCHITECTURE.md`
- `design/01-05-current-priority-overview.md`
- `design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
- `design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
- `design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
- `design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md`
- `design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
- `design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
- `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
- `design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
- `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
- `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `design/03-rust-web/process/3-15-runtime-fallback-retirement-checklist-v1.md`
- `design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
@@ -17,7 +17,7 @@
- `design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md`
- `design/05-editor-mainline/done/5-4-leptos-tiptap-mainline-correction-v1.md`
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
重点读取代码:
@@ -19,7 +19,7 @@
- `design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
- `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
- `recycle/20260522-convex-runtime-retirement/infra/convex/README.md`
- `wolai-frontend/convex/`
@@ -28,7 +28,7 @@
### Mindmap
- 设计口径清晰:`design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md` 明确 Phase 6 主线是 `Rust kernel truth -> mindmap.simple_mind_map_scene.v1 -> leptos-mindmap editor island -> simple-mind-map runtime -> command bridge`,并禁止把 runtime data 当唯一事实源。
- 设计口径清晰:`design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md` 明确 Phase 6 主线是 `Rust kernel truth -> mindmap.simple_mind_map_scene.v1 -> leptos-mindmap editor island -> simple-mind-map runtime -> command bridge`,并禁止把 runtime data 当唯一事实源。
- 实现已对齐主线壳:`rust/crates/mnote-web/src/routes/mindmap_shell.rs:105-123` 输出 `mnote.mindmap_shell.v1``mindmap.simple_mind_map_scene.get``mindmap.command.apply``rust/crates/mnote-web/src/ssr/pages/mindmap.rs:50-56` 提供 standalone island 挂载点。
- 旧 React 块已自我标注为 legacy/compat/reference`wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx:3-5` 明确 3000 文档页默认主链是 `leptos-tiptap NodeView + leptos-mindmap adapter`
- 仍有 compat 数据层:`wolai-frontend/convex/schema.ts:164-184``mindmaps.data: v.any()` 仍承载导图数据;这与当前过渡态兼容,但不应被描述为长期 canonical truth。
@@ -145,7 +145,7 @@
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `wolai-frontend/src/lib/documents/page-aggregate-loader.ts`
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
执行项:
@@ -166,7 +166,7 @@
- `rust/crates/bridge-runtime/src/lib.rs``page.aggregate.get` 根据 query data 是否包含 `meta + content` 选择 `CompatMetaContentJoin`,否则保留 `KernelProjection`
- `rust/crates/mnote-web/src/routes/web_shell.rs``/api/page-aggregate/:id` 测试断言 `x-mnote-page-aggregate-owner=compat-join``result.source=CompatMetaContentJoin`
- `design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`:补充 Rust route 主读链与完整 kernel-native projection 的边界。
- `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`:补充 `CompatMetaContentJoin` / `KernelProjection` 验收口径。
- `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`:补充 `CompatMetaContentJoin` / `KernelProjection` 验收口径。
- `design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`:新增 `source` / provenance 契约说明。
- 已通过:`cargo test -p bridge-runtime page_aggregate_get_query_executes_into_core_projection -- --nocapture`
- 已通过:`cargo test -p mnote-web page_aggregate_endpoint_returns_snapshot_contract -- --nocapture`
@@ -421,7 +421,7 @@
- `design/03-rust-web/done/3-15-runtime-fallback-retirement-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,且与第 2 / 第 8 项 fallback、compat 退场证据一致。
- `design/06-mindmap/done/6-mindmap-phase6-kmind-parity-detail-checklist-v1.md`:从 `process/` 迁入 `done/`,文件自身 checkbox 已全部完成,并已有 `task167-mindmap-kmind-parity-smoke.js` 等 Phase 6 KMind parity 证据。
- 已验证:`rg -n "需要我给你|我可以|你要不要|是否需要|请告诉我|如果你愿意|要我|我来" design/90-reference` 无匹配。
- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md``design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md``design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md``design/06-mindmap/process/6-mindmap-kernel-phase6-projection-editor-v1.md``design/06-mindmap/process/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/``design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md``design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md``design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md``design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` 已迁入 `done/`,后续活跃 AI 体验面由 `design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md` 承接;`design/old/**` 不纳入活跃 process/done 迁移判断。
- 已完成只读状态审计:`design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md``design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md``design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md``design/06-mindmap/reference/6-mindmap-kernel-phase6-projection-editor-v1.md``design/06-mindmap/done/6-mindmap-phase6-leptos-ui-shell-reuse-checklist-v1.md` 仍有未完成项或长期主线尾项,继续保留在 `process/``design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md``design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md``design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md``design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` 已迁入 `done/`,后续活跃 AI 体验面由 `design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md` 承接;`design/old/**` 不纳入活跃 process/done 迁移判断。
## 11. 全局 Done Gate
@@ -9,10 +9,10 @@
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
@@ -154,7 +154,7 @@
- [x] 修改页面设置后检查 Page Aggregate 与 island runtime page options 同步。
- [x] 刷新页面后检查标题、正文、页面设置不回退到旧快照。
- [x] 破坏或暂停 Convex query,检查响应是 degraded/error,不返回伪 fixture。
- [x] 在 `design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。
- [x] 在 `design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` 勾选已验证项,并补证据路径。
2026-05-16 静态审计证据(历史 Next 前端退役前快照):
@@ -234,8 +234,8 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi
- [x] 给写工具补重复 idempotencyKey 的端到端用例。
- [x] 检查 `mnote.page.save` 在 manifest / UI 中继续标为页面级兜底,不显示为精确块编辑主入口。
- [x] 检查任何新增 AI surface 是否只是基础 review/context/tooling 验收,不是新功能扩展。
- [x] 更新 `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。
- [x] 更新 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。
- [x] 更新 `design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md` 的每个 phase 证据。
- [x] 更新 `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 的 Hermes 工具路由与审阅面 checklist。
2026-05-18 `mnote.block.insert_after` 多块插入证据:
@@ -269,7 +269,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_errors_wi
2026-05-18 Review Session 合同定义:
- 已在 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 定义 `mnote.page_ai_review_session.v1` 最小 schema。
- 已在 `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 定义 `mnote.page_ai_review_session.v1` 最小 schema。
- 已明确状态:`draft/planning/previewing/awaiting_user/accepted/rejected/applying/applied/failed/aborted/stale`
- 已明确动作约束:`accept` 必须重新读 Page Aggregate 并校验 revision / conflictDetectionKey / revisionRef`reject/abort` 不产生写入,`retry` 必须生成新 proposal 或 dry-run previewyolo 模式也应生成同构 audit 数据。
- 边界:这里只完成合同定义,不实施 Phase C 流式 apply 或新的审阅 UI。
@@ -502,7 +502,7 @@ node scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js
- 已进入 `3.2 Page Block AI Tooling` 的只读审计阶段,但尚未勾选 3.2 的任何新 checkbox。
- 已确认现有代码中 `mnote.doc.fetch` 已支持 `scope=selection``format=page_xml/text/markdown/json``schema=mnote.page_ai_context.v1``allowedTargetBlockIds`、truncation/warnings/continuation 等基础字段;`mnote.block.fetch` 已支持 `format=page_xml/text`manifest 已包含 `readonly/destructive/idempotent/requiresApproval/approvalMode/runtimeOwner/writeOwner/selectionEffect` 等 annotations。
- 已确认 `scripts/task-page-block-ai-tools-smoke.js` 覆盖基础块工具闭环,但还没有专门覆盖 `scope=selection``page_xml/text`、manifest annotations、选区外写入阻断、review session / conflict / idempotency 的完整验收。
- 用户目标中提到的 `design/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md` 当前在 `design/07-ai/process/` 下不存在;实际可读历史参考位于 `design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md`,当前执行口径应继续以 `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 为准。
- 用户目标中提到的 `design/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md` 当前在 `design/07-ai/process/` 下不存在;实际可读历史参考位于 `design/old/07-ai/process/7-11-blocknote-tiptap-ai-reference-and-mnote-ai-tool-runtime-v1.md`,当前执行口径应继续以 `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 为准。
恢复时的下一步建议:
@@ -552,7 +552,7 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300
2026-05-18 `7-12` runtime 口径闭合证据:
- `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 已明确 `7-11` 旧“自有 AI runtime”口径移入 `design/old/07-ai/process/`,当前执行口径以 `7-12` 为准。
- `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` 已明确 `7-11` 旧“自有 AI runtime”口径移入 `design/old/07-ai/process/`,当前执行口径以 `7-12` 为准。
- `7-12` 已定义 `PageAIContextBuilder``MnoteAIToolManifestProvider``PageAICommandRouter``PageAIReviewSession` 的边界,并明确 Phase C review / streaming apply 只做设计冻结,当前不实施。
- `7-12` Phase B 到 Phase F 保留 manifest / router / review session / event / smoke 的后续 checklist;其中 selection 外写入 blocked 已有真实 smoke 证据,其余未实施项继续保留未勾。
@@ -601,5 +601,5 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_AUTH_BASE_URL=http://127.0.0.1:300
## 6. 给后续 /goal 的持续执行 Prompt
```text
/goal objective: 持续核验 /mnt/Data1T/mnote/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md 的已归档结论,按 P0 -> P1 顺序推进 MNOTE 架构防回归、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源防回归;2) tree command cutover 防回归;3) tree realtime live cache 防回归;4) AI 基础工具与审阅底座,当前简单正文编辑主路径已是 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executorPhase C(流式 apply + suggest/review)仅设计冻结不实施;继续补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 scoped git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。
/goal objective: 持续核验 /mnt/Data1T/mnote/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md 的已归档结论,按 P0 -> P1 顺序推进 MNOTE 架构防回归、AI 基础底座和定向 bug hunt。每轮开始先读取 /home/lix/.codex/memories/PROFILE.md 与 ACTIVE.md,再读取 AGENTS.md、ARCHITECTURE.md、design/01-05-current-priority-overview.md、本 checklist、design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md、design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md、design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md。必须保护用户已有未提交改动,不回滚、不覆盖、不删除无关文件。优先使用多个 subagent 并行做只读审查和浏览器验证,主线程只整合证据和做小范围实现。执行顺序固定为:1) Page Aggregate 单一真源防回归;2) tree command cutover 防回归;3) tree realtime live cache 防回归;4) AI 基础工具与审阅底座,当前简单正文编辑主路径已是 `mnote.doc.markdown_edit` + `mnote.doc.fetch` + `page_ai_workflow.rs` 统一 tool executorPhase C(流式 apply + suggest/review)仅设计冻结不实施;继续补 context/selection/page_xml/tool annotations,不扩新 AI 功能;5) 定向 bug hunt。每完成一个小项都要更新对应 design/bugs 文档,补真实验证命令或证据路径。不要优先扩新功能,不要大改架构,不要把 compat/debug/fallback 当主链,不要把 BlockNote/Tiptap AI runtime 作为 mnote runtime 依赖。验证至少包含 scoped git diff --check、相关 cargo test / smoke;如涉及 3000 页面,使用 mnote-tester 或浏览器自动化并保留截图/JSON 证据。
```
@@ -5,8 +5,8 @@
> 日期:2026-05-16
>
> 关联主线:
> - `design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md`
@@ -104,7 +104,7 @@ Codex 已复核 worker 输出,并额外核对 `3-15` 的真实浏览器 smoke
以下文档已迁入 `reference/`,不再作为当前直接执行 checklist:
- `design/03-rust-web/reference/3-17-convex-export-web-entry-v1.md`
- `design/old/03-rust-web/process/3-17-convex-export-web-entry-v1.md`
- `design/04-tree-domain/reference/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md`
- `design/05-editor-mainline/reference/5-2-tiptap-notion-like-template-adoption-v1.md`
- `design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
@@ -0,0 +1,136 @@
# [reference] mnote-dev-squad 协作协议 v1
## 目的
把 mnote 开发固定为一个轻量协作组:Hermes 主控,多个 Codex worker 在独立 worktree 实现,Reasonix flash 做真实浏览器 smoke,每个环节都用 Markdown 交接。
本协议不使用 Codex harness;不允许多个写入型 agent 同时改同一个 checkout。
## 角色
### Hermes 主控
- 读取 mnote 当前设计与 AGENTS.md。
- 拆分任务 lane。
- 创建 run 目录与 task/result/smoke/review Markdown。
- 创建或指定 Codex worktree。
- 启动/观察 Codex worker。
- 启动 Reasonix smoke。
- 阅读 RESULT.md、SMOKE.md、git diff 后做合并裁决。
### Codex worker
- 一个 worker 只负责一个 lane。
- 一个 worker 只在自己的 worktree 中写入。
- 默认不提交、不推送、不改允许范围外文件。
- 必须输出 RESULT.md。
### Reasonix smoke tester
- 默认使用 Reasonix flash。
- 负责真实浏览器 smoke、截图、console、final URL、可见文本证据。
- 默认不改代码。
- 必须输出 SMOKE.md。
### Reviewer / 仲裁
- 默认由 Hermes + 主控 Codex完成。
- 有争议时升级到 gpt-5.5 或更强模型。
## 目录约定
每轮协作创建:
```text
/mnt/Data1T/mnote/tmp/agent-runs/<run-id>/
PLAN.md
codex-<lane>/TASK.md
codex-<lane>/RESULT.md
smoke/SMOKE.md
REVIEW.md
```
模板位于:
```text
/mnt/Data1T/mnote/.agent-templates/task.md
/mnt/Data1T/mnote/.agent-templates/result.md
/mnt/Data1T/mnote/.agent-templates/smoke.md
/mnt/Data1T/mnote/.agent-templates/review.md
```
## Worktree 约定
Codex worker 使用独立 worktree,例如:
```text
/mnt/Data1T/mnote-wt-<lane>-<YYYYMMDD-HHMM>
```
推荐 lane
- `rust-kernel`
- `mnote-web`
- `tree-domain`
- `editor-interaction`
- `onlyoffice`
- `ai-runtime`
- `docs-review`
- `qa-smoke`
## Ownership 规则
每个 TASK.md 必须写清:
- 允许修改范围
- 禁止修改范围
- 验收命令
- 交付物路径
若两个 lane 需要改同一个核心文件,必须停下来由 Hermes 重新拆分或指定唯一 owner。
## 交付标准
Codex worker 完成不等于可合并。可合并必须满足:
1. RESULT.md 存在。
2. 修改文件符合 ownership。
3. 验证命令已执行或明确说明阻塞原因。
4. Reasonix smoke 已完成,或明确说明该任务不需要浏览器 smoke。
5. Hermes / reviewer 已读 diff。
## Smoke 标准
涉及 UI、文档页、编辑器、树、拖拽、选择、hover、click、快捷键等,SMOKE.md 必须包含:
- 测试 URL / 环境
- 浏览器动作
- final URL
- screenshot 路径
- console error 数量
- PASS / FAIL
- 问题与复现步骤
块编辑器验收中,hover / click / drag / select 是强制步骤;缺任一步只能算未完成验收。
## 升级规则
升级到 gpt-5.5 / 主控 Codex 深度复核的情况:
- Reasonix smoke 与 Codex 结论冲突。
- smoke evidence 不足。
- 失败原因涉及架构边界。
- 涉及 AGENTS.md 中的长期方向判断。
- 需要合并多个 worktree 的重叠改动。
## 回收规则
旧 worktree、失败 worktree、临时实验目录优先软删除到 recycle,不直接硬删。
推荐:
```text
/mnt/Data1T/recycle/<reason>-<timestamp>/
```
保留 MANIFEST 或 README,说明来源与恢复方法。
@@ -1,4 +1,4 @@
# 1-9 [process] Batch B P1 Identity / BufferStore / Page Aggregate Execution Checklist v1
# 1-9 [recycle] Batch B P1 Identity / BufferStore / Page Aggregate Execution Checklist v1
> 创建时间:2026-05-21
>
@@ -6,7 +6,9 @@
>
> 阶段:Batch B / P1 工作区身份、BufferStore、Page Aggregate 收口
>
> 当前状态:`PROCESS`
> 当前状态:`recycle / old`
>
> 归档说明(2026-05-22):本文的 Batch B 尾项已被后续 Batch / `done/` 文档和 `1-6``5-6` 的当前缺口口径覆盖;保留为历史执行记录,不再作为 active process。
## 1. 目标
@@ -1,8 +1,8 @@
# 3-17 Convex Export Web Entry v1
# 3-17 [recycle] Convex Export Web Entry v1
状态:reference
状态:`recycle / old`
归档说明(2026-05-21):CLI 迁移闭环已具备并有 smoke 证据;本文只保留为后续 Web 管理入口设计参考,当前不作为 active implementation checklist。若决定短期落地 Web UI,应新建执行 checklist
归档说明(2026-05-22):CLI 迁移闭环已具备并有 smoke 证据;但当前 Convex 默认运行时、根 functions 与自托管入口已退役到历史 / compat / sync replica 边界,本文的 Web 管理入口设想不再作为 active 或 reference 执行入口。若未来重新做 cloud/import UI,应按当前 SQLite control-plane 和 local-first 口径新建设计稿
## 目标
@@ -1,8 +1,10 @@
# 7-10 [process] 页面块 AI 工具执行 checklist v1
# 7-10 [recycle] 页面块 AI 工具执行 checklist v1
> 更新时间:2026-05-16
>
> 当前状态:`PROCESS`
> 当前状态:`recycle / old`
>
> 归档说明(2026-05-22):本文的块级 AI 执行路线已被 local-first Markdown 编辑收敛口径覆盖;当前 active AI 编辑入口以 `design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md` 为准。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md`
@@ -1,13 +1,20 @@
# 7-14 [process] 在线 / 本地文档 AI Markdown 编辑路径收敛 v2
# 7-14 [recycle][process] Local-first AI Markdown 编辑路径收敛 v2
> 创建时间:2026-05-16
>
> 更新时间:2026-05-16v3:深度参考 CLI Main skill 系统,补全成熟度采纳清单
> 更新时间:2026-05-22recycle:本文仍受 CLI Main / tool-first 旧口径影响,已移入 old,不再作为当前 AI 编辑实现依据
>
> 归档说明(2026-05-22):本文试图从旧在线 / 本地收敛稿修正为 local-first,但仍把 CLI Main 与 `mnote.doc.*` 兼容工具放在过高位置,不符合当前 SQLite control-plane + local-first `.md` + agent 原生文件 patch/diff 主线。后续应新建更小的 active 设计稿,直接基于 SQLite、allowed roots、file refs、watcher、BufferStore、Page Aggregate 前台同步和审计链设计。
>
> 2026-05-18 口径补充:
> - local-first workspace 已成为早期产品默认形态;本地 `.md` 是默认 AI 编辑目标,在线 Convex 文档降级为可选 cloud / sync / share source
> - 本文早期把 `mnote.doc.markdown_edit` 描述为统一主路径;最新口径改为:local-first 普通 Markdown 编辑优先给 agent 授权文件引用,由 agent 使用自身成熟的 diff / apply_patch / 文件编辑能力完成;`mnote.doc.markdown_edit` 保留为 cloud / remote agent / compat fallback。
> - 页面内图片 / 附件上传已在 local source 下写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,AI 后续处理附件引用时也应保留相对路径,不改写为 Convex media asset。
> - local-first workspace 已成为早期产品默认形态;本地 `.md` 是默认 AI 编辑目标;旧 Convex 文档只作为历史迁移源或显式 compat/source replica 边界
> - 本文早期把 `mnote.doc.markdown_edit` 描述为统一主路径;最新口径改为:local-first 普通 Markdown 编辑优先给 agent 授权文件引用,由 agent 使用自身成熟的 diff / apply_patch / 文件编辑能力完成;`mnote.doc.markdown_edit` 保留为显式 remote agent / compat fallback。
> - 页面内图片 / 附件上传已在 local source 下写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,AI 后续处理附件引用时也应保留相对路径,不改写为历史 media asset。
>
> 2026-05-22 口径纠正:
> - 当前 Convex runtime / functions 已退役,不再作为当前正文主链、默认控制面、AI 会话主存储或新增能力目标。
> - 本文中的“在线文档”仅可理解为历史迁移源、显式 cloud compat / remote agent 边界或未来 sync replica,不再与 local-first `.md` 并列为当前主路径。
> - 当前主线应写成:本地 `.md` 文件 + Rust SQLite control-plane + allowed roots / file references + agent 原生 patch/diff + watcher / BufferStore / Page Aggregate 前台同步。
>
> 当前状态:`PROCESS`
>
@@ -15,12 +22,12 @@
> 1. 纠正 7-9 / 7-10 / 7-12 / 7-13 中隐含的「块级编辑是 AI 唯一写入路径」假设
> 2. 基于 CLI Main 参考实现,确立 mnote 的「agent 原生文件 patch/diff 为 local-first 默认路径,MNote 文本级兼容工具 + 块级结构性操作为 fallback / 辅助」两层模型
> 3. 规划 BlockNote AI 流式/review 能力的远期方向(当前不实施)
> 4. 统一 cloud / remote / compat 文档和本地 `.md` 文件的 AI 读取、权限、冲突与回读口径
> 4. 明确历史 cloud / remote / compat 文档边界,并把当前 AI 读取、权限、冲突与回读口径收口到 local-first `.md` 文件
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
@@ -42,13 +49,13 @@
## 1. 结论
**在线 Convex 文档和本地 `.md` 文件本质上是同一个东西:一段 markdown 文本,Tiptap 只是块级 UI 表现层。** 进一步切到 local-first 后,本地 `.md` 已经是普通文件,因此不需要再为常规正文编辑发明一套 MNote 专用工具。当前 07-ai 设计把 AI 编辑契约钉死在 `EditorBlockDocument` 的块级操作上,导致三个连锁问题:
**当前事实源是 local-first `.md` 文件,Tiptap 只是块级 UI 表现层。** Convex 相关正文链已经退役为历史 / compat 边界,不能再和本地 `.md` 并列描述为当前主路径。既然默认对象已经是普通 Markdown 文件,不需要再为常规正文编辑发明一套 MNote 专用块级工具。当前 07-ai 设计把 AI 编辑契约钉死在 `EditorBlockDocument` 的块级操作上,导致三个连锁问题:
1. **AI 被迫在块级操作**:对「改写这段话」「补充一段总结」「调整语气」「把所有 TODO 改成 DONE」等自然请求,AI 必须产出 `{ op: "replace", blockId: "block_1", ... }` 格式的块操作,而不能直接产出修改后的文本或搜索替换对。这增加了 AI 的认知负担和出错概率。
2. **本地 `.md` 文件没有 AI 能力**:本地文件没有稳定的 `blockId`(每次解析重新分配),因此块级工具无法应用于本地文件。当前 Hermes 的 13 个工具没有一个是面向本地文件的
2. **旧块级工具不适合 local-first `.md`**:本地文件没有稳定的 `blockId`(每次解析重新分配),因此块级工具不能作为普通 Markdown 编辑默认路径
3. **两套口径维护**:在线文档(块操作)和本地文档(无 AI 路径)长期走两套路径,维护成本翻倍
3. **历史 compat 容易污染当前主线**:如果继续把 Convex / online 文档写成并列路径,后续实现会误把 `mnote.doc.*``documents.save` 当成新增能力入口,而不是 local-first 的 fallback / 迁移边界
### 参考实现的验证
@@ -64,11 +71,11 @@
### 正确方向
> mnote 的 AI 编辑路线:**本地授权文件 + agent 原生 patch/diff 为主;MNote 兼容工具 cloud/remote/结构化辅助;BlockNote AI 的流式/review 只作为远期交互参考。**
> mnote 的 AI 编辑路线:**本地授权文件 + agent 原生 patch/diff 为主;MNote 兼容工具仅用于显式 cloud/remote compat 或结构化辅助;BlockNote AI 的流式/review 只作为远期交互参考。**
具体:
- **当前实施**Phase A/B):local-first 页面 AI 只传当前文件引用、可选 selection 和用户指令;MNote 校验 `AiAccessScope` 后让 agent 在受限目录中使用原生 patch/diff 编辑 `.md``mnote.doc.markdown_edit` 作为兼容 / 远端代理 fallback`mnote.block.*` 作为复杂结构辅助。
- **当前实施**Phase A/B):local-first 页面 AI 只传当前文件引用、可选 selection 和用户指令;MNote 校验 `AiAccessScope` 后让 agent 在受限目录中使用原生 patch/diff 编辑 `.md``mnote.doc.markdown_edit` 作为显式 cloud / remote agent / compat fallback`mnote.block.*` 作为复杂结构辅助。
- **远期规划**Phase C):BlockNote AI 的流式增量 apply + suggest/review 层。当前先设计,不实施。
---
@@ -109,7 +116,7 @@ docs +update --command block_move_after --block-id "block_3" --target-block-id "
**对 mnote 的启示**
1. **两层操作模型直接适用**。mnote 的 `markdown_edit` = CLI Main 的 `str_replace`(文本级),`apply_block_ops` = CLI Main 的 `block_*`(块级)。
2. **但 mnote 的主格式是 markdown 而非 XML**在线文档的持久化格式(Convex `documents.content`)可以投影为 markdown,本地文件本身就是 markdown。所以我们不需要 XML 这一层——markdown 既是 AI 编辑格式,也是人类可读格式。
2. **但 mnote 的主格式是 markdown 而非 XML**当前 local-first 正文事实源就是本地 `.md`,历史 cloud / compat 内容也只能先投影为 markdown 后进入兼容工具。所以我们不需要 XML 这一层——markdown 既是 AI 编辑格式,也是人类可读格式。
3. **CLI Main 的 skill 格式**YAML frontmatter + Markdown body,定义 tool shortcuts + 上下文格式规范)值得学习。mnote 的 Hermes `mnote` plugin 可以用类似方式组织。
#### 2.1.1 Skill YAML 前端元数据
@@ -268,7 +275,7 @@ CLI Main 的 lark-doc skill 定义了嵌入式资源的显式路由表:
```
**→ mnote 采纳**(远期,当文档内嵌入其他资源类型时):
- 在线文档中嵌入的思维导图 → 路由到 `mnote-mindmap` skill
- local-first Markdown 中嵌入的思维导图资源 → 路由到 `mnote-mindmap` skill
- 嵌入的附件/文件 → 路由到 `mnote-file` skill
- 当前 Phase A 不实施,预留路由表字段
@@ -284,7 +291,7 @@ CLI Main 的格式选择规则:
| 整篇导入/导出 | Markdown | 人类可读,和 `.md` 互通 |
| AI 对话引用 | Markdown | 模型理解 markdown 远好于 XML |
**→ mnote 采纳**CLI Main 用 XML 做默认格式是因为飞书文档本身是 XML 存储。mnote 的在线文档持久化格式(Convex `documents.content`)和本地文件都是 markdown,因此 markdown 是 mnote 的默认和唯一 AI 格式。这是正确的差异化决策。
**→ mnote 采纳**CLI Main 用 XML 做默认格式是因为飞书文档本身是 XML 存储。mnote 当前主存储是 local-first `.md` 文件,历史 cloud / compat 内容也必须先投影成 markdown 后再进入 AI 编辑面,因此 markdown 是 mnote 的默认和唯一 AI 格式。这是正确的差异化决策。
#### 2.1.7 Skill CI 校验
@@ -320,8 +327,8 @@ LLM 流式输出 partial JSON
|------|------|---------------|
| `StreamTool` | 单操作的定义:name + inputSchema + validate + execute | mnote 已有(`mnote.block.*` / `mnote.doc.*`),不需要重构 |
| `StreamToolExecutor` | 流式解析 partial JSON → 逐条 enqueue → 按顺序 execute | Phase C 新增:`StreamApplyController` |
| `suggestChanges` | ProseMirror suggestion marksAI 编辑不直接落盘,先标记为待审阅 | Phase C 新增:在线文档的 `ReviewSession` |
| `RebaseTool` | 协作场景:用户同时在编辑 → AI 操作 rebase 到最新文档状态 | Phase C 考虑(协作场景依赖 Convex 的 revision 乐观锁) |
| `suggestChanges` | ProseMirror suggestion marksAI 编辑不直接落盘,先标记为待审阅 | Phase C 新增:local-first `ReviewSession` / 临时审阅层 |
| `RebaseTool` | 协作场景:用户同时在编辑 → AI 操作 rebase 到最新文档状态 | Phase C 考虑local-first 下优先基于 file version / BufferStore 冲突模型 |
| `delayAgentStep` | 逐条 apply 之间加 50-200ms 延迟,给用户"AI 正在操作"的可见性 | Phase C 可选改善 |
**BlockNote AI 不适合直接搬的原因**:它的所有操作都通过 `id`blockId)寻址。`add` 需要 `referenceId``update` 需要 `id``delete` 需要 `id`。这意味着:
@@ -354,23 +361,23 @@ LLM 流式输出 partial JSON
「把文章中所有 TODO 改成 DONE」这类跨块请求,当前需要逐一产出 N 个 `{ op: "replace", blockId: "..." }`。文本级 search/replace 只需一条:`{ search: "TODO", replace: "DONE" }`
### 3.2 本地文件没有 AI 写入路径
### 3.2 旧块级工具没有 local-first 默认写入语义
> 参考对照:CLI Main 的 skill 可以操作任何 `doc-token`(包括本地文件和云端文档),因为它用的是文本级 + XML 级工具,不依赖特定存储。
mnote 当前Hermes 只知道 workspace/document 模型,不知道本地文件夹/文件模型。本地 `.md` 文件在 AI 视角完全不可见
mnote 当前主线:页面已能定位 local folder `.md`,但普通 AI 正文编辑不能回到“必须构造完整 page context 或 block ops”的旧模型。AI 视角应看到受控文件引用、selection 和 allowed roots,而不是一份由前端拼出的第二事实源
### 3.3 两套体系互不相认
| | 在线 Convex 文档 | 本地 .md 文件 |
| | 历史 cloud / compat 文档 | 当前 local-first `.md` 文件 |
|---|---|---|
| 存储 | Convex `documents.content` | 文件系统 `*.md` |
| AI 读取 | `mnote.doc.fetch`block projection | 无 |
| AI 写入 | `mnote.block.*` / `mnote.doc.apply_block_ops` | 无 |
| 写入粒度 | 块级(需要 blockId) | —(无 AI 路径) |
| 设计覆盖 | 07-ai 全部文档 | 仅在 03-rust-web 讨论解析 |
| 存储 | 历史迁移源 / 显式 cloud compat / sync replica | 文件系统 `*.md` |
| AI 读取 | `mnote.doc.fetch` 兼容投影 | 当前文件引用 / Page Aggregate / local markdown |
| AI 写入 | `mnote.doc.*` `mnote.block.*` 兼容 fallback | agent 原生 patch/diff + watcher 同步 |
| 写入粒度 | 工具代理,不能作为新增默认入口 | 文件级 patch + selection 辅助 |
| 设计定位 | 历史 / compat 边界 | 当前主路径 |
两份体系在设计中互不相认。3-13 写明"不影响 Convex workspace 链路"——这是主动划清界限。**收敛是必须的,markdown 是共同分母。**
旧设计把 compat 文档和本地文件写成两套体系,容易让后续实现继续往旧 cloud / block 工具堆逻辑。当前必须收敛:**local-first `.md` 是默认事实源,markdown 是共同格式,compat 只服务迁移、显式 cloud 或 remote agent 边界。**
---
@@ -380,28 +387,28 @@ mnote 当前:Hermes 只知道 workspace/document 模型,不知道本地文
> 参考对照:CLI Main 用 Markdown 做整篇导入/导出和对话引用,用 XML 做精确块编辑。mnote 直接用 Markdown 做所有 AI 操作,因为 mnote 没有 XML 存储层。
AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 AI 概念。在线文档的持久化格式和本地文件的持久化格式都可以投影 markdown。
AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 AI 概念。当前 local-first 持久化格式就是 markdown;历史 cloud / compat 内容若需要进入 AI 编辑,也必须先投影 markdown。
### 4.2 两层操作模型(兼容层)
| 层 | 工具 | 寻址方式 | 适用场景 | 占比 |
|----|------|---------|---------|------|
| **文件级(主)** | agent 原生 `diff/apply_patch/文件编辑` | 授权文件引用 / selection | local-first 普通 Markdown 改写 | 80%+ |
| **文本级(兼容)** | `mnote.doc.markdown_edit` | search/replace 文本对 | cloud / remote agent / 兼容旧页面 AI | 次要 |
| **文本级(兼容)** | `mnote.doc.markdown_edit` | search/replace 文本对 | 显式 cloud / remote agent / 兼容旧页面 AI | 次要 |
| **块级(辅助)** | `mnote.doc.apply_block_ops` | blockId / matchText | "把第三块拖到第一块后面"、"精确删除引用块" | <20% |
### 4.3 在线和本地的主写入路径
### 4.3 local-first 主写入路径
```
页面 AI / ACP
→ resolve_source(documentId) → Convex | LocalFS
local-first: 传授权文件引用给 agent runtime
→ resolve_current_file(documentId, sourceKind=local_folder, rootUri)
→ 传授权文件引用 / selection / allowed roots 给 agent runtime
→ agent 原生 patch/diff 写入文件
→ MNote 做权限 / 审计 / refresh
→ cloud / remote fallback: mnote.doc.markdown_edit
→ MNote 做权限 / 审计 / watcher refresh / BufferStore 冲突
仅在显式 cloud / remote compat 时 fallback mnote.doc.markdown_edit
```
差异主要在执行层:local-first 直接让 agent 修改授权文件;cloud 或受限 remote runtime 无法直接访问本地文件时,`mnote.doc.markdown_edit` 代理。这和 CLI Main 的 `docs +update` 可以操作任何 `doc-token` 的设计一致,只是 mnote 进一步把“编辑算法”让渡给 agent runtime。
当前执行层不再二选一。默认只有 local-first 授权文件路径cloud 或受限 remote runtime 无法直接访问本地文件时,才显式`mnote.doc.markdown_edit` 代理。这和 CLI Main 的 `docs +update` 可以操作任何 `doc-token` 的设计一致,只是 mnote 进一步把“编辑算法”让渡给 agent runtime。
### 4.4 Diff 是内部实现细节
@@ -434,17 +441,17 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是
```
增强点:
- `format: "markdown"`新增值。在线文档由 Page Aggregate 产出 PageMarkdown;本地文件直接返回 `.md` 原文
- `documentId`自动检测 sourceConvex workspace 文档 vs 本地文件系统路径
- `format: "markdown"`兼容值。local-first 文档直接返回 `.md` 原文;历史 cloud / compat 文档必须经 Page Aggregate 投影成 markdown
- `documentId`当前默认解析为 local-first 文档;只有显式 sourceKind / compat 标记允许进入 cloud adapter
返回:
```json
{
"ok": true,
"source": "convex",
"documentId": "tree_xxx",
"revision": 3,
"source": "local_folder",
"documentId": "local-md:README.md",
"fileVersion": "local-md:README.md:...",
"format": "markdown",
"content": "# 标题\n\n段落内容...\n\n## 子标题\n\n...",
"truncated": false,
@@ -471,7 +478,7 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是
服务端处理流程:
```
1. resolve_source(documentId) → ConvexAdapter | LocalFSAdapter
1. resolve_source(documentId, sourceKind, rootUri) → LocalFSAdapter | explicit CompatAdapter
2. 读取当前 markdown 全文
3. 逐条 search_replace(精确匹配 → fuzzy fallback
4. 计算内部 diff(用于 BlockDelta 推送)
@@ -484,16 +491,16 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是
```json
{
"ok": true,
"source": "convex",
"documentId": "tree_xxx",
"revision": { "before": 3, "after": 4 },
"source": "local_folder",
"documentId": "local-md:README.md",
"fileVersion": { "before": "local-md:README.md:...", "after": "local-md:README.md:..." },
"operationsApplied": 2,
"operationsFailed": 0,
"failedOperations": [],
"changedText": "已将「原文片段」替换为「新文本」\n已将「另一段」替换为「改写后的内容」",
"blockDelta": {
"documentId": "tree_xxx",
"revision": 4,
"documentId": "local-md:README.md",
"fileVersion": "local-md:README.md:...",
"operations": [...]
}
}
@@ -518,7 +525,7 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是
多条 operation 按数组顺序串行执行。前一条的替换结果对后一条可见(和 sed 语义一致)。
**乐观锁**执行前用 `revision` 检测。如果写入时 revision 已过期,返回冲突错误,让 AI 重新 fetch + edit
**乐观锁**local-first 下执行前用 file version / conflict detection key 检测。如果写入时文件版本已过期,返回冲突错误,让 AI 重新 fetch + edit;历史 cloud compat 才能使用旧 revision 语义
---
@@ -534,7 +541,7 @@ AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是
2. 服务端逐条 apply 并推送 BlockDelta
3. 编辑器逐条渲染修改(带延迟,模拟"AI 正在编辑"
4. 用户可逐条 accept / rejectsuggestion 模式)
5. 确认后才最终写入 Convexreview 模式)
5. 确认后才最终写入本地 `.md` 或显式 compat 目标review 模式)
### 7.2 与 BlockNote AI 的差异
@@ -558,9 +565,9 @@ StreamApplyController(新增)
4. 可选:注入 delay(50-200ms)模拟人类编辑节奏
输出:逐条 BlockDelta
ReviewSession(新增,在线文档
ReviewSession(新增,local-first 审阅层
状态:pending → accepted | rejected
存储:Convex review_session 表或 documents 的 review 字段
存储:SQLite control-plane / 本地 transient review state;不依赖 Convex
生命周期:
- AI 编辑 → 创建 ReviewSession → 所有修改标记为 pending
- 用户逐条操作 → accept/reject → 更新 session 状态
@@ -577,10 +584,10 @@ GhostTextOverlay(新增,编辑器)
|------|------|------|
| C-1 | `StreamApplyController`:流式 apply + SSE 逐条推送 delta | 7-13 EditorRuntimeActor delta 通道 |
| C-2 | `GhostTextOverlay`:编辑器内 diff 展示(红删绿增) | leptos-tiptap 的 decoration 能力 |
| C-3 | `ReviewSession`在线文档的 accept/reject session | Convex review 设计 |
| C-3 | `ReviewSession`local-first accept/reject session | SQLite control-plane / editor transient review state 设计 |
| C-4 | `delayAgentStep`:可选的编辑节奏模拟 | C-1 完成 |
**当前不做实施决策**。Phase C 的启动时机以 Phase A/B 完成后,编辑器能力和 Convex review 设计就绪为前置条件。
**当前不做实施决策**。Phase C 的启动时机以 Phase A/B 完成后,编辑器 decoration 能力、BufferStore/file version 冲突模型和 SQLite control-plane review state 设计就绪为前置条件。
---
@@ -588,19 +595,19 @@ GhostTextOverlay(新增,编辑器)
### Phase A:文件引用主路径 + `mnote.doc.*` 兼容层
- [x] `mnote.doc.fetch` 增加 `format: "markdown"`在线文档 Page Aggregate → PageMarkdown
- [x] `mnote.doc.fetch` 增加本地文件 source 路由(自动检测 Convex vs 文件系统路径
- [x] 实现 `resolve_source(documentId)` — 本地文件路径 `local_fs` vs 其余走 Convex
- [x] `mnote.doc.fetch` 增加 `format: "markdown"`local-first `.md` / Page Aggregate markdown 投影
- [x] `mnote.doc.fetch` 增加本地文件 source 路由(`local_folder` / `rootUri` / `local-md:*`
- [x] 实现 `resolve_source(documentId, sourceKind, rootUri)` — 默认 `local_folder`;历史 cloud / compat 必须显式进入 compat adapter
- [ ] 页面 AI / ACP 普通正文编辑默认只传当前文件引用、可选 selection 和用户指令,不再默认构造完整 page context
- [ ] 本地 agent runtime 在 `allowed_roots / allowed_file_paths` 内执行 patch/diff,并把 changed files / diff 摘要回传 MNote
- [x] 实现 `search_replace(text, operations)` — 四级匹配策略(精确→宽松→段落 fuzzy→失败)
- [x] 实现 Convex 写入 adapter(复用 `doc_apply_block_ops` 链路)
- [x] 历史 cloud / compat 写入 adapter 已降级为 fallback,不再作为 local-first 默认入口
- [x] 实现本地文件写入 adapter`mnote.doc.markdown_edit` 检测到本地文件路径时直接 `fs::write` 写回,不经过 Convex
- [ ] 内部 diff 生成 + BlockDelta 推送(复用 7-13 delta 基础设施,待 Phase C 实现)
- [x] Hermes tool manifest 注册 `mnote.doc.markdown_edit`
- [x] Hermes `mnote` plugin 更新:`mnote_doc_fetch` schema + `mnote_doc_markdown_edit` 新增
- [x] 乐观锁:`revision` 冲突检测(`doc_apply_block_ops` 已有)
- [x] 浏览器 smoke(在线文档 `format: "markdown"` + `markdown_edit` 搜索替换)
- [x] 乐观锁:local-first 使用 file version / conflict detection keycompat 路径才保留 revision 语义
- [x] 历史 browser smokecloud compat `format: "markdown"` + `markdown_edit` 搜索替换)仅作兼容证据,不代表当前主路径
- [x] 单元测试(`search_replace` 精确/失败/全文、`blocks_to_markdown` with_ids/heading 共 5 测试通过)
- [x] 浏览器 smoke(本地 `.md` 文件读取 `mnote_doc_fetch` + 写入 `mnote_doc_markdown_edit``full_content` 创建 + `operations` 搜索替换 + 回读验证全部通过)
@@ -662,7 +669,7 @@ GhostTextOverlay(新增,编辑器)
- 不强迫 local-first agent 产出 MNote 自定义 diffHermes / Reasonix 可使用自身成熟 patch / diff / apply_patch 能力,MNote 负责白名单权限、文件版本冲突和审计。
- 不要求本地文件有稳定的 `blockId`(本地文件没有 block identity)。
- 不在 markdown_edit 内部引入新的 AI 模型调用(diff 是确定性算法)。
- 不把 Convex `documents:updateContent` 重新提升为 local-first 正文主存储;`markdown_edit` cloud / compat 场景复用受控保存路径。
- 不把 Convex / `documents:updateContent` 重新提升为当前正文主存储;`markdown_edit` 只在显式 cloud / compat 场景复用受控保存路径。
- 不把 `mnote.page.save` 重新描述为精确编辑主入口(它仍是兜底工具)。
- **不照搬 BlockNote AI 的纯 blockId 寻址模式**(与 mnote 的 markdown 优先策略冲突)。
@@ -670,12 +677,12 @@ GhostTextOverlay(新增,编辑器)
## 11. 成功标准
- [x] `mnote.doc.fetch(documentId, format: "markdown")`在线文档返回正确 markdown
- [x] `mnote.doc.fetch(documentId, format: "markdown")`历史 cloud / compat 文档返回正确 markdown(兼容证据)
- [ ] `mnote.doc.fetch(documentId, format: "markdown")` 对本地 `.md` 文件返回正确 markdown(兼容路径)
- [x] `mnote.doc.markdown_edit` 的简单搜索替换(1 条 operation)浏览器 smoke 通过
- [ ] `mnote.doc.markdown_edit` 的复杂改写(3+ 条 operations)成功率 > 80%
- [ ] 本地 `.md` 文件通过页面 AI 面板可被读取和写入,且默认通过 agent 原生 patch/diff 完成
- [x] 在线文档的 markdown_edit 不增加 Convex RTT(和当前块操作持平
- [x] 历史 cloud / compat markdown_edit 不增加额外 RTT(兼容证据,不代表当前主路径
- [x] `direct_block_edit_operations` 已退役(路由跳过,代码保留)
- [x] `page_ai_workflow.rs` 的 system prompt 已补全 search/replace schema
- [x] Phase C(流式/review)的设计已冻结,不阻塞 A/B 实施
+43 -2
View File
@@ -63,6 +63,14 @@ fn capabilities_json(capabilities: &[String]) -> Result<String, ControlPlaneErro
serde_json::to_string(capabilities).map_err(ControlPlaneError::from)
}
fn file_uri_to_legacy_path(value: &str) -> String {
value
.trim()
.strip_prefix("file://")
.unwrap_or("")
.to_string()
}
fn row_to_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserRecord> {
Ok(UserRecord {
id: row.get(0)?,
@@ -793,13 +801,21 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
root_uri: &str,
) -> Result<ResolvedAccess, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let root_path = file_uri_to_legacy_path(root_uri);
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision
FROM directory_grants
WHERE user_id = ?1 AND status = 'active' AND (?2 = root_uri OR (recursive = 1 AND ?2 LIKE root_uri || '%'))",
WHERE user_id = ?1
AND status = 'active'
AND (
?2 = root_uri
OR (recursive = 1 AND ?2 LIKE root_uri || '%')
OR (?3 != '' AND ?3 = root_path)
OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%')
)",
)?;
let grants = stmt
.query_map(params![actor_id, root_uri], row_to_grant)?
.query_map(params![actor_id, root_uri, root_path], row_to_grant)?
.collect::<Result<Vec<_>, _>>()?;
let mut permission = "none".to_string();
for grant in &grants {
@@ -1800,6 +1816,31 @@ mod tests {
assert_eq!(access.permission, "none");
}
#[test]
fn resolve_access_supports_legacy_directory_grants_with_plain_path_root_uri() {
let store = store();
create_user(&store, "legacy_reader");
store
.grant_directory_access(DirectoryGrantInput {
user_id: "legacy_reader".to_string(),
workspace_id: None,
root_uri: "/tmp/shared".to_string(),
root_path: "/tmp/shared".to_string(),
permission: "read".to_string(),
recursive: true,
capabilities: vec![],
source: "legacy".to_string(),
created_by: None,
})
.expect("legacy grant");
let access = store
.resolve_access("legacy_reader", "file:///tmp/shared/page.md")
.expect("resolve access");
assert_eq!(access.permission, "read");
assert_eq!(access.grant_ids.len(), 1);
}
#[test]
fn share_link_token_is_hashed_and_revocable() {
let store = store();
+21 -5
View File
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
control_plane_db_path_display, create_default_local_workspace_for_actor,
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_page_tree_snapshot, load_local_trash_entries,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
@@ -311,7 +311,7 @@ pub async fn root_entry(
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let workspace_id = snapshot
.dataset
@@ -614,7 +614,7 @@ pub async fn trash_entry(
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
@@ -1780,6 +1780,7 @@ fn handle_sqlite_auth_action(
&resolved.user.id,
resolved.user.email.as_deref().unwrap_or_default(),
&resolved.user.display_name,
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
))
}
@@ -1809,18 +1810,20 @@ fn build_sqlite_auth_response(
user_id: &str,
email: &str,
name: &str,
actor_type: &str,
) -> Response {
let mut response = axum::Json(json!({
"ok": true,
"userId": user_id,
"email": email,
"name": name,
"authMode": "sqliteSession"
"authMode": "sqliteSession",
"actorType": actor_type
}))
.into_response();
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_SESSION, session_token);
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, user_id);
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, actor_type);
if !email.trim().is_empty() {
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, email);
}
@@ -1835,6 +1838,19 @@ fn build_sqlite_auth_response(
response
}
fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
let role = stored_role.trim();
let fallback_role = if role.is_empty() { "user" } else { role };
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
user_id,
fallback_role,
) {
"admin".to_string()
} else {
fallback_role.to_string()
}
}
fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
+2 -2
View File
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access, load_local_folder_file_tree_snapshot,
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot,
};
use crate::routes::query_support::resolve_effective_workspace_id;
@@ -85,7 +85,7 @@ async fn project_projection(
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let snapshot = if projection == KernelProjectionKind::FileTree {
load_local_folder_file_tree_snapshot(root_uri)?
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access,
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
};
@@ -36,7 +36,8 @@ pub async fn local_folder_events(
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderEventsQuery>,
) -> Result<(HeaderMap, Sse<BoxedEventStream>), WebError> {
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
let canonical_root =
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let (mut headers, stream): (HeaderMap, BoxedEventStream) = if query.tree_live.unwrap_or(false) {
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::cmp::Ordering;
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
@@ -132,7 +132,7 @@ struct LocalShareGrant {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalAccessMode {
pub(crate) enum LocalAccessMode {
Read,
Write,
}
@@ -490,7 +490,7 @@ fn ensure_local_workspace_access_for_actor_with_mode(
))
}
fn ensure_local_workspace_access_with_state(
pub(crate) fn ensure_local_workspace_access_with_state(
state: &AppState,
context: &RequestContext,
root_uri: &str,
@@ -525,18 +525,7 @@ fn ensure_local_workspace_access_with_state(
)
}
pub(crate) fn ensure_local_workspace_read_access(
context: &RequestContext,
root_uri: &str,
) -> Result<PathBuf, WebError> {
ensure_local_workspace_read_access_for_actor(
&context.auth.actor_id,
&context.auth.actor_type,
root_uri,
)
}
fn ensure_local_workspace_read_access_with_state(
pub(crate) fn ensure_local_workspace_read_access_with_state(
state: &AppState,
context: &RequestContext,
root_uri: &str,
@@ -544,6 +533,14 @@ fn ensure_local_workspace_read_access_with_state(
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Read)
}
pub(crate) fn ensure_local_workspace_write_access_with_state(
state: &AppState,
context: &RequestContext,
root_uri: &str,
) -> Result<PathBuf, WebError> {
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write)
}
pub(crate) fn ensure_local_path_read_access(
context: &RequestContext,
root_uri: &str,
@@ -587,6 +584,17 @@ pub(crate) fn ensure_local_path_read_access(
Ok(canonical_target)
}
pub(crate) fn ensure_local_workspace_read_access(
context: &RequestContext,
root_uri: &str,
) -> Result<PathBuf, WebError> {
ensure_local_workspace_read_access_for_actor(
&context.auth.actor_id,
&context.auth.actor_type,
root_uri,
)
}
pub(crate) fn ensure_local_workspace_access(
context: &RequestContext,
root_uri: &str,
@@ -598,14 +606,6 @@ pub(crate) fn ensure_local_workspace_access(
)
}
fn ensure_local_workspace_write_access_with_state(
state: &AppState,
context: &RequestContext,
root_uri: &str,
) -> Result<PathBuf, WebError> {
ensure_local_workspace_access_with_state(state, context, root_uri, LocalAccessMode::Write)
}
fn local_access_policy_path() -> PathBuf {
std::env::var(ENV_LOCAL_ACCESS_POLICY_FILE)
.ok()
@@ -759,6 +759,10 @@ fn require_share_grants_admin(context: &RequestContext) -> Result<(), WebError>
pub(crate) fn is_local_access_policy_admin_context(context: &RequestContext) -> bool {
let actor_id = context.auth.actor_id.trim();
let actor_type = context.auth.actor_type.trim();
is_local_access_policy_admin_actor(actor_id, actor_type)
}
pub(crate) fn is_local_access_policy_admin_actor(actor_id: &str, actor_type: &str) -> bool {
if actor_id.is_empty()
|| actor_id == "anonymous"
|| actor_type.is_empty()
@@ -1629,6 +1633,7 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value {
json!({
"id": grant.id,
"userId": grant.user_id,
"workspaceId": grant.workspace_id,
"rootUri": grant.root_uri,
"rootPath": grant.root_path,
"permission": grant.permission,
@@ -1643,6 +1648,16 @@ fn control_plane_grant_payload(grant: &DirectoryGrantRecord) -> Value {
})
}
fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool {
grant.source.trim() == "auto"
&& grant.permission.trim() == "write"
&& grant.recursive
&& grant.created_by.as_deref().map(str::trim) == Some(grant.user_id.as_str())
&& grant.workspace_id.is_some()
&& grant.root_uri.starts_with("local://users/")
&& grant.root_uri.ends_with("/workspaces/my-space")
}
fn sqlite_access_policy_payload(
state: &AppState,
context: &RequestContext,
@@ -1689,7 +1704,10 @@ fn sqlite_user_access_policy_payload(
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
let grant_values = grants
.iter()
.filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id))
.filter(|grant| {
grant.user_id.trim() == actor_id
|| grant.created_by.as_deref().map(str::trim) == Some(actor_id)
})
.map(control_plane_grant_payload)
.collect::<Vec<_>>();
Ok(json!({
@@ -1871,6 +1889,13 @@ fn delete_sqlite_user_access_grant_for_context(
"目录授权不存在或不属于当前用户",
)
})?;
if is_default_workspace_auto_grant(grant) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_access_policy_system_grant_readonly",
"默认空间的系统授权不能撤销",
));
}
if grant.created_by.as_deref().map(str::trim) != Some(actor_id) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
@@ -1933,6 +1958,26 @@ fn delete_sqlite_local_access_grant_for_context(
"必须提供 grantId",
));
}
let grants = state
.control_plane()
.find_directory_grants(DirectoryGrantLookup {
grant_id: Some(grant_id.to_string()),
user_id: None,
root_uri: None,
include_revoked: false,
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
if grants
.first()
.map(is_default_workspace_auto_grant)
.unwrap_or(false)
{
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_access_policy_system_grant_readonly",
"默认空间的系统授权不能撤销",
));
}
state
.control_plane()
.revoke_directory_grant(grant_id, None)
@@ -2710,7 +2755,16 @@ pub fn resolve_local_markdown_page_aggregate(
})?;
let parsed = parse_markdown_page(&markdown, &markdown_file.file_name);
let title = parsed.title;
let content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body);
let attachment_paths = uploaded_asset_markdown_relative_paths_for_document(
&canonical_root,
&metadata,
document_id,
&markdown_file.path,
);
let content = crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths(
&parsed.body,
&attachment_paths,
);
let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64;
let page_subtree = markdown_page_subtree(document_id, &title, &content);
let workspace_id = local_workspace_id(&canonical_root);
@@ -3122,7 +3176,28 @@ pub async fn read_local_resource(
.unwrap_or("资源")
.to_string();
let content = if is_markdown_file(&file_name) {
let metadata = load_local_folder_metadata(&root)?;
let target_relative_path = normalize_relative_path(&root, &target)?;
let attachment_paths = metadata
.uploaded_assets
.get(&target_relative_path)
.map(|entry| {
uploaded_asset_markdown_relative_paths_for_document(
&root,
&metadata,
&entry.document_id,
&target,
)
})
.unwrap_or_default();
if attachment_paths.is_empty() {
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
} else {
crate::routes::local_markdown_parser::markdown_to_blocks_with_attachment_paths(
&text,
&attachment_paths,
)
}
} else {
text_to_editor_blocks(&text, &file_name)
};
@@ -6437,6 +6512,26 @@ fn find_markdown_by_page_id(
walk(root, root, metadata, document_id)
}
fn uploaded_asset_markdown_relative_paths_for_document(
root: &Path,
metadata: &LocalFolderMetadata,
document_id: &str,
markdown_path: &Path,
) -> BTreeSet<String> {
let Some(markdown_dir) = markdown_path.parent() else {
return BTreeSet::new();
};
metadata
.uploaded_assets
.values()
.filter(|entry| entry.document_id == document_id)
.filter_map(|entry| {
let path = root.join(&entry.relative_path);
normalize_markdown_relative_asset_path(markdown_dir, &path).ok()
})
.collect()
}
fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
let mut item = json!({
"rowId": row.row_id,
@@ -7125,7 +7220,7 @@ fn editor_blocks_to_markdown_with_rewrite(
.and_then(Value::as_str)
.unwrap_or("paragraph");
let inline_markdown = block_content_value(&block)
.map(inline_nodes_to_markdown)
.map(|content| inline_nodes_to_markdown(content, local_file_context))
.unwrap_or_default()
.trim()
.to_string();
@@ -7319,7 +7414,11 @@ fn rewrite_local_open_url_to_markdown_relative(
local_file_context: Option<(&Path, &Path)>,
) -> Option<String> {
let (root, markdown_path) = local_file_context?;
let parsed = Url::parse(value).ok()?;
let parsed = if value.starts_with('/') {
Url::parse(&format!("http://localhost{}", value)).ok()?
} else {
Url::parse(value).ok()?
};
if parsed.path() != "/api/local-folder/files/open" {
return None;
}
@@ -7605,7 +7704,7 @@ fn editor_block_table_to_markdown(block: &Value) -> String {
.map(|cell| {
let cell_text = cell
.get("content")
.map(inline_nodes_to_markdown)
.map(|content| inline_nodes_to_markdown(content, None))
.unwrap_or_default()
.replace('\n', " ")
.replace('|', r"\|")
@@ -7715,29 +7814,33 @@ fn block_content_value(block: &Value) -> Option<&Value> {
// 过渡实现:手写行内节点→Markdown 回写函数。从 editor block 的 content 数组中
// 逐个节点提取 text + styles,按 legacy 样式格式输出为内联 Markdown。
// AST + 中间 IR 迁移 complete 后应统一走 MarkdownInline→Markdown 反向映射。
fn inline_nodes_to_markdown(value: &Value) -> String {
fn inline_nodes_to_markdown(value: &Value, local_file_context: Option<(&Path, &Path)>) -> String {
if let Some(text) = value.as_str() {
return escape_markdown_inline_text(text);
}
if let Some(array) = value.as_array() {
return array
.iter()
.map(inline_node_to_markdown)
.map(|node| inline_node_to_markdown(node, local_file_context))
.collect::<Vec<_>>()
.join("");
}
if let Some(object) = value.as_object() {
if let Some(text) = object.get("text").and_then(Value::as_str) {
return markdown_text_with_styles(text, &inline_styles_from_object(object));
return markdown_text_with_styles(
text,
&inline_styles_from_object(object),
local_file_context,
);
}
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
return inline_nodes_to_markdown(content);
return inline_nodes_to_markdown(content, local_file_context);
}
}
String::new()
}
fn inline_node_to_markdown(node: &Value) -> String {
fn inline_node_to_markdown(node: &Value, local_file_context: Option<(&Path, &Path)>) -> String {
if let Some(text) = node.as_str() {
return escape_markdown_inline_text(text);
}
@@ -7747,10 +7850,14 @@ fn inline_node_to_markdown(node: &Value) -> String {
.and_then(Value::as_str)
.unwrap_or_default();
if !text.is_empty() {
return markdown_text_with_styles(text, &inline_styles_from_object(object));
return markdown_text_with_styles(
text,
&inline_styles_from_object(object),
local_file_context,
);
}
if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) {
return inline_nodes_to_markdown(content);
return inline_nodes_to_markdown(content, local_file_context);
}
}
String::new()
@@ -7802,7 +7909,11 @@ fn inline_styles_from_object(object: &Map<String, Value>) -> Value {
Value::Object(styles)
}
fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
fn markdown_text_with_styles(
text: &str,
styles: &Value,
local_file_context: Option<(&Path, &Path)>,
) -> String {
let mut value = escape_markdown_inline_text(text);
let link = styles
.get("link")
@@ -7838,6 +7949,8 @@ fn markdown_text_with_styles(text: &str, styles: &Value) -> String {
value = format!("~~{value}~~");
}
if let Some(href) = link {
let href =
rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href);
value = format!("[{}]({href})", value.replace(']', r"\]"));
}
value
@@ -7995,14 +8108,14 @@ mod tests {
initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
open_local_file, record_shared_cache, record_sync_pending_change,
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
SyncConflictReportRequest, SyncPendingChangeRequest,
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
};
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
@@ -8010,7 +8123,7 @@ mod tests {
use axum::http::{HeaderMap, Method, StatusCode};
use axum::Json;
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::Value;
use serde_json::{json, Value};
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
@@ -8452,6 +8565,193 @@ fn main() {}
assert_eq!(media["props"]["sourcePath"], "attachments/spec.pdf");
}
#[test]
fn local_markdown_page_aggregate_preserves_uploaded_markdown_assets_as_media_blocks() {
let root = temp_root("mnote-local-uploaded-md-assets-media");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
let root_uri = format!("file://{}", root.display());
let document_id = "local-md:Page~2FPage.md";
let first = write_local_markdown_asset(
&root_uri,
document_id,
"attachment",
LocalUploadFile {
name: "notes.md".to_string(),
content_type: "text/markdown".to_string(),
bytes: b"# Notes one\n".to_vec(),
},
)
.expect("upload first md asset");
let second = write_local_markdown_asset(
&root_uri,
document_id,
"attachment",
LocalUploadFile {
name: "notes.md".to_string(),
content_type: "text/markdown".to_string(),
bytes: b"# Notes two\n".to_vec(),
},
)
.expect("upload second md asset");
assert_eq!(first["sourcePath"], "notes.md");
assert_eq!(second["sourcePath"], "notes-1.md");
save_local_markdown_page(
&root_uri,
document_id,
None,
&json!([
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Page"}]},
{"type":"media","props":{"name":"notes.md","sourcePath":"notes.md"}},
{"type":"media","props":{"name":"notes-1.md","sourcePath":"notes-1.md"}}
]),
)
.expect("save uploaded md links");
let aggregate =
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
let media_paths = aggregate
.body
.content
.as_array()
.expect("blocks")
.iter()
.filter(|block| block["type"].as_str() == Some("media"))
.map(|block| {
block["props"]["sourcePath"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect::<Vec<_>>();
assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() {
let root = temp_root("mnote-local-uploaded-md-inline-link");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
let root_uri = format!("file://{}", root.display());
let document_id = "local-md:Page~2FPage.md";
let asset = write_local_markdown_asset(
&root_uri,
document_id,
"attachment",
LocalUploadFile {
name: "notes.md".to_string(),
content_type: "text/markdown".to_string(),
bytes: b"# Notes\n".to_vec(),
},
)
.expect("upload md asset");
let href = format!(
"http://127.0.0.1:3000/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md",
root_uri
);
save_local_markdown_page(
&root_uri,
document_id,
None,
&json!([
{
"type": "paragraph",
"content": [{
"type": "text",
"text": "notes.md",
"marks": [{
"type": "link",
"attrs": {
"href": href,
"class": "mnote-uploaded-attachment"
}
}]
}]
}
]),
)
.expect("save inline attachment link");
assert_eq!(asset["sourcePath"], "notes.md");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[notes.md](notes.md)"));
assert!(!saved.contains("/api/local-folder/files/open"));
let aggregate =
resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate");
let media = aggregate
.body
.content
.as_array()
.expect("blocks")
.iter()
.find(|block| block["type"].as_str() == Some("media"))
.expect("uploaded md inline link should reload as media block");
assert_eq!(media["props"]["sourcePath"], "notes.md");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() {
let root = temp_root("mnote-local-uploaded-md-relative-open-link");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
let root_uri = format!("file://{}", root.display());
let document_id = "local-md:Page~2FPage.md";
write_local_markdown_asset(
&root_uri,
document_id,
"attachment",
LocalUploadFile {
name: "notes.md".to_string(),
content_type: "text/markdown".to_string(),
bytes: b"# Notes\n".to_vec(),
},
)
.expect("upload md asset");
let href = format!(
"/api/local-folder/files/open?rootUri={}&path=Page%2Fnotes.md",
root_uri
);
save_local_markdown_page(
&root_uri,
document_id,
None,
&json!([
{
"type": "paragraph",
"content": [{
"type": "text",
"text": "notes.md",
"marks": [{
"type": "link",
"attrs": {
"href": href,
"class": "mnote-uploaded-attachment-row"
}
}]
}]
}
]),
)
.expect("save relative open url");
let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md");
assert!(saved.contains("[notes.md](notes.md)"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() {
let root = temp_root("mnote-local-markdown-save-basic-blocks");
@@ -9382,6 +9682,16 @@ fn main() {}
assert_eq!(listed["grants"][0]["id"], grant_id);
assert_eq!(listed["grants"][0]["permission"], "write");
let (_, Json(target_listed)) = get_user_access_policy(
State(state.clone()),
Extension(request_context("user_target", "user")),
)
.await
.expect("target can list incoming directory grants");
assert_eq!(target_listed["controlPlane"], "sqlite");
assert_eq!(target_listed["grants"][0]["id"], grant_id);
assert_eq!(target_listed["grants"][0]["userId"], "user_target");
let target_delete_error = delete_user_access_grant(
State(state.clone()),
Extension(request_context("user_target", "user")),
@@ -9426,6 +9736,44 @@ fn main() {}
let _ = std::fs::remove_dir_all(&outside_root);
}
#[tokio::test]
async fn user_access_policy_cannot_revoke_default_workspace_auto_grant() {
let _guard = env_lock().lock().expect("env lock");
let state = test_state();
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("shujuan".into()),
email: Some("shujuan@example.com".into()),
username: "shujuan".into(),
display_name: "shujuan".into(),
role: None,
password_hash: None,
})
.expect("upsert user");
state
.control_plane()
.ensure_default_workspace("shujuan")
.expect("default workspace");
let grant_id = state
.control_plane()
.list_directory_grants()
.expect("list grants")
.into_iter()
.find(|grant| grant.user_id == "shujuan" && grant.source == "auto")
.map(|grant| grant.id)
.expect("default auto grant");
let error = delete_user_access_grant(
State(state.clone()),
Extension(request_context("shujuan", "user")),
AxumPath(grant_id),
)
.await
.expect_err("default grant must be readonly");
assert_eq!(error.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_share_grant_allows_any_local_folder_with_minimal_fields() {
let _guard = env_lock().lock().expect("env lock");
@@ -10012,6 +10360,115 @@ fn main() {}
assert!(markdown.contains("资源正文"));
}
#[tokio::test]
async fn local_resource_read_preserves_uploaded_markdown_attachment_links() {
let root = temp_root("mnote-local-resource-read-uploaded-md-links");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page dir");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write page");
let root_uri = format!("file://{}", root.display());
let document_id = "local-md:Page~2FPage.md";
let resource = write_local_markdown_asset(
&root_uri,
document_id,
"attachment",
LocalUploadFile {
name: "resource.md".to_string(),
content_type: "text/markdown".to_string(),
bytes: b"# Resource\n".to_vec(),
},
)
.expect("upload resource markdown");
let first = write_local_markdown_asset(
&root_uri,
document_id,
"attachment",
LocalUploadFile {
name: "notes.md".to_string(),
content_type: "text/markdown".to_string(),
bytes: b"# Notes one\n".to_vec(),
},
)
.expect("upload first linked markdown");
let second = write_local_markdown_asset(
&root_uri,
document_id,
"attachment",
LocalUploadFile {
name: "notes.md".to_string(),
content_type: "text/markdown".to_string(),
bytes: b"# Notes two\n".to_vec(),
},
)
.expect("upload second linked markdown");
assert_eq!(resource["sourcePath"], "resource.md");
assert_eq!(first["sourcePath"], "notes.md");
assert_eq!(second["sourcePath"], "notes-1.md");
std::fs::write(
root.join("Page").join("resource.md"),
"# Resource\n\n[notes.md](notes.md)\n\n[notes-1.md](notes-1.md)\n",
)
.expect("write resource links");
let state = test_state();
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("resource_reader".into()),
email: Some("resource-reader@example.com".into()),
username: "resource_reader".into(),
display_name: "resource_reader".into(),
role: None,
password_hash: None,
})
.expect("upsert resource reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "resource_reader".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
.canonicalize()
.expect("canonical root")
.display()
.to_string(),
permission: "read".into(),
recursive: true,
capabilities: vec![],
source: "test".into(),
created_by: None,
})
.expect("grant read");
let context = request_context("resource_reader", "user");
let (_, _, payload) = read_local_resource(
State(state),
Extension(context),
Query(LocalResourceReadQuery {
root_uri: root_uri.clone(),
path: "Page/resource.md".into(),
}),
)
.await
.expect("read resource markdown");
let media_paths = payload["result"]["content"]
.as_array()
.expect("content blocks")
.iter()
.filter(|block| block["type"].as_str() == Some("media"))
.map(|block| {
block["props"]["sourcePath"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect::<Vec<_>>();
assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_delete_folder_moves_directory_to_trash() {
let root = temp_root("mnote-local-delete-folder");
@@ -10383,6 +10840,64 @@ fn main() {}
let _ = std::fs::remove_dir_all(&policy_root);
}
#[tokio::test]
async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root");
let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config");
let policy_file = policy_root.join("missing-access-policy.json");
let state = test_state();
let canonical_root = root.canonicalize().expect("canonical root");
let canonical_root_path = canonical_root.display().to_string();
let root_uri = format!("file://{canonical_root_path}");
std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("legacy_sqlite_reader".into()),
email: Some("legacy-sqlite-reader@example.com".into()),
username: "legacy_sqlite_reader".into(),
display_name: "legacy_sqlite_reader".into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "legacy_sqlite_reader".into(),
workspace_id: None,
root_uri: canonical_root_path.clone(),
root_path: canonical_root_path,
permission: "read".into(),
recursive: true,
capabilities: vec![],
source: "legacy-test".into(),
created_by: None,
})
.expect("grant legacy sqlite read");
let context = request_context("legacy_sqlite_reader", "user");
let (_, _, bytes) = open_local_file(
State(state),
Extension(context),
Query(LocalFileOpenQuery {
root_uri,
path: "README.txt".into(),
download: None,
}),
)
.await
.expect("legacy sqlite read grant can open local file");
assert_eq!(bytes, b"hello legacy sqlite");
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&policy_root);
}
#[tokio::test]
async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() {
let _guard = env_lock().lock().expect("env lock");
@@ -1,6 +1,7 @@
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{parse_document, Arena, Options};
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
@@ -134,7 +135,14 @@ fn find_first_h1(body: &str) -> Option<String> {
}
pub fn markdown_to_blocks(markdown: &str) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown))
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
}
pub fn markdown_to_blocks_with_attachment_paths(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
}
fn markdown_options() -> Options<'static> {
@@ -149,6 +157,13 @@ fn markdown_options() -> Options<'static> {
}
pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> {
parse_markdown_attachment_link_with_paths(trimmed, &BTreeSet::new())
}
fn parse_markdown_attachment_link_with_paths(
trimmed: &str,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let value = trimmed
.strip_prefix('!')
.unwrap_or(trimmed)
@@ -170,7 +185,9 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
}
let target_path = std::path::Path::new(target);
let extension = target_path.extension().and_then(|value| value.to_str())?;
if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") {
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
&& !attachment_paths.contains(target)
{
return None;
}
let fallback_name = target_path
@@ -186,20 +203,27 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
Some((name.to_string(), target.to_string()))
}
fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument {
fn parse_markdown_ast_document(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> MarkdownAstDocument {
let arena = Arena::new();
let options = markdown_options();
let root = parse_document(&arena, markdown, &options);
let mut blocks = Vec::new();
for node in root.children() {
append_ast_block(node, &mut blocks);
append_ast_block(node, &mut blocks, attachment_paths);
}
MarkdownAstDocument { blocks }
}
fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_block<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
match node.data.borrow().value.clone() {
NodeValue::Paragraph => append_ast_paragraph(node, blocks),
NodeValue::Paragraph => append_ast_paragraph(node, blocks, attachment_paths),
NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading {
level: heading.level,
content: collect_inline_children(node),
@@ -212,7 +236,12 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}),
NodeValue::List(list) => {
for item in node.children() {
append_ast_list_item(item, list.list_type == ListType::Ordered, blocks);
append_ast_list_item(
item,
list.list_type == ListType::Ordered,
blocks,
attachment_paths,
);
}
}
NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)),
@@ -233,7 +262,11 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}
}
fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_paragraph<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
if let Some((alt, source_path)) = paragraph_image(node) {
blocks.push(MarkdownBlock::Image { alt, source_path });
return;
@@ -242,11 +275,13 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
blocks.push(MarkdownBlock::Mindmap { name, source_path });
return;
}
if let Some((name, source_path)) = paragraph_attachment_media(node) {
if let Some((name, source_path)) = paragraph_attachment_media(node, attachment_paths) {
blocks.push(MarkdownBlock::Media { name, source_path });
return;
}
if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) {
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
blocks.push(MarkdownBlock::Media { name, source_path });
let content = merge_adjacent_inline_nodes(remaining);
if !content.is_empty() {
@@ -257,17 +292,22 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node)));
}
fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_list_item<'a>(
node: &'a AstNode<'a>,
ordered: bool,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
let (is_task, checked) = match node.data.borrow().value.clone() {
NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()),
NodeValue::Item(_) => (false, false),
_ => return append_ast_block(node, blocks),
_ => return append_ast_block(node, blocks, attachment_paths),
};
let mut content = Vec::new();
for child in node.children() {
match child.data.borrow().value.clone() {
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
_ => append_ast_block(child, blocks),
_ => append_ast_block(child, blocks, attachment_paths),
}
}
@@ -404,13 +444,16 @@ fn merge_adjacent_inline_nodes(nodes: Vec<MarkdownInline>) -> Vec<MarkdownInline
merged
}
fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn paragraph_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_attachment_media(first)
link_attachment_media(first, attachment_paths)
}
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
@@ -437,10 +480,11 @@ fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn paragraph_leading_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String, Vec<MarkdownInline>)> {
let mut children = node.children();
let first = children.next()?;
let (name, source_path) = link_attachment_media(first)?;
let (name, source_path) = link_attachment_media(first, attachment_paths)?;
let second = children.next()?;
if !matches!(
second.data.borrow().value,
@@ -455,11 +499,17 @@ fn paragraph_leading_attachment_media<'a>(
Some((name, source_path, remaining))
}
fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn link_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
parse_markdown_attachment_link_with_paths(
&format!("[{}]({})", collect_plain_text(node), link.url),
attachment_paths,
)
}
fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
@@ -3,8 +3,8 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use crate::routes::local_folder_source::{
ensure_local_workspace_access, ensure_local_workspace_read_access, read_local_mindmap_data,
write_local_mindmap_data,
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
read_local_mindmap_data, write_local_mindmap_data,
};
use crate::routes::query_support::{
execute_runtime_query_against_data, execute_runtime_query_via_convex,
@@ -151,7 +151,7 @@ pub async fn get_mindmap(
}
if is_local_folder_source(params.source_kind.as_deref()) {
let root_uri = local_root_uri(params.root_uri.as_deref(), None)?;
ensure_local_workspace_read_access(&context, root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let data = read_local_mindmap_data(root_uri, document_id, mindmap_id)
.map_err(|error| error.with_context(&context))?;
@@ -228,7 +228,7 @@ pub async fn apply_mindmap_command(
.or(params.source_kind.as_deref());
if is_local_folder_source(local_source_kind) {
let root_uri = local_root_uri(params.root_uri.as_deref(), body.root_uri.as_deref())?;
ensure_local_workspace_access(&context, root_uri)
ensure_local_workspace_write_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
if command_name == Some("mindmap.command.apply") {
let current = read_local_mindmap_data(root_uri, document_id, mindmap_id)
@@ -1268,7 +1268,9 @@ mod tests {
std::env::temp_dir().join(format!("mnote-local-mindmap-trash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write md");
let markdown_path = root.join("Page").join("Page.md");
let original_markdown = "# Page\n\n[map](map.mindmap.json)\n";
std::fs::write(&markdown_path, original_markdown).expect("write md");
std::fs::write(
root.join("Page").join("map.mindmap.json"),
r#"{"data":{"uid":"root","text":"KMIND"},"children":[]}"#,
@@ -1313,6 +1315,11 @@ mod tests {
.join("trash")
.join("map.mindmap.json")
.exists());
assert_eq!(
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
original_markdown,
"mindmap archive 只移动资源文件,不应改写正文引用"
);
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index");
assert!(trash_index.contains("local-file:Page/map.mindmap.json"));
@@ -1354,6 +1361,11 @@ mod tests {
"tree.resource.restore"
);
assert!(root.join("Page").join("map.mindmap.json").exists());
assert_eq!(
std::fs::read_to_string(&markdown_path).expect("read markdown after restore"),
original_markdown,
"mindmap restore 只恢复资源文件,不应改写正文引用"
);
let delete_again = app()
.oneshot(
+15 -4
View File
@@ -179,7 +179,9 @@ pub async fn documents(
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
local_search_index::query_local_search_index(
&root_path,
@@ -227,6 +229,7 @@ pub async fn documents(
}
pub async fn refresh_local_index(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<LocalSearchIndexRefreshRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -241,7 +244,9 @@ pub async fn refresh_local_index(
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let refreshed = local_search_index::refresh_local_search_index(
&root_path,
@@ -268,6 +273,7 @@ pub async fn refresh_local_index(
}
pub async fn local_index_backlinks(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalSearchIndexQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -294,7 +300,9 @@ pub async fn local_index_backlinks(
)
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let backlinks = local_search_index::query_local_backlinks(
&root_path,
@@ -322,6 +330,7 @@ pub async fn local_index_backlinks(
}
pub async fn local_index_tags(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalSearchIndexQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -336,7 +345,9 @@ pub async fn local_index_tags(
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
let mut headers = HeaderMap::new();
+165 -3
View File
@@ -78,10 +78,10 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
return SessionResponse {
ok: true,
owner: "mnote-web",
user_id: resolved.user.id,
user_id: resolved.user.id.clone(),
email: resolved.user.email.unwrap_or_default(),
name: resolved.user.display_name,
actor_type: "user".to_string(),
actor_type: effective_actor_type_for_user(&resolved.user.id, &resolved.user.role),
auth_mode: "sqliteSession",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
@@ -109,7 +109,7 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
state.config().dev_user_id.clone()
};
let actor_type = if has_forwarded_actor {
context.auth.actor_type
effective_actor_type_for_user(actor_id, &context.auth.actor_type)
} else {
"devFallback".to_string()
};
@@ -139,6 +139,19 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
}
}
fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String {
let role = stored_role.trim();
let fallback_role = if role.is_empty() { "user" } else { role };
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
user_id,
fallback_role,
) {
"admin".to_string()
} else {
fallback_role.to_string()
}
}
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
let token = context
.auth
@@ -376,6 +389,155 @@ mod tests {
assert_eq!(payload["authMode"], "sqliteSession");
}
#[tokio::test]
async fn session_returns_admin_for_sqlite_admin_user() {
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("liaibo".into()),
email: Some("liaibo@yeah.net".into()),
username: "liaibo".into(),
display_name: "liaibo".into(),
role: Some("admin".into()),
password_hash: None,
})
.expect("upsert admin user");
state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "liaibo".into(),
token_hash: session_token_hash("raw-admin-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create admin session");
let response = build_app(state)
.oneshot(
Request::builder()
.uri("/api/auth/session")
.header("cookie", "mnote_session=raw-admin-session-token")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
}
#[tokio::test]
async fn session_returns_admin_for_local_access_policy_admin() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
"mnote-session-access-policy-admin-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&policy_root);
std::fs::create_dir_all(&policy_root).expect("create policy root");
let policy_file = policy_root.join("access-policy.json");
std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#)
.expect("write access policy");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("liaibo".into()),
email: Some("liaibo@yeah.net".into()),
username: "liaibo".into(),
display_name: "liaibo".into(),
role: None,
password_hash: None,
})
.expect("upsert policy admin user");
state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "liaibo".into(),
token_hash: session_token_hash("raw-policy-admin-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create policy admin session");
let response = build_app(state)
.oneshot(
Request::builder()
.uri("/api/auth/session")
.header("cookie", "mnote_session=raw-policy-admin-session-token")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
let _ = std::fs::remove_dir_all(&policy_root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
}
#[tokio::test]
async fn legacy_whoami_alias_prefers_forwarded_actor_identity() {
let response = app()
+177 -5
View File
@@ -6,10 +6,10 @@ use crate::routes::command_support::{
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
};
use crate::routes::local_folder_source::{
ensure_local_workspace_access, ensure_local_workspace_read_access,
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_workspace_id_from_root_uri,
local_workspace_id_from_root_uri, LocalAccessMode,
};
use crate::routes::query_support::{
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
@@ -6084,10 +6084,11 @@ fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json<V
}
pub async fn local_folder_watch(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderWatchQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
ensure_local_workspace_read_access(&context, &query.root_uri)
ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let revision = local_folder_watch_revision(&query.root_uri)?;
Ok(json_response(
@@ -6185,7 +6186,7 @@ pub async fn tree_shell(
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&effective_context, root_uri)
ensure_local_workspace_read_access_with_state(&state, &effective_context, root_uri)
.map_err(|error| error.with_context(&effective_context))?;
(
local_workspace_id_from_root_uri(root_uri)?,
@@ -6808,7 +6809,12 @@ pub async fn tree_command(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
ensure_local_workspace_access(&context, root_uri)
ensure_local_workspace_access_with_state(
&state,
&context,
root_uri,
LocalAccessMode::Write,
)
.map_err(|error| error.with_context(&context))?;
let execution = execute_local_tree_command_with_sort(
root_uri,
@@ -6932,6 +6938,7 @@ mod tests {
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -7223,6 +7230,88 @@ mod tests {
assert!(html.contains("data-document-id=\"local-md:README.md\""));
}
#[tokio::test]
async fn tree_shell_local_folder_allows_sqlite_directory_read_grant() {
let root = std::env::temp_dir().join(format!(
"mnote-local-folder-sqlite-read-grant-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(root.join("README.md"), "# Shared\n").expect("write local md");
let root_uri = format!("file://{}", root.display());
init_local_workspace(&root, "owner_user");
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(user_id.into()),
email: Some(format!("{user_id}@example.com")),
username: user_id.into(),
display_name: user_id.into(),
role: role.map(str::to_string),
password_hash: None,
})
.expect("upsert grant user");
}
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "shujuan".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
.canonicalize()
.expect("canonical root")
.display()
.to_string(),
permission: "read".into(),
recursive: true,
capabilities: vec![],
source: "admin".into(),
created_by: Some("liaibo".into()),
})
.expect("grant sqlite read");
let response = build_app(state)
.oneshot(
Request::builder()
.uri(format!(
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
))
.header("x-mnote-actor-id", "shujuan")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn local_folder_tree_shell_does_not_reload_page_for_refresh() {
let root = std::env::temp_dir().join(format!(
@@ -7778,6 +7867,89 @@ mod tests {
assert_eq!(payload["code"], "local_workspace_access_denied");
}
#[tokio::test]
async fn tree_command_local_folder_allows_sqlite_directory_write_grant() {
let root = std::env::temp_dir().join(format!(
"mnote-local-tree-sqlite-write-grant-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
init_local_workspace(&root, "owner_user");
let root_uri = format!("file://{}", root.display());
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: true,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(user_id.into()),
email: Some(format!("{user_id}@example.com")),
username: user_id.into(),
display_name: user_id.into(),
role: role.map(str::to_string),
password_hash: None,
})
.expect("upsert grant user");
}
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "shujuan".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
.canonicalize()
.expect("canonical root")
.display()
.to_string(),
permission: "write".into(),
recursive: true,
capabilities: vec![],
source: "admin".into(),
created_by: Some("liaibo".into()),
})
.expect("grant sqlite write");
let response = build_app(state)
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "shujuan")
.header("x-mnote-actor-type", "user")
.body(Body::from(format!(
r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"授权新页面"}}"#
)))
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn tree_shell_embeds_renderer_input_contract() {
let filetree_response = app()
File diff suppressed because it is too large Load Diff
+73 -5
View File
@@ -111,6 +111,7 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]');
var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]');
var shareGrantsList = root.querySelector('[data-testid="mnote-admin-share-grants-list"]');
var currentActorId = document.body && document.body.getAttribute ? String(document.body.getAttribute('data-mnote-actor-id') || '').trim() : '';
var pageConfig = (function () {
var node = root.querySelector('#__MNOTE_ACCESS_POLICY_PAGE__');
try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; }
@@ -147,6 +148,48 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
return suffix ? base + suffix : base;
}
function pathToFileRootUri(value) {
var trimmed = String(value || '').trim();
if (!trimmed) return '';
if (/^file:\/\//i.test(trimmed)) return trimmed;
if (trimmed.charAt(0) !== '/') return '';
return 'file://' + trimmed.split('/').map(function(part, index) {
return index === 0 ? '' : encodeURIComponent(part);
}).join('/');
}
function localFolderOpenHref(grant) {
var rootUri = String(grant.rootUri || '').trim();
if (rootUri && !/^file:\/\//i.test(rootUri)) rootUri = '';
if (!rootUri) {
var rootPath = String(grant.rootPath || '').trim();
if (rootPath) rootUri = pathToFileRootUri(rootPath);
}
if (!rootUri) {
rootUri = pathToFileRootUri(String(grant.rootUri || '').trim());
}
if (!rootUri) return '';
var url = new URL('/', window.location.origin);
url.searchParams.set('treeView', 'filetree');
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
return url.pathname + url.search;
}
function isDefaultWorkspaceAutoGrant(grant) {
var source = String(grant && grant.source || '').trim();
var workspaceId = String(grant && grant.workspaceId || '').trim();
var rootUri = String(grant && grant.rootUri || '').trim();
var permission = String(grant && grant.permission || '').trim();
var createdBy = String(grant && (grant.createdBy || grant.ownerUserId) || '').trim();
var targetUser = String(grant && (grant.userId || grant.targetUserId) || '').trim();
return source === 'auto'
&& workspaceId
&& permission === 'write'
&& createdBy === targetUser
&& /^local:\/\/users\/.+\/workspaces\/my-space$/.test(rootUri);
}
function renderShareGrants(payload) {
if (!shareGrantsList) return;
var grants = readDirectoryGrants(payload);
@@ -156,12 +199,20 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
}
shareGrantsList.innerHTML = grants.map(function(grant) {
var active = grant.active === false || grant.status === 'revoked' ? '' : '';
var revokeButton = grant.active === false ? '' :
var openHref = localFolderOpenHref(grant);
var rootLabel = escapeHtml(grant.rootPath || grant.rootUri || '');
var createdBy = String(grant.createdBy || grant.ownerUserId || '').trim();
var systemOwned = isDefaultWorkspaceAutoGrant(grant);
var canRevoke = !systemOwned && (isAdmin || (createdBy && currentActorId && createdBy === currentActorId));
var rootNode = openHref
? '<a class="mnote-admin-policy-root-link" data-testid="mnote-admin-open-granted-root" href="' + escapeHtml(openHref) + '">' + rootLabel + '</a>'
: '<strong>' + rootLabel + '</strong>';
var revokeButton = grant.active === false || !canRevoke ? '' :
'<button type="button" data-admin-action="revoke-access-grant" data-grant-id="' + escapeHtml(grant.id || '') + '"></button>';
return '<article class="mnote-admin-policy-row">' +
'<div><strong>' + escapeHtml(grant.rootPath || grant.rootUri || '') + '</strong><span> ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' +
'<div>' + rootNode + '<span> ' + escapeHtml(grant.targetUserId || grant.userId || '') + '</span></div>' +
'<div>' + renderBadge(grant.permission) + renderBadge(active) + '</div>' +
'<div><span> ' + escapeHtml(grant.createdBy || grant.ownerUserId || '') + '</span><span> ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' +
'<div><span> ' + escapeHtml(createdBy) + '</span><span> ID ' + escapeHtml(grant.id || '') + '</span>' + revokeButton + '</div>' +
'</article>';
}).join('');
}
@@ -203,7 +254,8 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) {
event.preventDefault();
var values = shareGrantFormValues(event.currentTarget);
var form = event.currentTarget;
var values = shareGrantFormValues(form);
setText(createShareGrantResult, '...');
requestJson(accessPolicyUrl('/grants'), {
method: 'POST',
@@ -211,7 +263,7 @@ pub(crate) const ADMIN_POLICY_SCRIPT: &str = r#"
}).then(function (payload) {
setText(createShareGrantResult, payload);
setText(shareGrantsMessage, '');
event.currentTarget.reset();
form.reset();
return refreshShareGrants();
}).catch(function (error) {
setText(createShareGrantResult, { ok: false, error: error.message || '' });
@@ -292,4 +344,20 @@ mod tests {
assert!(!html.contains("capabilities"));
assert!(!html.contains("mnote-admin-validate-root-submit"));
}
#[test]
fn admin_policy_script_keeps_form_reference_for_async_reset() {
assert!(ADMIN_POLICY_SCRIPT.contains("var form = event.currentTarget;"));
assert!(ADMIN_POLICY_SCRIPT.contains("form.reset();"));
assert!(!ADMIN_POLICY_SCRIPT.contains("event.currentTarget.reset();"));
}
#[test]
fn admin_policy_script_links_grants_to_local_folder_entry() {
assert!(ADMIN_POLICY_SCRIPT.contains("function localFolderOpenHref(grant)"));
assert!(ADMIN_POLICY_SCRIPT.contains("sourceKind', 'local_folder'"));
assert!(ADMIN_POLICY_SCRIPT.contains("data-testid=\"mnote-admin-open-granted-root\""));
assert!(ADMIN_POLICY_SCRIPT.contains("function isDefaultWorkspaceAutoGrant(grant)"));
assert!(ADMIN_POLICY_SCRIPT.contains("var canRevoke = !systemOwned &&"));
}
}

Some files were not shown because too many files have changed in this diff Show More