feat(control-plane): add libSQL Turso backend

This commit is contained in:
Agent Board
2026-07-03 13:24:20 +08:00
parent ee6612028d
commit a75b3d11f9
44 changed files with 11070 additions and 666 deletions
+7 -4
View File
@@ -5,7 +5,7 @@
- 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first``Mindmap-first` 为准。
- 当前已经进入 local-first MVP 后阶段:最小可用闭环已建立,后续重点是底座统一、compat 瘦身和产品化闭环,不再把新增能力建立在旧 Convex / Next / BlockNote 主链上。
- 当前产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub/WeKnora AI + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`
- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。Rust SQLite `control-plane` 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;`Convex` / 服务端只保留为历史迁移源、显式 cloud source、compat 和 sync replica 边界,不再作为新增能力的默认正文、附件、AI 会话全文主存储或默认控制面。旧 ACP/Hermes runtime session 只保留为 legacy/debug/兼容边界。
- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。Turso/libSQL `control-plane`SQLite fallback是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;`Convex` / 服务端只保留为历史迁移源、显式 cloud source、compat 和 sync replica 边界,不再作为新增能力的默认正文、附件、AI 会话全文主存储或默认控制面。旧 ACP/Hermes runtime session 只保留为 legacy/debug/兼容边界。
- `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。
- `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat``fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree``/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。
- 前端 runtime 已完成第一轮模块级拆分:`layout.rs` / `tree.rs` / `web_shell.rs` 不再是大型内嵌 JS 的主要定位入口。查 Sidebar、FileTree、tree shell、document adapter 等浏览器功能时,默认先看 `rust/crates/mnote-web/browser/*.js`;查 tiptap island 行为时,默认先看 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`,再回到根 `lib.rs` / Rust route。
@@ -44,7 +44,7 @@
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。
- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/`
`convex/` functions 源码已软删除到这里,只作为历史审计 / 迁移对照素材;不再作为 active deploy source。默认控制面已由 Rust SQLite `control-plane` 承接。
`convex/` functions 源码已软删除到这里,只作为历史审计 / 迁移对照素材;不再作为 active deploy source。默认控制面已由 Turso/libSQL `control-plane`SQLite fallback承接。
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
Kernel 类型、projection 协议、树/图核心术语先看这里。
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
@@ -149,18 +149,21 @@
- 前端检查:Rust 测试 `cargo test -p mnote-web`Rust SSR 与 API),旧 React 前端已移入 recycle
- 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000`
- Convex 自托管:仅在显式 cloud / compat / sync replica 或历史导出场景参考 `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/infra/convex/README.md`
- Control-plane 后端选择:默认 `MNOTE_CONTROL_PLANE_BACKEND=sqlite`(本地 SQLite fallback);开发推荐 `MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica` 并配置 `MNOTE_TURSO_DATABASE_URL``MNOTE_TURSO_AUTH_TOKEN`
- Turso dev 数据库:[Turso CLI](https://docs.turso.tech/cli/installation) `turso db create mnote-dev`,创建 token 后设置环境变量即可。
## Smoke 基线
- 当前 smoke 分类与默认基线以 `scripts/TESTING_REFERENCE.md` 为准。
- 默认浏览器验证入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + SQLite control-plane auth`
- 默认浏览器验证入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + Turso/libSQL control-plane auth`
- **脚本纪律**:所有 smoke 和测试脚本不得直接 `sqlite3` CLI 写 control-plane DB;需要 seed 数据时必须走 Rust API、测试 seed endpoint、`scripts/lib/control-plane-dev-seed.js``scripts/lib/control-plane-test-env.js` helper。直写脚本必须标注理由和退出条件。
- 退役 Convex / Next / 3104 / BlockNote 路径的 smoke 不作为默认回归基线;只有显式 legacy/cloud/compat 任务才运行对应脚本。
## 默认测试账号
- 后续网页测试、浏览和 smoke 默认使用当前 SQLite control-plane Auth 测试账号:邮箱 `mnote.e2e@example.com`,密码 `MnoteE2E123!`,用户名 `mnote-e2e`
- 优先从 `http://localhost:3000/auth` 点击“测试账号快速登录”进入;若需手动注册,也必须注册同一组账号,不要改用 `MNOTE_DEV_AUTH=1` 跳过真实 auth。
- 需要核验登录是否真实生效时,优先检查当前 `mnote_session` 是否能解析到 SQLite control-plane 用户会话,避免把 `devFallback` 当成真实登录;Convex JWT 只属于历史 / cloud compat 边界。
- 需要核验登录是否真实生效时,优先检查当前 `mnote_session` 是否能解析到 control-plane 用户会话(当前默认 `sqlite` fallbackTurso 后端切换后检查 Turso 会话),避免把 `devFallback` 当成真实登录;Convex JWT 只属于历史 / cloud compat 边界。
## 前端测试方法
+57 -4
View File
@@ -2,7 +2,7 @@
> 更新时间:2026-06-07
>
> 2026-05-22 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Rust SQLite control-plane 承接 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 等默认控制面。旧 ACP/Hermes runtime session 只保留为迁移兼容层。Convex 仅保留为历史迁移源、显式 cloud source / compat / sync replica 边界;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,不再作为 active deploy source。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 与 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。
> 2026-05-22 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Turso/libSQL control-planeSQLite fallback承接 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 等默认控制面。旧 ACP/Hermes runtime session 只保留为迁移兼容层。Convex 仅保留为历史迁移源、显式 cloud source / compat / sync replica 边界;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,不再作为 active deploy source。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 与 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。
>
> **当前阶段:local-first MVP 后阶段。** 3000 下文档页、Sidebar/File Tree/Page Tree 三层树模型、tree command`tree.*` preferred)、tree realtime WS 主链 / SSE fallback、本地 Markdown 上传、上传下载、AI 会话本地化与 VSCode-like agent 运行口径均已建立。剩余工作集中在 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、VSCode-like 冲突合并 UI、agent diff 审计、本地轻量搜索 / 分享 / 同步闭环,而不是继续扩张专用 page-ai fast-path 或 Convex 主存储链。
>
@@ -23,7 +23,7 @@
当前主线固定为:
1. `tree-first graph kernel` 是长期对象真相层
2. 本地文件夹是默认数据真相,Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 退居历史迁移源与显式 cloud / compat 边界
2. 本地文件夹是默认数据真相,Turso/libSQL + SQLite fallback control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 退居历史迁移源与显式 cloud / compat 边界
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` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留
@@ -32,7 +32,7 @@
一句话收口:
> **Rust 持有语义主导权,本地文件夹是默认数据真相,SQLite control-plane 持有默认控制面;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。**
> **Rust 持有语义主导权,本地文件夹是默认数据真相,Turso/libSQL + SQLite fallback control-plane 持有默认控制面;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。**
补充口径:
@@ -51,7 +51,7 @@
- `/mnt/Data1T/mnote/rust/crates/mnote-web/`
Rust Web route、kernel projection、documents/search/mindmap shell、OpenHub bridge、stream transport、compat,以及显式开启时的 debug shell;它是当前主 Web gateway / shell / transport owner。旧 Hermes bridge 命名仅作为 legacy route / tool facade 兼容层存在。
- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/`
`convex/` functions 源码已退役并软删除到这里;不再作为 active deploy source。历史 `schema.ts``aiSessions.ts`、Auth functions 仅用于审计 / 迁移对照,默认控制面已由 Rust SQLite control-plane 承接。
`convex/` functions 源码已退役并软删除到这里;不再作为 active deploy source。历史 `schema.ts``aiSessions.ts`、Auth functions 仅用于审计 / 迁移对照,默认控制面已由 Turso/libSQL control-planeSQLite fallback承接。
- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/`
已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。
- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/infra/convex/`
@@ -418,3 +418,56 @@ OnlyOffice 仍然是:
当前真正的卡点已经从“能不能跑”变成:
> **管理员目录授权、文件版本冲突合并、agent diff 审计、本地轻量搜索 / 分享 / 同步如何产品化;知识库 RAG 已转向 WeKnora / OpenHubPage Aggregate ClientState、projection / command / realtime 兼容链如何继续瘦身。**
## 10. Control-plane 后端架构与运行配置
### 10.1 后端架构
当前 control-plane 存储后端已从单一 `rusqlite` 抽象为 `ControlPlaneStore` trait,支持多后端切换:
```text
ControlPlaneStore trait
├── SqliteControlPlaneStore 本地 fallback / 回滚 / 测试(rusqlite
├── TursoControlPlaneStore 默认目标后端(Turso/libSQL remote
└── libsql-local 模式 无云纯本地 libSQL 模式
```
### 10.2 环境变量
```text
MNOTE_CONTROL_PLANE_BACKEND=sqlite|turso|turso-local-replica
MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
MNOTE_TURSO_DATABASE_URL=libsql://...
MNOTE_TURSO_AUTH_TOKEN=...
MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db
```
### 10.3 默认策略
- **开发初期**`sqlite` 为默认,`turso` 显式开启。
- **Turso ready 后**:本机开发默认 `turso-local-replica`CI 跑 `sqlite``turso` 双矩阵。
- **稳定后**:普通运行默认 `turso-local-replica`,保留 `sqlite` 作为离线和回滚模式。
### 10.4 未迁移数据(第一阶段不迁)
- 本地 `.md` 页面正文:仍是 local-first 正文真相。
- 本地附件、图片、mindmap、OnlyOffice 文件。
- `.mnote/index/evidence.sqlite`:已从当前主线退役。
- LightRAG 自身存储、WeKnora / OpenHub 自身存储。
- OpenHub 自身 SQLite 会话库(第二阶段单独评估)。
### 10.5 回滚策略
```text
MNOTE_CONTROL_PLANE_BACKEND=sqlite
MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
```
回滚前从 Turso 导出最新 control-plane dump,恢复后保留 Turso DB 至少 30 天用于审计。
### 10.6 测试与脚本纪律
- 所有 smoke 和脚本**不得直接 `sqlite3` CLI 写 production control-plane DB**。
- 需要 seed 数据时必须走 Rust API / 测试 seed endpoint / `scripts/lib/control-plane-dev-seed.js``scripts/lib/control-plane-test-env.js` helper。
- 禁止通过临时文件覆盖 `MNOTE_CONTROL_PLANE_DB_PATH` 绕过真实 store。
- 相关设计:`design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md`
+26 -3
View File
@@ -21,12 +21,13 @@
- tiptap 是 Markdown 的前端显示与交互层,不是 agent 的主工作面。
- OpenHub / native agent 默认应像 VSCode 中的 agent 一样,在授权目录白名单内用自身 patch / diff / 文件编辑能力修改文件。
- WeKnora / OpenHub 是当前默认知识库与 Page AI 融合方向;MNote 负责 source registry、权限、FileTree 灯号、dashboard/status UI 和 citation/open-reference 映射。LightRAG 只保留 legacy fallback / parse 兼容边界。
- Rust SQLite control-plane 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,也不再是默认控制面,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。旧 ACP/Hermes runtime session 属于迁移兼容层。
- Turso/libSQL + SQLite fallback control-plane 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,也不再是默认控制面,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。旧 ACP/Hermes runtime session 属于迁移兼容层。
上位设计已完成并迁入:
- [2-2 local-first workspace 与 Convex 控制面降级方案](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md)
- [2-8 Convex 替换为 Rust SQLite 控制面](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md)
- [2-9 Turso / libSQL 控制面切换](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md)
## 2. 当前分层
@@ -65,7 +66,7 @@
- `mnote.doc.fetch``mnote.doc.markdown_edit``mnote.doc.apply_block_ops``mnote.block.*``mnote.page.*` 保留为 cloud / remote agent / compat / 复杂结构辅助工具。
- `/api/page-ai/block-edit-workflow` 不再作为 local-first 普通正文编辑主路径。
- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl``ai-sessions/shared/<share-id>/*.jsonl`OpenHub scope / AI policy 默认落到 SQLite control-plane;旧 ACP/Hermes runtime session 仅为迁移兼容层;Convex 只作为显式 legacy/cloud metadata / audit / sync replica 边界。
- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl``ai-sessions/shared/<share-id>/*.jsonl`OpenHub scope / AI policy 默认落到 Turso/libSQL control-planeSQLite fallback;旧 ACP/Hermes runtime session 仅为迁移兼容层;Convex 只作为显式 legacy/cloud metadata / audit / sync replica 边界。
### 2.5 Knowledge RAG / WeKnora
@@ -75,6 +76,28 @@
- FileTree 的资料库灯号来自 source registry:绿灯已索引、黄灯正在索引或删除提交中、红灯失败 / 过期、无灯未索引或已移除。
- LightRAG 相关 dashboard、status bridge、`lightrag-source-registry` 和 MCP bridge 只保留为 legacy fallback;新增能力不得依赖 LightRAG 默认存在。
- Agent 只通过 `mnote.knowledge_rag.status/query/open_reference` 访问知识库;旧 LiteParse、OCR sidecar、`mnote.evidence.*` 与本地资料索引工具已退役为历史 / recycle 边界。
### 2.6 Control-plane 后端
当前 control-plane 存储后端已抽象为 `ControlPlaneStore` trait
- **SqliteControlPlaneStore**`rusqlite`):本地 fallback,开发/离线/回滚默认。
- **TursoControlPlaneStore**`libsql`):默认目标后端,支持 remote URL + token 或 local replica + sync。
- 启动时通过 `MNOTE_CONTROL_PLANE_BACKEND` 选择;`sqlite` 为当前默认,`turso-local-replica` 为开发优先推荐。
环境变量:
| 变量 | 用途 | 默认值 |
|------|------|--------|
| `MNOTE_CONTROL_PLANE_BACKEND` | 后端模式(sqlite/turso/turso-local-replica | `sqlite` |
| `MNOTE_CONTROL_PLANE_DB_PATH` | SQLite fallback 文件路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane.db` |
| `MNOTE_TURSO_DATABASE_URL` | Turso remote URL | — |
| `MNOTE_TURSO_AUTH_TOKEN` | Turso 认证 token | — |
| `MNOTE_TURSO_LOCAL_REPLICA_PATH` | Turso local replica 路径 | `{MNOTE_CONTROL_PLANE_DB_PATH}-replica.db` |
第一阶段**只迁移 control-plane 元数据**users、sessions、workspaces、directory grants、share links、
sync state、AI policy/runtime、audit),本地 `.md` / 附件 / mindmap / OnlyOffice 文件不迁入 Turso。
OpenHub 会话库后续作为单独阶段评估。
## 3. 已完成 MVP 收口
@@ -85,7 +108,7 @@
- 本地 AI 会话已区分 private / shared / cloud,并在 UI 上显示来源。
- Convex guard 已阻止 active 路径重新引入未标注的 Convex documents / media / aiSessions 默认主存储口径,并拦截根 `convex/` functions 源码回流默认控制面。
- Runtime 可维护收口已完成:`document-editor-adapter-runtime.js``sidebar-tree-runtime.js``tree-shell-runtime.js``leptos-tiptap-spike/src/lib.rs` 已拆到模块级,`mnote-web --lib``leptos-tiptap-spike --lib`、格式化、diff check 与 CodeGraph pending 0 已作为 `design/10-review/done/16-*` 的验收记录。
- 当前 smoke 分类与默认基线以 `scripts/TESTING_REFERENCE.md` 为准;默认入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + SQLite control-plane auth`
- 当前 smoke 分类与默认基线以 `scripts/TESTING_REFERENCE.md` 为准;默认入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + Turso/libSQL + SQLite fallback control-plane auth`
## 4. MVP 后阶段功能缺口
@@ -0,0 +1,720 @@
# 2-9 Turso / libSQL 控制面切换 checklist v1
> 创建时间:2026-07-02
>
> 当前状态:`process`
>
> 目标:在接受 Turso / libSQL Beta 与云服务风险的前提下,把 MNote 当前 Rust SQLite 控制面切换到 Turso / libSQL 后端,降低多个进程直接写同一个 SQLite 文件带来的损坏风险,并为后续云同步、审计、权限和 AI 控制面维护提供更稳的存储边界。
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.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`
---
## 1. 结论
当前可以开始转向 Turso,但迁移边界必须收紧:
> **第一阶段只切 MNote control-plane,不迁移 local-first 文件真相,不迁移页面正文、附件、mindmap、Office 文件,不迁移已经退役的 `evidence.sqlite`。**
Turso 的收益不在于“SQLite 语法换个驱动”本身,而在于把控制面写入统一收口到受控 store / API 后端,避免脚本、测试、进程和运行时同时直接写同一个 SQLite 文件。若继续保留脚本直接 `sqlite3` 写库、测试临时覆盖 `MNOTE_CONTROL_PLANE_DB_PATH`、OpenHub 直接写自己的 SQLite 文件,那么换 Turso 只能解决一部分问题,不能解决当前最致命的并发写入纪律问题。
2026-07-03 补充决策:已经获得真实 Turso `mnote` dev DB URL/token,并完成 remote dry-run 连接验证;但分叉讨论与本轮评估结论一致,**Turso Cloud 对当前 MNote 的意义不是“立刻把产品变成云同步应用”**。当前阶段仍以 local-first 文件真相和本机开发稳定性为主,云端能力只作为 control-plane 的受控验证、备份、迁移演练和后续多设备准备层。
因此默认路线调整为:
- `dev-hot` 继续优先使用 `libsql-local`,覆盖本地 store、migration、脚本纪律和 SQLite fallback。
- `desktop-hot` 不立即切到 `turso-remote``turso-local-replica`
- Turso remote 只做 dev DB dry-run、迁移演练、回滚演练和后续受保护 smoke,不作为当前本地功能测试的阻塞项。
- local-first 文件正文、附件、mindmap、OnlyOffice 仍完全留在本地文件系统,不迁入 Turso。
推荐执行顺序:
1. 先把 `ControlPlaneStore` 后端抽象和脚本直写清掉。
2. 再新增 Turso / libSQL store。
3. 本地默认先切 `libsql-local`,真实 Turso remote 只做受控验证。
4. 等密码哈希、remote smoke、回滚导出和 token 管理都稳定后,再评估是否让 `turso-local-replica` 成为普通默认。
5. 最后评估 OpenHub 会话库是否单独迁移。
---
## 2. 范围
### 2.1 必迁移
- Rust `control-plane` crate 当前的 SQLite store
- users / password identities / sessions
- workspaces / workspace members
- directory grants / share links
- sync state / outbox / audit log
- sidebar shortcuts / UI preferences / navigation recent
- AI policy / AI runtime run 和 event
- AI agent profile access
- external conversation bindings
- `mnote-web` 对 control-plane 的构造、注入和测试 helper。
- 所有直接 `sqlite3` 写 control-plane 的 smoke / 测试脚本。
- 启动脚本和部署脚本里的 `MNOTE_CONTROL_PLANE_DB_PATH` 默认路径逻辑。
### 2.2 第一阶段不迁移
- 本地 `.md` 页面正文。它仍是 local-first 正文真相。
- 本地附件、图片、mindmap、OnlyOffice 文件。
- `.mnote/index/evidence.sqlite`。该库已从当前主线退役,本轮不迁移。
- LightRAG 自身存储。知识库/RAG provider 存储不在 control-plane 切换范围内。
- OpenHub 自身 SQLite 会话库。它可以作为第二阶段单独迁移,不应和 Rust control-plane 首次切换绑死。
- WeKnora / RAGFlow。当前不作为主线迁移目标。
- 浏览器直连 Turso。所有读写仍必须经过 Rust / OpenHub API。
---
## 3. 目标架构
```text
Browser / Desktop Shell / Agent
|
v
Rust mnote-web :3000
- auth/session
- workspace/profile
- access policy
- share/invite
- sync state
- AI policy/runtime
- audit/outbox
|
v
ControlPlaneStore trait
|
+-- SqliteControlPlaneStore 本地 fallback / 回滚 / 测试
|
+-- TursoControlPlaneStore 默认目标后端
```
推荐环境变量:
```text
MNOTE_CONTROL_PLANE_BACKEND=sqlite|turso|turso-local-replica
MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
MNOTE_TURSO_DATABASE_URL=libsql://...
MNOTE_TURSO_AUTH_TOKEN=...
MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db
```
默认策略建议:
- 当前已落地:`dev-hot` 默认 `libsql-local``desktop-hot` 仍保守默认 `sqlite`
- 已配置真实 Turso dev DB:仅用于 remote dry-run、迁移演练、回滚演练和受保护 smoke,不改变 local-first 主路径。
- Turso store 全测试通过后:CI 先跑 `sqlite` + `libsql-local``turso-dev` 作为需要 secret 的受保护 job。
- 稳定后才评估:普通运行默认 `turso-local-replica`,保留 `sqlite` 作为离线和回滚模式。
本轮新增云端边界:
- remote / replica / synced 更接近“控制面云备份、迁移演练、多设备准备层”,不是本地文件网盘。
- control-plane 可以进入 Turso;正文、附件、mindmap、OnlyOffice、OpenHub 会话库和 RAG 存储不能因为这次 cutover 顺带迁入。
- remote 上线前必须先完成密码哈希从 `sha256-v1` 升级到 Argon2id,避免把弱哈希带入云端控制面。
- token 只允许存放在本机私有 env 或 secret manager,不写入仓库、日志、截图和设计文档。
---
## 4. 当前代码事实
### 4.1 Rust control-plane
- `rust/crates/control-plane/src/store.rs`
- `ControlPlaneStore` 是同步 trait,当前是正确抽象边界,应保持接口稳定。
- `rust/crates/control-plane/src/sqlite.rs`
- 唯一实现是 `SqliteControlPlaneStore`
- 内部是 `Mutex<rusqlite::Connection>`
- 当前配置 `journal_mode=WAL``synchronous=NORMAL``foreign_keys=ON``busy_timeout=5000`
- `rust/crates/control-plane/src/migrations.rs`
- 直接依赖 `rusqlite::Connection`
- `rust/crates/control-plane/src/error.rs`
- 直接实现 `From<rusqlite::Error>`
- `rust/crates/control-plane/Cargo.toml`
- 当前声明 `rusqlite = { version = "0.34", features = ["bundled"] }`
### 4.2 Rust mnote-web
- `rust/crates/mnote-web/src/app.rs`
- 当前直接导入 `SqliteControlPlaneStore`
- `AppState.control_plane` 当前是 `Arc<SqliteControlPlaneStore>`,应改成 `Arc<dyn ControlPlaneStore>`
- `open_control_plane_store()` 读取 `MNOTE_CONTROL_PLANE_DB_PATH` 并打开 SQLite。
- `rust/crates/mnote-web/src/local_folder_watcher_registry.rs`
- 字段已经是 `Arc<dyn ControlPlaneStore>`,但 `new()` 参数仍偏向具体 SQLite 类型,需要收口。
- `rust/crates/mnote-web/src/routes/local_search_index.rs`
- 历史上使用过 `rusqlite` 处理 `evidence.sqlite`;该库已退役,不能作为 Turso 迁移目标。
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 部分 best-effort 索引刷新路径会自行打开 control-plane storePhase 1 已适配 trait object,后续可评估复用 `AppState` 中的 store。
- `rust/crates/mnote-web/src/ssr/pages/admin.rs`
- admin 页面存在 control-plane DB 路径展示逻辑;Turso 默认后端切换时需要改成后端状态展示,不应继续只显示 SQLite 文件路径。
### 4.3 脚本和 smoke
当前至少有两类脚本必须改:
- 直接调用 `sqlite3` CLI 写库。
- 通过临时 `control-plane.sqlite3``MNOTE_CONTROL_PLANE_DB_PATH` 绕过真实 store。
这些脚本如果不改,即使 runtime 切 Turso,测试和开发链路仍会制造第二套写入口。
---
## 5. Phase 0:风险登记和依赖确认
- [x] 确认 Turso / libSQL 的当前 Beta 风险:API 稳定性、SLA、区域、限流、连接数、备份能力、最大数据库大小。
- 当前记录为运行手册中的准入风险:真实 remote / replica / synced 作为云端同步/备份/多设备准备层后续启用;默认保留 `sqlite` fallback,不把本地文件真相迁入 Turso。
- [x] 固定 Rust `libsql` crate 版本,不使用浮动主分支依赖。
- [x] 确认当前 8 个 migration SQL 在 libSQL 上可执行。
- [x] 明确 remote-only、local replica、pure local 三种模式的行为差异。
- [x] 明确 token 管理规则:只走环境变量或本机 secret 文件,不写入仓库。
- [x] 记录故障边界:认证失败、网络超时、Turso 5xx、限流、schema migration 失败、local replica 同步失败。
- 代码已覆盖 missing/empty URL/token、timeout/rate-limit error mapping、invalid local path;真实 5xx/限流/同步失败属于后续云端验证,不阻塞本地 control-plane 功能测试。
- [x] 决定第一阶段是否允许离线写入。当前策略:`sqlite` fallback 允许离线写入,`turso-local-replica` 模式下本地 replica 可离线读、上线后同步;纯 `turso` remote 模式禁止离线写入,返回 `ServiceUnavailable`
验收:
- [x] 已形成 Turso dev 数据库和 dev token,用于云端同步/备份链路验收。
- 本机私有 env`/mnt/Data1T/Mnote_data/control-plane/turso.env`,权限 `0600`,不入仓库。
- 数据库 URL host 已验证为 `mnote-liaibo.aws-ap-northeast-1.turso.io`token 不写入设计文档。
- [x] 后续 `libsql` 最小连接 demo 能跑通 dev 数据库。
- 2026-07-03 已执行 `control-plane-admin init --backend turso-remote --dry-run`,返回 `ok=true`
- 2026-07-03 已执行 `control-plane-admin migrate-sqlite-to-target --backend turso-remote --dry-run`,返回源库表计数和 `targetBackend=TursoRemote`
- [x] 本机无 token 时不会 panic,返回 `InvalidInput`;真实断网场景作为后续 Turso dev DB 验证。
---
## 6. Phase 1:先加固 ControlPlaneStore 边界
目标:在不改变数据库后端的情况下,先把 runtime 从具体 SQLite 类型中解耦。
- [x] `rust/crates/mnote-web/src/app.rs`
- [x] `AppState.control_plane: Arc<SqliteControlPlaneStore>` 改为 `Arc<dyn ControlPlaneStore>`
- [x] `open_control_plane_store()` 改为返回 trait object。
- [x] 新增 `MNOTE_CONTROL_PLANE_BACKEND` factory 入口,已实现 sqlite/libsql-local/turso-remote/turso-local-replica/turso-synced 全部分支。
- [x] 当前默认 SQLite 行为与现状一致。
- [x] `rust/crates/mnote-web/src/local_folder_watcher_registry.rs`
- [x] `new()` 参数改为 `Arc<dyn ControlPlaneStore>`
- [x] watcher 测试通过 trait object 构造器编译与运行。
- [x] `rust/crates/mnote-web/src/routes/local_search_index.rs`
- [x] 已复核:该文件中的 SQLite 写入仅限 `#[cfg(test)]``evidence.sqlite` / local search legacy 测试,不是 control-plane DB 直写;本轮不迁移。
- [x] `rust/crates/control-plane/src/lib.rs`
- [x] 保持 `SqliteControlPlaneStore` 导出,新增后端前不破坏现有测试。
验收:
- [x] `cargo test --manifest-path rust/Cargo.toml -p control-plane`
- [x] `cargo test --manifest-path rust/Cargo.toml -p mnote-web` 已运行并确认本轮 control-plane 相关路径无新增失败。
- 全量结果为 855 passed, 1 failed;唯一失败是既存 CSS 体积阈值 `ssr::styles::tests::mnote_css_is_reasonably_sized`,与 control-plane cutover 无关。
- [x] `MNOTE_CONTROL_PLANE_BACKEND=sqlite npm run desktop:hot` 可正常启动。
---
## 7. Phase 2:新增 TursoControlPlaneStore
目标:新增实现,不先删除 SQLite fallback。
- [x] `rust/crates/control-plane/Cargo.toml`
- [x] 增加 `libsql` 依赖。
- [x] 暂时保留 `rusqlite`,直到 SQLite fallback 和测试策略明确。
- [x] `rust/crates/control-plane/src/turso.rs`
- [x] 新增 `TursoControlPlaneStore`
- [x] 支持 remote URL + token。
- [x] 支持 local replica path + remote sync URL。
- [x] 支持 pure local libSQL 模式,用于无云测试。
- [x] 实现 `ControlPlaneStore` 全部方法。
- [x] `rust/crates/control-plane/src/migrations.rs`
- [x]`rusqlite::Connection` 绑定改为后端无关迁移入口,或新增 libSQL 迁移入口。
- [x] PRAGMA 只在本地 SQLite / local replica 下执行;remote Turso 跳过 WAL 类配置。
- [x] `rust/crates/control-plane/src/error.rs`
- [x] 增加 `From<libsql::Error>`
- [x] 区分 constraint、not found、auth、network、timeout、rate limit。
- [x] `rust/crates/control-plane/src/lib.rs`
- [x] 导出 `TursoControlPlaneStore`
设计注意:
- 当前 trait 是同步接口。第一阶段建议保持同步边界,避免把所有 route 改成异步 store trait。
- 如果 `libsql` API 只能异步访问 remote,应在 store 内部集中处理 async runtime 包装,不要让每个 route 自己 block。
- 多语句写入必须明确事务边界;不能把原来 SQLite 单连接串行行为误拆成多个独立远程请求。
验收:
- [x] `cargo test --manifest-path rust/Cargo.toml -p control-plane --features turso-unit-tests -- --test-threads=1`
- [x] 同一套 store 行为测试已在本地可测后端运行:`sqlite` ✅ / `libsql-local` ✅ / feature-gated local Turso store tests ✅。
- [ ] 后续云端验证:同一套 store 行为测试在 `turso-dev` 后端运行。
- `turso-dev` 需要 dev DB/token,不阻塞本地功能测试。
- [ ] migration idempotent 测试在 Turso dev 数据库通过。
- `libsql-local` migration idempotent ✅;真实 Turso dev DB 为后续云端验证项(缺 dev DB/token
---
## 8. Phase 3:移除脚本直写数据库
目标:所有测试和 smoke 通过 API / helper seed 数据,不再直接写 control-plane DB 文件。
### 8.1 直接 `sqlite3` CLI 用户
以下脚本必须改为调用 Rust API、测试 seed endpoint,或统一 helper
- [x] `scripts/task557-page-ai-run-resume-smoke.js`
- [x] `scripts/task558-page-ai-reasonix-live-session-context-smoke.js`
- [x] `scripts/task559-page-ai-terminal-status-reconciliation-smoke.js`
- [x] `scripts/task560-page-ai-hermes-load-replay-smoke.js`
- [x] `scripts/task561-page-ai-session-dashboard-smoke.js`
- [x] `scripts/task562-page-ai-reasonix-approval-plan-smoke.js`
- [x] `scripts/task512-chatonly-doubao-sync-smoke.js`
- [x] `scripts/task513-chatonly-provider-sync-smoke.js`
- [x] `scripts/task527-chatonly-api-provider-smoke.js`
- [x] `scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`
### 8.2 临时 DB path 覆盖用户
以下脚本必须改为使用统一 test control-plane backend,不再自己创建临时 SQLite 文件后覆盖环境变量:
- [x] `scripts/task492-sidebar-starred-shortcuts-smoke.js`
- [x] `scripts/task494-filetree-lazy-loading-dedup-smoke.js`
- [x] `scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js`
- [x] `scripts/task497-local-page-tree-filetree-open-performance-smoke.js`
- [x] `scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js`
- [x] `scripts/task499-sidebar-tree-view-state-smoke.js`
- [x] `scripts/task500-navigation-page-route-guard-smoke.js`
- [x] `scripts/task167-local-markdown-title-body-options-no-convex-smoke.js`
### 8.3 启动和部署脚本
- [x] `scripts/prod-build-start.js`
- [x] 不再只设置 `MNOTE_CONTROL_PLANE_DB_PATH`
- [x] 支持 `MNOTE_CONTROL_PLANE_BACKEND` 和 Turso env。
- [x] `scripts/desktop-hot.js`
- [x] 明确 dev 默认后端。
- [x] 防止多个 hot 进程同时写同一个 SQLite fallback。
- [x] `scripts/dev-hot.js`
- [x] 同步 backend 环境变量策略。
验收:
- [x] `rg -n "sqlite3|MNOTE_CONTROL_PLANE_DB_PATH|control-plane\\.sqlite3|control-plane\\.db" scripts rust/crates --glob '!target/**' --glob '!recycle/**'` 中不再出现未解释的直写入口。
- [x] 需要 seed 的脚本统一走 `scripts/lib/control-plane-dev-seed.js` / `scripts/lib/control-plane-test-env.js` 或测试 API。
- [x] smoke 失败时不再留下孤立临时 control-plane DB`scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` 使用 `/tmp` 前缀并在 finally 清理)。
---
## 9. Phase 4:数据初始化和迁移
当前仍是测试期,不需要保留正式业务数据,但仍需要迁移工具来保证以后可运维。
- [x] 新增 control-plane 初始化命令:
- [x] 创建 schema。
- [x] 创建默认 admin / e2e 用户。
- [x] 创建默认 workspace / membership。
- [x] 写入必要 AI policy / UI preference 默认值。
- [x] 新增本地 SQLite 到 Turso 的一次性迁移工具:
- [x] 支持 `--dry-run`
- [x] 输出源表行数、目标表行数、跳过项、失败项。
- [x] 支持目标清空后重建 dev DB。
- [x] 新增 Turso 到 SQLite 的回滚导出工具:
- [x] 回滚前自动备份本地 DB。
- [x] 支持只导出 control-plane schema。
验收:
- [ ] 后续云端验证:空 Turso dev DB 可通过初始化命令启动 MNote(缺 `MNOTE_TURSO_DATABASE_URL` / `MNOTE_TURSO_AUTH_TOKEN`)。
- [ ] 初始化后的测试账号 `mnote.e2e@example.com` 可登录。
- 后续云端验证:同 Turso dev DB/token 缺
- [x] 本地 SQLite fallback 可从 libSQL local 导出恢复;真实 Turso remote 导出待 dev DB/token。
---
## 10. Phase 5:并发、损坏和故障测试
目标:证明切换确实解决当前痛点,而不是只换驱动名。
- [x] 并发写测试:
- [x] 多线程同时创建 sessionlibSQL local)。
- [x] 多线程同时 upsert sidebar shortcutlibSQL local)。
- [x] 多线程同时写 AI runtime eventslibSQL local)。
- [x] 多线程同时 append audit/outboxlibSQL local)。
- [ ] 故障注入:
- [x] Turso token 缺失返回 `InvalidInput` 且不 panic。
- [ ] Turso URL 不可达。
- 后续云端验证:缺 Turso dev DBlibSQL local 模式下无远程连接)
- [ ] 网络超时。
- 后续云端验证:缺 Turso dev DB
- [ ] 远端返回 5xx。
- 后续云端验证:缺 Turso dev DB
- [ ] 远端限流。
- 后续云端验证:缺 Turso dev DB
- [ ] local replica 文件不可写。
- 后续云端验证:缺 Turso dev DB
- [x] 数据一致性:
- [x] unique constraint 冲突仍返回可处理错误。
- [x] session 创建和查询一致。
- [x] outbox 不重复投递。
- [x] AI runtime event 顺序可恢复。
验收:
- [x] 新增 `cargo test -p control-plane` 并发测试。
- [x] 新增 Node smoke 覆盖 admin init / SQLite→libSQL local / libSQL local→SQLite roundtrip,且失败时清理临时库。
- [x] 已覆盖缺 token 时不 panic;远端网络/5xx/限流作为后续真实 Turso dev DB 验证。
---
## 11. Phase 6:默认切换
> dev-hot.js 已默认使用 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local``dev-hot.js:75`);desktop-hot.js 仍默认 `sqlite`。remote / local-replica / synced 更接近云端同步、备份和多设备准备层,不影响当前 local-first 本地功能测试;它们作为后续云端验收项,不再阻塞本轮本地 libSQL cutover。
>
> 2026-07-03 决策更新:真实 Turso dev DB/token 已可用,但云端价值对当前阶段有限;本轮只把它接入为“可验证但不默认”的控制面 dev target,不把 remote / replica 作为 local-first 功能测试前置条件。
- [x] dev 环境先切到本地 libSQL
- [x] `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`
- [x] 不依赖 Turso dev DB/token,覆盖本地 store、migration、script discipline 和 SQLite fallback。
- [x] Turso dev DB/token 已配置为本机私有 env
- [x] `/mnt/Data1T/Mnote_data/control-plane/turso.env`
- [x] 文件权限 `0600`
- [x] repo 内未出现 JWT/token 明文命中。
- [x] Turso remote 最小验证:
- [x] `control-plane-admin init --backend turso-remote --dry-run` 返回 `ok=true`
- [x] `control-plane-admin migrate-sqlite-to-target --backend turso-remote --dry-run` 成功读取本地 SQLite 源库并规划导入 remote。
- [ ] 后续云端同步/备份链路切换(低优先级,非当前默认):
- [ ] `MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica`
- [ ] 先完成 Argon2id password migration。
- [ ] 观察登录、workspace、授权、分享、sidebar、navigation、AI runtime。
- [ ] 验证 replica initial sync、`MNOTE_TURSO_SYNC_INTERVAL_MS`、断网和 token 失效边界。
- [ ] CI 增加后端矩阵:
- [ ] `sqlite`
- [ ] `libsql-local`
- [ ] `turso-dev` 作为后续受保护云端 job,需要 secret 时才跑;失败不阻塞本地 local-first smoke。
- [ ] 本机正式默认切换到 remote / replica 前(后续云端验证):
- [ ] 备份当前 `/mnt/Data1T/Mnote_data/control-plane/control-plane.db`
- [ ] 记录当前 schema version。
- [ ] 跑完整 smoke 基线。
默认切换验收:
- [x] 本地 cutover`libsql-local` store 行为、migration、admin roundtrip、seed helper、SQLite fallback 已验证。
- [ ] 云端 cutover`npm run desktop:hot` 使用 `turso-local-replica` 启动后可登录测试账号。
- [ ] 云端 cutover`/api/auth/session` 返回真实 Turso control-plane session。
- [x] 文件树和文档页仍读取本地工作区文件。
- [x] 新建/重命名/打开页面不依赖 Turso 存正文。
- [ ] 云端 cutover:断开 Turso 后错误边界符合 Phase 0 决策。
### 11.1 当前不做的 Turso 云端事项
- 不把 remote Turso 当成“网盘备份”替代本地文件系统;它只保存 control-plane 元数据。
- 不把 remote / replica / synced 设为 `desktop-hot` 默认。
- 不把 OpenHub SQLite、local search、RAG provider 数据跟随 control-plane 一起迁移。
- 不在 repo 内保存真实 token`tmp/turso.md``turso.env` 都属于本机私有文件。
### 11.2 下一轮可执行 checklist
- [x] `rust/crates/mnote-web/src/app.rs`:解析 `MNOTE_TURSO_SYNC_INTERVAL_MS`,传入 remote replica / synced builder。
- [ ] `rust/crates/control-plane/src/turso.rs`remote replica 建库时启用 explicit initial sync;必要时启用 `read_your_writes(true)`
- [ ] `rust/crates/mnote-web/src/routes`:新增安全的 control-plane health endpoint,暴露 backend/mode/schema version/sync status,不暴露 token。
- [ ] `rust/crates/control-plane`:补真实 remote dev DB 的 store contract smoke,作为 secret-gated 测试。
- [ ] `rust/crates/control-plane`:补 Argon2id password hash migration,再允许 remote 成为长期候选默认。
- [ ] `docs/operations/control-plane-turso.md`:记录本机私有 env、remote dry-run、低优先级云端边界和 token 防泄露检查。
### 11.3 Turso / libSQL 优势落地状态校准
> 2026-07-03 校准:上一轮 P0/P1/P2 是“最值得利用的能力清单”,不是全部已实现清单。当前已完成的是 local cutover、脚本直写清理、local libSQL 并发防损坏测试和 remote dry-run;读写并发性能设计、sync health、Argon2id、CDC/Tantivy 都尚未落地。
| 优先级 | 能力 | 当前状态 | 结论 |
| --- | --- | --- | --- |
| P0 | 唯一写入口 / 脚本不直写 control-plane DB | 已完成 | 继续保持所有 seed / smoke 走 API、helper 或 `control-plane-admin`。这是降低 SQLite 损坏风险的核心收益。 |
| P0 | `libsql-local` store + migration + browser QA | 已完成 | 当前可作为本地测试主线;不依赖 Turso Cloud。 |
| P0 | 并发防损坏测试 | 已完成 local 版 | `rust/crates/control-plane/tests/turso_store.rs` 已用多线程同时创建 session、shortcut、AI runtime events、audit/outbox,验证 local libSQL store 在当前单进程并发下不损坏。 |
| P0 | 真实 Turso remote 凭据 dry-run | 已完成 non-destructive 版 | 已验证 URL/token 可用,`init --dry-run``migrate-sqlite-to-target --dry-run` 成功;未把 remote 设为默认。 |
| P0 | `MNOTE_TURSO_SYNC_INTERVAL_MS` / initial sync | 已完成:env var 解析与传递 | `mnote-web/src/app.rs` 已解析 `MNOTE_TURSO_SYNC_INTERVAL_MS` 并传给 remote replica / syncedexplicit initial sync 仍待后续验证。 |
| P0 | control-plane health endpoint | 未完成 | 需要新增只读诊断端点,暴露 backend/mode/schema/sync 状态,不暴露 token。 |
| P0 | Argon2id password hash migration | 未完成 | remote 成为长期默认前必须完成,避免把 `sha256-v1` 占位哈希带入云端。 |
| P1 | 读写并发性能设计 | 仅完成安全基线,未实现性能优化 | 当前 `TursoControlPlaneStore``Mutex<TursoConnection>`,读写全部串行;这能保证简单安全,但不能发挥 remote / replica 的读并发优势。 |
| P2 | CDC / change data capture | 未采用 | 当前 control-plane 已有显式 `audit_log``outbox_events`,CDC 只适合作为后续审计/同步 spike,不进入本轮。 |
| P2 | Tantivy-powered full-text search | 未采用 | 当前 control-plane 表主要是元数据,全文搜索收益有限;正文和知识库搜索仍属于 OpenHub/WeKnora/local search 方向,不跟 control-plane 绑死。 |
| P3 | Browser WASM / OPFS database | 未采用 | 对纯 Web 离线笔记有价值,但当前 MNote 主形态是本地文件夹 + Rust mnote-web + 文件系统 watcher;浏览器内 SQL 不能替代本地 `.md` 真相,也不适合作为 control-plane 权限真源。 |
### 11.4 读写并发设计原则
当前实现优先解决“不要损坏”和“不要绕过写入口”,不是追求最大吞吐:
- `TursoControlPlaneStore` 现在用单个 `Mutex<TursoConnection>` 串行化所有 control-plane 操作。
- 这能避免本轮最致命的问题:多个运行时、脚本或测试直接写同一个 SQLite 文件导致损坏。
- 这也意味着 remote / replica 模式下,读操作暂时不能和写操作并行,属于保守实现。
后续若要优化读写并发,必须按以下顺序推进:
- [ ] 先把 `ControlPlaneStore` 方法按只读 / 写入 / 多表事务分类,形成文件级清单。
- [ ] 写入路径继续保持单写者队列或独占写锁;多表写入必须显式事务,不允许拆成多个独立锁周期。
- [ ] 只读路径可以引入独立 read connection pool 或 `RwLock`,但必须先证明 libSQL remote / replica 连接在当前 Rust SDK 版本下可安全共享。
- [ ] remote replica 只把读放在本地副本;写仍以 remote primary 为准,不把 embedded replica 当成高并发本地写主路径。
- [ ] 在改 `Mutex -> RwLock/read pool` 前,先补 benchmark 和压力测试:并发 session lookup、grant resolve、navigation recent、AI event append、audit/outbox drain。
- [ ] 在同步 trait 未改 async 前,不扩大 `block_in_place` 使用面;否则大量并发请求可能耗尽 Tokio worker。
推荐下一步不是直接把 `Mutex` 改成 `RwLock`,而是先加:
- `MNOTE_TURSO_SYNC_INTERVAL_MS`
- explicit initial sync
- control-plane health endpoint
- secret-gated remote store contract smoke
这些完成后,再评估读连接池或 async store trait。
### 11.5 CDC 与 Tantivy FTS 评估
#### CDC / Change Data Capture
Turso 文档中的 CDC 通过 `PRAGMA capture_data_changes_conn(...)` 按连接开启,把 insert/update/delete/DDL 变更写入 CDC 表;它尊重事务边界,rollback 不产生 CDC 记录。当前本地文档还明确 CDC 与 MVCC 在同一连接上互斥。
对 MNote 的判断:
- 当前不作为 P0control-plane 已有业务语义明确的 `audit_log``outbox_events`,比通用 CDC 更适合作为权限、分享、AI policy 的审计与事件源。
- 可作为 P2 spike:如果后续要把 control-plane 变更桥接到 OpenHub、实时分析、外部消息队列或审计归档,可以评估 CDC 作为补充。
- 不替代业务审计:CDC 记录的是表级变更,不知道“用户授权目录”“AI policy 变更”“分享撤销”等业务意图,仍需要应用层 audit/outbox。
- 不进首轮 remote:CDC 仍是早期能力,且可能捕获敏感字段;必须先定义字段脱敏、保留期、权限和导出路径。
结论:**P2 / research only**,不进入当前 Turso control-plane 默认切换。
#### Tantivy-powered Full-text Search
Turso 文档显示 FTS 通过 `CREATE INDEX ... USING fts``fts_match` / `fts_score` / `fts_highlight` 使用 Tantivy,不是 SQLite FTS5 的完全同语法替换。
对 MNote 的判断:
- 当前 control-plane 不需要:users、workspaces、grants、recent、shortcuts、policy 都是轻量元数据,`LIKE` 或精确索引足够。
- 不迁正文:页面正文 `.md` 仍在本地文件系统,知识库问答主线是 OpenHub/WeKnora,不把正文全文塞进 control-plane。
- 可作为 P2/P3:如果以后要做轻量“标题 / 文件路径 / 页面摘要 / AI 会话标题”的本地快速搜索,可以单独设计 Turso FTS 索引。
- 不替代 WeKnora/OpenHub RAGTantivy FTS 是关键词检索,不等于知识库引用、OCR、语义检索和 agent citation 链。
结论:**对当前 control-plane 价值低;对未来轻量元数据搜索有价值;不进入本轮。**
#### Browser WASM / OPFS
Turso / libSQL 的 Browser WASM + OPFS 路线适合“纯浏览器应用也要持久化 SQL 数据库”的场景。官方资料也能看到 browser WASM 示例、`@tursodatabase/sync-wasm` 和本地浏览器数据库方向;Context7 返回的 sync 文档强调 synced database 可做离线写入再同步。
对 MNote 的判断:
- 当前不作为 P0/P1:MNote 的主形态不是纯浏览器笔记应用,而是 `3000 Rust SSR + local-first workspace + 本地文件系统`。本地 `.md`、附件、mindmap、OnlyOffice 已经有真实文件真相和 watcher,同步到浏览器 OPFS 反而会制造第三份数据真相。
- 不替代 control-planeauth、session、grant、AI policy 仍必须在 Rust control-plane 中受控;浏览器 OPFS 数据库不能成为权限真源,否则会弱化服务端校验和 agent 访问边界。
- 不适合当前桌面链路:浏览器 WASM/OPFS 往往受浏览器 storage quota、origin 隔离、清站点数据、cross-origin isolation / SharedArrayBuffer 等运行条件影响;这些不如当前本地文件系统路径可审计、可备份、可由 agent 直接 patch。
- 可作为 P3 独立 spike:如果未来要做“无桌面端 / 无本地服务”的纯 Web 离线阅读、临时草稿、移动端 PWA、离线收集箱,可以评估 WASM OPFS 存储轻量 cache、草稿或最近访问索引。
- 不存敏感真相:即使做 P3,也只应存可重建 cache / draft / projection,不存长期 auth token、分享授权真相、完整知识库和不可恢复正文唯一副本。
结论:**对当前网页中的笔记系统不是主线优化;对未来纯 Web/PWA 离线模式有研究价值,归 P3 spike。**
---
## 12. Phase 7OpenHub 会话库单独评估
OpenHub 属于当前 AI 主线,但它不是 Rust control-plane。不能把 Rust control-plane 切换和 OpenHub SQLite 切换混成一个不可回滚的大改。
> 当前 Phase 7 为独立后续评估:OpenHub 不属于 Rust control-plane,其 SQLite 会话库迁移不绑定 Rust control-plane 切换。OpenHub 侧直写 SQLite 的文件路径和 schema 需单独审计(属于 OpenHub 自身治理范围);Rust control-plane 本地 libSQL cutover 已完成,remote / replica / synced 仅作为后续云端同步/备份验收项,不影响 OpenHub 单独评估。
- [ ] 先确认 OpenHub 当前数据库文件、schema 和写入路径。
- [ ] 确认哪些数据属于可丢弃会话缓存,哪些属于用户需要保留的 AI 历史。
- [ ] 新增 `OPENHUB_DB_BACKEND=sqlite|turso`,默认先保持 `sqlite`
- [ ] OpenHub Python 侧增加 Turso/libSQL client abstraction。
- [ ] 会话创建、消息写入、会话恢复、MNote 嵌入 Page AI 全链路验证。
- [ ] 若 OpenHub 会话写入频率高于 control-plane,单独做性能和限流评估。
OpenHub 切换门槛:
- [ ] Rust control-plane Turso 已稳定。
- [ ] OpenHub 不再通过脚本直接改 SQLite。
- [ ] Page AI / OpenHub smoke 可在 Turso 后端通过。
---
## 13. Phase 8:文档和运行手册
- [x] 更新 `ARCHITECTURE.md`
- [x] control-plane 从 `Rust SQLite` 改为 `Turso/libSQL + SQLite fallback`
- [x] 明确本地 `.md` 文件仍是正文真相。
- [x] 更新 `CURRENT_ARCHITECTURE.md`
- [x] 当前默认后端、fallback、回滚方式。
- [x] 更新 `AGENTS.md`
- [x] 常用命令增加 Turso backend env。
- [x] smoke 基线说明不得直写 control-plane DB。
- [x] 新增运行手册:`docs/operations/control-plane-turso.md`
- [x] dev token 配置。
- [x] dev DB reset / init。
- [x] 本地 fallback 备份。
- [x] Turso 到 SQLite 回滚。
- [x] 故障排查。
---
## 14. 回滚策略
必须保留一键回滚:
```text
MNOTE_CONTROL_PLANE_BACKEND=sqlite
MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
```
回滚 checklist(仅在 remote / replica / synced 成为默认后需要完整执行):
- [ ] 切回前从 Turso 导出最新 control-plane dump。
- [ ] 备份当前本地 SQLite。
- [ ] 导入 Turso 数据到本地 SQLite 或接受 dev 数据重置。
- [ ] 重启 `npm run desktop:hot`
- [ ] 验证登录、workspace、授权、sidebar、navigation、AI policy。
- [ ] Turso DB 保留至少 30 天用于审计和补导出。
---
## 15. 最小落地顺序
如果只追求最快降低 SQLite 损坏风险,按这个顺序做:
1. `AppState.control_plane` 改成 `Arc<dyn ControlPlaneStore>`
2. 增加 control-plane factory 和 backend env。
3. 改掉 17 个直写/临时 DB 脚本。
4. 增加 `TursoControlPlaneStore`,先跑 libSQL local。
5. 跑 control-plane store 行为测试矩阵。
6. 接 Turso dev DB。
7.`desktop:hot` dev 默认后端。
8. 补并发写和故障注入测试。
9. 再决定是否迁 OpenHub 会话库。
不建议一开始就删除 `SqliteControlPlaneStore`。它仍是本地 fallback、回滚、测试隔离和故障排查的必要后门。
---
## 16. 执行记录
### 2026-07-02 Phase 1 起步
- [x] 已将 `rust/crates/mnote-web/src/app.rs``AppState.control_plane` 解耦为 `Arc<dyn ControlPlaneStore>`
- [x] 已将 `open_control_plane_store()` 改为返回 trait object,内部仍使用 `SqliteControlPlaneStore`,未引入 Turso 实现。
- [x] 已新增 `MNOTE_CONTROL_PLANE_BACKEND` factory 入口,Phase 2 已完成 sqlite/libsql-local/turso-remote/turso-local-replica/turso-synced 全部分支(详见下文全 Phase 同步记录)。
- [x] 已将 `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` 构造器参数改为 `Arc<dyn ControlPlaneStore>`
- [x] 已适配 `rust/crates/mnote-web/src/routes/local_folder_source.rs` 中 best-effort 索引刷新调用。
- [x] 已复核脚本直写入口:P0 为 10 个生产 control-plane `sqlite3` 直写 smokeP1/P2 为 Rust 初始化路径和 8 个临时 DB smoke。
- [x] 验证通过:`cargo check --manifest-path rust/Cargo.toml -p mnote-web`
- [x] 验证通过:`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_watcher -- --test-threads=1`
- [x] 验证通过:`cargo test --manifest-path rust/Cargo.toml -p control-plane`
- [x] 已验证(已知失败):`cargo test --manifest-path rust/Cargo.toml -p mnote-web` 全量为 855 passed, 1 failed(既有 CSS 体积阈值 `ssr::styles::tests::mnote_css_is_reasonably_sized`,断言 `MNOTE_CSS.len() < 190000`,与控制面切换无关)。
### 2026-07-02 全 Phase 同步与收尾
本轮同步完成了 Phase 2/3/5/6/8 的大幅推进,并更新 checklist 以反映实际代码状态。
**已实现文件(本轮变更范围):**
| 文件 | 变更 |
|------|------|
| `rust/crates/control-plane/Cargo.toml` | 新增 `libsql``tokio` 依赖与 `turso-unit-tests` feature |
| `rust/crates/control-plane/src/turso.rs` | 4738 行完整 TursoControlPlaneStore 实现:Local/Remote/RemoteReplica/Synced 四种模式 |
| `rust/crates/control-plane/src/migrations.rs` | 新增 `run_libsql_migrations()` async 迁移入口 |
| `rust/crates/control-plane/src/error.rs` | 新增 `From<libsql::Error>`,区分 constraint/unauthorized/storage |
| `rust/crates/control-plane/src/lib.rs` | 导出 `TursoControlPlaneConfig``TursoControlPlaneMode``TursoControlPlaneStore` |
| `rust/crates/control-plane/src/sqlite.rs` | 保持 SqliteControlPlaneStore 作为本地 fallbackstore 行为测试覆盖完整 |
| `rust/crates/control-plane/src/model.rs` | 新增字段 |
| `rust/crates/mnote-web/src/app.rs` | `AppState.control_plane: Arc<dyn ControlPlaneStore>` 解耦;5 种后端 factory |
| `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` | 构造器改为 `Arc<dyn ControlPlaneStore>` |
| `rust/crates/mnote-web/src/routes/local_folder_source.rs` | best-effort 索引刷新适配 trait object |
| `rust/crates/mnote-web/src/ssr/pages/admin.rs` | 适配 control-plane 后端状态展示 |
| `docs/operations/control-plane-turso.md` | 新增 Turso/libSQL control-plane 初始化、迁移、回滚、验证和故障边界运行手册 |
| `scripts/desktop-hot.js` | 支持 `MNOTE_CONTROL_PLANE_BACKEND`,默认 `sqlite` |
| `scripts/dev-hot.js` | 支持 `MNOTE_CONTROL_PLANE_BACKEND`,默认 `libsql-local` |
| `scripts/prod-build-start.js` | 支持 `MNOTE_CONTROL_PLANE_BACKEND` |
| `scripts/lib/control-plane-test-env.js` | 新增统一 test env builder,支持全部后端模式 |
| `scripts/lib/control-plane-dev-seed.js` | 新增 dev seed API helper,替代 smoke 脚本直接写库 |
| `scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` | 新增 admin CLI SQLite → libSQL local → SQLite roundtrip smoke |
| `rust/crates/mnote-web/src/routes/dev_seed.rs` | 新增 `/api/dev/seed` gated endpoint 与 targeted tests |
| 17 个 smoke 脚本 | 从直接 `sqlite3` CLI 写库改为 API `POST /api/dev/seed``buildControlPlaneTestEnv` |
| `ARCHITECTURE.md` | control-plane 从 `Rust SQLite` 改为 `Turso/libSQL + SQLite fallback` |
| `CURRENT_ARCHITECTURE.md` | 记录 ControlPlaneStore trait 架构、后端选择、env vars |
| `AGENTS.md` | 常用命令增加 Turso backend env;脚本纪律不得直写 control-plane DB |
**已通过测试验证:**
- `cargo check --manifest-path rust/Cargo.toml -p mnote-web`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_watcher -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web app_state_initializes_sqlite_control_plane_store`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web dev_seed -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p control-plane` ✅(28 unit + 3 integration
- `cargo test --manifest-path rust/Cargo.toml -p control-plane --features turso-unit-tests -- --test-threads=1` ✅(64 unit + 3 integration
- `node --test scripts/desktop-hot.test.js`
- `node scripts/task-dev-hot-plan-test.js`
- `node scripts/task-control-plane-admin-libsql-roundtrip-smoke.js`
- `node --check` 覆盖 control-plane helper 与已迁移 smoke 脚本 ✅
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web` ❌ 855 passed, 1 failed(既有 CSS 体积阈值 `MNOTE_CSS.len() < 190000`,与控制面切换无关)
**后续云端验证项:**
- `MNOTE_TURSO_DATABASE_URL``MNOTE_TURSO_AUTH_TOKEN` 未配置 → 暂不测试 Turso remote / local-replica / synced 模式;不影响当前本地功能测试
- 真实 Turso dev DB / token 未配置,remote、local-replica、synced、真实 5xx/限流/断网/同步失败作为后续云端验收
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web` 全量 855 passed, 1 failed(既有 CSS 体积阈值失败);本轮 control-plane 相关 targeted tests 已通过
**回滚命令(保持 Phase 1 前行为):**
```text
MNOTE_CONTROL_PLANE_BACKEND=sqlite
MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
```
**local-first 不迁移边界(已验证未踏入):**
- 本地 `.md` 文件正文 → 未迁移
- 本地附件、图片、mindmap、OnlyOffice 文件 → 未迁移
- `.mnote/index/evidence.sqlite`(已退役)→ 未迁移
- LightRAG 自身存储 → 未迁移
- OpenHub 自身 SQLite 会话库 → 未迁移(Phase 7 推迟)
- WeKnora / RAGFlow → 未迁移
- 浏览器直连 Turso → 未实现(所有读写经过 Rust API)
### 2026-07-03 最终执行记录
本轮为 stale 状态修正与最终记录归档,不修改代码。
**已修正的 stale 状态:**
- [x] `cargo test --manifest-path rust/Cargo.toml -p control-plane` ✅(28 unit + 3 integration
- [x] `cargo test --manifest-path rust/Cargo.toml -p control-plane --features turso-unit-tests -- --test-threads=1` ✅(64 unit + 3 integration
- [x] `cargo test --manifest-path rust/Cargo.toml -p mnote-web` 全量 855 passed, 1 failed(既有 CSS 体积阈值 `MNOTE_CSS.len() < 190000`,与控制面切换无关)
- [x] Phase 7 状态修正:OpenHub 不属于 Rust control-plane,其 SQLite 会话库迁移不绑定 Rust control-plane 切换;本地 libSQL cutover 已完成,remote / replica / synced 仅作为后续云端同步/备份验收项
**已通过验证(截至 2026-07-03):**
- `cargo check --manifest-path rust/Cargo.toml -p mnote-web`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_watcher -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p control-plane` ✅(28 unit + 3 integration
- `cargo test --manifest-path rust/Cargo.toml -p control-plane --features turso-unit-tests -- --test-threads=1` ✅(64 unit + 3 integration
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web dev_seed -- --test-threads=1`
- `node --test scripts/desktop-hot.test.js`
- `node scripts/task-dev-hot-plan-test.js`
- `node scripts/task-control-plane-admin-libsql-roundtrip-smoke.js`
- `MNOTE_CONTROL_PLANE_BACKEND=sqlite npm run desktop:hot` 可正常启动 ✅
**已知失败:**
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web` ❌ 855 passed, 1 failed(既有 CSS 体积阈值 `MNOTE_CSS.len() < 190000`,与控制面切换无关,不属于本轮范围)
**后续云端验证项:**
- `MNOTE_TURSO_DATABASE_URL``MNOTE_TURSO_AUTH_TOKEN` 未配置 → 暂不测试 Turso remote / local-replica / synced 模式;不影响当前本地功能测试
- 真实 Turso dev DB / token 未配置,remote、local-replica、synced、真实 5xx/限流/断网/同步失败作为后续云端验收
- `desktop:hot` 默认后端切换到 `turso-local-replica` 暂缓;当前本地开发默认已可用 `libsql-local`
- OpenHub SQLite 会话库迁移(Phase 7)属于 OpenHub 独立评估,不绑定 Rust control-plane 切换
**SQLite fallback / rollback 环境:**
```text
# 回滚到纯本地 SQLite(保持 Phase 1 前行为)
MNOTE_CONTROL_PLANE_BACKEND=sqlite
MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
# 本地 libSQL 模式(无远程依赖,推荐开发测试)
MNOTE_CONTROL_PLANE_BACKEND=libsql-local
# Turso remote(需配置 dev DB/token 后启用)
MNOTE_CONTROL_PLANE_BACKEND=turso-remote
MNOTE_TURSO_DATABASE_URL=libsql://...
MNOTE_TURSO_AUTH_TOKEN=...
# Turso local replica(需配置 remote URL/token 后启用)
MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica
MNOTE_TURSO_DATABASE_URL=libsql://...
MNOTE_TURSO_AUTH_TOKEN=...
MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db
```
+299
View File
@@ -0,0 +1,299 @@
# MNote control-plane Turso / libSQL 运行手册
> 状态:首轮 Rust control-plane cutover 手册。
> 范围:只覆盖 MNote Rust control-plane;本地 `.md`、附件、mindmap、OnlyOffice 文件仍是 local-first 文件真相,不迁入 Turso。
## 后端选择
```bash
# 默认回滚 / 离线模式
export MNOTE_CONTROL_PLANE_BACKEND=sqlite
export MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
# 无云本地 libSQL 验证
export MNOTE_CONTROL_PLANE_BACKEND=libsql-local
export MNOTE_TURSO_LOCAL_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
# 真实 Turso remote
export MNOTE_CONTROL_PLANE_BACKEND=turso-remote
export MNOTE_TURSO_DATABASE_URL=libsql://...
export MNOTE_TURSO_AUTH_TOKEN=...
# Turso local replica
export MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica
export MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db
export MNOTE_TURSO_DATABASE_URL=libsql://...
export MNOTE_TURSO_AUTH_TOKEN=...
# libSQL synced database
export MNOTE_CONTROL_PLANE_BACKEND=turso-synced
export MNOTE_TURSO_SYNCED_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db
export MNOTE_TURSO_DATABASE_URL=libsql://...
export MNOTE_TURSO_AUTH_TOKEN=...
```
# Sync interval for turso-local-replica / turso-synced
export MNOTE_TURSO_SYNC_INTERVAL_MS=5000
## 当前决策边界
2026-07-03 已配置真实 Turso dev DB/token,但当前不把 Turso Cloud 作为 MNote 本地功能测试的前置条件,也不把它当成本地文件网盘。
- 本地 `.md`、附件、mindmap、OnlyOffice 文件仍是 local-first 文件真相,不迁入 Turso。
- Turso 只承接 Rust control-plane 元数据:auth、session、workspace、grant、AI policy/runtime、audit/outbox 等。
- `dev-hot` 默认可继续使用 `libsql-local``desktop-hot` 仍保守保留 `sqlite` fallback 默认。
- `turso-remote` / `turso-local-replica` / `turso-synced` 只作为 dev DB dry-run、迁移演练、回滚演练和后续受保护 smoke。
- remote 成为长期候选默认前,必须先完成 Argon2id password hash migration、remote smoke、rollback export 和 token 轮换策略。
## 本机私有 Turso env
真实 URL/token 不写入仓库。当前本机私有配置文件:
```bash
/mnt/Data1T/Mnote_data/control-plane/turso.env
```
要求:
- 文件权限必须是 `0600`
- 只在本机 shell 中 `source`,不要复制到设计文档、日志、issue、截图或 commit。
- repo 内不得出现 JWT 明文、`MNOTE_TURSO_AUTH_TOKEN=<jwt>` 或带 token 的 libSQL URL。
- `tmp/turso.md` 仅作为本机临时凭据来源,已被 `tmp/` ignore,不纳入版本管理。
加载方式:
```bash
set -a
source /mnt/Data1T/Mnote_data/control-plane/turso.env
set +a
```
确认不打印 token
```bash
printf 'backend=%s\n' "$MNOTE_CONTROL_PLANE_BACKEND"
printf 'url_host=%s\n' "$(printf '%s' "$MNOTE_TURSO_DATABASE_URL" | sed -E 's#^libsql://([^/]+).*#\1#')"
test -n "$MNOTE_TURSO_AUTH_TOKEN" && echo 'token_present=yes'
```
## 初始化
```bash
cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \
init \
--backend "$MNOTE_CONTROL_PLANE_BACKEND"
```
默认会创建测试账号:
- 邮箱:`mnote.e2e@example.com`
- 用户名:`mnote-e2e`
- 默认 workspace、directory grant、AI policy、UI preference、audit/outbox 初始化记录
可先 dry-run
```bash
cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \
init \
--backend "$MNOTE_CONTROL_PLANE_BACKEND" \
--dry-run
```
## SQLite 到 libSQL / Turso 迁移
```bash
cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \
migrate-sqlite-to-target \
--source /mnt/Data1T/Mnote_data/control-plane/control-plane.db \
--backend "$MNOTE_CONTROL_PLANE_BACKEND" \
--dry-run
cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \
migrate-sqlite-to-target \
--source /mnt/Data1T/Mnote_data/control-plane/control-plane.db \
--backend "$MNOTE_CONTROL_PLANE_BACKEND" \
--reset-target
```
## 回滚到 SQLite
```bash
cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \
export-target-to-sqlite \
--backend "$MNOTE_CONTROL_PLANE_BACKEND" \
--output /mnt/Data1T/Mnote_data/control-plane/control-plane.db \
--backup-existing
export MNOTE_CONTROL_PLANE_BACKEND=sqlite
export MNOTE_CONTROL_PLANE_DB_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane.db
npm run desktop:hot
```
回滚前必须保留 Turso DB 至少 30 天,便于审计和补导出。
## 验证命令
```bash
cargo check --manifest-path rust/Cargo.toml -p mnote-web
cargo test --manifest-path rust/Cargo.toml -p control-plane
cargo test --manifest-path rust/Cargo.toml -p control-plane --features turso-unit-tests -- --test-threads=1
cargo test --manifest-path rust/Cargo.toml -p mnote-web app_state_initializes_sqlite_control_plane_store
cargo test --manifest-path rust/Cargo.toml -p mnote-web dev_seed -- --test-threads=1
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_watcher -- --test-threads=1
node --test scripts/desktop-hot.test.js
node scripts/task-dev-hot-plan-test.js
node scripts/task-control-plane-admin-libsql-roundtrip-smoke.js
codegraph sync .
```
脚本审计:
```bash
rg -n "sqlite3|better-sqlite3|node:sqlite|MNOTE_CONTROL_PLANE_DB_PATH|control-plane\\.(sqlite|db)" scripts rust/crates \
-g '!target' -g '!node_modules' -g '!recycle'
```
允许命中范围:
- `control-plane-admin` CLI
- `sqlite` fallback 环境变量
- `scripts/lib/control-plane-test-env.js`
- `desktop-hot` / `dev-hot` / `prod-build-start` 的后端选择逻辑
- legacy `evidence.sqlite` / local search 测试边界
不允许命中范围:
- smoke 脚本直接调用 `sqlite3` CLI 写 control-plane DB
- Node 脚本用 SQLite 库写生产 control-plane DB
## 故障边界
- 缺 URL/token:应返回 `InvalidInput` 或启动时报清晰错误,不允许 panic。
- 本地 libSQL path 不可写:应返回 storage error,不允许 panic。
- remote 网络不可达、5xx、限流、replica 同步失败:属于后续云端同步/备份链路验收,必须在真实 Turso dev DB 配好后补注入测试。
- 真实 remote / replica / synced 未验证前,默认保留 `sqlite` fallback;本地功能测试优先使用 `libsql-local`
## Turso dev DB 后续云端验证项
remote / local-replica / synced 更接近云端同步、备份和多设备准备层;它们不影响当前 local-first 本地功能测试。真实 Turso 验证需要:
```bash
export MNOTE_TURSO_DATABASE_URL=libsql://...
export MNOTE_TURSO_AUTH_TOKEN=...
```
然后执行:
```bash
set -a
source /mnt/Data1T/Mnote_data/control-plane/turso.env
set +a
MNOTE_CONTROL_PLANE_BACKEND=turso-remote \
cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- init --backend turso-remote
MNOTE_CONTROL_PLANE_BACKEND=turso-remote \
cargo run --manifest-path rust/Cargo.toml -p control-plane --bin control-plane-admin -- \
migrate-sqlite-to-target \
--source /mnt/Data1T/Mnote_data/control-plane/control-plane.db \
--backend turso-remote \
--dry-run
```
当前已完成的非破坏性 remote dry-run
```bash
set -a
source /mnt/Data1T/Mnote_data/control-plane/turso.env
set +a
cargo run --manifest-path rust/Cargo.toml -q -p control-plane --bin control-plane-admin -- \
init --backend turso-remote --dry-run
cargo run --manifest-path rust/Cargo.toml -q -p control-plane --bin control-plane-admin -- \
migrate-sqlite-to-target \
--source /mnt/Data1T/Mnote_data/control-plane/control-plane.db \
--backend turso-remote \
--dry-run
```
2026-07-03 结果:
- `init --backend turso-remote --dry-run` 返回 `ok=true`
- `migrate-sqlite-to-target --backend turso-remote --dry-run` 返回 `ok=true`,并成功读取本地 SQLite 源库表计数,`targetBackend=TursoRemote`
- 这只证明 URL/token 和 admin CLI 配置可用,不代表 remote 已成为默认后端。
通过后再进入 remote / replica 默认切换;不要把 OpenHub SQLite 和 Rust control-plane 首轮本地 cutover 绑死。
## 后续优化 checklist
- [x] 增加 `MNOTE_TURSO_SYNC_INTERVAL_MS`,让 remote replica / synced 同步间隔可配置。
- [ ] remote replica 启动后做 explicit initial sync,并验证 `read_your_writes` 行为。
- [ ] 新增 control-plane health endpoint,仅暴露 backend/mode/schema/sync 状态,不暴露 token。
- [ ] Argon2id password migration 完成前,不把 remote Turso 作为长期默认。
- [ ] secret-gated `turso-dev` store contract smokeinit、migration、session、grant、audit/outbox、export rollback。
- [ ] browser QA 覆盖 `turso-remote``turso-local-replica`,但失败不阻塞本地 `libsql-local` smoke。
## P0 / P1 / P2 状态
当前已完成的是 local cutover 和安全基线,不是所有 Turso 优势都已经落地:
- P0 已完成:唯一写入口、脚本直写清退、`libsql-local` store、migration、admin roundtrip、browser QA、local 多线程并发防损坏测试、remote dry-run 凭据验证、`MNOTE_TURSO_SYNC_INTERVAL_MS`
- P0 未完成:explicit initial sync、control-plane health endpoint、Argon2id password migration。
- P1 未完成:读写并发性能优化。当前 `TursoControlPlaneStore` 仍是单 `Mutex<TursoConnection>`,读写都串行;这是故意保守的安全基线,不是最终性能设计。
- P2/P3 暂缓:CDC、Tantivy FTS、vector/hybrid search、Browser WASM/OPFS、OpenHub SQLite 迁移。
## 读写并发设计
当前实现优先保证一致性和可回滚:
- 单进程内通过 `Mutex<TursoConnection>` 串行化 control-plane 操作。
- 脚本和 smoke 不再直接写生产 control-plane DB,降低多进程直写损坏风险。
- local 多线程测试已覆盖 session、sidebar shortcut、AI runtime event、audit/outbox 并发写。
后续性能优化需要单独实施:
- 写入路径保持单写者或独占写锁;多表写入必须显式事务。
- 读路径可以评估 read connection pool 或 `RwLock`,但必须先按 store 方法分类只读/写入,并跑压力测试。
- remote replica 只适合本地读副本;写仍转发 remote primary,不把 embedded replica 当成高并发本地写主路径。
- 在同步 trait 未改 async 前,不扩大 `block_in_place` 使用面。
## CDC / Tantivy 评估
CDC 当前不进入默认路线:
- Turso CDC 通过 `PRAGMA capture_data_changes_conn(...)` 把变更写入 CDC 表,按连接开启,尊重事务边界。
- CDC 可以用于后续审计归档、外部消息桥接、实时分析或同步 spike。
- MNote control-plane 当前已有 `audit_log``outbox_events`,这些记录业务语义,比通用表级 CDC 更适合当前主链。
- CDC 可能捕获敏感字段,启用前必须定义脱敏、保留期、权限和导出边界。
Tantivy FTS 当前不进入 control-plane
- Turso FTS 是 `CREATE INDEX ... USING fts` + `fts_match` / `fts_score` / `fts_highlight`,不是 SQLite FTS5 的无成本替换。
- 当前 control-plane 只存轻量元数据,全文搜索收益有限。
- 页面正文、知识库和 RAG 仍走 local-first 文件 / OpenHub / WeKnora,不迁入 Turso control-plane。
- 后续如需搜索页面标题、路径、AI 会话标题或轻量摘要,可单独做 P2/P3 spike。
Browser WASM / OPFS 当前不进入主线:
- 它适合纯浏览器 / PWA 离线 SQL 存储,但当前 MNote 主路径是本地文件夹 + Rust mnote-web + watcher。
- 浏览器 OPFS 不能替代本地 `.md`、附件、mindmap、OnlyOffice 文件真相,否则会制造第三份正文真相。
- 浏览器内数据库也不能作为 auth、grant、AI policy 的权限真源;这些仍必须由 Rust control-plane 校验。
- 后续可作为 P3 spike:只存可重建 cache、草稿、最近访问索引或纯 Web 离线收集箱,不存不可恢复正文唯一副本和长期 secret。
## 密钥泄露检查
```bash
rg -n "eyJhbGciOi|MNOTE_TURSO_AUTH_TOKEN=.*ey|authToken=.*ey|mnote-liaibo\.aws-ap-northeast-1\.turso\.io" . \
-g '!target/**' \
-g '!node_modules/**' \
-g '!recycle/**' \
-g '!*.db' \
-g '!*.sqlite' \
-g '!*.sqlite3' \
-g '!*.png' \
-g '!*.jpg' \
-g '!*.jpeg'
```
允许结果:无输出;或者只命中文档中不含 token 的 host 示例。任何 JWT 明文命中都必须立即移出仓库路径并轮换 token。
+1073 -103
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -5,10 +5,16 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
[features]
default = []
turso-unit-tests = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.34", features = ["bundled"] }
libsql = "0.10.0-pre.4"
tokio = { version = "1", features = ["rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
File diff suppressed because it is too large Load Diff
+39
View File
@@ -8,6 +8,8 @@ pub enum ControlPlaneError {
Storage(String),
Unauthorized(String),
SessionExpired(String),
Timeout(String),
RateLimit(String),
}
impl fmt::Display for ControlPlaneError {
@@ -19,6 +21,8 @@ impl fmt::Display for ControlPlaneError {
ControlPlaneError::Storage(msg) => write!(f, "Storage: {msg}"),
ControlPlaneError::Unauthorized(msg) => write!(f, "Unauthorized: {msg}"),
ControlPlaneError::SessionExpired(msg) => write!(f, "SessionExpired: {msg}"),
ControlPlaneError::Timeout(msg) => write!(f, "Timeout: {msg}"),
ControlPlaneError::RateLimit(msg) => write!(f, "RateLimit: {msg}"),
}
}
}
@@ -31,6 +35,41 @@ impl From<rusqlite::Error> for ControlPlaneError {
}
}
impl From<libsql::Error> for ControlPlaneError {
fn from(e: libsql::Error) -> Self {
let message = e.to_string();
let lower = message.to_lowercase();
if message.contains("UNIQUE constraint failed")
|| message.contains("constraint failed")
|| message.contains("constraint violation")
{
ControlPlaneError::Conflict(message)
} else if message.contains("Unauthorized")
|| message.contains("authorization")
|| message.contains("401")
{
ControlPlaneError::Unauthorized(message)
} else if lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("deadline")
|| lower.contains("connection refused")
|| lower.contains("connection reset")
|| lower.contains("eof")
{
ControlPlaneError::Timeout(message)
} else if lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("too many requests")
|| lower.contains("429")
{
ControlPlaneError::RateLimit(message)
} else {
ControlPlaneError::Storage(message)
}
}
}
impl From<serde_json::Error> for ControlPlaneError {
fn from(e: serde_json::Error) -> Self {
ControlPlaneError::Storage(format!("JSON error: {e}"))
+2
View File
@@ -3,9 +3,11 @@ pub mod migrations;
pub mod model;
pub mod sqlite;
pub mod store;
pub mod turso;
pub use error::ControlPlaneError;
pub use migrations::run_migrations;
pub use model::*;
pub use sqlite::SqliteControlPlaneStore;
pub use store::*;
pub use turso::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
@@ -3,6 +3,7 @@
//! Each migration is a named step that runs inside a transaction.
//! The `migrations` table tracks which steps have been applied.
use crate::error::ControlPlaneError;
use rusqlite::{Connection, Result as SqlResult};
const MIGRATIONS: &[(&str, &str)] = &[
@@ -72,6 +73,34 @@ pub fn run_migrations(conn: &Connection) -> SqlResult<()> {
Ok(())
}
pub async fn run_libsql_migrations(conn: &libsql::Connection) -> Result<(), ControlPlaneError> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
)
.await?;
let mut rows = conn.query("SELECT name FROM _migrations", ()).await?;
let mut already_applied = Vec::new();
while let Some(row) = rows.next().await? {
already_applied.push(row.get::<String>(0)?);
}
for (name, sql) in MIGRATIONS {
if already_applied.iter().any(|value| value == name) {
continue;
}
tracing::info!("Running libSQL migration: {name}");
conn.execute_batch(sql).await?;
conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [*name])
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -96,6 +125,22 @@ mod tests {
> 0
}
#[cfg(feature = "turso-unit-tests")]
async fn libsql_table_exists(conn: &libsql::Connection, name: &str) -> bool {
let mut rows = conn
.query(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
[name],
)
.await
.unwrap_or_else(|_| panic!("query libSQL table {name}"));
if let Some(row) = rows.next().await.unwrap_or(None) {
row.get::<i64>(0).unwrap_or(0) > 0
} else {
false
}
}
#[test]
fn migration_is_idempotent() {
let conn = fresh_db();
@@ -136,4 +181,53 @@ mod tests {
assert!(table_exists(&conn, "users"));
assert!(table_exists(&conn, "_migrations"));
}
#[cfg(feature = "turso-unit-tests")]
#[test]
fn libsql_migration_is_idempotent() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build tokio runtime");
let database = rt
.block_on(async { libsql::Builder::new_local(":memory:").build().await })
.expect("build libSQL in-memory database");
let conn = database.connect().expect("connect libSQL");
rt.block_on(async {
run_libsql_migrations(&conn)
.await
.expect("first libSQL migration run");
run_libsql_migrations(&conn)
.await
.expect("second libSQL migration run (idempotent)");
});
let tables: &[&str] = &[
"users",
"auth_identities",
"auth_sessions",
"workspaces",
"workspace_members",
"directory_grants",
"share_links",
"sync_state",
"ai_policies",
"audit_log",
"outbox_events",
"legacy_id_map",
"ai_runtime_runs",
"ai_runtime_events",
"sidebar_shortcuts",
"user_ui_preferences",
"user_navigation_recent",
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
];
for table in tables {
let exists = rt.block_on(async { libsql_table_exists(&conn, table).await });
assert!(exists, "libSQL table {table} should exist");
}
}
}
+11
View File
@@ -112,6 +112,17 @@ pub struct WorkspaceRecord {
pub revision: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpsertWorkspaceInput {
pub id: Option<EntityId>,
pub owner_user_id: EntityId,
pub name: String,
pub kind: Option<String>,
pub root_uri: String,
pub root_path: String,
pub source_kind: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirectoryGrantRecord {
pub id: EntityId,
+246 -49
View File
@@ -18,7 +18,8 @@ use crate::model::{
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -555,6 +556,14 @@ fn list_ai_agent_profile_access_rows(
Ok(by_profile.into_values().collect())
}
impl SqliteControlPlaneStore {
fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> {
self.conn
.lock()
.map_err(|e| ControlPlaneError::Storage(format!("sqlite lock poisoned: {e}")))
}
}
impl ControlPlaneStore for SqliteControlPlaneStore {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
if input.username.trim().is_empty() {
@@ -568,7 +577,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let id = input.id.unwrap_or_else(|| new_id("usr"));
let role = input.role.unwrap_or_else(|| "user".to_string());
@@ -646,7 +655,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let user_id = input.user_id;
let password_hash = password_hash_v1(&input.password);
@@ -698,7 +707,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let provider = if account.contains('@') {
"password_email"
} else {
@@ -744,7 +753,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let expires_at = input
.expires_at
@@ -786,7 +795,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let resolved = conn
.query_row(
@@ -837,7 +846,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"session_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.execute(
"UPDATE auth_sessions SET revoked_at = ?1 WHERE id = ?2 AND revoked_at IS NULL",
params![now_text(), session_id],
@@ -849,7 +858,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
&self,
actor_id: &str,
) -> Result<WorkspaceRecord, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existing = conn
.query_row(
"SELECT id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision
@@ -933,11 +942,89 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(workspace)
}
fn upsert_workspace(
&self,
input: UpsertWorkspaceInput,
) -> Result<WorkspaceRecord, ControlPlaneError> {
let owner_user_id = input.owner_user_id.trim().to_string();
let name = input.name.trim().to_string();
let root_uri = input.root_uri.trim().to_string();
let root_path = input.root_path.trim().to_string();
if owner_user_id.is_empty() || name.is_empty() || root_uri.is_empty() || root_path.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"workspace owner/name/root 不能为空".to_string(),
));
}
let kind = input
.kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("personal")
.to_string();
let source_kind = input
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("local_folder")
.to_string();
let now = now_text();
let id = input.id.unwrap_or_else(|| new_id("ws"));
let mut conn = self.lock_conn()?;
let tx = conn.transaction()?;
tx.query_row(
"SELECT id FROM users WHERE id = ?1",
params![&owner_user_id],
|row| row.get::<_, String>(0),
)
.optional()?
.ok_or_else(|| ControlPlaneError::NotFound(format!("user not found: {owner_user_id}")))?;
tx.execute(
"INSERT INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, 1)
ON CONFLICT(id) DO UPDATE SET
owner_user_id = excluded.owner_user_id,
name = excluded.name,
kind = excluded.kind,
root_uri = excluded.root_uri,
root_path = excluded.root_path,
source_kind = excluded.source_kind,
updated_at = excluded.updated_at,
revision = workspaces.revision + 1",
params![
&id,
&owner_user_id,
&name,
&kind,
&root_uri,
&root_path,
&source_kind,
&now,
&now
],
)?;
tx.execute(
"INSERT OR IGNORE INTO workspace_members (id, workspace_id, user_id, role, status, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, 'owner', 'active', ?4, ?5, 1)",
params![new_id("wsm"), &id, &owner_user_id, &now, &now],
)?;
let workspace = tx.query_row(
"SELECT id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision
FROM workspaces WHERE id = ?1",
params![&id],
row_to_workspace,
)?;
tx.commit()?;
Ok(workspace)
}
fn grant_directory_access(
&self,
input: DirectoryGrantInput,
) -> Result<DirectoryGrantRecord, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let record = DirectoryGrantRecord {
id: new_id("grant"),
@@ -979,7 +1066,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
fn list_directory_grants(&self) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
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
@@ -1001,7 +1088,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if actor_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
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
@@ -1019,7 +1106,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
&self,
lookup: DirectoryGrantLookup,
) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let grant_id = lookup
.grant_id
.as_deref()
@@ -1067,7 +1154,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"grant_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existing_revision = conn
.query_row(
"SELECT revision FROM directory_grants WHERE id = ?1 AND status = 'active'",
@@ -1097,7 +1184,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
actor_id: &str,
root_uri: &str,
) -> Result<ResolvedAccess, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
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
@@ -1150,7 +1237,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let token_hash = share_token_hash_v1(&token);
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let link = ShareLinkRecord {
id: input.id.unwrap_or_else(|| new_id("share")),
@@ -1193,7 +1280,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision
FROM share_links
@@ -1215,7 +1302,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if token_hash.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let link = conn
.query_row(
"SELECT id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision
@@ -1236,7 +1323,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"link_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existed = conn.execute(
"UPDATE share_links
SET revoked_at = ?1, updated_at = ?2, revision = revision + 1
@@ -1259,7 +1346,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"audit action/target_kind 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.execute(
"INSERT INTO audit_log (id, actor_user_id, action, target_kind, target_id, metadata_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
@@ -1277,7 +1364,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
fn list_audit_log(&self, limit: usize) -> Result<Vec<AuditLogRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, actor_user_id, action, target_kind, target_id, metadata_json, created_at
FROM audit_log
@@ -1295,7 +1382,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
&self,
input: OutboxEventInput,
) -> Result<OutboxEventRecord, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let record = OutboxEventRecord {
id: new_id("evt"),
topic: input.topic,
@@ -1320,7 +1407,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
fn drain_outbox(&self, limit: usize) -> Result<Vec<OutboxEventRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, topic, event_type, payload_json, created_at, delivered_at, attempts
FROM outbox_events
@@ -1348,7 +1435,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"event_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let updated = conn.execute(
"UPDATE outbox_events
SET delivered_at = COALESCE(delivered_at, ?1), attempts = attempts + 1
@@ -1393,7 +1480,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"sidebar shortcut kind 只能是 page 或 folder".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO sidebar_shortcuts (
@@ -1454,7 +1541,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at,
@@ -1480,7 +1567,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at,
@@ -1510,7 +1597,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"sidebar shortcut user_id/shortcut_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE sidebar_shortcuts
SET status = 'removed', updated_at = ?1, revision = revision + 1
@@ -1541,7 +1628,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
serde_json::from_str::<serde_json::Value>(&input.value_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO user_ui_preferences (
@@ -1604,7 +1691,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
let source_kind = source_kind.map(str::trim).unwrap_or_default();
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
@@ -1641,7 +1728,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
@@ -1675,7 +1762,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai agent profile user_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO ai_agent_profiles (
@@ -1897,7 +1984,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
relative_path.as_deref(),
document_id.as_deref(),
)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO user_navigation_recent (
@@ -1961,7 +2048,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
return Ok(Vec::new());
}
let limit = limit.clamp(1, 100) as i64;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let rows = if let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) {
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
@@ -2007,7 +2094,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(&input.model_policy_json)?;
serde_json::from_str::<serde_json::Value>(&input.quota_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2090,7 +2177,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if actor_id.is_empty() && workspace_id.is_none() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
if let Some(workspace_id) = workspace_id {
let workspace_policy = conn
.query_row(
@@ -2139,7 +2226,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(error_json)?;
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2222,7 +2309,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if workspace_id.is_empty() || remote_kind.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.query_row(
"SELECT id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision
FROM sync_state
@@ -2250,7 +2337,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(&input.runtime_json)?;
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2354,7 +2441,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiRuntimeRunRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision
FROM ai_runtime_runs
@@ -2381,7 +2468,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
user_id: &str,
run_id: &str,
) -> Result<Option<AiRuntimeRunRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.query_row(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision
FROM ai_runtime_runs
@@ -2408,7 +2495,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let record = AiRuntimeEventRecord {
id: input.id.unwrap_or_else(|| new_id("are")),
user_id: input.user_id,
@@ -2442,13 +2529,33 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(record)
}
fn delete_ai_runtime_events_for_run(
&self,
user_id: &str,
run_id: &str,
) -> Result<usize, ControlPlaneError> {
let user_id = user_id.trim();
let run_id = run_id.trim();
if user_id.is_empty() || run_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"user_id/run_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let rows = conn.execute(
"DELETE FROM ai_runtime_events WHERE user_id = ?1 AND run_id = ?2",
params![user_id, run_id],
)?;
Ok(rows)
}
fn list_ai_runtime_events(
&self,
user_id: &str,
run_id: &str,
limit: usize,
) -> Result<Vec<AiRuntimeEventRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at
FROM ai_runtime_events
@@ -2473,7 +2580,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
after_seq: i64,
limit: usize,
) -> Result<Vec<AiRuntimeJournalEventRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"WITH ordered AS (
SELECT
@@ -2521,7 +2628,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
validate_ai_external_conversation_status(&input.status)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2639,7 +2746,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.query_row(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
@@ -2655,6 +2762,38 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
.map_err(ControlPlaneError::from)
}
fn list_ai_external_conversation_bindings(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
limit: usize,
) -> Result<Vec<AiExternalConversationBindingRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
if user_id.is_empty() || mnote_session_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
WHERE user_id = ?1
AND (?2 IS NULL OR workspace_id = ?2)
AND mnote_session_id = ?3
ORDER BY updated_at DESC
LIMIT ?4",
)?;
let rows = stmt
.query_map(
params![user_id, workspace_id, mnote_session_id, limit.max(1) as i64],
row_to_ai_external_conversation_binding,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn mark_ai_external_conversation_binding_status(
&self,
user_id: &str,
@@ -2673,7 +2812,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(0);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let deleted_at = if status == "active" {
None
@@ -2716,7 +2855,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai runtime session user_id/session_id/title 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"UPDATE ai_runtime_runs
@@ -2759,7 +2898,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai runtime session user_id/session_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existing = conn
.query_row(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision
@@ -2813,7 +2952,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai runtime session user_id/session_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE ai_runtime_runs
SET deleted_at = ?1, updated_at = ?1, revision = revision + 1
@@ -2928,6 +3067,55 @@ mod tests {
assert_eq!(access.grant_ids.len(), 1);
}
#[test]
fn upsert_workspace_supports_explicit_local_root_for_dev_seed() {
let store = store();
create_user(&store, "seed_owner");
let workspace = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:seed-owner:custom".to_string()),
owner_user_id: "seed_owner".to_string(),
name: "Seed Workspace".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-seed-workspace".to_string(),
root_path: "/tmp/mnote-seed-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("upsert workspace");
assert_eq!(workspace.id, "local-ws:seed-owner:custom");
assert_eq!(workspace.status, "active");
assert_eq!(workspace.root_uri, "file:///tmp/mnote-seed-workspace");
let updated = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some(workspace.id.clone()),
owner_user_id: "seed_owner".to_string(),
name: "Seed Workspace Renamed".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-seed-workspace".to_string(),
root_path: "/tmp/mnote-seed-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("update workspace");
assert_eq!(updated.name, "Seed Workspace Renamed");
assert_eq!(updated.revision, workspace.revision + 1);
assert_eq!(updated.status, "active");
let missing_user = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:missing:custom".to_string()),
owner_user_id: "missing".to_string(),
name: "Missing".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/missing".to_string(),
root_path: "/tmp/missing".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect_err("missing owner should fail");
assert!(matches!(missing_user, ControlPlaneError::NotFound(_)));
}
#[test]
fn explicit_write_grant_out_ranks_read_grant() {
let store = store();
@@ -3772,6 +3960,15 @@ mod tests {
assert_eq!(journal_events.len(), 1);
assert_eq!(journal_events[0].seq, 2);
assert_eq!(journal_events[0].event.event_type, "run.completed");
let deleted = store
.delete_ai_runtime_events_for_run("ai_runtime_user", "run_1")
.expect("delete runtime events");
assert_eq!(deleted, 2);
assert!(store
.list_ai_runtime_events("ai_runtime_user", "run_1", 10)
.expect("list after delete")
.is_empty());
}
#[test]
+31 -1
View File
@@ -11,7 +11,8 @@ use crate::model::{
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
};
pub trait ControlPlaneStore: Send + Sync {
@@ -44,6 +45,16 @@ pub trait ControlPlaneStore: Send + Sync {
actor_id: &str,
) -> Result<WorkspaceRecord, ControlPlaneError>;
/// Upsert a workspace record after the caller has authorized the operation.
///
/// The store only enforces schema-level invariants and user existence. It does
/// not validate filesystem paths or decide whether the actor may manage the
/// workspace.
fn upsert_workspace(
&self,
input: UpsertWorkspaceInput,
) -> Result<WorkspaceRecord, ControlPlaneError>;
fn grant_directory_access(
&self,
input: DirectoryGrantInput,
@@ -219,6 +230,17 @@ pub trait ControlPlaneStore: Send + Sync {
input: AppendAiRuntimeEventInput,
) -> Result<AiRuntimeEventRecord, ControlPlaneError>;
/// Hard-delete AI runtime events for a run after the caller has authorized it.
///
/// This is intended for controlled test/dev seeding and recovery workflows.
/// Product flows should prefer append-only events or higher-level session
/// deletion APIs.
fn delete_ai_runtime_events_for_run(
&self,
user_id: &str,
run_id: &str,
) -> Result<usize, ControlPlaneError>;
fn list_ai_runtime_events(
&self,
user_id: &str,
@@ -247,6 +269,14 @@ pub trait ControlPlaneStore: Send + Sync {
provider: &str,
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError>;
fn list_ai_external_conversation_bindings(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
limit: usize,
) -> Result<Vec<AiExternalConversationBindingRecord>, ControlPlaneError>;
fn mark_ai_external_conversation_binding_status(
&self,
user_id: &str,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
use std::fs;
use std::sync::Arc;
use std::thread;
use control_plane::{
AppendAiRuntimeEventInput, AppendAuditInput, ControlPlaneError, ControlPlaneStore,
CreateSessionInput, DirectoryGrantInput, OutboxEventInput, TursoControlPlaneConfig,
TursoControlPlaneMode, TursoControlPlaneStore, UpsertAiExternalConversationBindingInput,
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertUserInput, UpsertWorkspaceInput,
};
fn local_turso_store() -> (TursoControlPlaneStore, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!(
"mnote-control-plane-libsql-{}.db",
uuid::Uuid::new_v4().simple()
));
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("db-wal"));
let _ = fs::remove_file(path.with_extension("db-shm"));
(
TursoControlPlaneStore::open_local(&path).expect("open libSQL local control plane"),
path,
)
}
#[test]
fn libsql_local_store_covers_control_plane_core_flows() {
let (store, path) = local_turso_store();
let user = store
.upsert_user(UpsertUserInput {
id: Some("libsql_user".to_string()),
email: Some("libsql_user@example.com".to_string()),
username: "libsql_user".to_string(),
display_name: "libSQL User".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("upsert user");
assert_eq!(user.id, "libsql_user");
let workspace = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:libsql-user:workspace".to_string()),
owner_user_id: user.id.clone(),
name: "libSQL Workspace".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-libsql-workspace".to_string(),
root_path: "/tmp/mnote-libsql-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("upsert workspace");
assert_eq!(workspace.owner_user_id, user.id);
store
.grant_directory_access(DirectoryGrantInput {
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
root_uri: workspace.root_uri.clone(),
root_path: workspace.root_path.clone(),
permission: "write".to_string(),
recursive: true,
capabilities: vec!["ai".to_string()],
source: "test".to_string(),
created_by: Some("libsql_user".to_string()),
})
.expect("grant workspace access");
let access = store
.resolve_access("libsql_user", "file:///tmp/mnote-libsql-workspace/page.md")
.expect("resolve workspace access");
assert_eq!(access.permission, "write");
let run = store
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: None,
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
title: Some("libSQL run".to_string()),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
trace_id: Some("trace_1".to_string()),
status: "running".to_string(),
runtime_json: "{\"status\":\"running\"}".to_string(),
payload_json: "{\"message\":\"hello\"}".to_string(),
})
.expect("upsert ai runtime run");
assert_eq!(run.revision, 1);
store
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: None,
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
event_type: "message.delta".to_string(),
payload_json: "{\"text\":\"hello\"}".to_string(),
})
.expect("append runtime event");
assert_eq!(
store
.list_ai_runtime_events("libsql_user", "run_1", 10)
.expect("list runtime events")
.len(),
1
);
let binding = store
.upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput {
id: None,
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
mnote_session_id: "sess_1".to_string(),
acp_session_id: Some("acp_1".to_string()),
agent_id: "chat_only".to_string(),
profile: "openclaw-doubao-chat".to_string(),
provider: "doubao-web".to_string(),
remote_conversation_id: "remote_1".to_string(),
remote_url: Some("https://www.doubao.com/chat/remote_1".to_string()),
status: "active".to_string(),
metadata_json: "{\"source\":\"test\"}".to_string(),
})
.expect("upsert external conversation binding");
assert_eq!(binding.status, "active");
let found = store
.find_ai_external_conversation_binding(
"libsql_user",
Some(&workspace.id),
"sess_1",
"doubao-web",
)
.expect("find binding")
.expect("binding exists");
assert_eq!(found.id, binding.id);
drop(store);
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("db-wal"));
let _ = fs::remove_file(path.with_extension("db-shm"));
}
#[test]
fn libsql_local_store_handles_parallel_control_plane_writes() {
let (store, path) = local_turso_store();
let store = Arc::new(store);
let user = store
.upsert_user(UpsertUserInput {
id: Some("parallel_user".to_string()),
email: Some("parallel_user@example.com".to_string()),
username: "parallel_user".to_string(),
display_name: "Parallel User".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("upsert parallel user");
let workspace = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:parallel-user:workspace".to_string()),
owner_user_id: user.id.clone(),
name: "Parallel Workspace".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-libsql-parallel-workspace".to_string(),
root_path: "/tmp/mnote-libsql-parallel-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("upsert parallel workspace");
store
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: Some("parallel_run_record".to_string()),
user_id: user.id.clone(),
workspace_id: Some(workspace.id.clone()),
document_id: Some("parallel_doc".to_string()),
session_id: "parallel_session".to_string(),
run_id: "parallel_run".to_string(),
title: Some("Parallel run".to_string()),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
trace_id: None,
status: "running".to_string(),
runtime_json: "{}".to_string(),
payload_json: "{}".to_string(),
})
.expect("seed parallel run");
let mut handles = Vec::new();
for index in 0..8 {
let store = store.clone();
let workspace_id = workspace.id.clone();
handles.push(thread::spawn(move || {
let suffix = index.to_string();
store
.create_session(CreateSessionInput {
id: Some(format!("parallel_session_{suffix}")),
user_id: "parallel_user".to_string(),
token_hash: format!("parallel_token_hash_{suffix}"),
user_agent: Some("parallel-test".to_string()),
ip_hash: None,
expires_at: None,
})
.expect("create session in parallel");
store
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
id: Some(format!("parallel_shortcut_{suffix}")),
user_id: "parallel_user".to_string(),
workspace_id: workspace_id.clone(),
root_uri: Some("file:///tmp/mnote-libsql-parallel-workspace".to_string()),
kind: "page".to_string(),
source_kind: "local_folder".to_string(),
target_id: format!("doc_{suffix}"),
relative_path: Some(format!("doc_{suffix}.md")),
document_id: Some(format!("local-md:doc_{suffix}.md")),
title: format!("Doc {suffix}"),
icon: None,
sort_order: index,
metadata_json: "{}".to_string(),
})
.expect("upsert shortcut in parallel");
store
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: Some(format!("parallel_event_{suffix}")),
user_id: "parallel_user".to_string(),
workspace_id: Some(workspace_id.clone()),
document_id: Some("parallel_doc".to_string()),
session_id: "parallel_session".to_string(),
run_id: "parallel_run".to_string(),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
event_type: "message.delta".to_string(),
payload_json: format!("{{\"index\":{index}}}"),
})
.expect("append runtime event in parallel");
store
.append_audit(AppendAuditInput {
actor_user_id: Some("parallel_user".to_string()),
action: "parallel.write".to_string(),
target_kind: "control_plane".to_string(),
target_id: Some(format!("target_{suffix}")),
metadata_json: "{}".to_string(),
})
.expect("append audit in parallel");
store
.append_outbox(OutboxEventInput {
topic: "parallel".to_string(),
event_type: "parallel.write".to_string(),
payload_json: format!("{{\"index\":{index}}}"),
})
.expect("append outbox in parallel");
}));
}
for handle in handles {
handle.join().expect("parallel writer thread should not panic");
}
assert_eq!(
store
.list_ai_runtime_events("parallel_user", "parallel_run", 100)
.expect("list parallel events")
.len(),
8
);
assert_eq!(
store
.list_sidebar_shortcuts("parallel_user", &workspace.id)
.expect("list parallel shortcuts")
.len(),
8
);
assert_eq!(
store
.list_audit_log(20)
.expect("list parallel audit")
.len(),
8
);
assert_eq!(
store.drain_outbox(20).expect("drain parallel outbox").len(),
8
);
drop(store);
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("db-wal"));
let _ = fs::remove_file(path.with_extension("db-shm"));
}
#[test]
fn turso_config_rejects_missing_remote_credentials_without_panic() {
let result = TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig {
mode: TursoControlPlaneMode::Remote,
path: None,
remote_url: Some("libsql://example.turso.io".to_string()),
auth_token: None,
sync_interval: None,
});
let Err(error) = result else {
panic!("missing token should return an error");
};
assert!(matches!(error, ControlPlaneError::InvalidInput(_)));
}
+153 -6
View File
@@ -9,9 +9,13 @@ use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
#[cfg(not(test))]
use control_plane::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
use std::env;
use std::fs;
use std::sync::Arc;
#[cfg(not(test))]
use std::time::Duration;
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
@@ -130,6 +134,21 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
None
}
#[cfg(not(test))]
fn parse_turso_sync_interval_ms() -> Option<Duration> {
let value = env::var("MNOTE_TURSO_SYNC_INTERVAL_MS")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())?;
let ms: u64 = value
.parse()
.expect("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数(毫秒),例如 5000");
if ms == 0 {
panic!("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数,当前值: {ms}");
}
Some(Duration::from_millis(ms))
}
use tokio::sync::broadcast;
#[derive(Clone)]
@@ -141,7 +160,7 @@ pub struct AppState {
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
pub acp_runtime: Arc<AcpRuntimeManager>,
pub buffer_store: BufferStore,
control_plane: Arc<SqliteControlPlaneStore>,
control_plane: Arc<dyn ControlPlaneStore>,
}
impl AppState {
@@ -151,7 +170,7 @@ impl AppState {
let actor = EditorRuntimeActor::new();
actor.set_block_delta_tx(block_delta_tx.clone());
let buffer_store = BufferStore::new();
let control_plane = Arc::new(open_control_plane_store());
let control_plane = open_control_plane_store();
Self {
config: Arc::new(config),
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(
@@ -181,12 +200,33 @@ impl AppState {
}
#[cfg(test)]
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面")
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面"))
}
#[cfg(not(test))]
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let backend = env::var("MNOTE_CONTROL_PLANE_BACKEND")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "sqlite".to_string());
match backend.as_str() {
"sqlite" => open_sqlite_control_plane_store(),
"libsql-local" | "turso-local" | "turso" => open_turso_local_control_plane_store(),
"turso-remote" => open_turso_remote_control_plane_store(),
"turso-local-replica" | "turso-remote-replica" => {
open_turso_remote_replica_control_plane_store()
}
"turso-synced" => open_turso_synced_control_plane_store(),
other => panic!(
"不支持的控制面后端 {other},支持 sqlite/libsql-local/turso-remote/turso-local-replica/turso-synced"
),
}
}
#[cfg(not(test))]
fn open_sqlite_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
@@ -194,7 +234,114 @@ pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 SQLite 控制面目录");
}
SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面")
Arc::new(SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面"))
}
#[cfg(not(test))]
fn open_turso_local_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let db_path = env::var("MNOTE_TURSO_LOCAL_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db".to_string()
});
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 libSQL local 控制面目录");
}
Arc::new(TursoControlPlaneStore::open_local(&db_path).expect("初始化 libSQL local 控制面"))
}
#[cfg(not(test))]
fn open_turso_remote_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(TursoControlPlaneStore::open_remote(url, token).expect("初始化 Turso remote 控制面"))
}
#[cfg(not(test))]
fn open_turso_remote_replica_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let replica_path = env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db".to_string()
});
if let Some(parent) = std::path::Path::new(&replica_path).parent() {
fs::create_dir_all(parent).expect("创建 Turso local replica 控制面目录");
}
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(
TursoControlPlaneStore::open_remote_replica(
replica_path,
url,
token,
parse_turso_sync_interval_ms(),
)
.expect("初始化 Turso local replica 控制面"),
)
}
#[cfg(not(test))]
fn open_turso_synced_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let local_path = env::var("MNOTE_TURSO_SYNCED_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db".to_string()
});
if let Some(parent) = std::path::Path::new(&local_path).parent() {
fs::create_dir_all(parent).expect("创建 Turso synced 控制面目录");
}
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(
TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig {
mode: TursoControlPlaneMode::Synced,
path: Some(local_path.into()),
remote_url: Some(url),
auth_token: Some(token),
sync_interval: parse_turso_sync_interval_ms(),
})
.expect("初始化 Turso synced 控制面"),
)
}
pub fn build_app(state: AppState) -> Router {
@@ -4,7 +4,7 @@ use crate::routes::{
refresh_local_search_index_for_change_path_with_store,
refresh_local_search_index_if_scheduled_due_with_store,
};
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use control_plane::ControlPlaneStore;
use notify::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value};
@@ -32,7 +32,7 @@ impl std::fmt::Debug for LocalFolderWatcherRegistry {
}
impl LocalFolderWatcherRegistry {
pub fn new(buffer_store: BufferStore, control_plane: Arc<SqliteControlPlaneStore>) -> Self {
pub fn new(buffer_store: BufferStore, control_plane: Arc<dyn ControlPlaneStore>) -> Self {
Self {
inner: Arc::new(LocalFolderWatcherRegistryInner {
entries: Mutex::new(HashMap::new()),
@@ -381,7 +381,12 @@ fn is_local_search_index_path(path: &Path) -> bool {
component
.as_os_str()
.to_str()
.map(|value| value == ".mnote")
.map(|value| {
matches!(
value,
".mnote" | ".git" | "node_modules" | "target" | "dist" | "build"
)
})
.unwrap_or(false)
}) {
return false;
@@ -573,6 +578,15 @@ mod tests {
assert!(!is_local_search_index_path(&std::path::Path::new(
".mnote/page-options.json"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
"node_modules/pkg/README.md"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
"target/doc/README.md"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
".git/HEAD"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
"docs/.DS_Store"
)));
@@ -0,0 +1,600 @@
use crate::app::AppState;
use crate::error::WebError;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use control_plane::{
AppendAiRuntimeEventInput, CreatePasswordIdentityInput, DirectoryGrantInput,
UpsertAiRuntimeRunInput, UpsertUserInput, UpsertWorkspaceInput,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DevSeedRequest {
seeds: Vec<DevSeedOperation>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum DevSeedOperation {
SetupWorkspace {
user_id: String,
#[serde(default)]
email: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
password: Option<String>,
workspace_id: String,
workspace_name: String,
root_uri: String,
root_path: String,
#[serde(default)]
source_kind: Option<String>,
#[serde(default)]
permission: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
grant_source: Option<String>,
#[serde(default)]
grant_created_by: Option<String>,
},
SeedAiRuntime {
#[serde(default)]
id: Option<String>,
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
#[serde(default)]
document_id: Option<String>,
session_id: String,
run_id: String,
#[serde(default)]
title: Option<String>,
profile: String,
acp_runtime: String,
#[serde(default)]
trace_id: Option<String>,
status: String,
#[serde(default = "empty_json_object")]
runtime_json: Value,
#[serde(default = "empty_json_object")]
payload_json: Value,
#[serde(default)]
events: Vec<DevSeedRuntimeEvent>,
},
ClearRuntimeEvents {
user_id: String,
run_id: String,
},
GetAiRuntimeRun {
user_id: String,
run_id: String,
},
ListAiRuntimeRuns {
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
#[serde(default)]
document_id: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default = "default_dev_seed_limit")]
limit: usize,
},
ListAiRuntimeEvents {
user_id: String,
run_id: String,
#[serde(default = "default_dev_seed_limit")]
limit: usize,
#[serde(default)]
event_type: Option<String>,
},
CountAiRuntimeEvents {
user_id: String,
run_id: String,
#[serde(default)]
event_type: Option<String>,
},
FindExternalConversationBinding {
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
mnote_session_id: String,
provider: String,
},
ListExternalConversationBindings {
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
mnote_session_id: String,
#[serde(default = "default_dev_seed_limit")]
limit: usize,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DevSeedRuntimeEvent {
#[serde(default)]
id: Option<String>,
event_type: String,
#[serde(default = "empty_json_object")]
payload_json: Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DevSeedResponse {
ok: bool,
results: Vec<Value>,
}
fn empty_json_object() -> Value {
json!({})
}
fn default_dev_seed_limit() -> usize {
200
}
pub async fn seed(
State(state): State<AppState>,
Json(request): Json<DevSeedRequest>,
) -> Result<Json<DevSeedResponse>, WebError> {
if !state.config().allow_dev_fixtures {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"dev_seed_disabled",
"dev seed API 默认关闭,需要 MNOTE_WEB_ALLOW_DEV_FIXTURES=1",
));
}
let mut results = Vec::with_capacity(request.seeds.len());
for operation in request.seeds {
results.push(apply_seed_operation(&state, operation)?);
}
Ok(Json(DevSeedResponse { ok: true, results }))
}
fn apply_seed_operation(
state: &AppState,
operation: DevSeedOperation,
) -> Result<Value, WebError> {
match operation {
DevSeedOperation::SetupWorkspace {
user_id,
email,
username,
display_name,
role,
password,
workspace_id,
workspace_name,
root_uri,
root_path,
source_kind,
permission,
capabilities,
grant_source,
grant_created_by,
} => {
let username = username.unwrap_or_else(|| user_id.clone());
let display_name = display_name.unwrap_or_else(|| username.clone());
let email = email.or_else(|| Some(format!("{username}@example.com")));
let user = state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(user_id.clone()),
email: email.clone(),
username: username.clone(),
display_name: display_name.clone(),
role,
password_hash: None,
})
.map_err(dev_seed_error)?;
if let Some(password) = password.filter(|value| !value.is_empty()) {
state
.control_plane()
.create_password_identity(CreatePasswordIdentityInput {
user_id: user.id.clone(),
email,
username,
password,
})
.map_err(dev_seed_error)?;
}
let workspace = state
.control_plane()
.upsert_workspace(UpsertWorkspaceInput {
id: Some(workspace_id),
owner_user_id: user.id.clone(),
name: workspace_name,
kind: Some("personal".to_string()),
root_uri: root_uri.clone(),
root_path: root_path.clone(),
source_kind,
})
.map_err(dev_seed_error)?;
let grant = state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: user.id.clone(),
workspace_id: Some(workspace.id.clone()),
root_uri,
root_path,
permission: permission.unwrap_or_else(|| "write".to_string()),
recursive: true,
capabilities: if capabilities.is_empty() {
vec!["ai".to_string()]
} else {
capabilities
},
source: grant_source.unwrap_or_else(|| "dev_seed".to_string()),
created_by: grant_created_by.or_else(|| Some(user.id.clone())),
})
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "setupWorkspace",
"user": user,
"workspace": workspace,
"grant": grant,
}))
}
DevSeedOperation::SeedAiRuntime {
id,
user_id,
workspace_id,
document_id,
session_id,
run_id,
title,
profile,
acp_runtime,
trace_id,
status,
runtime_json,
payload_json,
events,
} => {
let run = state
.control_plane()
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id,
user_id: user_id.clone(),
workspace_id: workspace_id.clone(),
document_id: document_id.clone(),
session_id: session_id.clone(),
run_id: run_id.clone(),
title,
profile: profile.clone(),
acp_runtime: acp_runtime.clone(),
trace_id,
status,
runtime_json: runtime_json.to_string(),
payload_json: payload_json.to_string(),
})
.map_err(dev_seed_error)?;
let mut event_records = Vec::with_capacity(events.len());
for event in events {
event_records.push(
state
.control_plane()
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: event.id,
user_id: user_id.clone(),
workspace_id: workspace_id.clone(),
document_id: document_id.clone(),
session_id: session_id.clone(),
run_id: run_id.clone(),
profile: profile.clone(),
acp_runtime: acp_runtime.clone(),
event_type: event.event_type,
payload_json: event.payload_json.to_string(),
})
.map_err(dev_seed_error)?,
);
}
Ok(json!({
"kind": "seedAiRuntime",
"run": run,
"events": event_records,
}))
}
DevSeedOperation::ClearRuntimeEvents { user_id, run_id } => {
let deleted = state
.control_plane()
.delete_ai_runtime_events_for_run(&user_id, &run_id)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "clearRuntimeEvents",
"deleted": deleted,
}))
}
DevSeedOperation::GetAiRuntimeRun { user_id, run_id } => {
let run = state
.control_plane()
.find_ai_runtime_run(&user_id, &run_id)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "getAiRuntimeRun",
"run": run,
}))
}
DevSeedOperation::ListAiRuntimeRuns {
user_id,
workspace_id,
document_id,
session_id,
limit,
} => {
let runs = state
.control_plane()
.list_ai_runtime_runs(
&user_id,
workspace_id.as_deref(),
document_id.as_deref(),
session_id.as_deref(),
limit.clamp(1, 1_000),
)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "listAiRuntimeRuns",
"runs": runs,
}))
}
DevSeedOperation::ListAiRuntimeEvents {
user_id,
run_id,
limit,
event_type,
} => {
let events = state
.control_plane()
.list_ai_runtime_events(&user_id, &run_id, limit.clamp(1, 10_000))
.map_err(dev_seed_error)?;
let events: Vec<_> = events
.into_iter()
.filter(|event| {
event_type
.as_deref()
.map(|expected| event.event_type == expected)
.unwrap_or(true)
})
.collect();
Ok(json!({
"kind": "listAiRuntimeEvents",
"events": events,
}))
}
DevSeedOperation::CountAiRuntimeEvents {
user_id,
run_id,
event_type,
} => {
let events = state
.control_plane()
.list_ai_runtime_events(&user_id, &run_id, 10_000)
.map_err(dev_seed_error)?;
let count = events
.iter()
.filter(|event| {
event_type
.as_deref()
.map(|expected| event.event_type == expected)
.unwrap_or(true)
})
.count();
Ok(json!({
"kind": "countAiRuntimeEvents",
"count": count,
}))
}
DevSeedOperation::FindExternalConversationBinding {
user_id,
workspace_id,
mnote_session_id,
provider,
} => {
let binding = state
.control_plane()
.find_ai_external_conversation_binding(
&user_id,
workspace_id.as_deref(),
&mnote_session_id,
&provider,
)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "findExternalConversationBinding",
"binding": binding,
}))
}
DevSeedOperation::ListExternalConversationBindings {
user_id,
workspace_id,
mnote_session_id,
limit,
} => {
let bindings = state
.control_plane()
.list_ai_external_conversation_bindings(
&user_id,
workspace_id.as_deref(),
&mnote_session_id,
limit.clamp(1, 1_000),
)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "listExternalConversationBindings",
"bindings": bindings,
}))
}
}
}
fn dev_seed_error(error: control_plane::ControlPlaneError) -> WebError {
match error {
control_plane::ControlPlaneError::InvalidInput(message) => {
WebError::bad_request_code("dev_seed_invalid", message)
}
control_plane::ControlPlaneError::NotFound(message) => {
WebError::new(StatusCode::NOT_FOUND, "dev_seed_not_found", message)
}
control_plane::ControlPlaneError::Conflict(message) => {
WebError::bad_request_code("dev_seed_conflict", message)
}
other => WebError::internal(format!("dev seed failed: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::AppConfig;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::routing::post;
use axum::Router;
use serde_json::{json, Value};
use tower::ServiceExt;
fn test_router(allow_dev_fixtures: bool) -> Router {
Router::new()
.route("/api/dev/seed", post(super::seed))
.with_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: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures,
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(),
}))
}
async fn send_seed(router: Router, body: Value) -> (StatusCode, Value) {
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/api/dev/seed")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("valid request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes");
let body_str = String::from_utf8_lossy(&body);
let payload: Value = serde_json::from_slice(&body)
.unwrap_or_else(|e| panic!("send_seed: status={status} body={body_str} error={e}"));
(status, payload)
}
#[tokio::test]
async fn dev_seed_returns_forbidden_when_disabled() {
let (status, payload) = send_seed(test_router(false), json!({ "seeds": [] })).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(payload["code"], "dev_seed_disabled");
}
#[tokio::test]
async fn dev_seed_setup_workspace_writes_to_control_plane() {
let router = test_router(true);
// Step 1: setupWorkspace → 创建用户 + workspace + directory grant
let (status, payload) = send_seed(
router.clone(),
json!({
"seeds": [{
"kind": "setupWorkspace",
"user_id": "dev-seed-test-user",
"workspace_id": "dev-seed-test-ws",
"workspace_name": "测试开发空间",
"root_uri": "file:///tmp/dev-seed-test",
"root_path": "/tmp/dev-seed-test"
}]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "setupWorkspace 应成功: {payload}");
assert!(payload["ok"].as_bool().unwrap_or(false));
assert_eq!(payload["results"][0]["kind"], "setupWorkspace");
assert_eq!(payload["results"][0]["user"]["id"], "dev-seed-test-user");
assert_eq!(payload["results"][0]["workspace"]["id"], "dev-seed-test-ws");
assert!(payload["results"][0]["grant"].is_object());
// Step 2: seedAiRuntime → 创建 AI runtime run + event
let (status, payload) = send_seed(
router.clone(),
json!({
"seeds": [{
"kind": "seedAiRuntime",
"user_id": "dev-seed-test-user",
"session_id": "test-session-1",
"run_id": "test-run-1",
"profile": "test-profile",
"acp_runtime": "reasonix",
"status": "running",
"events": [{
"eventType": "message",
"payloadJson": { "text": "hello" }
}]
}]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "seedAiRuntime 应成功: {payload}");
assert!(payload["ok"].as_bool().unwrap_or(false));
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["status"], "running");
assert_eq!(
payload["results"][0]["events"].as_array().unwrap().len(),
1
);
// Step 3: getAiRuntimeRun → 回读验证
let (status, payload) = send_seed(
router,
json!({
"seeds": [{
"kind": "getAiRuntimeRun",
"user_id": "dev-seed-test-user",
"run_id": "test-run-1"
}]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "getAiRuntimeRun 应成功: {payload}");
assert!(payload["results"][0]["run"].is_object());
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["status"], "running");
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::provider_identity_sync::sync_provider_identities;
use crate::routes::local_folder_source::{
control_plane_db_path_display, create_default_local_workspace_for_actor,
control_plane_status_display, create_default_local_workspace_for_actor,
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
@@ -198,7 +198,7 @@ pub async fn admin_access_policy_entry(
.with_context(&context));
}
let workspace_name = default_workspace_name_for_context(&state, &context);
let share_grants_path = control_plane_db_path_display();
let share_grants_path = control_plane_status_display();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} share_grants_path={share_grants_path} is_admin=true />
});
@@ -15,7 +15,7 @@ use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::project_legacy_content_to_block_document;
use control_plane::AppendAuditInput;
use control_plane::{AppendAuditInput, ResolvedAccess};
use control_plane::{
CreateShareLinkInput, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord,
OutboxEventInput, ShareLinkRecord,
@@ -657,10 +657,24 @@ pub(crate) fn ensure_local_workspace_access_with_state(
));
}
let actor_id = context.auth.actor_id.trim();
let canonical_root_uri = file_uri_for_path(&canonical_root);
let sqlite_access = state
.control_plane()
.resolve_access(actor_id, &file_uri_for_path(&canonical_root))
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查询失败: {error}")))?;
.resolve_access(actor_id, &canonical_root_uri)
.unwrap_or_else(|error| {
tracing::warn!(
%actor_id,
%error,
root_uri = %canonical_root_uri,
"控制面授权查询失败,回退到本地文件系统权限检查"
);
ResolvedAccess {
user_id: actor_id.to_string(),
root_uri: canonical_root_uri.clone(),
permission: String::new(),
grant_ids: Vec::new(),
}
});
if local_access_permission_allows(&sqlite_access.permission, mode) {
return Ok(canonical_root);
}
@@ -766,18 +780,78 @@ fn local_access_policy_path() -> PathBuf {
})
}
pub(crate) fn control_plane_db_path_display() -> String {
std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
pub(crate) fn control_plane_status_display() -> String {
let backend = std::env::var("MNOTE_CONTROL_PLANE_BACKEND")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
default_local_workspace_base_dir()
.join("control-plane")
.join("control-plane.db")
.display()
.to_string()
})
.unwrap_or_else(|| "sqlite".to_string());
match backend.as_str() {
"sqlite" => {
let db_path = std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
default_local_workspace_base_dir()
.join("control-plane")
.join("control-plane.db")
.display()
.to_string()
});
format!("backend=sqlite; db={db_path}")
}
"libsql-local" | "turso-local" | "turso" => {
let db_path = std::env::var("MNOTE_TURSO_LOCAL_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
default_local_workspace_base_dir()
.join("control-plane")
.join("control-plane-libsql.db")
.display()
.to_string()
});
format!("backend={backend}; local={db_path}")
}
"turso-local-replica" | "turso-remote-replica" => {
let db_path = std::env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
default_local_workspace_base_dir()
.join("control-plane")
.join("control-plane-replica.db")
.display()
.to_string()
});
format!("backend={backend}; replica={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
}
"turso-remote" => "backend=turso-remote; remote=env:MNOTE_TURSO_DATABASE_URL".to_string(),
"turso-synced" => {
let db_path = std::env::var("MNOTE_TURSO_SYNCED_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| {
default_local_workspace_base_dir()
.join("control-plane")
.join("control-plane-synced.db")
.display()
.to_string()
});
format!("backend=turso-synced; local={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
}
other => format!("backend={other}; unsupported"),
}
}
fn local_share_grants_path() -> PathBuf {
@@ -3304,7 +3378,7 @@ fn save_local_markdown_page_inner(
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
let control_plane = open_control_plane_store();
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
&control_plane,
control_plane.as_ref(),
root,
root_uri,
workspace_id,
+7 -4
View File
@@ -1,6 +1,7 @@
mod bridge;
pub(crate) mod command_support;
mod compat;
mod dev_seed;
pub(crate) mod dev_hot;
mod documents;
mod editor;
@@ -45,10 +46,11 @@ mod ws;
pub(crate) use gateway::current_actor_id;
pub(crate) use local_folder_source::{
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key,
local_workspace_id_from_root_uri, update_local_markdown_title,
write_local_markdown_page_body,
};
#[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings;
@@ -87,6 +89,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/evidence/search", post(evidence::search))
.route("/api/evidence/read", post(evidence::read))
.route("/api/evidence/open", post(evidence::open))
.route("/api/dev/seed", post(dev_seed::seed))
.route("/api/knowledge-rag/status", get(knowledge_rag::status))
.route(
"/api/knowledge-rag/pipeline-events",
+3 -3
View File
@@ -1,5 +1,6 @@
//! MNOTE 文件夹授权管理组件
use crate::routes::control_plane_status_display;
use leptos::prelude::*;
#[component]
@@ -13,8 +14,7 @@ pub fn AdminAccessPolicyPanel(
.unwrap_or_else(|| "开发用户 的空间".to_string())
.trim()
.to_string();
let share_grants_path = share_grants_path
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
let share_grants_path = share_grants_path.unwrap_or_else(control_plane_status_display);
let scope_text = if is_admin {
"管理员可以授权任意本地文件夹。"
} else {
@@ -40,7 +40,7 @@ pub fn AdminAccessPolicyPanel(
<strong data-testid="mnote-admin-workspace-name">{workspace_name.clone()}</strong>
</div>
<div class="mnote-admin-policy-summary-item">
<span class="mnote-admin-policy-summary-label">"授权记录"</span>
<span class="mnote-admin-policy-summary-label">"控制面"</span>
<code data-testid="mnote-admin-share-grants-path">{share_grants_path.clone()}</code>
</div>
</section>
+43
View File
@@ -397,3 +397,46 @@ node scripts/task164-page-options-visible-effect-smoke.js
2. 再补一份“如何读取 smoke 结果”的轻量脚本或汇总器
这样能直接解决当前最真实的痛点:`A + C`,也就是“结果更直观、测试更像真的看过页面”。
## 7. 脚本纪律:不得直写 control-plane 数据库
所有测试和 smoke 脚本**不得直接通过 `sqlite3` CLI、`node:sqlite``better-sqlite3` 或任何本地 SQLite 库写 control-plane DB**。需要写入测试数据时,必须走以下批准路径:
### 7.1 通过 API seed(推荐)
使用 `scripts/lib/control-plane-dev-seed.js` 提供的 helper,通过 `POST /api/dev/seed` 写入:
```js
const { setupWorkspaceAccess, seedAiRuntime } = require("./lib/control-plane-dev-seed");
await setupWorkspaceAccess(requestContext, baseUrl, { ... });
```
该 helper 调用的是 Rust `/api/dev/seed` 端点,不直写数据库。
初始环境(测试账号 + workspace)也可通过 `control-plane-admin init --backend ...` 准备,见 `docs/operations/control-plane-turso.md`
### 7.2 通过统一环境变量构建
使用 `scripts/lib/control-plane-test-env.js``buildControlPlaneTestEnv()` 获取正确的控制面环境变量:
```js
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
// 然后传给 spawn(cargo, args, { env: { ...controlPlaneEnv } })
```
### 7.3 例外
- `scripts/lib/control-plane-dev-seed.js``scripts/lib/control-plane-test-env.js` 本身是批准 helper。
- `scripts/desktop-hot.js``scripts/dev-hot.js``scripts/prod-build-start.js` 作为启动基础设施,设置环境变量路径是必要的。
- `rust/crates/` 下的 Rust 代码通过 `ControlPlaneStore` trait 访问数据库是正常路径。
- `scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` 使用 `cargo run --bin control-plane-admin`,走 Rust admin CLI。
- `sqlite` fallback、admin CLI、legacy evidence/local_search 不在脚本纪律约束范围内。
- OpenHub 自身 SQLite 不绑定本轮 control-plane Turso 切换;OpenHub 数据库迁移独立安排,不在当前首轮范围内。
### 7.4 违规后果
- 直接 `sqlite3` 写 control-plane DB 会导致多个进程同时写同一个 SQLite 文件,增加 WAL 损坏和并发写入冲突的风险。
- 直接写库的脚本在切换到 Turso/libSQL 后端后将无法运行。
- 违反此纪律的脚本将被要求改用上述批准路径。
具体约束条目见 AGENTS.md 中"脚本纪律"一节。
+215
View File
@@ -0,0 +1,215 @@
"use strict";
const assert = require("node:assert/strict");
async function postDevSeed(requestContext, baseUrl, seeds, timeoutMs) {
const response = await requestContext.fetch(`${baseUrl.replace(/\/+$/, "")}/api/dev/seed`, {
method: "POST",
headers: { "content-type": "application/json" },
data: { seeds },
timeout: timeoutMs,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
assert(
response.ok(),
`/api/dev/seed 失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
return payload;
}
async function setupWorkspaceAccess(requestContext, baseUrl, options) {
const actorId = options.actorId;
const rootPath = options.rootPath || options.root;
const rootUri = options.rootUri || `file://${rootPath}`;
return postDevSeed(
requestContext,
baseUrl,
[
{
kind: "setupWorkspace",
userId: actorId,
email: options.email || `${actorId}@example.com`,
username: options.username || actorId,
displayName: options.displayName || actorId,
role: options.role || "user",
workspaceId: options.workspaceId,
workspaceName: options.workspaceName || "MNote Smoke Workspace",
rootUri,
rootPath,
sourceKind: options.sourceKind || "local_folder",
permission: options.permission || "write",
capabilities: options.capabilities || ["ai"],
grantSource: options.grantSource || "smoke",
grantCreatedBy: options.grantCreatedBy || actorId,
},
],
options.timeoutMs,
);
}
async function seedAiRuntime(requestContext, baseUrl, options) {
return postDevSeed(
requestContext,
baseUrl,
[
{
kind: "seedAiRuntime",
id: options.id,
userId: options.userId,
workspaceId: options.workspaceId,
documentId: options.documentId,
sessionId: options.sessionId,
runId: options.runId,
title: options.title,
profile: options.profile || "reasonix",
acpRuntime: options.acpRuntime || options.profile || "reasonix",
traceId: options.traceId,
status: options.status || "running",
runtimeJson: options.runtimeJson || {},
payloadJson: options.payloadJson || {},
events: options.events || [],
},
],
options.timeoutMs,
);
}
async function clearRuntimeEvents(requestContext, baseUrl, options) {
return postDevSeed(
requestContext,
baseUrl,
[
{
kind: "clearRuntimeEvents",
userId: options.userId,
runId: options.runId,
},
],
options.timeoutMs,
);
}
async function getAiRuntimeRun(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "getAiRuntimeRun",
userId: options.userId,
runId: options.runId,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].run : null;
}
async function listAiRuntimeRuns(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "listAiRuntimeRuns",
userId: options.userId,
workspaceId: options.workspaceId,
documentId: options.documentId,
sessionId: options.sessionId,
limit: options.limit,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].runs || [] : [];
}
async function listAiRuntimeEvents(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "listAiRuntimeEvents",
userId: options.userId,
runId: options.runId,
limit: options.limit,
eventType: options.eventType,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].events || [] : [];
}
async function countAiRuntimeEvents(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "countAiRuntimeEvents",
userId: options.userId,
runId: options.runId,
eventType: options.eventType,
},
],
options.timeoutMs,
);
return Number(payload.results && payload.results[0] ? payload.results[0].count : 0);
}
async function findExternalConversationBinding(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "findExternalConversationBinding",
userId: options.userId,
workspaceId: options.workspaceId,
mnoteSessionId: options.mnoteSessionId,
provider: options.provider,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].binding : null;
}
async function listExternalConversationBindings(requestContext, baseUrl, options) {
const payload = await postDevSeed(
requestContext,
baseUrl,
[
{
kind: "listExternalConversationBindings",
userId: options.userId,
workspaceId: options.workspaceId,
mnoteSessionId: options.mnoteSessionId,
limit: options.limit,
},
],
options.timeoutMs,
);
return payload.results && payload.results[0] ? payload.results[0].bindings || [] : [];
}
module.exports = {
postDevSeed,
setupWorkspaceAccess,
seedAiRuntime,
clearRuntimeEvents,
getAiRuntimeRun,
listAiRuntimeRuns,
listAiRuntimeEvents,
countAiRuntimeEvents,
findExternalConversationBinding,
listExternalConversationBindings,
};
+44
View File
@@ -0,0 +1,44 @@
"use strict";
const path = require("node:path");
function buildControlPlaneTestEnv(dataRoot, baseEnv = process.env) {
const backend = String(baseEnv.MNOTE_SMOKE_CONTROL_PLANE_BACKEND || "libsql-local").trim();
if (!backend) {
throw new Error("MNOTE_SMOKE_CONTROL_PLANE_BACKEND 不能为空");
}
const env = {
MNOTE_CONTROL_PLANE_BACKEND: backend,
};
if (backend === "sqlite") {
env.MNOTE_CONTROL_PLANE_DB_PATH =
baseEnv.MNOTE_SMOKE_CONTROL_PLANE_DB_PATH || path.join(dataRoot, "control-plane.sqlite3");
return env;
}
if (backend === "libsql-local" || backend === "turso-local" || backend === "turso") {
env.MNOTE_TURSO_LOCAL_PATH =
baseEnv.MNOTE_SMOKE_TURSO_LOCAL_PATH || path.join(dataRoot, "control-plane-libsql.db");
return env;
}
if (backend === "turso-local-replica" || backend === "turso-remote-replica") {
env.MNOTE_TURSO_LOCAL_REPLICA_PATH =
baseEnv.MNOTE_SMOKE_TURSO_LOCAL_REPLICA_PATH
|| baseEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH
|| path.join(dataRoot, "control-plane-replica.db");
return env;
}
if (backend === "turso-remote" || backend === "turso-synced") {
return env;
}
throw new Error(`Unsupported MNOTE_SMOKE_CONTROL_PLANE_BACKEND: ${backend}`);
}
module.exports = {
buildControlPlaneTestEnv,
};
@@ -0,0 +1,200 @@
#!/usr/bin/env node
"use strict";
/**
* task-control-plane-admin-libsql-roundtrip-smoke.js
*
* 验证 control-plane-admin CLI SQLite libSQL local SQLite 往返:
* init migrate-sqlite-to-target export-target-to-sqlite
*
* 假设:
* - 工作区根 /mnt/Data1T/mnote
* - cargo 工作区包含 control-plane
* - libsql (turso) 原生依赖已可运行 (cargo build 通过)
*
* 不使用:
* - 生产 control-plane 路径
* - sqlite3 CLI
* 所有数据库文件创建在 /tmp
*/
const assert = require("node:assert");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs/promises");
const path = require("node:path");
// ─── 配置 ──────────────────────────────────────────────────────────
const PROJECT_ROOT = "/mnt/Data1T/mnote";
const CARGO_MANIFEST = path.join(PROJECT_ROOT, "rust/Cargo.toml");
const PKG = "control-plane";
const BIN = "control-plane-admin";
const TMP_PREFIX = path.join("/tmp", `cp-admin-smoke-${Date.now().toString(36)}`);
// ─── 检查的键表 ────────────────────────────────────────────────────
const KEY_TABLES = ["users", "workspaces", "directory_grants", "auth_identities"];
// ─── 辅助 ──────────────────────────────────────────────────────────
function cargoRun(...args) {
const fullArgs = [
"run",
"--manifest-path", CARGO_MANIFEST,
"-p", PKG,
"--bin", BIN,
"--",
...args,
];
const result = spawnSync("cargo", fullArgs, {
cwd: PROJECT_ROOT,
encoding: "utf-8",
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, RUST_LOG: "error" },
});
const stdout = (result.stdout || "").trim();
const stderr = (result.stderr || "").trim();
if (result.error) {
throw new Error(`cargo run 执行失败: ${result.error.message}\nARGS: ${fullArgs.join(" ")}`);
}
return { code: result.status, stdout, stderr };
}
function parseJsonOutput(text, label) {
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`${label}: 无法解析命令 JSON 输出\n原始输出:\n${text}`);
}
}
function assertOk(json, label) {
assert.strictEqual(
json.ok, true,
`${label}: json.ok 应为 true,实际为 ${JSON.stringify(json.ok)}`,
);
}
function assertTableRows(json, label) {
const tables = json.tables;
assert(tables !== undefined && tables !== null, `${label}: 应包含 tables 字段`);
const missing = KEY_TABLES.filter((t) => !(t in tables));
assert.strictEqual(
missing.length, 0,
`${label}: tables 缺少键表: ${missing.join(", ")}`,
);
for (const table of KEY_TABLES) {
const count = tables[table];
assert(
typeof count === "number" && count > 0,
`${label}: ${table} 行数应为正值,实际为 ${count}`,
);
}
}
async function cleanTempFiles() {
try {
const dir = path.dirname(TMP_PREFIX);
const prefix = path.basename(TMP_PREFIX);
const entries = await fs.readdir(dir);
for (const entry of entries) {
if (entry.startsWith(prefix)) {
await fs.rm(path.join(dir, entry), { force: true, recursive: true });
}
}
} catch { /* cleanup 失败不阻塞最终报告 */ }
}
// ─── 主流程 ────────────────────────────────────────────────────────
async function main() {
const sourceDb = `${TMP_PREFIX}-source.db`;
const targetDb = `${TMP_PREFIX}-target.db`;
const exportDb = `${TMP_PREFIX}-export.db`;
let sourceOk = false;
let migrateOk = false;
let exportOk = false;
console.log("=== control-plane-admin libSQL 往返 smoke ===");
console.log(` source: ${sourceDb}`);
console.log(` target: ${targetDb}`);
console.log(` export: ${exportDb}\n`);
try {
// ─── Step 1: init ─────────────────────────────────────────────
console.log("── Step 1: init (SQLite) ──");
const r1 = cargoRun(
"init",
"--backend", "sqlite",
"--sqlite-path", sourceDb,
);
assert.strictEqual(r1.code, 0, `init exit code = ${r1.code}:\n${r1.stderr}`);
const j1 = parseJsonOutput(r1.stdout, "init");
assertOk(j1, "init");
console.log(` ok, userId=${j1.userId}, workspaceId=${j1.workspaceId}\n`);
sourceOk = true;
// ─── Step 2: migrate-sqlite-to-target ─────────────────────────
console.log("── Step 2: migrate-sqlite-to-target (→libSQL local) ──");
const r2 = cargoRun(
"migrate-sqlite-to-target",
"--source", sourceDb,
"--backend", "libsql-local",
"--libsql-path", targetDb,
"--reset-target",
);
assert.strictEqual(r2.code, 0, `migrate exit code = ${r2.code}:\n${r2.stderr}`);
const j2 = parseJsonOutput(r2.stdout, "migrate-sqlite-to-target");
assertOk(j2, "migrate-sqlite-to-target");
assertTableRows(j2, "migrate-sqlite-to-target");
console.log(` ok, tables=${JSON.stringify(j2.tables)}\n`);
migrateOk = true;
// ─── Step 3: export-target-to-sqlite ──────────────────────────
console.log("── Step 3: export-target-to-sqlite (libSQL→SQLite) ──");
const r3 = cargoRun(
"export-target-to-sqlite",
"--backend", "libsql-local",
"--libsql-path", targetDb,
"--output", exportDb,
"--backup-existing",
);
assert.strictEqual(r3.code, 0, `export exit code = ${r3.code}:\n${r3.stderr}`);
const j3 = parseJsonOutput(r3.stdout, "export-target-to-sqlite");
assertOk(j3, "export-target-to-sqlite");
assertTableRows(j3, "export-target-to-sqlite");
console.log(` ok, tables=${JSON.stringify(j3.tables)}\n`);
exportOk = true;
// ─── 汇总 ─────────────────────────────────────────────────────
console.log("=== 全部通过 ===");
console.log(` init: PASS (userId=${j1.userId})`);
console.log(` migrate: PASS (tables=${Object.keys(j2.tables).length})`);
console.log(` export: PASS (tables=${Object.keys(j3.tables).length})`);
// 确认关键表在两次转储中行数一致
for (const table of KEY_TABLES) {
const srcCount = j2.tables[table];
const expCount = j3.tables[table];
assert.strictEqual(
srcCount, expCount,
`${table} 行数不一致: migrate=${srcCount}, export=${expCount}`,
);
}
console.log(" 行数一致性检查: PASS");
} catch (err) {
console.error("\n❌ FAILED:", err.message);
process.exitCode = 1;
} finally {
await cleanTempFiles();
console.log("\n清理完成");
}
}
main().catch((err) => {
console.error("unhandled error:", err);
process.exitCode = 1;
});
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task492-sidebar-starred-shortcuts-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -76,7 +77,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -85,7 +86,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task494-filetree-lazy-loading-dedup-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -81,7 +82,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -90,7 +91,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task496-editor-open-parallel-runtime-aggregate-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -76,7 +77,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -85,7 +86,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task497-local-page-tree-filetree-open-performance-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
@@ -152,7 +153,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -161,7 +162,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task498-starred-page-tree-scope-and-local-edit-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
@@ -155,7 +156,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -164,7 +165,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task499-sidebar-tree-view-state-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
@@ -179,7 +180,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -188,7 +189,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -427,6 +428,7 @@ async function main() {
} finally {
await browser.close().catch(() => {});
server.kill("SIGTERM");
fs.rmSync(dataRoot, { recursive: true, force: true });
}
}
@@ -9,6 +9,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
const TASK = "task500-navigation-page-route-guard-smoke";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
@@ -125,7 +126,7 @@ async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`));
const dbPath = path.join(dataRoot, "control-plane.sqlite3");
const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
const actorId = `${TASK}-${process.pid}-${Date.now()}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
@@ -134,7 +135,7 @@ async function main() {
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_CONTROL_PLANE_DB_PATH: dbPath,
...controlPlaneEnv,
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -265,6 +266,7 @@ async function main() {
} finally {
await browser.close().catch(() => {});
server.kill("SIGTERM");
fs.rmSync(dataRoot, { recursive: true, force: true });
}
}
+34 -59
View File
@@ -5,18 +5,20 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
findExternalConversationBinding,
} = require("./lib/control-plane-dev-seed");
const TASK = "task512-chatonly-doubao-sync-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const DOUBAO_CDP_URL = process.env.MNOTE_DOUBAO_CDP_URL || "http://127.0.0.1:9233";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
@@ -52,41 +54,6 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task512 Doubao Smoke', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function firstDoubaoPage(cdpBrowser) {
for (const context of cdpBrowser.contexts()) {
const page = context.pages().find((candidate) => candidate.url().includes("doubao.com"));
@@ -226,13 +193,6 @@ async function main() {
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# Doubao ChatOnly Sync", "", marker, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task512_${suffix}`,
});
const doubaoBrowser = await chromium.connectOverCDP(DOUBAO_CDP_URL);
const doubaoPage = await firstDoubaoPage(doubaoBrowser);
@@ -275,6 +235,15 @@ async function main() {
});
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: "Task512 Doubao Smoke",
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
@@ -337,13 +306,16 @@ async function main() {
assert.strictEqual(markerAssistantCount, 1, "MNote 可见豆包回复应只有一条");
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(bindingRows && bindingRows.length === 1, "SQLite 应保存豆包远端会话绑定");
assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingRows[0].remote_conversation_id;
const bindingBefore = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: "doubao-web",
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingBefore, "control-plane 应保存豆包远端会话绑定");
assert.strictEqual(bindingBefore.status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingBefore.remoteConversationId;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureDoubaoPage(doubaoPage, {
@@ -428,12 +400,15 @@ async function main() {
"豆包删除后左侧历史列表不应继续存在本轮 conversation 行",
);
const deletedRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计");
assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const bindingAfter = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: "doubao-web",
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingAfter, "删除后 binding 仍应可审计");
assert.strictEqual(bindingAfter.status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
fs.writeFileSync(
RESULT_PATH,
@@ -446,8 +421,8 @@ async function main() {
doubaoMessageStats,
deleteCallCount: deleteCalls.length,
deleteResponse: deleteResponses.at(-1),
bindingBefore: bindingRows[0],
bindingAfter: deletedRows[0],
bindingBefore,
bindingAfter,
screenshots,
providerCaptures,
}, null, 2)}\n`,
+34 -59
View File
@@ -5,13 +5,16 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
findExternalConversationBinding,
} = require("./lib/control-plane-dev-seed");
const PROVIDERS = {
deepseek: {
@@ -44,7 +47,6 @@ if (!provider) {
const OUTPUT_DIR = path.join(process.cwd(), "tmp", provider.taskName);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROMIUM_EXECUTABLE_PATH =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
[
@@ -84,41 +86,6 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function logSize(logPath) {
try {
return fs.statSync(logPath).size;
@@ -298,13 +265,6 @@ async function main() {
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), [`# ${provider.title}`, "", marker, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task513_${providerKey}_${suffix}`,
});
const browser = await chromium.launch({
headless: true,
@@ -338,6 +298,15 @@ async function main() {
});
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: actorId,
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
@@ -393,13 +362,16 @@ async function main() {
assert.strictEqual(markerAssistantCount, 1, "MNote 可见 provider 回复应只有一条");
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(bindingRows && bindingRows.length === 1, "SQLite 应保存远端会话绑定");
assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingRows[0].remote_conversation_id;
const bindingBefore = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: provider.expectedProvider,
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingBefore, "control-plane 应保存远端会话绑定");
assert.strictEqual(bindingBefore.status, "active", "删除前 binding 应为 active");
const remoteConversationId = bindingBefore.remoteConversationId;
assert(remoteConversationId, "binding 应包含 remote_conversation_id");
providerCaptures.afterMessage = await captureProvider(remoteConversationId, `${providerKey}-after-message`);
@@ -449,12 +421,15 @@ async function main() {
"provider 删除后网页历史中不应继续存在本轮远端会话链接",
);
const deletedRows = sqliteJson(
`SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`,
[],
);
assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计");
assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const bindingAfter = await findExternalConversationBinding(context.request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
provider: provider.expectedProvider,
timeoutMs: UI_TIMEOUT_MS,
});
assert(bindingAfter, "删除后 binding 仍应可审计");
assert.strictEqual(bindingAfter.status, "remote_deleted", "删除后 binding 应标记 remote_deleted");
const providerLog = readLogSince(provider.gatewayLog, gatewayLogOffset);
let providerSendCount = countMatches(providerLog, provider.sendLogPattern);
@@ -474,8 +449,8 @@ async function main() {
remoteConversationId,
providerSendCount,
deleteResponse: deleteResponses.at(-1),
bindingBefore: bindingRows[0],
bindingAfter: deletedRows[0],
bindingBefore,
bindingAfter,
screenshots,
providerCaptures,
}, null, 2)}\n`,
+30 -54
View File
@@ -5,15 +5,18 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const {
setupWorkspaceAccess,
listAiRuntimeRuns,
listExternalConversationBindings,
} = require("./lib/control-plane-dev-seed");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROMIUM_EXECUTABLE_PATH =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ||
[
@@ -79,41 +82,6 @@ function writeWorkspaceManifest(root, ownerId, workspaceId) {
);
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function sqliteJson(sql, fallback = null) {
try {
const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function saveScreenshot(page, name) {
const target = path.join(OUTPUT_DIR, `${name}.png`);
await page.screenshot({ path: target, fullPage: false });
@@ -152,7 +120,7 @@ async function selectProvider(page, provider) {
);
}
async function runProviderSmoke(page, provider, suffix) {
async function runProviderSmoke(page, provider, suffix, { actorId, workspaceId }) {
const marker = `${provider.markerPrefix}_${suffix}`;
const runRequests = [];
const runResponses = [];
@@ -218,10 +186,13 @@ async function runProviderSmoke(page, provider, suffix) {
assert.strictEqual(markerAssistantCount, 1, `${provider.key} 可见 API 回复应只有一条`);
const sessionId = String(run.sessionId);
const bindingRows = sqliteJson(
`SELECT mnote_session_id, provider, status FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote("mnote-e2e")} AND mnote_session_id=${sqlQuote(sessionId)} ORDER BY updated_at DESC LIMIT 5;`,
[],
);
const bindingRows = await listExternalConversationBindings(page.context().request, BASE_URL, {
userId: actorId,
workspaceId,
mnoteSessionId: sessionId,
limit: 5,
timeoutMs: UI_TIMEOUT_MS,
});
assert.strictEqual(bindingRows.length, 0, `${provider.key} API ChatOnly 不应写网页 provider conversation binding`);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
@@ -259,10 +230,13 @@ async function runProviderSmoke(page, provider, suffix) {
`${provider.key} remoteDelete reason 应说明 API Chat 无远端会话`,
);
const remainingRows = sqliteJson(
`SELECT session_id, status FROM ai_runtime_runs WHERE user_id=${sqlQuote("mnote-e2e")} AND session_id=${sqlQuote(sessionId)} AND deleted_at IS NULL LIMIT 5;`,
[],
);
const remainingRows = await listAiRuntimeRuns(page.context().request, BASE_URL, {
userId: actorId,
workspaceId,
sessionId,
limit: 5,
timeoutMs: UI_TIMEOUT_MS,
});
assert.strictEqual(remainingRows.length, 0, `${provider.key} 删除后 SQLite active run 不应残留`);
return {
@@ -294,13 +268,6 @@ async function main() {
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, relativePath), ["# API ChatOnly", "", `MNOTE_API_CHAT_WORKSPACE_${suffix}`, ""].join("\n"), "utf8");
grantWorkspaceAccess({
actorId,
workspaceId,
root,
rootUri,
grantId: `grant_task527_api_chat_${suffix}`,
});
const browser = await chromium.launch({
headless: true,
@@ -320,6 +287,15 @@ async function main() {
try {
await ensureAuthenticated(page, context.request);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
workspaceId,
workspaceName: actorId,
rootPath: root,
rootUri,
capabilities: ["ai", "markdown_edit"],
timeoutMs: UI_TIMEOUT_MS,
});
const response = await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
@@ -333,7 +309,7 @@ async function main() {
await saveScreenshot(page, "initial-drawer");
for (const provider of PROVIDERS) {
results.push(await runProviderSmoke(page, provider, suffix));
results.push(await runProviderSmoke(page, provider, suffix, { actorId, workspaceId }));
}
} catch (error) {
caughtError = error;
@@ -4,18 +4,18 @@
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUTPUT_DIR = path.join(ROOT, "tmp", "task530-knowledge-rag-page-ai-final-answer-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-answer.png");
const CITATION_OPEN_SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-knowledge-rag-citation-open.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR_ID = "mnote-e2e";
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
@@ -25,30 +25,6 @@ const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
const EXPECTED_RESOURCE = "新页面233155/image copy 6.png";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureGrant() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task530_knowledge_rag', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function ensureOwnerPage() {
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
@@ -259,7 +235,6 @@ function summarizeRun(body) {
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureGrant();
ensureOwnerPage();
const browser = await chromium.launch({
@@ -289,6 +264,19 @@ async function main() {
try {
await signIn(context);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
username: ACTOR_ID,
displayName: ACTOR_ID,
role: "user",
workspaceId: WORKSPACE_ID,
workspaceName: "MNote E2E Space",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
capabilities: ["ai"],
timeoutMs: UI_TIMEOUT_MS,
});
await assertKnowledgeRagDescriptor(context);
await ensureKnowledgeRagSourceIndexed(context);
await assertKnowledgeRagQueryReturnsCitationMarkdown(context);
+36 -39
View File
@@ -4,17 +4,18 @@
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
seedAiRuntime,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const TASK = "task557-page-ai-run-resume-smoke";
const OUTPUT_DIR = path.join(ROOT, "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-run-resume.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const RESUME_TIMEOUT_MS = Number(process.env.MNOTE_PAGE_AI_RESUME_TIMEOUT_MS || 90_000);
const ACTOR_ID = "mnote-e2e";
@@ -24,56 +25,42 @@ const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-57/PageAiRunResumeSmoke.md";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureFixture() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
fs.writeFileSync(ownerPath, "# Page AI Run Resume Smoke\n\n用于验证 Page AI host run journal afterSeq 恢复。\n", "utf8");
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task557_page_ai_run_resume', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function seedRun() {
async function seedRun(requestContext) {
const stamp = Date.now();
const runId = `run_task557_resume_${stamp}`;
const sessionId = `mnote_task557_resume_${stamp}`;
const now = new Date().toISOString();
const runtimeJson = JSON.stringify({ runId, status: "running", lastEvent: "message.delta" });
const payloadJson = JSON.stringify({
requestId: `task557_resume_${stamp}`,
agentId: "reasonix",
message: "resume smoke user prompt",
});
sqliteExec(`
INSERT OR REPLACE INTO ai_runtime_runs
(id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision)
VALUES
(${sqlQuote(`arr_${stamp}`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'Resume smoke', 'reasonix', 'reasonix', ${sqlQuote(`trace_task557_${stamp}`)}, 'running', ${sqlQuote(runtimeJson)}, ${sqlQuote(payloadJson)}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
DELETE FROM ai_runtime_events WHERE user_id=${sqlQuote(ACTOR_ID)} AND run_id=${sqlQuote(runId)};
INSERT INTO ai_runtime_events
(id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES
(${sqlQuote(`are_${stamp}_1`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"old-token"}', ${sqlQuote(now)}),
(${sqlQuote(`are_${stamp}_2`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"resumed-token"}', ${sqlQuote(now)});
`);
await seedAiRuntime(requestContext, BASE_URL, {
id: `arr_${stamp}`,
userId: ACTOR_ID,
workspaceId: WORKSPACE_ID,
documentId: DOCUMENT_ID,
sessionId,
runId,
title: "Resume smoke",
profile: "reasonix",
acpRuntime: "reasonix",
traceId: `trace_task557_${stamp}`,
status: "running",
runtimeJson: JSON.parse(runtimeJson),
payloadJson: JSON.parse(payloadJson),
events: [
{ id: `are_${stamp}_1`, eventType: "message.delta", payloadJson: { delta: "old-token" } },
{ id: `are_${stamp}_2`, eventType: "message.delta", payloadJson: { delta: "resumed-token" } },
],
timeoutMs: UI_TIMEOUT_MS,
});
return { runId, sessionId };
}
@@ -98,7 +85,6 @@ async function signIn(context) {
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureFixture();
const seeded = seedRun();
const browser = await chromium.launch({
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
@@ -125,6 +111,17 @@ async function main() {
});
try {
await signIn(context);
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
workspaceId: WORKSPACE_ID,
workspaceName: "MNote E2E Space",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
capabilities: ["ai"],
timeoutMs: UI_TIMEOUT_MS,
});
const seeded = await seedRun(context.request);
await page.addInitScript(({ documentId, runId, sessionId }) => {
window.localStorage.setItem(`hermes_page_ai_session:${documentId}:active-run`, JSON.stringify({
schema: "mnote.page_ai_active_run_snapshot.v1",
@@ -5,51 +5,20 @@ const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const {
setupWorkspaceAccess,
getAiRuntimeRun,
countAiRuntimeEvents,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-"));
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function fileUrl(localPath) {
return `file://${localPath}`;
}
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function sqliteJson(sql) {
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
return out.trim() ? JSON.parse(out) : [];
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task558 Reasonix Live', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function signIn(context) {
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
method: "POST",
@@ -124,36 +93,13 @@ async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) {
throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`);
}
function sessionInfoForRuns(runIds) {
const quoted = runIds.map(sqlQuote).join(",");
return sqliteJson(`
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
FROM ai_runtime_events
WHERE run_id IN (${quoted}) AND event_type = 'session.info.updated'
ORDER BY created_at ASC, id ASC;
`).map((row) => ({
runId: row.runId,
eventType: row.eventType,
payload: JSON.parse(row.payloadJson || "{}"),
}));
}
function runtimeRunsForSession(sessionId) {
return sqliteJson(`
SELECT run_id AS runId, status
FROM ai_runtime_runs
WHERE session_id=${sqlQuote(sessionId)} AND run_id LIKE 'run_%'
ORDER BY created_at ASC;
`);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-"));
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task558-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
@@ -175,7 +121,24 @@ async function main() {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8");
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task558_${suffix}` });
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, BASE_URL, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task558 Reasonix Live",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: UI_TIMEOUT_MS,
});
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
@@ -188,7 +151,7 @@ async function main() {
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
await page.locator("[data-page-ai-input]").fill(
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说可以,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说"可以",你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
@@ -229,14 +192,24 @@ async function main() {
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
const runtimeRuns = runtimeRunsForSession(capturedRuns[0].sessionId);
assert(runtimeRuns.length >= 2, `应有至少两条 runtime run: ${JSON.stringify(runtimeRuns)}`);
const first = { runId: runtimeRuns[0].runId, text: firstMarker };
const second = { runId: runtimeRuns[1].runId, text: secondMarker };
const infos = sessionInfoForRuns([first.runId, second.runId]);
assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`);
const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(infos)}`);
// 通过 API helper + capturedRuns 验证 DB 持久化和 acpSessionId 连续性,不再直读 SQLite
const firstRunId = capturedRuns[0].runId;
const secondRunId = capturedRuns[1].runId;
const first = { runId: firstRunId, text: firstMarker };
const second = { runId: secondRunId, text: secondMarker };
const firstRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: firstRunId });
const secondRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: secondRunId });
assert(firstRun, `first run ${firstRunId} 应 persist`);
assert(secondRun, `second run ${secondRunId} 应 persist`);
const infoCount1 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: firstRunId, eventType: "session.info.updated" });
const infoCount2 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: secondRunId, eventType: "session.info.updated" });
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(acpSessionIds)}`);
assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id");
const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png");
@@ -254,7 +227,6 @@ async function main() {
queuedPreview,
acpSessionId: acpSessionIds[0],
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
sessionInfo: infos,
screenshotPath,
};
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
@@ -7,23 +7,21 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
getAiRuntimeRun,
countAiRuntimeEvents,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_TERMINAL_STATUS_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task559-terminal-status-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -97,18 +95,6 @@ async function signIn(context, baseUrl) {
return JSON.parse(await whoami.text());
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task559 Terminal Status', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
@@ -140,13 +126,30 @@ async function main() {
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36).toUpperCase();
const workspaceId = `local-ws:${actorId}:task559-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task559-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task559 Terminal Status\n\n${suffix}\n`, "utf8");
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task559_${suffix}` });
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task559 Terminal Status",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
@@ -166,10 +169,12 @@ async function main() {
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
assert(String(runtimeStrip || '').includes('reasonix'), `runtime strip 应显示 reasonix: ${runtimeStrip}`);
assert(!String(runtimeStrip || '').includes('输出中'), `完成后 runtime strip 不应残留输出中: ${runtimeStrip}`);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, "completed", "SQLite run status 应为 completed");
const terminalEvents = sqliteJson(`SELECT COUNT(*) AS count FROM ai_runtime_events WHERE run_id=${sqlQuote(runId)} AND event_type='run.completed';`);
assert(Number(terminalEvents[0] && terminalEvents[0].count) >= 1, "应持久化 run.completed");
// 通过 API helper 验证 run 持久化状态,不再直读 SQLite
const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
assert(persistedRun, `run ${runId} 应 persist`);
assert.equal(persistedRun.status, "completed", "API run status 应为 completed");
const terminalEventCount = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId, eventType: "run.completed" });
assert(Number(terminalEventCount) >= 1, "应持久化 run.completed");
const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
await page.locator('[data-page-ai-runtime-strip]').click({ timeout: TIMEOUT_MS });
@@ -7,30 +7,21 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
getAiRuntimeRun,
countAiRuntimeEvents,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_HERMES_LOAD_REPLAY_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task560-hermes-load-replay-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function sqliteJson(sql) {
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
return out.trim() ? JSON.parse(out) : [];
}
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -53,10 +44,7 @@ function waitForHttpOk(url, timeoutMs) {
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => {
request.destroy();
retry();
});
request.setTimeout(1000, () => { request.destroy(); retry(); });
};
const retry = () => {
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
@@ -66,27 +54,6 @@ function waitForHttpOk(url, timeoutMs) {
});
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task560 Hermes Load Replay', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function writeFakeHermesAcp(scriptPath) {
fs.writeFileSync(scriptPath, `
import * as readline from 'node:readline';
@@ -186,20 +153,6 @@ async function sendPrompt(page, prompt, expectedCompact) {
return { runId, text };
}
function eventsForRuns(runIds) {
const quoted = runIds.map(sqlQuote).join(',');
return sqliteJson(`
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
FROM ai_runtime_events
WHERE run_id IN (${quoted})
ORDER BY created_at ASC, id ASC;
`).map((row) => ({
runId: row.runId,
eventType: row.eventType,
payload: JSON.parse(row.payloadJson || '{}'),
}));
}
function readFakeLog(logPath) {
if (!fs.existsSync(logPath)) return [];
return fs.readFileSync(logPath, 'utf8').trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line));
@@ -239,13 +192,30 @@ async function runVariant({ replay }) {
const actorId = viewer.userId || ACTOR;
const suffix = `${Date.now().toString(36)}_${variant.replace('-', '_')}`.toUpperCase();
const workspaceId = `local-ws:${actorId}:task560-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task560-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, '.mnote'), { recursive: true });
fs.writeFileSync(path.join(root, '.mnote', 'workspace.json'), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, 'utf8');
fs.writeFileSync(path.join(root, relativePath), `# Task560 Hermes Load Replay\n\n${suffix}\n`, 'utf8');
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task560_${suffix}` });
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task560 Hermes Load Replay",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
@@ -261,19 +231,29 @@ async function runVariant({ replay }) {
assert.equal(capturedRuns.length, 2, `应捕获两次 Hermes Page AI run,实际 ${capturedRuns.length}`);
assert(capturedRuns.every((body) => body.acpRuntime === 'hermes'), '两次 run 都应走 Hermes ACP');
assert(capturedRuns[1].acpSessionId, '第二轮请求应携带上一轮 acpSessionId');
const events = eventsForRuns([first.runId, second.runId]);
const sessionInfos = events.filter((event) => event.eventType === 'session.info.updated');
assert.equal(sessionInfos.length, 2, `应有两条 session.info.updated,实际 ${sessionInfos.length}`);
const acpSessionIds = sessionInfos.map((event) => String(event.payload.acpSessionId || '')).filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(sessionInfos)}`);
const replayEvents = events.filter((event) => event.payload && event.payload.source === 'adapter_replay');
assert.equal(replayEvents.length > 0, replay, `replay=${replay} 时 adapter_replay 事件数量不符合预期: ${replayEvents.length}`);
// 通过 API helper + capturedRuns + fake ACP log 验证持久化和 replay 行为,不再直读 SQLite events
const firstRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: first.runId });
const secondRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: second.runId });
assert(firstRun, `first run ${first.runId} 应 persist`);
assert(secondRun, `second run ${second.runId} 应 persist`);
const infoCount1 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: first.runId, eventType: 'session.info.updated' });
const infoCount2 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: second.runId, eventType: 'session.info.updated' });
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(acpSessionIds)}`);
// adapter_replay 事件的 payload filtersource === 'adapter_replay')需要新的 dev seed kind
// listAiRuntimeEvents 或 countByPayloadField),当前通过 fake ACP log 验证 session/load 行为
const fakeLogRows = readFakeLog(fakeLog);
assert(fakeLogRows.some((row) => row.method === 'session/new'), '第一轮应调用 session/new');
assert(fakeLogRows.some((row) => row.method === 'session/load' && row.sessionId === acpSessionIds[0]), '第二轮应先 session/load stored acpSessionId');
const screenshotPath = path.join(variantDir, 'task560-hermes-load-replay.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], replayEvents, fakeLogRows, capturedRuns, screenshotPath };
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], fakeLogRows, capturedRuns, screenshotPath };
fs.writeFileSync(path.join(variantDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await context.close().catch(() => undefined);
return result;
@@ -293,7 +273,7 @@ async function main() {
const results = [];
results.push(await runVariant({ replay: false }));
results.push(await runVariant({ replay: true }));
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, replayEvents: result.replayEvents.length, screenshotPath: result.screenshotPath })) };
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, screenshotPath: result.screenshotPath })) };
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.log(JSON.stringify(summary, null, 2));
}
@@ -7,23 +7,21 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
seedAiRuntime,
getAiRuntimeRun,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_SESSION_DASHBOARD_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task561-session-dashboard-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -69,24 +67,6 @@ async function signIn(context, baseUrl) {
return JSON.parse(await whoami.text());
}
function seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix }) {
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task561 Session Dashboard', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(`grant_task561_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO ai_runtime_runs (id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision)
VALUES (${sqlQuote(`airun_${runId}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, ${sqlQuote(`Task561 Seed ${suffix}`)}, 'reasonix', 'reasonix', ${sqlQuote(`trace_${suffix}`)}, 'completed', ${sqlQuote(JSON.stringify({ status: 'completed', acpSessionId: `acp_${suffix}` }))}, ${sqlQuote(JSON.stringify({ message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: 'reasonix', acpRuntime: 'reasonix' }))}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES (${sqlQuote(`aievt_${runId}_1`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', ${sqlQuote(JSON.stringify({ delta: `Task561 assistant answer ${suffix}` }))}, ${sqlQuote(now)});
INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES (${sqlQuote(`aievt_${runId}_2`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'run.completed', ${sqlQuote(JSON.stringify({ stopReason: 'EndTurn' }))}, ${sqlQuote(now)});
`);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const port = await pickPort();
@@ -110,13 +90,50 @@ async function main() {
const workspaceId = `local-ws:${actorId}:task561-${suffix}`;
const sessionId = `mnote_task561_${suffix}`;
const runId = `run_task561_${suffix}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task561-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task561 Dashboard\n\n${suffix}\n`, "utf8");
seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix });
// 通过 API helper 写入 workspace/用户/授权 + run/events,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task561 Session Dashboard",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
await seedAiRuntime(context.request, baseUrl, {
id: `airun_${runId}`,
userId: actorId,
workspaceId,
documentId,
sessionId,
runId,
title: `Task561 Seed ${suffix}`,
profile: "reasonix",
acpRuntime: "reasonix",
traceId: `trace_${suffix}`,
status: "completed",
runtimeJson: { status: "completed", acpSessionId: `acp_${suffix}` },
payloadJson: { message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: "reasonix", acpRuntime: "reasonix" },
events: [
{ id: `aievt_${runId}_1`, eventType: "message.delta", payloadJson: { delta: `Task561 assistant answer ${suffix}` } },
{ id: `aievt_${runId}_2`, eventType: "run.completed", payloadJson: { stopReason: "EndTurn" } },
],
timeoutMs: TIMEOUT_MS,
});
const page = await context.newPage();
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
@@ -177,8 +194,10 @@ async function main() {
await page.evaluate(() => { window.confirm = () => true; });
await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
await page.waitForFunction((id) => !document.querySelector(`[data-page-ai-session-row="${id}"]`), sessionId, { timeout: TIMEOUT_MS });
const deletedRows = sqliteJson(`SELECT deleted_at AS deletedAt FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert(deletedRows[0] && deletedRows[0].deletedAt, "delete 应软删除 SQLite session runs");
// 通过 API helper 验证软删除,不再直读 SQLite
const deletedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
assert(deletedRun, `run ${runId} 应仍 persist(软删除)`);
assert(deletedRun.deletedAt, "delete 应软删除 SQLite session runs");
const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, settingsScreenshotPath, beforeDeleteScreenshotPath, screenshotPath };
@@ -7,23 +7,20 @@ const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const {
setupWorkspaceAccess,
getAiRuntimeRun,
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -178,22 +175,30 @@ async function main() {
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36).toUpperCase();
const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const rootUri = `file://${root}`;
const relativePath = `Task562-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8");
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task562 Reasonix Approval', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
sqliteExec(`
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(`grant_task562_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
await setupWorkspaceAccess(context.request, baseUrl, {
actorId,
email: `${actorId}@example.com`,
username: actorId,
displayName: actorId,
role: "user",
workspaceId,
workspaceName: "Task562 Reasonix Approval",
rootUri,
rootPath: root,
sourceKind: "local_folder",
permission: "write",
capabilities: ["ai"],
grantSource: "smoke",
grantCreatedBy: actorId,
timeoutMs: TIMEOUT_MS,
});
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
@@ -282,8 +287,10 @@ async function main() {
assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, 'completed', 'SQLite run status 应为 completed');
// 通过 API helper 读取 run 持久化状态,不再直读 SQLite
const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId });
assert(persistedRun, `run ${runId} 应 persist`);
assert.equal(persistedRun.status, 'completed', 'API run status 应为 completed');
const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };