feat(control-plane): add libSQL Turso backend
This commit is contained in:
@@ -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` fallback,Turso 后端切换后检查 Turso 会话),避免把 `devFallback` 当成真实登录;Convex JWT 只属于历史 / cloud compat 边界。
|
||||
|
||||
## 前端测试方法
|
||||
|
||||
|
||||
+57
-4
@@ -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-plane(SQLite 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 stream;Next 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-plane(SQLite 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 / OpenHub;Page 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
@@ -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-plane(SQLite 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 后阶段功能缺口
|
||||
|
||||
|
||||
+720
@@ -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 store;Phase 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] 多线程同时创建 session(libSQL local)。
|
||||
- [x] 多线程同时 upsert sidebar shortcut(libSQL local)。
|
||||
- [x] 多线程同时写 AI runtime events(libSQL local)。
|
||||
- [x] 多线程同时 append audit/outbox(libSQL local)。
|
||||
- [ ] 故障注入:
|
||||
- [x] Turso token 缺失返回 `InvalidInput` 且不 panic。
|
||||
- [ ] Turso URL 不可达。
|
||||
- 后续云端验证:缺 Turso dev DB(libSQL 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 / synced;explicit 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 的判断:
|
||||
|
||||
- 当前不作为 P0:control-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 RAG:Tantivy 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-plane:auth、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 7:OpenHub 会话库单独评估
|
||||
|
||||
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` 直写 smoke;P1/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 作为本地 fallback,store 行为测试覆盖完整 |
|
||||
| `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
|
||||
```
|
||||
@@ -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 smoke:init、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。
|
||||
Generated
+1073
-103
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}"))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 中"脚本纪律"一节。
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 filter(source === '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 };
|
||||
|
||||
Reference in New Issue
Block a user