From a75b3d11f9dc146c5892af2dc8927dfb00337ad3 Mon Sep 17 00:00:00 2001 From: Agent Board Date: Fri, 3 Jul 2026 13:24:20 +0800 Subject: [PATCH] feat(control-plane): add libSQL Turso backend --- AGENTS.md | 11 +- ARCHITECTURE.md | 61 +- CURRENT_ARCHITECTURE.md | 29 +- .../2-9-turso-control-plane-cutover-v1.md | 720 +++ docs/operations/control-plane-turso.md | 299 + rust/Cargo.lock | 1176 +++- rust/crates/control-plane/Cargo.toml | 6 + .../src/bin/control-plane-admin.rs | 1502 +++++ rust/crates/control-plane/src/error.rs | 39 + rust/crates/control-plane/src/lib.rs | 2 + rust/crates/control-plane/src/migrations.rs | 94 + rust/crates/control-plane/src/model.rs | 11 + rust/crates/control-plane/src/sqlite.rs | 295 +- rust/crates/control-plane/src/store.rs | 32 +- rust/crates/control-plane/src/turso.rs | 4903 +++++++++++++++++ .../crates/control-plane/tests/turso_store.rs | 309 ++ rust/crates/mnote-web/src/app.rs | 159 +- .../src/local_folder_watcher_registry.rs | 20 +- rust/crates/mnote-web/src/routes/dev_seed.rs | 600 ++ rust/crates/mnote-web/src/routes/gateway.rs | 4 +- .../src/routes/local_folder_source.rs | 100 +- rust/crates/mnote-web/src/routes/mod.rs | 11 +- rust/crates/mnote-web/src/ssr/pages/admin.rs | 6 +- scripts/TESTING_REFERENCE.md | 43 + scripts/lib/control-plane-dev-seed.js | 215 + scripts/lib/control-plane-test-env.js | 44 + ...trol-plane-admin-libsql-roundtrip-smoke.js | 200 + ...task492-sidebar-starred-shortcuts-smoke.js | 5 +- ...sk494-filetree-lazy-loading-dedup-smoke.js | 5 +- ...r-open-parallel-runtime-aggregate-smoke.js | 5 +- ...ge-tree-filetree-open-performance-smoke.js | 5 +- ...ed-page-tree-scope-and-local-edit-smoke.js | 5 +- .../task499-sidebar-tree-view-state-smoke.js | 6 +- ...sk500-navigation-page-route-guard-smoke.js | 6 +- scripts/task512-chatonly-doubao-sync-smoke.js | 93 +- .../task513-chatonly-provider-sync-smoke.js | 93 +- .../task527-chatonly-api-provider-smoke.js | 84 +- ...nowledge-rag-page-ai-final-answer-smoke.js | 44 +- scripts/task557-page-ai-run-resume-smoke.js | 75 +- ...-ai-reasonix-live-session-context-smoke.js | 116 +- ...ai-terminal-status-reconciliation-smoke.js | 59 +- ...ask560-page-ai-hermes-load-replay-smoke.js | 112 +- ...task561-page-ai-session-dashboard-smoke.js | 81 +- ...62-page-ai-reasonix-approval-plan-smoke.js | 51 +- 44 files changed, 11070 insertions(+), 666 deletions(-) create mode 100644 design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md create mode 100644 docs/operations/control-plane-turso.md create mode 100644 rust/crates/control-plane/src/bin/control-plane-admin.rs create mode 100644 rust/crates/control-plane/src/turso.rs create mode 100644 rust/crates/control-plane/tests/turso_store.rs create mode 100644 rust/crates/mnote-web/src/routes/dev_seed.rs create mode 100644 scripts/lib/control-plane-dev-seed.js create mode 100644 scripts/lib/control-plane-test-env.js create mode 100644 scripts/task-control-plane-admin-libsql-roundtrip-smoke.js diff --git a/AGENTS.md b/AGENTS.md index 61158673..c6be09e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 边界。 ## 前端测试方法 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 220aab2d..295e5575 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` diff --git a/CURRENT_ARCHITECTURE.md b/CURRENT_ARCHITECTURE.md index 6d3459ed..aed3799b 100644 --- a/CURRENT_ARCHITECTURE.md +++ b/CURRENT_ARCHITECTURE.md @@ -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//*.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//*.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 后阶段功能缺口 diff --git a/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md new file mode 100644 index 00000000..74875314 --- /dev/null +++ b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md @@ -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`。 + - 当前配置 `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`。 +- `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`,应改成 `Arc`。 + - `open_control_plane_store()` 读取 `MNOTE_CONTROL_PLANE_DB_PATH` 并打开 SQLite。 +- `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` + - 字段已经是 `Arc`,但 `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` 改为 `Arc`。 + - [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`。 + - [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`。 + - [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`,读写全部串行;这能保证简单安全,但不能发挥 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` 串行化所有 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`。 +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`。 +- [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`。 +- [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`,区分 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` 解耦;5 种后端 factory | +| `rust/crates/mnote-web/src/local_folder_watcher_registry.rs` | 构造器改为 `Arc` | +| `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 +``` diff --git a/docs/operations/control-plane-turso.md b/docs/operations/control-plane-turso.md new file mode 100644 index 00000000..e939518d --- /dev/null +++ b/docs/operations/control-plane-turso.md @@ -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=` 或带 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`,读写都串行;这是故意保守的安全基线,不是最终性能设计。 +- P2/P3 暂缓:CDC、Tantivy FTS、vector/hybrid search、Browser WASM/OPFS、OpenHub SQLite 迁移。 + +## 读写并发设计 + +当前实现优先保证一致性和可回滚: + +- 单进程内通过 `Mutex` 串行化 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。 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 03f77e64..cc052849 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -6,7 +6,7 @@ version = 4 name = "adapter-onlyoffice" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "hmac", "serde", "serde_json", @@ -31,6 +31,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.8.48", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -40,6 +52,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -142,6 +160,28 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -195,24 +235,52 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core 0.3.4", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 0.1.2", + "tower 0.4.13", + "tower-layer", + "tower-service", +] + [[package]] name = "axum" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core", - "base64", + "axum-core 0.5.6", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "multer", @@ -223,15 +291,32 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-tungstenite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -240,12 +325,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -257,12 +342,56 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -278,6 +407,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bridge-runtime" version = "0.1.0" @@ -310,6 +448,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "bzip2" @@ -345,6 +486,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.60" @@ -357,6 +507,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -393,6 +552,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.0" @@ -433,6 +603,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "codee" version = "0.3.5" @@ -466,9 +645,9 @@ dependencies = [ "entities", "finl_unicode", "jetscii", - "phf", - "phf_codegen", - "rustc-hash", + "phf 0.13.1", + "phf_codegen 0.13.1", + "rustc-hash 2.1.2", "smallvec", "typed-arena", ] @@ -550,10 +729,12 @@ version = "0.1.0" dependencies = [ "chrono", "hex", + "libsql", "rusqlite", "serde", "serde_json", "sha2", + "tokio", "tracing", "uuid", ] @@ -589,6 +770,16 @@ dependencies = [ name = "core-domain" version = "0.1.0" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -804,6 +995,12 @@ dependencies = [ "core-domain", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -844,6 +1041,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1006,6 +1209,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-net" version = "0.6.0" @@ -1016,7 +1225,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -1046,6 +1255,41 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1061,6 +1305,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1091,6 +1344,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -1100,6 +1362,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1110,6 +1383,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1117,7 +1401,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1128,11 +1412,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + [[package]] name = "httparse" version = "1.10.1" @@ -1161,6 +1451,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1171,8 +1485,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1182,20 +1496,50 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.22.4", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "webpki-roots 0.26.11", +] + [[package]] name = "hyper-rustls" version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", - "rustls", + "rustls 0.23.38", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite", + "tokio", + "tokio-io-timeout", ] [[package]] @@ -1204,18 +1548,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1365,6 +1709,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1383,7 +1737,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -1403,6 +1757,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1443,6 +1798,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1517,7 +1881,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", ] @@ -1527,6 +1891,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1553,7 +1923,7 @@ dependencies = [ "or_poisoned", "paste", "reactive_graph", - "rustc-hash", + "rustc-hash 2.1.2", "rustc_version", "send_wrapper", "serde", @@ -1608,7 +1978,7 @@ checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" dependencies = [ "anyhow", "camino", - "indexmap", + "indexmap 2.14.0", "or_poisoned", "proc-macro2", "quote", @@ -1629,7 +1999,7 @@ dependencies = [ "convert_case 0.11.0", "convert_case_extras", "html-escape", - "itertools", + "itertools 0.14.0", "leptos_hot_reload", "prettyplease", "proc-macro-error2", @@ -1649,7 +2019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "codee", "futures", "hydration_context", @@ -1668,6 +2038,152 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libsql" +version = "0.10.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e6206c72726b16e7ec9d68c3661d50ebe70947f8e60e0ebb4388d327e1fc2b" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "base64 0.21.7", + "bincode", + "bitflags 2.11.1", + "bytes", + "chrono", + "crc32fast", + "fallible-iterator 0.3.0", + "futures", + "http 0.2.12", + "hyper 0.14.32", + "hyper-rustls 0.25.0", + "libsql-hrana", + "libsql-sqlite3-parser", + "libsql-sys", + "libsql_replication", + "parking_lot", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tonic-web", + "tower 0.4.13", + "tower-http 0.4.4", + "tracing", + "uuid", + "zerocopy 0.7.35", +] + +[[package]] +name = "libsql-ffi" +version = "0.10.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c293281d505e6794f2ba14d05d1877b983f39b258a804f7b86407bd5cd1f59f1" +dependencies = [ + "bindgen", + "cc", + "cmake", + "glob", +] + +[[package]] +name = "libsql-hrana" +version = "0.10.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fce77fd40abf780ff5c96e58f251a162a3f51458a6cb67d3319574b9c00d6b1" +dependencies = [ + "base64 0.21.7", + "bytes", + "prost", + "serde", +] + +[[package]] +name = "libsql-rusqlite" +version = "0.10.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685f4a6f05002d892150c73f4f3d0b3ad346d0bbf44f5b6f55a8ea94b0cd4477" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator 0.2.0", + "fallible-streaming-iterator", + "hashlink 0.8.4", + "libsql-ffi", + "smallvec", +] + +[[package]] +name = "libsql-sqlite3-parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a90128c708356af8f7d767c9ac2946692c9112b4f74f07b99a01a60680e413" +dependencies = [ + "bitflags 2.11.1", + "cc", + "fallible-iterator 0.3.0", + "indexmap 2.14.0", + "log", + "memchr", + "phf 0.11.3", + "phf_codegen 0.11.3", + "phf_shared 0.11.3", + "uncased", +] + +[[package]] +name = "libsql-sys" +version = "0.10.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44108a23ced4df97823bbf89111e42651b334d70bea7a5568a517e9514f7ac6d" +dependencies = [ + "bytes", + "libsql-ffi", + "libsql-rusqlite", + "once_cell", + "tracing", + "zerocopy 0.7.35", +] + +[[package]] +name = "libsql_replication" +version = "0.10.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1667cab44a47146366a8a8d6d773387b6608e0aa3eafeec1b005e9049f9000" +dependencies = [ + "aes", + "async-stream", + "async-trait", + "bytes", + "cbc", + "libsql-rusqlite", + "libsql-sys", + "parking_lot", + "prost", + "serde", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tracing", + "uuid", + "zerocopy 0.7.35", +] + [[package]] name = "libsqlite3-sys" version = "0.32.0" @@ -1679,12 +2195,27 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1741,6 +2272,12 @@ dependencies = [ "quote", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -1769,6 +2306,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1815,14 +2358,14 @@ name = "mnote-web" version = "0.1.0" dependencies = [ "adapter-onlyoffice", - "axum", - "base64", + "axum 0.8.9", + "base64 0.22.1", "bridge-runtime", "comrak", "control-plane", "core-protocol", "futures-util", - "hyper", + "hyper 1.9.0", "hyper-util", "leptos", "mnote-editor-core", @@ -1835,8 +2378,8 @@ dependencies = [ "tokio", "tokio-stream", "tokio-tungstenite", - "tower", - "tower-http", + "tower 0.5.3", + "tower-http 0.6.8", "tracing", "tracing-subscriber", "uuid", @@ -1852,7 +2395,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -1866,13 +2409,23 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "fsevent-sys", "inotify", "kqueue", @@ -1890,7 +2443,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -1948,6 +2501,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "or_poisoned" version = "0.1.0" @@ -1960,6 +2519,29 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -1982,30 +2564,65 @@ dependencies = [ "hmac", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", ] [[package]] @@ -2015,7 +2632,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "uncased", ] [[package]] @@ -2080,7 +2707,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.48", ] [[package]] @@ -2148,6 +2775,29 @@ dependencies = [ "yansi", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2159,9 +2809,9 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", - "rustls", - "socket2", + "rustc-hash 2.1.2", + "rustls 0.23.38", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2177,10 +2827,10 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", - "rustc-hash", - "rustls", + "rustc-hash 2.1.2", + "rustls 0.23.38", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2198,7 +2848,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2246,14 +2896,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -2263,7 +2934,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2286,11 +2966,11 @@ dependencies = [ "futures", "guardian", "hydration_context", - "indexmap", + "indexmap 2.14.0", "or_poisoned", "paste", "pin-project-lite", - "rustc-hash", + "rustc-hash 2.1.2", "rustc_version", "send_wrapper", "serde", @@ -2306,13 +2986,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" dependencies = [ "guardian", - "indexmap", - "itertools", + "indexmap 2.14.0", + "itertools 0.14.0", "or_poisoned", "paste", "reactive_graph", "reactive_stores_macro", - "rustc-hash", + "rustc-hash 2.1.2", "send_wrapper", ] @@ -2329,6 +3009,15 @@ dependencies = [ "syn", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "regex" version = "1.12.3" @@ -2364,16 +3053,16 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", @@ -2381,24 +3070,24 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.38", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", - "tower", - "tower-http", + "tower 0.5.3", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -2436,14 +3125,20 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" dependencies = [ - "bitflags", - "fallible-iterator", + "bitflags 2.11.1", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", - "hashlink", + "hashlink 0.10.0", "libsqlite3-sys", "smallvec", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2459,6 +3154,33 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.38" @@ -2468,11 +3190,33 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.12", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2483,6 +3227,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.12" @@ -2515,6 +3270,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2633,13 +3426,13 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d60e4c1dfccd91fe0990141f69f1d5cf5679797ad53aa1b45e5bd658eb119f0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "const-str", "const_format", "futures", "gloo-net", - "http", + "http 1.4.0", "inventory", "js-sys", "or_poisoned", @@ -2766,6 +3559,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -2823,6 +3626,12 @@ dependencies = [ "syn", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2857,8 +3666,8 @@ dependencies = [ "erased", "futures", "html-escape", - "indexmap", - "itertools", + "indexmap 2.14.0", + "itertools 0.14.0", "js-sys", "next_tuple", "oco_ref", @@ -2866,7 +3675,7 @@ dependencies = [ "paste", "reactive_graph", "reactive_stores", - "rustc-hash", + "rustc-hash 2.1.2", "rustc_version", "send_wrapper", "slotmap", @@ -3000,13 +3809,24 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -3018,13 +3838,24 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.38", "tokio", ] @@ -3096,6 +3927,73 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.6.20", + "base64 0.21.7", + "bytes", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-web" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1" +dependencies = [ + "base64 0.21.7", + "bytes", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "pin-project", + "tokio-stream", + "tonic", + "tower-http 0.4.4", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.6", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -3105,27 +4003,47 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "http-range-header", + "pin-project-lite", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-http" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -3226,10 +4144,10 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", - "rand", + "rand 0.9.4", "sha1", "thiserror 2.0.18", ] @@ -3266,6 +4184,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicase" version = "2.9.0" @@ -3349,6 +4276,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -3485,7 +4413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -3544,9 +4472,9 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -3570,6 +4498,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -3579,6 +4516,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -3840,7 +4789,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -3870,8 +4819,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3890,7 +4839,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -3950,13 +4899,34 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + [[package]] name = "zerocopy" version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.8.48", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -4061,7 +5031,7 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac", - "indexmap", + "indexmap 2.14.0", "lzma-rs", "memchr", "pbkdf2", diff --git a/rust/crates/control-plane/Cargo.toml b/rust/crates/control-plane/Cargo.toml index 53e684a9..28f69415 100644 --- a/rust/crates/control-plane/Cargo.toml +++ b/rust/crates/control-plane/Cargo.toml @@ -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" diff --git a/rust/crates/control-plane/src/bin/control-plane-admin.rs b/rust/crates/control-plane/src/bin/control-plane-admin.rs new file mode 100644 index 00000000..11f468f9 --- /dev/null +++ b/rust/crates/control-plane/src/bin/control-plane-admin.rs @@ -0,0 +1,1502 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use control_plane::{ + migrations, ControlPlaneError, ControlPlaneStore, DirectoryGrantInput, OutboxEventInput, + SqliteControlPlaneStore, TursoControlPlaneStore, UpsertAiPolicyInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UpsertWorkspaceInput, +}; +use libsql::{Builder, Connection as LibsqlConnection, Database as LibsqlDatabase, Value}; +use rusqlite::{types::Value as SqliteValue, Connection as SqliteConnection}; +use serde_json::json; + +const TABLE_ORDER: &[&str] = &[ + "_migrations", + "users", + "auth_identities", + "auth_sessions", + "workspaces", + "workspace_members", + "directory_grants", + "share_links", + "audit_log", + "outbox_events", + "ai_runtime_runs", + "ai_runtime_events", + "sidebar_shortcuts", + "user_ui_preferences", + "user_navigation_recent", + "ai_policies", + "sync_state", + "ai_agent_profiles", + "ai_agent_profile_grants", + "ai_external_conversation_bindings", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Backend { + Sqlite, + LibsqlLocal, + TursoRemote, + TursoRemoteReplica, + TursoSynced, +} + +#[derive(Debug, Clone)] +struct BackendConfig { + backend: Backend, + sqlite_path: Option, + libsql_local_path: Option, + replica_path: Option, + synced_path: Option, + turso_url: Option, + turso_token: Option, +} + +#[derive(Debug, Clone)] +struct Args { + command: String, + flags: BTreeMap, + switches: BTreeSet, +} + +enum StoreHandle { + Sqlite(SqliteControlPlaneStore), + Turso(TursoControlPlaneStore), +} + +impl ControlPlaneStore for StoreHandle { + fn upsert_user( + &self, + input: control_plane::UpsertUserInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_user(input), + StoreHandle::Turso(store) => store.upsert_user(input), + } + } + + fn create_password_identity( + &self, + input: control_plane::CreatePasswordIdentityInput, + ) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.create_password_identity(input), + StoreHandle::Turso(store) => store.create_password_identity(input), + } + } + + fn authenticate_password( + &self, + input: control_plane::AuthenticatePasswordInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.authenticate_password(input), + StoreHandle::Turso(store) => store.authenticate_password(input), + } + } + + fn create_session( + &self, + input: control_plane::CreateSessionInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.create_session(input), + StoreHandle::Turso(store) => store.create_session(input), + } + } + + fn get_session_by_token_hash( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.get_session_by_token_hash(token_hash), + StoreHandle::Turso(store) => store.get_session_by_token_hash(token_hash), + } + } + + fn revoke_session(&self, session_id: &str) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.revoke_session(session_id), + StoreHandle::Turso(store) => store.revoke_session(session_id), + } + } + + fn ensure_default_workspace( + &self, + actor_id: &str, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.ensure_default_workspace(actor_id), + StoreHandle::Turso(store) => store.ensure_default_workspace(actor_id), + } + } + + fn upsert_workspace( + &self, + input: control_plane::UpsertWorkspaceInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_workspace(input), + StoreHandle::Turso(store) => store.upsert_workspace(input), + } + } + + fn grant_directory_access( + &self, + input: control_plane::DirectoryGrantInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.grant_directory_access(input), + StoreHandle::Turso(store) => store.grant_directory_access(input), + } + } + + fn list_directory_grants( + &self, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_directory_grants(), + StoreHandle::Turso(store) => store.list_directory_grants(), + } + } + + fn list_directory_grants_for_actor( + &self, + actor_id: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_directory_grants_for_actor(actor_id), + StoreHandle::Turso(store) => store.list_directory_grants_for_actor(actor_id), + } + } + + fn find_directory_grants( + &self, + lookup: control_plane::DirectoryGrantLookup, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.find_directory_grants(lookup), + StoreHandle::Turso(store) => store.find_directory_grants(lookup), + } + } + + fn revoke_directory_grant( + &self, + grant_id: &str, + expected_revision: Option, + ) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.revoke_directory_grant(grant_id, expected_revision), + StoreHandle::Turso(store) => store.revoke_directory_grant(grant_id, expected_revision), + } + } + + fn resolve_access( + &self, + actor_id: &str, + root_uri: &str, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.resolve_access(actor_id, root_uri), + StoreHandle::Turso(store) => store.resolve_access(actor_id, root_uri), + } + } + + fn create_share_link( + &self, + input: control_plane::CreateShareLinkInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.create_share_link(input), + StoreHandle::Turso(store) => store.create_share_link(input), + } + } + + fn list_share_links( + &self, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_share_links(workspace_id), + StoreHandle::Turso(store) => store.list_share_links(workspace_id), + } + } + + fn resolve_share_link( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.resolve_share_link(token_hash), + StoreHandle::Turso(store) => store.resolve_share_link(token_hash), + } + } + + fn revoke_share_link(&self, link_id: &str) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.revoke_share_link(link_id), + StoreHandle::Turso(store) => store.revoke_share_link(link_id), + } + } + + fn append_audit( + &self, + input: control_plane::AppendAuditInput, + ) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.append_audit(input), + StoreHandle::Turso(store) => store.append_audit(input), + } + } + + fn list_audit_log( + &self, + limit: usize, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_audit_log(limit), + StoreHandle::Turso(store) => store.list_audit_log(limit), + } + } + + fn append_outbox( + &self, + input: control_plane::OutboxEventInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.append_outbox(input), + StoreHandle::Turso(store) => store.append_outbox(input), + } + } + + fn drain_outbox( + &self, + limit: usize, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.drain_outbox(limit), + StoreHandle::Turso(store) => store.drain_outbox(limit), + } + } + + fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.mark_outbox_delivered(event_id), + StoreHandle::Turso(store) => store.mark_outbox_delivered(event_id), + } + } + + fn upsert_sidebar_shortcut( + &self, + input: control_plane::UpsertSidebarShortcutInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_sidebar_shortcut(input), + StoreHandle::Turso(store) => store.upsert_sidebar_shortcut(input), + } + } + + fn list_sidebar_shortcuts( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_sidebar_shortcuts(user_id, workspace_id), + StoreHandle::Turso(store) => store.list_sidebar_shortcuts(user_id, workspace_id), + } + } + + fn list_sidebar_shortcuts_with_global_local( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.list_sidebar_shortcuts_with_global_local(user_id, workspace_id) + } + StoreHandle::Turso(store) => { + store.list_sidebar_shortcuts_with_global_local(user_id, workspace_id) + } + } + } + + fn delete_sidebar_shortcut( + &self, + user_id: &str, + shortcut_id: &str, + ) -> Result<(), ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.delete_sidebar_shortcut(user_id, shortcut_id), + StoreHandle::Turso(store) => store.delete_sidebar_shortcut(user_id, shortcut_id), + } + } + + fn upsert_user_ui_preference( + &self, + input: control_plane::UpsertUserUiPreferenceInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_user_ui_preference(input), + StoreHandle::Turso(store) => store.upsert_user_ui_preference(input), + } + } + + fn list_user_ui_preferences( + &self, + user_id: &str, + workspace_id: Option<&str>, + source_kind: Option<&str>, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.list_user_ui_preferences(user_id, workspace_id, source_kind) + } + StoreHandle::Turso(store) => { + store.list_user_ui_preferences(user_id, workspace_id, source_kind) + } + } + } + + fn list_user_ui_preferences_for_scope( + &self, + workspace_id: Option<&str>, + source_kind: Option<&str>, + scope_kind: &str, + scope_id: &str, + key: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_user_ui_preferences_for_scope( + workspace_id, + source_kind, + scope_kind, + scope_id, + key, + ), + StoreHandle::Turso(store) => store.list_user_ui_preferences_for_scope( + workspace_id, + source_kind, + scope_kind, + scope_id, + key, + ), + } + } + + fn ensure_ai_agent_profile_policy( + &self, + user_id: &str, + is_admin: bool, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.ensure_ai_agent_profile_policy(user_id, is_admin), + StoreHandle::Turso(store) => store.ensure_ai_agent_profile_policy(user_id, is_admin), + } + } + + fn resolve_ai_agent_profile( + &self, + user_id: &str, + is_admin: bool, + profile_id: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.resolve_ai_agent_profile(user_id, is_admin, profile_id) + } + StoreHandle::Turso(store) => { + store.resolve_ai_agent_profile(user_id, is_admin, profile_id) + } + } + } + + fn upsert_navigation_recent( + &self, + input: control_plane::UpsertNavigationRecentInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_navigation_recent(input), + StoreHandle::Turso(store) => store.upsert_navigation_recent(input), + } + } + + fn list_navigation_recent( + &self, + user_id: &str, + kind: Option<&str>, + limit: usize, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_navigation_recent(user_id, kind, limit), + StoreHandle::Turso(store) => store.list_navigation_recent(user_id, kind, limit), + } + } + + fn upsert_ai_policy( + &self, + input: control_plane::UpsertAiPolicyInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_ai_policy(input), + StoreHandle::Turso(store) => store.upsert_ai_policy(input), + } + } + + fn get_ai_policy( + &self, + actor_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.get_ai_policy(actor_id, workspace_id), + StoreHandle::Turso(store) => store.get_ai_policy(actor_id, workspace_id), + } + } + + fn upsert_sync_state( + &self, + input: control_plane::UpsertSyncStateInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_sync_state(input), + StoreHandle::Turso(store) => store.upsert_sync_state(input), + } + } + + fn get_sync_state( + &self, + workspace_id: &str, + remote_kind: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.get_sync_state(workspace_id, remote_kind), + StoreHandle::Turso(store) => store.get_sync_state(workspace_id, remote_kind), + } + } + + fn upsert_ai_runtime_run( + &self, + input: control_plane::UpsertAiRuntimeRunInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_ai_runtime_run(input), + StoreHandle::Turso(store) => store.upsert_ai_runtime_run(input), + } + } + + fn list_ai_runtime_runs( + &self, + user_id: &str, + workspace_id: Option<&str>, + document_id: Option<&str>, + session_id: Option<&str>, + limit: usize, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.list_ai_runtime_runs(user_id, workspace_id, document_id, session_id, limit) + } + StoreHandle::Turso(store) => { + store.list_ai_runtime_runs(user_id, workspace_id, document_id, session_id, limit) + } + } + } + + fn find_ai_runtime_run( + &self, + user_id: &str, + run_id: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.find_ai_runtime_run(user_id, run_id), + StoreHandle::Turso(store) => store.find_ai_runtime_run(user_id, run_id), + } + } + + fn append_ai_runtime_event( + &self, + input: control_plane::AppendAiRuntimeEventInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.append_ai_runtime_event(input), + StoreHandle::Turso(store) => store.append_ai_runtime_event(input), + } + } + + fn delete_ai_runtime_events_for_run( + &self, + user_id: &str, + run_id: &str, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.delete_ai_runtime_events_for_run(user_id, run_id), + StoreHandle::Turso(store) => store.delete_ai_runtime_events_for_run(user_id, run_id), + } + } + + fn list_ai_runtime_events( + &self, + user_id: &str, + run_id: &str, + limit: usize, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_ai_runtime_events(user_id, run_id, limit), + StoreHandle::Turso(store) => store.list_ai_runtime_events(user_id, run_id, limit), + } + } + + fn list_ai_runtime_journal_events( + &self, + user_id: &str, + run_id: &str, + after_seq: i64, + limit: usize, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.list_ai_runtime_journal_events(user_id, run_id, after_seq, limit) + } + StoreHandle::Turso(store) => { + store.list_ai_runtime_journal_events(user_id, run_id, after_seq, limit) + } + } + } + + fn upsert_ai_external_conversation_binding( + &self, + input: control_plane::UpsertAiExternalConversationBindingInput, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.upsert_ai_external_conversation_binding(input), + StoreHandle::Turso(store) => store.upsert_ai_external_conversation_binding(input), + } + } + + fn find_ai_external_conversation_binding( + &self, + user_id: &str, + workspace_id: Option<&str>, + mnote_session_id: &str, + provider: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.find_ai_external_conversation_binding( + user_id, + workspace_id, + mnote_session_id, + provider, + ), + StoreHandle::Turso(store) => store.find_ai_external_conversation_binding( + user_id, + workspace_id, + mnote_session_id, + provider, + ), + } + } + + fn list_ai_external_conversation_bindings( + &self, + user_id: &str, + workspace_id: Option<&str>, + mnote_session_id: &str, + limit: usize, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => store.list_ai_external_conversation_bindings( + user_id, + workspace_id, + mnote_session_id, + limit, + ), + StoreHandle::Turso(store) => store.list_ai_external_conversation_bindings( + user_id, + workspace_id, + mnote_session_id, + limit, + ), + } + } + + fn mark_ai_external_conversation_binding_status( + &self, + user_id: &str, + workspace_id: Option<&str>, + mnote_session_id: &str, + provider: &str, + status: &str, + metadata_json: Option<&str>, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => store.mark_ai_external_conversation_binding_status( + user_id, + workspace_id, + mnote_session_id, + provider, + status, + metadata_json, + ), + StoreHandle::Turso(store) => store.mark_ai_external_conversation_binding_status( + user_id, + workspace_id, + mnote_session_id, + provider, + status, + metadata_json, + ), + } + } + + fn rename_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + title: &str, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.rename_ai_runtime_session(user_id, session_id, workspace_id, title) + } + StoreHandle::Turso(store) => { + store.rename_ai_runtime_session(user_id, session_id, workspace_id, title) + } + } + } + + fn auto_title_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError> { + match self { + StoreHandle::Sqlite(store) => { + store.auto_title_ai_runtime_session(user_id, session_id, workspace_id) + } + StoreHandle::Turso(store) => { + store.auto_title_ai_runtime_session(user_id, session_id, workspace_id) + } + } + } + + fn delete_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result { + match self { + StoreHandle::Sqlite(store) => { + store.delete_ai_runtime_session(user_id, session_id, workspace_id) + } + StoreHandle::Turso(store) => { + store.delete_ai_runtime_session(user_id, session_id, workspace_id) + } + } + } +} + +#[derive(Debug)] +enum TargetConnection { + Sqlite(SqliteConnection), + Libsql { + runtime: Arc, + _database: LibsqlDatabase, + conn: LibsqlConnection, + }, +} + +fn main() { + if let Err(error) = run() { + eprintln!("control-plane-admin failed: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let args = parse_args()?; + match args.command.as_str() { + "init" => cmd_init(&args)?, + "migrate-sqlite-to-target" => cmd_migrate_sqlite_to_target(&args)?, + "export-target-to-sqlite" => cmd_export_target_to_sqlite(&args)?, + "help" | "--help" | "-h" => print_help(), + other => return Err(format!("unknown command: {other}").into()), + } + Ok(()) +} + +fn parse_args() -> Result { + let mut iter = std::env::args().skip(1); + let command = iter.next().unwrap_or_else(|| "help".to_string()); + let mut flags = BTreeMap::new(); + let mut switches = BTreeSet::new(); + while let Some(arg) = iter.next() { + if !arg.starts_with("--") { + return Err(format!("unexpected positional argument: {arg}")); + } + let key = arg.trim_start_matches("--").to_string(); + match key.as_str() { + "dry-run" | "reset-target" | "backup-existing" => { + switches.insert(key); + } + _ => { + let value = iter + .next() + .ok_or_else(|| format!("missing value for --{key}"))?; + flags.insert(key, value); + } + } + } + Ok(Args { + command, + flags, + switches, + }) +} + +fn print_help() { + println!( + r#"control-plane-admin + +Commands: + init [--backend sqlite|libsql-local|turso-remote|turso-local-replica|turso-synced] [--dry-run] + migrate-sqlite-to-target --source [--backend ...] [--dry-run] [--reset-target] + export-target-to-sqlite --output [--backend ...] [--dry-run] [--backup-existing] + +Common env: + MNOTE_CONTROL_PLANE_BACKEND=sqlite|libsql-local|turso-remote|turso-local-replica|turso-synced + MNOTE_CONTROL_PLANE_DB_PATH=/path/control-plane.db + MNOTE_TURSO_LOCAL_PATH=/path/control-plane-libsql.db + MNOTE_TURSO_DATABASE_URL=libsql://... + MNOTE_TURSO_AUTH_TOKEN=... + MNOTE_TURSO_LOCAL_REPLICA_PATH=/path/control-plane-replica.db +"# + ); +} + +fn cmd_init(args: &Args) -> Result<(), Box> { + let dry_run = args.switches.contains("dry-run"); + let config = backend_config(args)?; + if dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "ok": true, + "dryRun": true, + "backend": format!("{:?}", config.backend), + "planned": [ + "run schema migrations", + "upsert default e2e/admin user", + "create password identity", + "upsert default local workspace", + "grant write access", + "upsert AI policy and UI preference", + "append init audit and outbox event" + ] + }))? + ); + return Ok(()); + } + + let store = open_store(&config)?; + let user_id = flag_or_env(args, "user-id", "MNOTE_CONTROL_PLANE_INIT_USER_ID") + .unwrap_or_else(|| "mnote-e2e".to_string()); + let email = flag_or_env(args, "email", "MNOTE_CONTROL_PLANE_INIT_EMAIL") + .unwrap_or_else(|| "mnote.e2e@example.com".to_string()); + let username = flag_or_env(args, "username", "MNOTE_CONTROL_PLANE_INIT_USERNAME") + .unwrap_or_else(|| "mnote-e2e".to_string()); + let display_name = flag_or_env( + args, + "display-name", + "MNOTE_CONTROL_PLANE_INIT_DISPLAY_NAME", + ) + .unwrap_or_else(|| username.clone()); + let password = flag_or_env(args, "password", "MNOTE_CONTROL_PLANE_INIT_PASSWORD") + .unwrap_or_else(|| "MnoteE2E123!".to_string()); + let workspace_id = flag_or_env( + args, + "workspace-id", + "MNOTE_CONTROL_PLANE_INIT_WORKSPACE_ID", + ) + .unwrap_or_else(|| format!("local-ws:{user_id}:default")); + let workspace_name = flag_or_env( + args, + "workspace-name", + "MNOTE_CONTROL_PLANE_INIT_WORKSPACE_NAME", + ) + .unwrap_or_else(|| "MNote E2E Workspace".to_string()); + let root_path = flag_or_env(args, "root-path", "MNOTE_CONTROL_PLANE_INIT_ROOT_PATH") + .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/workspaces/default".to_string()); + let root_uri = flag_or_env(args, "root-uri", "MNOTE_CONTROL_PLANE_INIT_ROOT_URI") + .unwrap_or_else(|| format!("file://{root_path}")); + + let user = store.upsert_user(UpsertUserInput { + id: Some(user_id.clone()), + email: Some(email.clone()), + username: username.clone(), + display_name, + role: Some("admin".to_string()), + password_hash: None, + })?; + let _ = store.create_password_identity(control_plane::CreatePasswordIdentityInput { + user_id: user.id.clone(), + email: Some(email), + username, + password, + }); + let workspace = store.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: Some("local_folder".to_string()), + })?; + store.grant_directory_access(DirectoryGrantInput { + user_id: user.id.clone(), + workspace_id: Some(workspace.id.clone()), + root_uri: root_uri.clone(), + root_path, + permission: "write".to_string(), + recursive: true, + capabilities: vec!["ai".to_string(), "share".to_string()], + source: "control-plane-admin".to_string(), + created_by: Some(user.id.clone()), + })?; + store.upsert_ai_policy(UpsertAiPolicyInput { + id: Some(format!("ai_policy_{}", user.id)), + user_id: Some(user.id.clone()), + workspace_id: Some(workspace.id.clone()), + allowed_roots_json: serde_json::to_string(&vec![root_uri])?, + model_policy_json: "{}".to_string(), + quota_json: "{}".to_string(), + })?; + store.upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: Some(format!("pref_{}_default_workspace", user.id)), + user_id: user.id.clone(), + workspace_id: Some(workspace.id.clone()), + source_kind: Some("local_folder".to_string()), + scope_kind: "workspace".to_string(), + scope_id: workspace.id.clone(), + key: "defaultWorkspace".to_string(), + value_json: serde_json::to_string(&json!({"workspaceId": workspace.id}))?, + })?; + store.append_outbox(OutboxEventInput { + topic: "control-plane".to_string(), + event_type: "control_plane.initialized".to_string(), + payload_json: serde_json::to_string( + &json!({"userId": user.id, "workspaceId": workspace.id}), + )?, + })?; + + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "ok": true, + "backend": format!("{:?}", config.backend), + "userId": user.id, + "workspaceId": workspace.id + }))? + ); + Ok(()) +} + +fn cmd_migrate_sqlite_to_target(args: &Args) -> Result<(), Box> { + let source_path = require_path(args, "source")?; + let dry_run = args.switches.contains("dry-run"); + let reset_target = args.switches.contains("reset-target"); + let config = backend_config(args)?; + + let mut target = open_target_connection(&config)?; + let source = SqliteConnection::open(&source_path)?; + migrations::run_migrations(&source)?; + let summary = collect_sqlite_counts(&source)?; + if dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "ok": true, + "dryRun": true, + "source": source_path, + "targetBackend": format!("{:?}", config.backend), + "tables": summary, + }))? + ); + return Ok(()); + } + copy_sqlite_to_target(&source, &mut target, reset_target)?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "ok": true, + "source": source_path, + "targetBackend": format!("{:?}", config.backend), + "resetTarget": reset_target, + "tables": summary, + }))? + ); + Ok(()) +} + +fn cmd_export_target_to_sqlite(args: &Args) -> Result<(), Box> { + let output_path = require_path(args, "output")?; + let dry_run = args.switches.contains("dry-run"); + let backup_existing = args.switches.contains("backup-existing"); + let config = backend_config(args)?; + let mut source = open_target_connection(&config)?; + let summary = collect_target_counts(&mut source)?; + if dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "ok": true, + "dryRun": true, + "sourceBackend": format!("{:?}", config.backend), + "output": output_path, + "tables": summary, + }))? + ); + return Ok(()); + } + if output_path.exists() && backup_existing { + let backup = output_path.with_extension(format!( + "backup-{}.db", + chrono::Utc::now().format("%Y%m%d%H%M%S") + )); + std::fs::copy(&output_path, &backup)?; + } + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent)?; + } + let output = SqliteConnection::open(&output_path)?; + migrations::run_migrations(&output)?; + copy_target_to_sqlite(&mut source, &output)?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "ok": true, + "sourceBackend": format!("{:?}", config.backend), + "output": output_path, + "tables": summary, + }))? + ); + Ok(()) +} + +fn backend_config(args: &Args) -> Result> { + let backend = flag_or_env(args, "backend", "MNOTE_CONTROL_PLANE_BACKEND") + .unwrap_or_else(|| "sqlite".to_string()); + let backend = match backend.as_str() { + "sqlite" => Backend::Sqlite, + "libsql-local" | "turso-local" | "turso" => Backend::LibsqlLocal, + "turso-remote" => Backend::TursoRemote, + "turso-local-replica" | "turso-remote-replica" => Backend::TursoRemoteReplica, + "turso-synced" => Backend::TursoSynced, + other => return Err(format!("unsupported backend: {other}").into()), + }; + Ok(BackendConfig { + backend, + sqlite_path: path_flag_or_env(args, "sqlite-path", "MNOTE_CONTROL_PLANE_DB_PATH"), + libsql_local_path: path_flag_or_env(args, "libsql-path", "MNOTE_TURSO_LOCAL_PATH"), + replica_path: path_flag_or_env(args, "replica-path", "MNOTE_TURSO_LOCAL_REPLICA_PATH"), + synced_path: path_flag_or_env(args, "synced-path", "MNOTE_TURSO_SYNCED_PATH"), + turso_url: flag_or_env(args, "turso-url", "MNOTE_TURSO_DATABASE_URL"), + turso_token: flag_or_env(args, "turso-token", "MNOTE_TURSO_AUTH_TOKEN"), + }) +} + +fn open_store(config: &BackendConfig) -> Result> { + Ok(match config.backend { + Backend::Sqlite => StoreHandle::Sqlite(SqliteControlPlaneStore::open( + config + .sqlite_path + .clone() + .unwrap_or_else(default_sqlite_path), + )?), + Backend::LibsqlLocal => StoreHandle::Turso(TursoControlPlaneStore::open_local( + config + .libsql_local_path + .clone() + .unwrap_or_else(default_libsql_path), + )?), + Backend::TursoRemote => StoreHandle::Turso(TursoControlPlaneStore::open_remote( + required_string(config.turso_url.clone(), "MNOTE_TURSO_DATABASE_URL")?, + required_string(config.turso_token.clone(), "MNOTE_TURSO_AUTH_TOKEN")?, + )?), + Backend::TursoRemoteReplica => { + StoreHandle::Turso(TursoControlPlaneStore::open_remote_replica( + config + .replica_path + .clone() + .unwrap_or_else(default_replica_path), + required_string(config.turso_url.clone(), "MNOTE_TURSO_DATABASE_URL")?, + required_string(config.turso_token.clone(), "MNOTE_TURSO_AUTH_TOKEN")?, + None, + )?) + } + Backend::TursoSynced => StoreHandle::Turso(TursoControlPlaneStore::open_synced( + config + .synced_path + .clone() + .or_else(|| config.replica_path.clone()) + .unwrap_or_else(default_synced_path), + required_string(config.turso_url.clone(), "MNOTE_TURSO_DATABASE_URL")?, + required_string(config.turso_token.clone(), "MNOTE_TURSO_AUTH_TOKEN")?, + None, + )?), + }) +} + +fn open_target_connection( + config: &BackendConfig, +) -> Result> { + match config.backend { + Backend::Sqlite => { + let path = config + .sqlite_path + .clone() + .unwrap_or_else(default_sqlite_path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let conn = SqliteConnection::open(path)?; + migrations::run_migrations(&conn)?; + Ok(TargetConnection::Sqlite(conn)) + } + Backend::LibsqlLocal + | Backend::TursoRemote + | Backend::TursoRemoteReplica + | Backend::TursoSynced => { + let runtime = Arc::new( + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?, + ); + let database = runtime.block_on(async { build_libsql_database(config).await })?; + let conn = database.connect()?; + runtime.block_on(migrations::run_libsql_migrations(&conn))?; + Ok(TargetConnection::Libsql { + runtime, + _database: database, + conn, + }) + } + } +} + +async fn build_libsql_database( + config: &BackendConfig, +) -> Result { + match config.backend { + Backend::LibsqlLocal => Builder::new_local( + config + .libsql_local_path + .clone() + .unwrap_or_else(default_libsql_path), + ) + .build() + .await + .map_err(ControlPlaneError::from), + Backend::TursoRemote => Builder::new_remote( + required_string(config.turso_url.clone(), "MNOTE_TURSO_DATABASE_URL") + .map_err(|e| ControlPlaneError::InvalidInput(e.to_string()))?, + required_string(config.turso_token.clone(), "MNOTE_TURSO_AUTH_TOKEN") + .map_err(|e| ControlPlaneError::InvalidInput(e.to_string()))?, + ) + .build() + .await + .map_err(ControlPlaneError::from), + Backend::TursoRemoteReplica => Builder::new_remote_replica( + config + .replica_path + .clone() + .unwrap_or_else(default_replica_path), + required_string(config.turso_url.clone(), "MNOTE_TURSO_DATABASE_URL") + .map_err(|e| ControlPlaneError::InvalidInput(e.to_string()))?, + required_string(config.turso_token.clone(), "MNOTE_TURSO_AUTH_TOKEN") + .map_err(|e| ControlPlaneError::InvalidInput(e.to_string()))?, + ) + .sync_interval(Duration::from_secs(5)) + .build() + .await + .map_err(ControlPlaneError::from), + Backend::TursoSynced => Builder::new_synced_database( + config + .synced_path + .clone() + .or_else(|| config.replica_path.clone()) + .unwrap_or_else(default_synced_path), + required_string(config.turso_url.clone(), "MNOTE_TURSO_DATABASE_URL") + .map_err(|e| ControlPlaneError::InvalidInput(e.to_string()))?, + required_string(config.turso_token.clone(), "MNOTE_TURSO_AUTH_TOKEN") + .map_err(|e| ControlPlaneError::InvalidInput(e.to_string()))?, + ) + .sync_interval(Duration::from_secs(5)) + .build() + .await + .map_err(ControlPlaneError::from), + Backend::Sqlite => Err(ControlPlaneError::InvalidInput( + "sqlite is not a libSQL backend".to_string(), + )), + } +} + +fn copy_sqlite_to_target( + source: &SqliteConnection, + target: &mut TargetConnection, + reset_target: bool, +) -> Result<(), Box> { + let source_tables = ordered_existing_tables_sqlite(source)?; + if reset_target { + clear_target_tables(target, &source_tables)?; + } + for table in source_tables { + let columns = sqlite_columns(source, &table)?; + let quoted_table = quote_ident(&table); + let quoted_columns = columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let mut stmt = source.prepare(&format!("SELECT {quoted_columns} FROM {quoted_table}"))?; + let row_count = columns.len(); + let rows = stmt.query_map([], |row| { + let mut values = Vec::with_capacity(row_count); + for index in 0..row_count { + values.push(row.get::<_, SqliteValue>(index)?); + } + Ok(values) + })?; + for row in rows { + let values = row?; + insert_row(target, &table, &columns, values)?; + } + } + Ok(()) +} + +fn copy_target_to_sqlite( + source: &mut TargetConnection, + output: &SqliteConnection, +) -> Result<(), Box> { + let tables = ordered_existing_tables_target(source)?; + clear_sqlite_tables(output, &tables)?; + for table in tables { + let columns = target_columns(source, &table)?; + let rows = target_rows(source, &table, &columns)?; + for row in rows { + insert_sqlite_row(output, &table, &columns, row)?; + } + } + Ok(()) +} + +fn collect_sqlite_counts( + conn: &SqliteConnection, +) -> Result, rusqlite::Error> { + let mut counts = BTreeMap::new(); + for table in ordered_existing_tables_sqlite(conn).unwrap_or_default() { + let count: i64 = conn.query_row( + &format!("SELECT count(*) FROM {}", quote_ident(&table)), + [], + |row| row.get(0), + )?; + counts.insert(table, count); + } + Ok(counts) +} + +fn collect_target_counts( + target: &mut TargetConnection, +) -> Result, Box> { + match target { + TargetConnection::Sqlite(conn) => Ok(collect_sqlite_counts(conn)?), + TargetConnection::Libsql { runtime, conn, .. } => { + let tables = ordered_existing_tables_libsql(runtime, conn)?; + let mut counts = BTreeMap::new(); + for table in tables { + let sql = format!("SELECT count(*) FROM {}", quote_ident(&table)); + let count = runtime.block_on(async { + let mut rows = conn.query(&sql, ()).await?; + let Some(row) = rows.next().await? else { + return Ok::(0); + }; + row.get::(0) + })?; + counts.insert(table, count); + } + Ok(counts) + } + } +} + +fn ordered_existing_tables_sqlite( + conn: &SqliteConnection, +) -> Result, Box> { + let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table'")?; + let existing = stmt + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(TABLE_ORDER + .iter() + .filter(|table| existing.contains(**table)) + .map(|table| (*table).to_string()) + .collect()) +} + +fn ordered_existing_tables_libsql( + runtime: &tokio::runtime::Runtime, + conn: &LibsqlConnection, +) -> Result, Box> { + let existing = runtime.block_on(async { + let mut rows = conn + .query("SELECT name FROM sqlite_master WHERE type='table'", ()) + .await?; + let mut existing = BTreeSet::new(); + while let Some(row) = rows.next().await? { + existing.insert(row.get::(0)?); + } + Ok::<_, libsql::Error>(existing) + })?; + Ok(TABLE_ORDER + .iter() + .filter(|table| existing.contains(**table)) + .map(|table| (*table).to_string()) + .collect()) +} + +fn ordered_existing_tables_target( + target: &mut TargetConnection, +) -> Result, Box> { + match target { + TargetConnection::Sqlite(conn) => ordered_existing_tables_sqlite(conn), + TargetConnection::Libsql { runtime, conn, .. } => { + ordered_existing_tables_libsql(runtime, conn) + } + } +} + +fn sqlite_columns(conn: &SqliteConnection, table: &str) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", quote_ident(table)))?; + let columns = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + Ok(columns) +} + +fn target_columns( + target: &mut TargetConnection, + table: &str, +) -> Result, Box> { + match target { + TargetConnection::Sqlite(conn) => Ok(sqlite_columns(conn, table)?), + TargetConnection::Libsql { runtime, conn, .. } => { + let sql = format!("PRAGMA table_info({})", quote_ident(table)); + Ok(runtime.block_on(async { + let mut rows = conn.query(&sql, ()).await?; + let mut columns = Vec::new(); + while let Some(row) = rows.next().await? { + columns.push(row.get::(1)?); + } + Ok::<_, libsql::Error>(columns) + })?) + } + } +} + +fn target_rows( + target: &mut TargetConnection, + table: &str, + columns: &[String], +) -> Result>, Box> { + match target { + TargetConnection::Sqlite(conn) => { + let quoted_columns = columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let mut stmt = conn.prepare(&format!( + "SELECT {quoted_columns} FROM {}", + quote_ident(table) + ))?; + let rows = stmt + .query_map([], |row| { + let mut values = Vec::new(); + for index in 0..columns.len() { + values.push(row.get::<_, SqliteValue>(index)?); + } + Ok(values) + })? + .collect::, _>>()?; + Ok(rows) + } + TargetConnection::Libsql { runtime, conn, .. } => { + let quoted_columns = columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let sql = format!("SELECT {quoted_columns} FROM {}", quote_ident(table)); + Ok(runtime.block_on(async { + let mut rows = conn.query(&sql, ()).await?; + let mut out = Vec::new(); + while let Some(row) = rows.next().await? { + let mut values = Vec::new(); + for index in 0..columns.len() { + values.push(libsql_to_sqlite(row.get_value(index as i32)?)); + } + out.push(values); + } + Ok::<_, libsql::Error>(out) + })?) + } + } +} + +fn clear_target_tables( + target: &mut TargetConnection, + tables: &[String], +) -> Result<(), Box> { + match target { + TargetConnection::Sqlite(conn) => clear_sqlite_tables(conn, tables), + TargetConnection::Libsql { runtime, conn, .. } => { + for table in tables.iter().rev() { + let sql = format!("DELETE FROM {}", quote_ident(table)); + runtime.block_on(async { conn.execute(&sql, ()).await })?; + } + Ok(()) + } + } +} + +fn clear_sqlite_tables( + conn: &SqliteConnection, + tables: &[String], +) -> Result<(), Box> { + for table in tables.iter().rev() { + conn.execute(&format!("DELETE FROM {}", quote_ident(table)), [])?; + } + Ok(()) +} + +fn insert_row( + target: &mut TargetConnection, + table: &str, + columns: &[String], + values: Vec, +) -> Result<(), Box> { + match target { + TargetConnection::Sqlite(conn) => insert_sqlite_row(conn, table, columns, values), + TargetConnection::Libsql { runtime, conn, .. } => { + let sql = insert_sql(table, columns); + let values = values.into_iter().map(sqlite_to_libsql).collect::>(); + runtime.block_on(async { conn.execute(&sql, values).await })?; + Ok(()) + } + } +} + +fn insert_sqlite_row( + conn: &SqliteConnection, + table: &str, + columns: &[String], + values: Vec, +) -> Result<(), Box> { + let sql = insert_sql(table, columns); + let params = rusqlite::params_from_iter(values); + conn.execute(&sql, params)?; + Ok(()) +} + +fn insert_sql(table: &str, columns: &[String]) -> String { + let quoted_columns = columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let placeholders = (1..=columns.len()) + .map(|index| format!("?{index}")) + .collect::>() + .join(", "); + format!( + "INSERT OR REPLACE INTO {} ({quoted_columns}) VALUES ({placeholders})", + quote_ident(table) + ) +} + +fn sqlite_to_libsql(value: SqliteValue) -> Value { + match value { + SqliteValue::Null => Value::Null, + SqliteValue::Integer(value) => Value::Integer(value), + SqliteValue::Real(value) => Value::Real(value), + SqliteValue::Text(value) => Value::Text(value), + SqliteValue::Blob(value) => Value::Blob(value), + } +} + +fn libsql_to_sqlite(value: Value) -> SqliteValue { + match value { + Value::Null => SqliteValue::Null, + Value::Integer(value) => SqliteValue::Integer(value), + Value::Real(value) => SqliteValue::Real(value), + Value::Text(value) => SqliteValue::Text(value), + Value::Blob(value) => SqliteValue::Blob(value), + } +} + +fn quote_ident(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn require_path(args: &Args, key: &str) -> Result> { + args.flags + .get(key) + .map(PathBuf::from) + .ok_or_else(|| format!("missing --{key}").into()) +} + +fn flag_or_env(args: &Args, key: &str, env_key: &str) -> Option { + args.flags + .get(key) + .cloned() + .or_else(|| std::env::var(env_key).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn path_flag_or_env(args: &Args, key: &str, env_key: &str) -> Option { + flag_or_env(args, key, env_key).map(PathBuf::from) +} + +fn required_string( + value: Option, + key: &'static str, +) -> Result> { + value.ok_or_else(|| format!("missing {key}").into()) +} + +fn default_sqlite_path() -> PathBuf { + Path::new("/mnt/Data1T/Mnote_data/control-plane/control-plane.db").to_path_buf() +} + +fn default_libsql_path() -> PathBuf { + Path::new("/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db").to_path_buf() +} + +fn default_replica_path() -> PathBuf { + Path::new("/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db").to_path_buf() +} + +fn default_synced_path() -> PathBuf { + Path::new("/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db").to_path_buf() +} diff --git a/rust/crates/control-plane/src/error.rs b/rust/crates/control-plane/src/error.rs index f119fe5b..4574a570 100644 --- a/rust/crates/control-plane/src/error.rs +++ b/rust/crates/control-plane/src/error.rs @@ -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 for ControlPlaneError { } } +impl From 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 for ControlPlaneError { fn from(e: serde_json::Error) -> Self { ControlPlaneError::Storage(format!("JSON error: {e}")) diff --git a/rust/crates/control-plane/src/lib.rs b/rust/crates/control-plane/src/lib.rs index a2e11994..639ee0aa 100644 --- a/rust/crates/control-plane/src/lib.rs +++ b/rust/crates/control-plane/src/lib.rs @@ -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}; diff --git a/rust/crates/control-plane/src/migrations.rs b/rust/crates/control-plane/src/migrations.rs index 7dbc5a8c..3c735d31 100644 --- a/rust/crates/control-plane/src/migrations.rs +++ b/rust/crates/control-plane/src/migrations.rs @@ -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::(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::(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"); + } + } } diff --git a/rust/crates/control-plane/src/model.rs b/rust/crates/control-plane/src/model.rs index b08c8b73..281f4bbe 100644 --- a/rust/crates/control-plane/src/model.rs +++ b/rust/crates/control-plane/src/model.rs @@ -112,6 +112,17 @@ pub struct WorkspaceRecord { pub revision: i64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpsertWorkspaceInput { + pub id: Option, + pub owner_user_id: EntityId, + pub name: String, + pub kind: Option, + pub root_uri: String, + pub root_path: String, + pub source_kind: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DirectoryGrantRecord { pub id: EntityId, diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index d598a47c..f79ac5cf 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -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, 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 { 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 { - 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 { + 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 { - 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, 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, 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 { - 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, 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 { - 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, 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::(&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::(&input.model_policy_json)?; serde_json::from_str::(&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::(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::(&input.runtime_json)?; serde_json::from_str::(&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, 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, 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::(&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 { + 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, 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, 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::(&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, 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::, _>>() + .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] diff --git a/rust/crates/control-plane/src/store.rs b/rust/crates/control-plane/src/store.rs index 43e8a156..11ada567 100644 --- a/rust/crates/control-plane/src/store.rs +++ b/rust/crates/control-plane/src/store.rs @@ -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; + /// 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; + fn grant_directory_access( &self, input: DirectoryGrantInput, @@ -219,6 +230,17 @@ pub trait ControlPlaneStore: Send + Sync { input: AppendAiRuntimeEventInput, ) -> Result; + /// 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; + fn list_ai_runtime_events( &self, user_id: &str, @@ -247,6 +269,14 @@ pub trait ControlPlaneStore: Send + Sync { provider: &str, ) -> Result, ControlPlaneError>; + fn list_ai_external_conversation_bindings( + &self, + user_id: &str, + workspace_id: Option<&str>, + mnote_session_id: &str, + limit: usize, + ) -> Result, ControlPlaneError>; + fn mark_ai_external_conversation_binding_status( &self, user_id: &str, diff --git a/rust/crates/control-plane/src/turso.rs b/rust/crates/control-plane/src/turso.rs new file mode 100644 index 00000000..daed2c10 --- /dev/null +++ b/rust/crates/control-plane/src/turso.rs @@ -0,0 +1,4903 @@ +//! libSQL/Turso-backed control-plane store. +//! +//! This implementation intentionally mirrors the SQLite store while keeping the +//! public ControlPlaneStore trait synchronous for the rest of mnote-web. + +use std::collections::BTreeMap; +use std::future::Future; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use libsql::{Builder, Connection, Database, Value}; + +use crate::error::ControlPlaneError; +use crate::migrations; +use crate::model::{ + password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord, + AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord, + AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, + AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, + AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, + CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, + DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, + ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, + UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, + UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, + WorkspaceRecord, +}; +use crate::store::ControlPlaneStore; + +#[derive(Debug, Clone)] +pub enum TursoControlPlaneMode { + Local, + Remote, + RemoteReplica, + Synced, +} + +#[derive(Debug, Clone)] +pub struct TursoControlPlaneConfig { + pub mode: TursoControlPlaneMode, + pub path: Option, + pub remote_url: Option, + pub auth_token: Option, + pub sync_interval: Option, +} + +pub struct TursoControlPlaneStore { + conn: Mutex, + _database: Database, +} + +#[derive(Clone)] +struct TursoConnection { + conn: Connection, + runtime: Arc, +} + +struct TursoStatement { + conn: TursoConnection, + sql: String, +} + +struct TursoMappedRows { + rows: std::vec::IntoIter>, +} + +struct TursoTransaction { + conn: TursoConnection, + committed: bool, +} + +trait OptionalExtension { + fn optional(self) -> libsql::Result>; +} + +impl OptionalExtension for libsql::Result { + fn optional(self) -> libsql::Result> { + match self { + Ok(value) => Ok(Some(value)), + Err(libsql::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(error), + } + } +} + +trait ToTursoValue { + fn to_turso_value(self) -> Value; +} + +fn to_turso_value(value: T) -> Value { + value.to_turso_value() +} + +macro_rules! params { + ($($value:expr),* $(,)?) => { + vec![$(to_turso_value(&$value)),*] + }; +} + +trait IntoTursoParams { + fn into_turso_values(self) -> libsql::Result>; +} + +impl IntoTursoParams for Vec { + fn into_turso_values(self) -> libsql::Result> { + Ok(self) + } +} + +impl IntoTursoParams for [T; N] { + fn into_turso_values(self) -> libsql::Result> { + Ok(self.into_iter().map(to_turso_value).collect()) + } +} + +impl IntoTursoParams for () { + fn into_turso_values(self) -> libsql::Result> { + Ok(Vec::new()) + } +} + +impl ToTursoValue for () { + fn to_turso_value(self) -> Value { + Value::Null + } +} + +impl ToTursoValue for Value { + fn to_turso_value(self) -> Value { + self + } +} + +impl ToTursoValue for &Value { + fn to_turso_value(self) -> Value { + self.clone() + } +} + +impl ToTursoValue for &&Value { + fn to_turso_value(self) -> Value { + (*self).clone() + } +} + +impl ToTursoValue for String { + fn to_turso_value(self) -> Value { + Value::Text(self) + } +} + +impl ToTursoValue for &String { + fn to_turso_value(self) -> Value { + Value::Text(self.clone()) + } +} + +impl ToTursoValue for &&String { + fn to_turso_value(self) -> Value { + Value::Text((*self).clone()) + } +} + +impl ToTursoValue for &str { + fn to_turso_value(self) -> Value { + Value::Text(self.to_string()) + } +} + +impl ToTursoValue for &&str { + fn to_turso_value(self) -> Value { + Value::Text((*self).to_string()) + } +} + +impl ToTursoValue for &&&str { + fn to_turso_value(self) -> Value { + Value::Text((**self).to_string()) + } +} + +impl ToTursoValue for Option { + fn to_turso_value(self) -> Value { + self.map(Value::Text).unwrap_or(Value::Null) + } +} + +impl ToTursoValue for &Option { + fn to_turso_value(self) -> Value { + self.clone().map(Value::Text).unwrap_or(Value::Null) + } +} + +impl ToTursoValue for &&Option { + fn to_turso_value(self) -> Value { + (*self).clone().map(Value::Text).unwrap_or(Value::Null) + } +} + +impl ToTursoValue for Option<&str> { + fn to_turso_value(self) -> Value { + self.map(|value| Value::Text(value.to_string())) + .unwrap_or(Value::Null) + } +} + +impl ToTursoValue for &Option<&str> { + fn to_turso_value(self) -> Value { + self.map(|value| Value::Text(value.to_string())) + .unwrap_or(Value::Null) + } +} + +impl ToTursoValue for Option<&String> { + fn to_turso_value(self) -> Value { + self.cloned().map(Value::Text).unwrap_or(Value::Null) + } +} + +impl ToTursoValue for &Option<&String> { + fn to_turso_value(self) -> Value { + self.cloned().map(Value::Text).unwrap_or(Value::Null) + } +} + +impl ToTursoValue for i64 { + fn to_turso_value(self) -> Value { + Value::Integer(self) + } +} + +impl ToTursoValue for &i64 { + fn to_turso_value(self) -> Value { + Value::Integer(*self) + } +} + +impl ToTursoValue for i32 { + fn to_turso_value(self) -> Value { + Value::Integer(self as i64) + } +} + +impl ToTursoValue for &i32 { + fn to_turso_value(self) -> Value { + Value::Integer(*self as i64) + } +} + +impl ToTursoValue for usize { + fn to_turso_value(self) -> Value { + Value::Integer(self as i64) + } +} + +impl ToTursoValue for &usize { + fn to_turso_value(self) -> Value { + Value::Integer(*self as i64) + } +} + +impl ToTursoValue for u64 { + fn to_turso_value(self) -> Value { + Value::Integer(self as i64) + } +} + +impl ToTursoValue for &u64 { + fn to_turso_value(self) -> Value { + Value::Integer(*self as i64) + } +} + +impl ToTursoValue for bool { + fn to_turso_value(self) -> Value { + Value::Integer(if self { 1 } else { 0 }) + } +} + +impl ToTursoValue for &bool { + fn to_turso_value(self) -> Value { + Value::Integer(if *self { 1 } else { 0 }) + } +} + +impl TursoConnection { + fn new(conn: Connection, runtime: Arc) -> Self { + Self { conn, runtime } + } + + fn execute

(&self, sql: &str, params: P) -> libsql::Result + where + P: IntoTursoParams, + { + let values = params.into_turso_values()?; + block_on_runtime(self.runtime.as_ref(), async { + self.conn + .execute(sql, values) + .await + .map(|changed| changed as usize) + }) + } + + fn execute_batch(&self, sql: &str) -> libsql::Result<()> { + block_on_runtime(self.runtime.as_ref(), async { + self.conn.execute_batch(sql).await.map(|_| ()) + }) + } + + fn prepare(&self, sql: &str) -> libsql::Result { + Ok(TursoStatement { + conn: self.clone(), + sql: sql.to_string(), + }) + } + + fn query_row(&self, sql: &str, params: P, mapper: F) -> libsql::Result + where + P: IntoTursoParams, + F: FnOnce(&libsql::Row) -> libsql::Result, + { + let values = params.into_turso_values()?; + block_on_runtime(self.runtime.as_ref(), async { + let mut rows = self.conn.query(sql, values).await?; + let Some(row) = rows.next().await? else { + return Err(libsql::Error::QueryReturnedNoRows); + }; + mapper(&row) + }) + } + + fn transaction(&self) -> libsql::Result { + block_on_runtime(self.runtime.as_ref(), async { + self.conn + .execute("BEGIN IMMEDIATE", ()) + .await + .map(|_| TursoTransaction { + conn: self.clone(), + committed: false, + }) + }) + } +} + +impl TursoTransaction { + fn execute

(&self, sql: &str, params: P) -> libsql::Result + where + P: IntoTursoParams, + { + self.conn.execute(sql, params) + } + + fn query_row(&self, sql: &str, params: P, mapper: F) -> libsql::Result + where + P: IntoTursoParams, + F: FnOnce(&libsql::Row) -> libsql::Result, + { + self.conn.query_row(sql, params, mapper) + } + + fn commit(mut self) -> libsql::Result<()> { + self.conn.execute_batch("COMMIT")?; + self.committed = true; + Ok(()) + } +} + +impl Drop for TursoTransaction { + fn drop(&mut self) { + if !self.committed { + let _ = self.conn.execute_batch("ROLLBACK"); + } + } +} + +impl Iterator for TursoMappedRows { + type Item = libsql::Result; + + fn next(&mut self) -> Option { + self.rows.next() + } +} + +impl TursoStatement { + fn query_map(&mut self, params: P, mut mapper: F) -> libsql::Result> + where + P: IntoTursoParams, + F: FnMut(&libsql::Row) -> libsql::Result, + { + let values = params.into_turso_values()?; + let rows = block_on_runtime(self.conn.runtime.as_ref(), async { + let mut rows = self.conn.conn.query(&self.sql, values).await?; + let mut mapped = Vec::new(); + while let Some(row) = rows.next().await? { + mapped.push(mapper(&row)); + } + Ok::<_, libsql::Error>(mapped) + })?; + Ok(TursoMappedRows { + rows: rows.into_iter(), + }) + } +} + +impl TursoControlPlaneStore { + pub fn open(path: impl AsRef) -> Result { + Self::open_local(path) + } + + pub fn open_local(path: impl AsRef) -> Result { + Self::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::Local, + path: Some(path.as_ref().to_path_buf()), + remote_url: None, + auth_token: None, + sync_interval: None, + }) + } + + pub fn in_memory() -> Result { + Self::open_local(":memory:") + } + + pub fn open_remote(url: String, auth_token: String) -> Result { + Self::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::Remote, + path: None, + remote_url: Some(url), + auth_token: Some(auth_token), + sync_interval: None, + }) + } + + pub fn open_remote_replica( + path: impl AsRef, + url: String, + auth_token: String, + sync_interval: Option, + ) -> Result { + Self::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::RemoteReplica, + path: Some(path.as_ref().to_path_buf()), + remote_url: Some(url), + auth_token: Some(auth_token), + sync_interval, + }) + } + + pub fn open_synced( + path: impl AsRef, + url: String, + auth_token: String, + sync_interval: Option, + ) -> Result { + Self::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::Synced, + path: Some(path.as_ref().to_path_buf()), + remote_url: Some(url), + auth_token: Some(auth_token), + sync_interval, + }) + } + + pub fn open_with_config(config: TursoControlPlaneConfig) -> Result { + let runtime = Arc::new( + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| ControlPlaneError::Storage(format!("libSQL runtime: {error}")))?, + ); + let mode = config.mode.clone(); + let database = block_on_runtime(runtime.as_ref(), async { build_database(config).await })?; + let raw_conn = database.connect()?; + let conn = TursoConnection::new(raw_conn.clone(), runtime); + configure_connection(&conn, &mode)?; + block_on_runtime( + conn.runtime.as_ref(), + migrations::run_libsql_migrations(&raw_conn), + )?; + Ok(Self { + conn: Mutex::new(conn), + _database: database, + }) + } + + fn lock_conn(&self) -> Result, ControlPlaneError> { + self.conn + .lock() + .map_err(|error| ControlPlaneError::Storage(format!("libSQL control-plane lock poisoned: {error}"))) + } +} + +fn block_on_runtime(runtime: &tokio::runtime::Runtime, future: F) -> F::Output +where + F: Future, +{ + match tokio::runtime::Handle::try_current() { + Ok(handle) + if matches!( + handle.runtime_flavor(), + tokio::runtime::RuntimeFlavor::MultiThread + ) => + { + tokio::task::block_in_place(|| runtime.block_on(future)) + } + _ => runtime.block_on(future), + } +} + +async fn build_database(config: TursoControlPlaneConfig) -> Result { + match config.mode { + TursoControlPlaneMode::Local => { + let path = config + .path + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 libSQL local path".into()))?; + Builder::new_local(path) + .build() + .await + .map_err(ControlPlaneError::from) + } + TursoControlPlaneMode::Remote => { + let url = config + .remote_url + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 Turso URL".into()))?; + let token = config + .auth_token + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 Turso auth token".into()))?; + Builder::new_remote(url, token) + .build() + .await + .map_err(ControlPlaneError::from) + } + TursoControlPlaneMode::RemoteReplica => { + let path = config.path.ok_or_else(|| { + ControlPlaneError::InvalidInput("缺少 Turso local replica path".into()) + })?; + let url = config + .remote_url + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 Turso URL".into()))?; + let token = config + .auth_token + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 Turso auth token".into()))?; + let mut builder = Builder::new_remote_replica(path, url, token); + if let Some(interval) = config.sync_interval { + builder = builder.sync_interval(interval); + } + builder.build().await.map_err(ControlPlaneError::from) + } + TursoControlPlaneMode::Synced => { + let path = config + .path + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 Turso synced path".into()))?; + let url = config + .remote_url + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 Turso URL".into()))?; + let token = config + .auth_token + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| ControlPlaneError::InvalidInput("缺少 Turso auth token".into()))?; + let mut builder = Builder::new_synced_database(path, url, token); + if let Some(interval) = config.sync_interval { + builder = builder.sync_interval(interval); + } + builder.build().await.map_err(ControlPlaneError::from) + } + } +} + +fn configure_connection( + conn: &TursoConnection, + mode: &TursoControlPlaneMode, +) -> Result<(), ControlPlaneError> { + match mode { + TursoControlPlaneMode::Local + | TursoControlPlaneMode::RemoteReplica + | TursoControlPlaneMode::Synced => conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000;", + )?, + TursoControlPlaneMode::Remote => { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + } + } + Ok(()) +} + +fn now_text() -> String { + chrono::Utc::now().to_rfc3339() +} + +fn new_id(prefix: &str) -> String { + format!("{prefix}_{}", uuid::Uuid::new_v4().simple()) +} + +fn capabilities_json(capabilities: &[String]) -> Result { + serde_json::to_string(capabilities).map_err(ControlPlaneError::from) +} + +fn file_uri_to_legacy_path(value: &str) -> String { + value + .trim() + .strip_prefix("file://") + .unwrap_or("") + .to_string() +} + +fn row_to_user(row: &libsql::Row) -> libsql::Result { + Ok(UserRecord { + id: row.get(0)?, + email: row.get(1)?, + username: row.get(2)?, + display_name: row.get(3)?, + role: row.get(4)?, + status: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + revision: row.get(8)?, + }) +} + +fn row_to_auth_session(row: &libsql::Row) -> libsql::Result { + Ok(AuthSessionRecord { + id: row.get(0)?, + user_id: row.get(1)?, + token_hash: row.get(2)?, + user_agent: row.get(3)?, + ip_hash: row.get(4)?, + created_at: row.get(5)?, + expires_at: row.get(6)?, + revoked_at: row.get(7)?, + last_seen_at: row.get(8)?, + }) +} + +fn row_to_workspace(row: &libsql::Row) -> libsql::Result { + Ok(WorkspaceRecord { + id: row.get(0)?, + owner_user_id: row.get(1)?, + name: row.get(2)?, + kind: row.get(3)?, + root_uri: row.get(4)?, + root_path: row.get(5)?, + source_kind: row.get(6)?, + status: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + revision: row.get(10)?, + }) +} + +fn row_to_grant(row: &libsql::Row) -> libsql::Result { + Ok(DirectoryGrantRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + root_uri: row.get(3)?, + root_path: row.get(4)?, + permission: row.get(5)?, + recursive: row.get::(6)? != 0, + capabilities_json: row.get(7)?, + source: row.get(8)?, + status: row.get(9)?, + created_by: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, + revision: row.get(13)?, + }) +} + +fn row_to_outbox(row: &libsql::Row) -> libsql::Result { + Ok(OutboxEventRecord { + id: row.get(0)?, + topic: row.get(1)?, + event_type: row.get(2)?, + payload_json: row.get(3)?, + created_at: row.get(4)?, + delivered_at: row.get(5)?, + attempts: row.get(6)?, + }) +} + +fn row_to_share_link(row: &libsql::Row) -> libsql::Result { + Ok(ShareLinkRecord { + id: row.get(0)?, + workspace_id: row.get(1)?, + resource_kind: row.get(2)?, + resource_id: row.get(3)?, + token_hash: row.get(4)?, + permission: row.get(5)?, + created_by: row.get(6)?, + expires_at: row.get(7)?, + revoked_at: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + revision: row.get(11)?, + }) +} + +fn row_to_audit_log(row: &libsql::Row) -> libsql::Result { + Ok(AuditLogRecord { + id: row.get(0)?, + actor_user_id: row.get(1)?, + action: row.get(2)?, + target_kind: row.get(3)?, + target_id: row.get(4)?, + metadata_json: row.get(5)?, + created_at: row.get(6)?, + }) +} + +fn row_to_sidebar_shortcut(row: &libsql::Row) -> libsql::Result { + Ok(SidebarShortcutRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + root_uri: row.get(3)?, + kind: row.get(4)?, + source_kind: row.get(5)?, + target_id: row.get(6)?, + relative_path: row.get(7)?, + document_id: row.get(8)?, + title: row.get(9)?, + icon: row.get(10)?, + sort_order: row.get(11)?, + status: row.get(12)?, + metadata_json: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + revision: row.get(16)?, + }) +} + +fn empty_string_to_option(value: String) -> Option { + if value.trim().is_empty() { + None + } else { + Some(value) + } +} + +fn option_to_stored_text(value: Option) -> String { + value + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .unwrap_or_default() +} + +fn sanitize_profile_component(value: &str) -> String { + let mut sanitized = value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + while sanitized.contains("--") { + sanitized = sanitized.replace("--", "-"); + } + sanitized = sanitized.trim_matches('-').to_string(); + if sanitized.is_empty() { + "user".to_string() + } else { + sanitized + } +} + +fn row_to_ai_agent_profile(row: &libsql::Row) -> libsql::Result { + let owner_user_id: String = row.get(3)?; + Ok(AiAgentProfileRecord { + id: row.get(0)?, + agent_id: row.get(1)?, + profile_kind: row.get(2)?, + owner_user_id: empty_string_to_option(owner_user_id), + base_profile_name: row.get(4)?, + isolated_profile_name: row.get(5)?, + display_name: row.get(6)?, + status: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + revision: row.get(10)?, + }) +} + +fn row_to_ai_agent_profile_access(row: &libsql::Row) -> libsql::Result { + Ok(AiAgentProfileAccessRecord { + profile: row_to_ai_agent_profile(row)?, + grant: AiAgentProfileGrantRecord { + id: row.get(11)?, + profile_id: row.get(12)?, + user_id: empty_string_to_option(row.get(13)?), + role: row.get(14)?, + can_run: row.get::(15)? != 0, + can_manage_skills: row.get::(16)? != 0, + can_manage_config: row.get::(17)? != 0, + created_at: row.get(18)?, + updated_at: row.get(19)?, + revision: row.get(20)?, + }, + }) +} + +fn row_to_user_ui_preference(row: &libsql::Row) -> libsql::Result { + Ok(UserUiPreferenceRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: empty_string_to_option(row.get(2)?), + source_kind: empty_string_to_option(row.get(3)?), + scope_kind: row.get(4)?, + scope_id: row.get(5)?, + key: row.get(6)?, + value_json: row.get(7)?, + status: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + revision: row.get(11)?, + }) +} + +fn row_to_navigation_recent(row: &libsql::Row) -> libsql::Result { + Ok(NavigationRecentRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: empty_string_to_option(row.get(2)?), + kind: row.get(3)?, + source_kind: row.get(4)?, + root_uri: row.get(5)?, + target_key: row.get(6)?, + relative_path: row.get(7)?, + document_id: row.get(8)?, + title: row.get(9)?, + status: row.get(10)?, + metadata_json: row.get(11)?, + visited_at: row.get(12)?, + created_at: row.get(13)?, + updated_at: row.get(14)?, + revision: row.get(15)?, + }) +} + +fn navigation_recent_target_key( + kind: &str, + root_uri: &str, + relative_path: Option<&str>, + document_id: Option<&str>, +) -> Result { + match kind { + "folder" => Ok(format!( + "folder:{}:{}", + root_uri.trim(), + relative_path.map(str::trim).unwrap_or_default() + )), + "page" => { + let document_id = document_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ControlPlaneError::InvalidInput( + "navigation recent page 需要 document_id".to_string(), + ) + })?; + Ok(format!("page:{}:{document_id}", root_uri.trim())) + } + _ => Err(ControlPlaneError::InvalidInput( + "navigation recent kind 只能是 folder 或 page".to_string(), + )), + } +} + +fn validate_ai_external_conversation_status(status: &str) -> Result<(), ControlPlaneError> { + match status { + "active" + | "local_deleted" + | "remote_deleted" + | "remote_delete_failed" + | "remote_missing" => Ok(()), + _ => Err(ControlPlaneError::InvalidInput( + "external conversation status 不合法".to_string(), + )), + } +} + +fn row_to_ai_policy(row: &libsql::Row) -> libsql::Result { + Ok(AiPolicyRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + allowed_roots_json: row.get(3)?, + model_policy_json: row.get(4)?, + quota_json: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + revision: row.get(8)?, + }) +} + +fn row_to_sync_state(row: &libsql::Row) -> libsql::Result { + Ok(SyncStateRecord { + id: row.get(0)?, + workspace_id: row.get(1)?, + remote_kind: row.get(2)?, + remote_id: row.get(3)?, + cursor: row.get(4)?, + last_synced_at: row.get(5)?, + status: row.get(6)?, + error_json: row.get(7)?, + updated_at: row.get(8)?, + revision: row.get(9)?, + }) +} + +fn row_to_ai_runtime_run(row: &libsql::Row) -> libsql::Result { + Ok(AiRuntimeRunRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + document_id: row.get(3)?, + session_id: row.get(4)?, + run_id: row.get(5)?, + title: row.get(6)?, + profile: row.get(7)?, + acp_runtime: row.get(8)?, + trace_id: row.get(9)?, + status: row.get(10)?, + runtime_json: row.get(11)?, + payload_json: row.get(12)?, + deleted_at: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + revision: row.get(16)?, + }) +} + +fn row_to_ai_runtime_event(row: &libsql::Row) -> libsql::Result { + Ok(AiRuntimeEventRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + document_id: row.get(3)?, + session_id: row.get(4)?, + run_id: row.get(5)?, + profile: row.get(6)?, + acp_runtime: row.get(7)?, + event_type: row.get(8)?, + payload_json: row.get(9)?, + created_at: row.get(10)?, + }) +} + +fn row_to_ai_runtime_journal_event( + row: &libsql::Row, +) -> libsql::Result { + Ok(AiRuntimeJournalEventRecord { + seq: row.get(0)?, + event: AiRuntimeEventRecord { + id: row.get(1)?, + user_id: row.get(2)?, + workspace_id: row.get(3)?, + document_id: row.get(4)?, + session_id: row.get(5)?, + run_id: row.get(6)?, + profile: row.get(7)?, + acp_runtime: row.get(8)?, + event_type: row.get(9)?, + payload_json: row.get(10)?, + created_at: row.get(11)?, + }, + }) +} + +fn row_to_ai_external_conversation_binding( + row: &libsql::Row, +) -> libsql::Result { + Ok(AiExternalConversationBindingRecord { + id: row.get(0)?, + user_id: row.get(1)?, + workspace_id: row.get(2)?, + mnote_session_id: row.get(3)?, + acp_session_id: row.get(4)?, + agent_id: row.get(5)?, + profile: row.get(6)?, + provider: row.get(7)?, + remote_conversation_id: row.get(8)?, + remote_url: row.get(9)?, + status: row.get(10)?, + metadata_json: row.get(11)?, + created_at: row.get(12)?, + updated_at: row.get(13)?, + deleted_at: row.get(14)?, + revision: row.get(15)?, + }) +} + +fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String { + let title = serde_json::from_str::(payload_json) + .ok() + .and_then(|payload| { + payload + .get("title") + .or_else(|| payload.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| fallback.to_string()); + title.chars().take(80).collect() +} + +fn prune_navigation_recent_for_kind( + conn: &TursoConnection, + user_id: &str, + kind: &str, +) -> Result<(), ControlPlaneError> { + let keep = match kind { + "folder" => 10_i64, + "page" => 20_i64, + _ => 20_i64, + }; + conn.execute( + "UPDATE user_navigation_recent + SET status = 'removed', updated_at = ?1, revision = revision + 1 + WHERE user_id = ?2 + AND kind = ?3 + AND status = 'active' + AND id NOT IN ( + SELECT id FROM user_navigation_recent + WHERE user_id = ?2 AND kind = ?3 AND status = 'active' + ORDER BY visited_at DESC, updated_at DESC + LIMIT ?4 + )", + params![now_text(), user_id, kind, keep], + )?; + Ok(()) +} + +fn list_ai_agent_profile_access_rows( + conn: &TursoConnection, + user_id: &str, +) -> Result, ControlPlaneError> { + let mut stmt = conn.prepare( + "SELECT + p.id, p.agent_id, p.profile_kind, p.owner_user_id, p.base_profile_name, + p.isolated_profile_name, p.display_name, p.status, p.created_at, p.updated_at, + p.revision, + g.id, g.profile_id, g.user_id, g.role, g.can_run, g.can_manage_skills, + g.can_manage_config, g.created_at, g.updated_at, g.revision + FROM ai_agent_profiles p + JOIN ai_agent_profile_grants g ON g.profile_id = p.id + WHERE p.agent_id = 'hermes' + AND p.status = 'active' + AND g.can_run = 1 + AND ( + (p.profile_kind = 'personal' AND p.owner_user_id = ?1 AND g.user_id = ?1) + OR (p.profile_kind = 'shared' AND (g.user_id = '' OR g.user_id = ?1)) + ) + ORDER BY + CASE p.profile_kind WHEN 'personal' THEN 0 ELSE 1 END, + p.display_name ASC, + g.can_manage_skills DESC, + g.can_manage_config DESC", + )?; + let rows = stmt + .query_map(params![user_id], row_to_ai_agent_profile_access)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + let mut by_profile = BTreeMap::::new(); + for row in rows { + by_profile + .entry(row.profile.id.clone()) + .and_modify(|existing| { + if row.grant.can_manage_skills && !existing.grant.can_manage_skills { + *existing = row.clone(); + } + }) + .or_insert(row); + } + Ok(by_profile.into_values().collect()) +} + +impl ControlPlaneStore for TursoControlPlaneStore { + fn upsert_user(&self, input: UpsertUserInput) -> Result { + if input.username.trim().is_empty() { + return Err(ControlPlaneError::InvalidInput( + "username 不能为空".to_string(), + )); + } + if input.display_name.trim().is_empty() { + return Err(ControlPlaneError::InvalidInput( + "display_name 不能为空".to_string(), + )); + } + + 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()); + + let existing = conn + .query_row( + "SELECT id, email, username, display_name, role, status, created_at, updated_at, revision + FROM users WHERE id = ?1", + params![id], + row_to_user, + ) + .optional()?; + + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE users + SET email = ?1, username = ?2, display_name = ?3, role = ?4, updated_at = ?5, revision = ?6 + WHERE id = ?7", + params![ + input.email, + input.username, + input.display_name, + role, + now, + revision, + existing.id + ], + )?; + return Ok(UserRecord { + id: existing.id, + email: input.email, + username: input.username, + display_name: input.display_name, + role, + status: existing.status, + created_at: existing.created_at, + updated_at: now, + revision, + }); + } + + conn.execute( + "INSERT INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, 1)", + params![id, input.email, input.username, input.display_name, role, now, now], + )?; + + Ok(UserRecord { + id, + email: input.email, + username: input.username, + display_name: input.display_name, + role, + status: "active".to_string(), + created_at: now.clone(), + updated_at: now, + revision: 1, + }) + } + + fn create_password_identity( + &self, + input: CreatePasswordIdentityInput, + ) -> Result<(), ControlPlaneError> { + let username = input.username.trim(); + if username.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "username 不能为空".to_string(), + )); + } + if input.password.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "password 不能为空".to_string(), + )); + } + + let conn = self.lock_conn()?; + let now = now_text(); + let user_id = input.user_id; + let password_hash = password_hash_v1(&input.password); + conn.execute( + "INSERT INTO auth_identities (id, user_id, provider, provider_subject, password_hash, password_version, created_at, updated_at) + VALUES (?1, ?2, 'password_username', ?3, ?4, 1, ?5, ?6) + ON CONFLICT(provider, provider_subject) + DO UPDATE SET user_id = excluded.user_id, password_hash = excluded.password_hash, updated_at = excluded.updated_at", + params![ + new_id("ident"), + user_id, + username, + password_hash, + now, + now + ], + )?; + if let Some(email) = input + .email + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + conn.execute( + "INSERT INTO auth_identities (id, user_id, provider, provider_subject, password_hash, password_version, created_at, updated_at) + VALUES (?1, ?2, 'password_email', ?3, ?4, 1, ?5, ?6) + ON CONFLICT(provider, provider_subject) + DO UPDATE SET user_id = excluded.user_id, password_hash = excluded.password_hash, updated_at = excluded.updated_at", + params![ + new_id("ident"), + user_id, + email, + password_hash_v1(&input.password), + now, + now + ], + )?; + } + Ok(()) + } + + fn authenticate_password( + &self, + input: AuthenticatePasswordInput, + ) -> Result { + let account = input.account.trim(); + if account.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "account 不能为空".to_string(), + )); + } + + let conn = self.lock_conn()?; + let provider = if account.contains('@') { + "password_email" + } else { + "password_username" + }; + let expected_hash = password_hash_v1(&input.password); + let user = conn + .query_row( + "SELECT u.id, u.email, u.username, u.display_name, u.role, u.status, u.created_at, u.updated_at, u.revision + FROM auth_identities i + JOIN users u ON u.id = i.user_id + WHERE i.provider = ?1 + AND i.provider_subject = ?2 + AND i.password_hash = ?3 + AND u.status = 'active' + LIMIT 1", + params![provider, account, expected_hash], + row_to_user, + ) + .optional()? + .ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?; + drop(conn); + + let session = self.create_session(CreateSessionInput { + id: input.session_id, + user_id: user.id.clone(), + token_hash: input.token_hash, + user_agent: input.user_agent, + ip_hash: input.ip_hash, + expires_at: input.expires_at, + })?; + Ok(ResolvedAuthSession { session, user }) + } + + fn create_session( + &self, + input: CreateSessionInput, + ) -> Result { + let token_hash = input.token_hash.trim(); + if token_hash.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "token_hash 不能为空".to_string(), + )); + } + + let conn = self.lock_conn()?; + let now = now_text(); + let expires_at = input + .expires_at + .unwrap_or_else(|| (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339()); + let record = AuthSessionRecord { + id: input.id.unwrap_or_else(|| new_id("sess")), + user_id: input.user_id, + token_hash: token_hash.to_string(), + user_agent: input.user_agent, + ip_hash: input.ip_hash, + created_at: now.clone(), + expires_at, + revoked_at: None, + last_seen_at: now.clone(), + }; + conn.execute( + "INSERT INTO auth_sessions (id, user_id, token_hash, user_agent, ip_hash, created_at, expires_at, revoked_at, last_seen_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)", + params![ + record.id, + record.user_id, + record.token_hash, + record.user_agent, + record.ip_hash, + record.created_at, + record.expires_at, + record.last_seen_at + ], + )?; + Ok(record) + } + + fn get_session_by_token_hash( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError> { + let token_hash = token_hash.trim(); + if token_hash.is_empty() { + return Ok(None); + } + + let conn = self.lock_conn()?; + let now = now_text(); + let resolved = conn + .query_row( + "SELECT + s.id, s.user_id, s.token_hash, s.user_agent, s.ip_hash, s.created_at, s.expires_at, s.revoked_at, s.last_seen_at, + u.id, u.email, u.username, u.display_name, u.role, u.status, u.created_at, u.updated_at, u.revision + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?1 + AND s.revoked_at IS NULL + AND s.expires_at > ?2 + AND u.status = 'active' + LIMIT 1", + params![token_hash, now], + |row| { + Ok(ResolvedAuthSession { + session: row_to_auth_session(row)?, + user: UserRecord { + id: row.get(9)?, + email: row.get(10)?, + username: row.get(11)?, + display_name: row.get(12)?, + role: row.get(13)?, + status: row.get(14)?, + created_at: row.get(15)?, + updated_at: row.get(16)?, + revision: row.get(17)?, + }, + }) + }, + ) + .optional()?; + + if let Some(resolved) = &resolved { + conn.execute( + "UPDATE auth_sessions SET last_seen_at = ?1 WHERE id = ?2", + params![now, resolved.session.id], + )?; + } + + Ok(resolved) + } + + fn revoke_session(&self, session_id: &str) -> Result<(), ControlPlaneError> { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "session_id 不能为空".to_string(), + )); + } + 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], + )?; + Ok(()) + } + + fn ensure_default_workspace( + &self, + actor_id: &str, + ) -> Result { + 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 + FROM workspaces + WHERE owner_user_id = ?1 AND kind = 'personal' AND status = 'active' + LIMIT 1", + params![actor_id], + row_to_workspace, + ) + .optional()?; + if let Some(workspace) = existing { + return Ok(workspace); + } + + let user = conn + .query_row( + "SELECT id, email, username, display_name, role, status, created_at, updated_at, revision + FROM users WHERE id = ?1", + params![actor_id], + row_to_user, + ) + .optional()? + .ok_or_else(|| ControlPlaneError::NotFound(format!("user not found: {actor_id}")))?; + + let now = now_text(); + let workspace = WorkspaceRecord { + id: new_id("ws"), + owner_user_id: user.id.clone(), + name: format!("{} 的空间", user.display_name), + kind: "personal".to_string(), + root_uri: format!("local://users/{}/workspaces/my-space", user.id), + root_path: format!( + "/mnt/Data1T/Mnote_data/users/{}/workspaces/my-space", + user.id + ), + source_kind: "local_folder".to_string(), + status: "active".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + revision: 1, + }; + + conn.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, ?8, ?9, ?10, ?11)", + params![ + workspace.id, + workspace.owner_user_id, + workspace.name, + workspace.kind, + workspace.root_uri, + workspace.root_path, + workspace.source_kind, + workspace.status, + workspace.created_at, + workspace.updated_at, + workspace.revision + ], + )?; + conn.execute( + "INSERT 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"), workspace.id, user.id, now, now], + )?; + conn.execute( + "INSERT 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 (?1, ?2, ?3, ?4, ?5, 'write', 1, ?6, 'auto', 'active', ?7, ?8, ?9, 1)", + params![ + new_id("grant"), + workspace.owner_user_id, + workspace.id, + workspace.root_uri, + workspace.root_path, + capabilities_json(&["ai".to_string(), "share".to_string()])?, + workspace.owner_user_id, + now, + now + ], + )?; + + Ok(workspace) + } + + fn upsert_workspace( + &self, + input: UpsertWorkspaceInput, + ) -> Result { + 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 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::(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 { + let conn = self.lock_conn()?; + let now = now_text(); + let record = DirectoryGrantRecord { + id: new_id("grant"), + user_id: input.user_id, + workspace_id: input.workspace_id, + root_uri: input.root_uri, + root_path: input.root_path, + permission: input.permission, + recursive: input.recursive, + capabilities_json: capabilities_json(&input.capabilities)?, + source: input.source, + status: "active".to_string(), + created_by: input.created_by, + created_at: now.clone(), + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT 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 (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.root_uri, + record.root_path, + record.permission, + if record.recursive { 1 } else { 0 }, + record.capabilities_json, + record.source, + record.status, + record.created_by, + record.created_at, + record.updated_at, + record.revision + ], + )?; + Ok(record) + } + + fn list_directory_grants(&self) -> Result, ControlPlaneError> { + 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 + WHERE status = 'active' + ORDER BY created_at ASC", + )?; + let grants = stmt + .query_map((), row_to_grant)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(grants) + } + + fn list_directory_grants_for_actor( + &self, + actor_id: &str, + ) -> Result, ControlPlaneError> { + let actor_id = actor_id.trim(); + if actor_id.is_empty() { + return Ok(Vec::new()); + } + 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 + WHERE user_id = ?1 AND status = 'active' + ORDER BY created_at ASC", + )?; + let grants = stmt + .query_map(params![actor_id], row_to_grant)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(grants) + } + + fn find_directory_grants( + &self, + lookup: DirectoryGrantLookup, + ) -> Result, ControlPlaneError> { + let conn = self.lock_conn()?; + let grant_id = lookup + .grant_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let user_id = lookup + .user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let root_uri = lookup + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let status = if lookup.include_revoked { + "%" + } else { + "active" + }; + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision + FROM directory_grants + WHERE (?1 IS NULL OR id = ?1) + AND (?2 IS NULL OR user_id = ?2) + AND (?3 IS NULL OR root_uri = ?3) + AND status LIKE ?4 + ORDER BY created_at ASC", + )?; + let grants = stmt + .query_map(params![grant_id, user_id, root_uri, status], row_to_grant)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(grants) + } + + fn revoke_directory_grant( + &self, + grant_id: &str, + expected_revision: Option, + ) -> Result<(), ControlPlaneError> { + let grant_id = grant_id.trim(); + if grant_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "grant_id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let existing_revision = conn + .query_row( + "SELECT revision FROM directory_grants WHERE id = ?1 AND status = 'active'", + params![grant_id], + |row| row.get::(0), + ) + .optional()? + .ok_or_else(|| ControlPlaneError::NotFound(format!("grant not found: {grant_id}")))?; + if let Some(expected_revision) = expected_revision { + if expected_revision != existing_revision { + return Err(ControlPlaneError::Conflict( + "目录授权 revision 已变化".to_string(), + )); + } + } + conn.execute( + "UPDATE directory_grants + SET status = 'revoked', updated_at = ?1, revision = revision + 1 + WHERE id = ?2 AND status = 'active'", + params![now_text(), grant_id], + )?; + Ok(()) + } + + fn resolve_access( + &self, + actor_id: &str, + root_uri: &str, + ) -> Result { + 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 + FROM directory_grants + WHERE user_id = ?1 + AND status = 'active' + AND ( + ?2 = root_uri + OR (recursive = 1 AND ?2 LIKE root_uri || '/%') + OR (recursive = 1 AND root_uri LIKE '%/' AND ?2 LIKE root_uri || '%') + OR (?3 != '' AND ?3 = root_path) + OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%') + )", + )?; + let grants = stmt + .query_map(params![actor_id, root_uri, root_path], row_to_grant)? + .collect::, _>>()?; + let mut permission = "none".to_string(); + for grant in &grants { + permission = max_permission(&permission, &grant.permission).to_string(); + } + Ok(ResolvedAccess { + user_id: actor_id.to_string(), + root_uri: root_uri.to_string(), + permission, + grant_ids: grants.into_iter().map(|grant| grant.id).collect(), + }) + } + + fn create_share_link( + &self, + input: CreateShareLinkInput, + ) -> Result { + let workspace_id = input.workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "workspace_id 不能为空".to_string(), + )); + } + let resource_kind = input.resource_kind.trim().to_string(); + let resource_id = input.resource_id.trim().to_string(); + let permission = input.permission.trim().to_string(); + if resource_kind.is_empty() || resource_id.is_empty() || permission.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "share_link 资源与权限不能为空".to_string(), + )); + } + let token = input + .token + .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.lock_conn()?; + let now = now_text(); + let link = ShareLinkRecord { + id: input.id.unwrap_or_else(|| new_id("share")), + workspace_id, + resource_kind, + resource_id, + token_hash: token_hash.clone(), + permission, + created_by: input.created_by, + expires_at: input.expires_at, + revoked_at: None, + created_at: now.clone(), + updated_at: now.clone(), + revision: 1, + }; + conn.execute( + "INSERT INTO share_links (id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, 1)", + params![ + link.id, + link.workspace_id, + link.resource_kind, + link.resource_id, + link.token_hash, + link.permission, + link.created_by, + link.expires_at, + link.created_at, + link.updated_at + ], + )?; + Ok(CreatedShareLink { link, token }) + } + + fn list_share_links( + &self, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + let workspace_id = workspace_id.trim(); + if workspace_id.is_empty() { + return Ok(Vec::new()); + } + 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 + WHERE workspace_id = ?1 + ORDER BY created_at ASC", + )?; + let links = stmt + .query_map(params![workspace_id], row_to_share_link)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(links) + } + + fn resolve_share_link( + &self, + token_hash: &str, + ) -> Result, ControlPlaneError> { + let token_hash = token_hash.trim(); + if token_hash.is_empty() { + return Ok(None); + } + 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 + FROM share_links + WHERE token_hash = ?1 AND revoked_at IS NULL + LIMIT 1", + params![token_hash], + row_to_share_link, + ) + .optional()?; + Ok(link) + } + + fn revoke_share_link(&self, link_id: &str) -> Result<(), ControlPlaneError> { + let link_id = link_id.trim(); + if link_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "link_id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let existed = conn.execute( + "UPDATE share_links + SET revoked_at = ?1, updated_at = ?2, revision = revision + 1 + WHERE id = ?3 AND revoked_at IS NULL", + params![now_text(), now_text(), link_id], + )?; + if existed == 0 { + return Err(ControlPlaneError::NotFound(format!( + "share link not found: {link_id}" + ))); + } + Ok(()) + } + + fn append_audit(&self, input: AppendAuditInput) -> Result<(), ControlPlaneError> { + let action = input.action.trim(); + let target_kind = input.target_kind.trim(); + if action.is_empty() || target_kind.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "audit action/target_kind 不能为空".to_string(), + )); + } + 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)", + params![ + new_id("audit"), + input.actor_user_id, + action, + target_kind, + input.target_id, + input.metadata_json, + now_text() + ], + )?; + Ok(()) + } + + fn list_audit_log(&self, limit: usize) -> Result, ControlPlaneError> { + 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 + ORDER BY created_at DESC + LIMIT ?1", + )?; + let rows = stmt + .query_map(params![limit as i64], row_to_audit_log)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn append_outbox( + &self, + input: OutboxEventInput, + ) -> Result { + let conn = self.lock_conn()?; + let record = OutboxEventRecord { + id: new_id("evt"), + topic: input.topic, + event_type: input.event_type, + payload_json: input.payload_json, + created_at: now_text(), + delivered_at: None, + attempts: 0, + }; + conn.execute( + "INSERT INTO outbox_events (id, topic, event_type, payload_json, created_at, delivered_at, attempts) + VALUES (?1, ?2, ?3, ?4, ?5, NULL, 0)", + params![ + record.id, + record.topic, + record.event_type, + record.payload_json, + record.created_at + ], + )?; + Ok(record) + } + + fn drain_outbox(&self, limit: usize) -> Result, ControlPlaneError> { + 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 + WHERE delivered_at IS NULL + ORDER BY created_at ASC + LIMIT ?1", + )?; + let rows = stmt + .query_map(params![limit as i64], row_to_outbox)? + .collect::, _>>()?; + let delivered_at = now_text(); + for row in &rows { + conn.execute( + "UPDATE outbox_events SET delivered_at = ?1, attempts = attempts + 1 WHERE id = ?2", + params![delivered_at, row.id], + )?; + } + Ok(rows) + } + + fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError> { + let event_id = event_id.trim(); + if event_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "event_id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let updated = conn.execute( + "UPDATE outbox_events + SET delivered_at = COALESCE(delivered_at, ?1), attempts = attempts + 1 + WHERE id = ?2", + params![now_text(), event_id], + )?; + if updated == 0 { + return Err(ControlPlaneError::NotFound(format!( + "outbox event not found: {event_id}" + ))); + } + Ok(()) + } + + fn upsert_sidebar_shortcut( + &self, + input: UpsertSidebarShortcutInput, + ) -> Result { + let user_id = input.user_id.trim().to_string(); + let workspace_id = input.workspace_id.trim().to_string(); + let root_uri = input + .root_uri + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let kind = input.kind.trim().to_string(); + let source_kind = input.source_kind.trim().to_string(); + let target_id = input.target_id.trim().to_string(); + let title = input.title.trim().to_string(); + if user_id.is_empty() + || workspace_id.is_empty() + || kind.is_empty() + || source_kind.is_empty() + || target_id.is_empty() + || title.is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "sidebar shortcut user/workspace/kind/target/title 不能为空".to_string(), + )); + } + if kind != "page" && kind != "folder" { + return Err(ControlPlaneError::InvalidInput( + "sidebar shortcut kind 只能是 page 或 folder".to_string(), + )); + } + let conn = self.lock_conn()?; + let now = now_text(); + conn.execute( + "INSERT INTO sidebar_shortcuts ( + 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, updated_at, revision + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'active', ?13, ?14, ?15, 1) + ON CONFLICT(user_id, workspace_id, kind, target_id) + DO UPDATE SET + root_uri = excluded.root_uri, + relative_path = excluded.relative_path, + document_id = excluded.document_id, + title = excluded.title, + icon = excluded.icon, + sort_order = excluded.sort_order, + status = 'active', + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at, + revision = sidebar_shortcuts.revision + 1", + params![ + input.id.unwrap_or_else(|| new_id("shortcut")), + user_id, + workspace_id, + root_uri, + kind, + source_kind, + target_id, + input.relative_path, + input.document_id, + title, + input.icon, + input.sort_order, + input.metadata_json, + now, + now + ], + )?; + let record = conn.query_row( + "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, + updated_at, revision + FROM sidebar_shortcuts + WHERE user_id = ?1 AND workspace_id = ?2 AND kind = ?3 AND target_id = ?4 + LIMIT 1", + params![user_id, workspace_id, kind, target_id], + row_to_sidebar_shortcut, + )?; + Ok(record) + } + + fn list_sidebar_shortcuts( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let workspace_id = workspace_id.trim(); + if user_id.is_empty() || workspace_id.is_empty() { + return Ok(Vec::new()); + } + 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, + updated_at, revision + FROM sidebar_shortcuts + WHERE user_id = ?1 AND workspace_id = ?2 AND status = 'active' + ORDER BY sort_order ASC, created_at ASC", + )?; + let rows = stmt + .query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn list_sidebar_shortcuts_with_global_local( + &self, + user_id: &str, + workspace_id: &str, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let workspace_id = workspace_id.trim(); + if user_id.is_empty() || workspace_id.is_empty() { + return Ok(Vec::new()); + } + 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, + updated_at, revision + FROM sidebar_shortcuts + WHERE user_id = ?1 + AND status = 'active' + AND (workspace_id = ?2 OR source_kind = 'local_folder') + ORDER BY sort_order ASC, created_at ASC", + )?; + let rows = stmt + .query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn delete_sidebar_shortcut( + &self, + user_id: &str, + shortcut_id: &str, + ) -> Result<(), ControlPlaneError> { + let user_id = user_id.trim(); + let shortcut_id = shortcut_id.trim(); + if user_id.is_empty() || shortcut_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "sidebar shortcut user_id/shortcut_id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let changed = conn.execute( + "UPDATE sidebar_shortcuts + SET status = 'removed', updated_at = ?1, revision = revision + 1 + WHERE id = ?2 AND user_id = ?3 AND status = 'active'", + params![now_text(), shortcut_id, user_id], + )?; + if changed == 0 { + return Err(ControlPlaneError::NotFound(format!( + "sidebar shortcut not found: {shortcut_id}" + ))); + } + Ok(()) + } + + fn upsert_user_ui_preference( + &self, + input: UpsertUserUiPreferenceInput, + ) -> Result { + let user_id = input.user_id.trim().to_string(); + let workspace_id = option_to_stored_text(input.workspace_id); + let source_kind = option_to_stored_text(input.source_kind); + let scope_kind = input.scope_kind.trim().to_string(); + let scope_id = input.scope_id.trim().to_string(); + let key = input.key.trim().to_string(); + if user_id.is_empty() || scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "user UI preference user/scope/key 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.value_json)?; + let conn = self.lock_conn()?; + let now = now_text(); + conn.execute( + "INSERT INTO user_ui_preferences ( + id, user_id, workspace_id, source_kind, scope_kind, scope_id, key, + value_json, status, created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, 1) + ON CONFLICT(user_id, workspace_id, source_kind, scope_kind, scope_id, key) + DO UPDATE SET + value_json = excluded.value_json, + status = 'active', + updated_at = excluded.updated_at, + revision = user_ui_preferences.revision + 1", + params![ + input.id.unwrap_or_else(|| new_id("ui_pref")), + user_id, + workspace_id, + source_kind, + scope_kind, + scope_id, + key, + input.value_json, + now, + now + ], + )?; + let record = conn.query_row( + "SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key, + value_json, status, created_at, updated_at, revision + FROM user_ui_preferences + WHERE user_id = ?1 + AND workspace_id = ?2 + AND source_kind = ?3 + AND scope_kind = ?4 + AND scope_id = ?5 + AND key = ?6 + LIMIT 1", + params![ + user_id, + workspace_id, + source_kind, + scope_kind, + scope_id, + key + ], + row_to_user_ui_preference, + )?; + Ok(record) + } + + fn list_user_ui_preferences( + &self, + user_id: &str, + workspace_id: Option<&str>, + source_kind: Option<&str>, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Ok(Vec::new()); + } + 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.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 + FROM user_ui_preferences + WHERE user_id = ?1 + AND status = 'active' + AND (workspace_id = '' OR workspace_id = ?2) + AND (source_kind = '' OR source_kind = ?3) + ORDER BY created_at ASC", + )?; + let rows = stmt + .query_map( + params![user_id, workspace_id, source_kind], + row_to_user_ui_preference, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn list_user_ui_preferences_for_scope( + &self, + workspace_id: Option<&str>, + source_kind: Option<&str>, + scope_kind: &str, + scope_id: &str, + key: &str, + ) -> Result, ControlPlaneError> { + let workspace_id = workspace_id.map(str::trim).unwrap_or_default(); + let source_kind = source_kind.map(str::trim).unwrap_or_default(); + let scope_kind = scope_kind.trim(); + let scope_id = scope_id.trim(); + let key = key.trim(); + if scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() { + return Ok(Vec::new()); + } + 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 + FROM user_ui_preferences + WHERE status = 'active' + AND workspace_id = ?1 + AND source_kind = ?2 + AND scope_kind = ?3 + AND scope_id = ?4 + AND key = ?5 + ORDER BY updated_at ASC", + )?; + let rows = stmt + .query_map( + params![workspace_id, source_kind, scope_kind, scope_id, key], + row_to_user_ui_preference, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn ensure_ai_agent_profile_policy( + &self, + user_id: &str, + is_admin: bool, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "ai agent profile user_id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let now = now_text(); + conn.execute( + "INSERT INTO ai_agent_profiles ( + id, agent_id, profile_kind, owner_user_id, base_profile_name, + isolated_profile_name, display_name, status, created_at, updated_at, revision + ) + VALUES ('shared_lite', 'hermes', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1) + ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) + DO UPDATE SET status = 'active', updated_at = excluded.updated_at", + params![now, now], + )?; + conn.execute( + "INSERT INTO ai_agent_profile_grants ( + id, profile_id, user_id, role, can_run, can_manage_skills, + can_manage_config, created_at, updated_at, revision + ) + VALUES ('grant_shared_lite_all', 'shared_lite', '', 'user', 1, 0, 0, ?1, ?2, 1) + ON CONFLICT(profile_id, user_id, role) + DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0, + updated_at = excluded.updated_at", + params![now, now], + )?; + + // 旧的泛化 OpenClaw 网页问答入口已拆成明确的三类 chat agent。 + conn.execute( + "UPDATE ai_agent_profiles + SET status = 'retired', updated_at = ?1 + WHERE id = 'shared_openclaw_webqa'", + params![now], + )?; + let shared_chat_profiles = [ + ( + "shared_deepseek_chat", + "deepseek-chat", + "openclaw-deepseek-chat", + "DeepSeek Chat", + ), + ( + "shared_gemini_chat", + "gemini-chat", + "openclaw-gemini-chat", + "Gemini Chat", + ), + ( + "shared_doubao_chat", + "doubao-chat", + "openclaw-doubao-chat", + "豆包 Chat", + ), + ( + "shared_api_deepseek_flash_chat", + "api-deepseek-flash-chat", + "api-deepseek-flash-chat", + "DeepSeek Flash Chat", + ), + ( + "shared_api_deepseek_pro_chat", + "api-deepseek-pro-chat", + "api-deepseek-pro-chat", + "DeepSeek Pro Chat", + ), + ( + "shared_api_gpt_chat", + "api-gpt-chat", + "api-gpt-chat", + "GPT Chat", + ), + ( + "shared_api_kimi_chat", + "api-kimi-chat", + "api-kimi-chat", + "Kimi Chat", + ), + ( + "shared_api_gemini_chat", + "api-gemini-chat", + "api-gemini-chat", + "Gemini API Chat", + ), + ( + "shared_api_grok_chat", + "api-grok-chat", + "api-grok-chat", + "Grok API Chat", + ), + ]; + for (profile_id, base_profile, isolated_profile, display_name) in shared_chat_profiles { + conn.execute( + "INSERT INTO ai_agent_profiles ( + id, agent_id, profile_kind, owner_user_id, base_profile_name, + isolated_profile_name, display_name, status, created_at, updated_at, revision + ) + VALUES (?1, 'hermes', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1) + ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) + DO UPDATE SET status = 'active', display_name = excluded.display_name, + isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at", + params![profile_id, base_profile, isolated_profile, display_name, now, now], + )?; + let grant_id = format!("grant_{profile_id}_all"); + conn.execute( + "INSERT INTO ai_agent_profile_grants ( + id, profile_id, user_id, role, can_run, can_manage_skills, + can_manage_config, created_at, updated_at, revision + ) + VALUES (?1, ?2, '', 'user', 1, 0, 0, ?3, ?4, 1) + ON CONFLICT(profile_id, user_id, role) + DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0, + updated_at = excluded.updated_at", + params![grant_id, profile_id, now, now], + )?; + } + + let user_part = sanitize_profile_component(user_id); + let personal_profile_id = format!("usr_{user_part}_default"); + let personal_profile_name = format!("mnote-u-{user_part}-default"); + conn.execute( + "INSERT INTO ai_agent_profiles ( + id, agent_id, profile_kind, owner_user_id, base_profile_name, + isolated_profile_name, display_name, status, created_at, updated_at, revision + ) + VALUES (?1, 'hermes', 'personal', ?2, 'default', ?3, '我的 Hermes', 'active', ?4, ?5, 1) + ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name) + DO UPDATE SET status = 'active', updated_at = excluded.updated_at", + params![personal_profile_id, user_id, personal_profile_name, now, now], + )?; + let personal_grant_id = format!("grant_{personal_profile_id}_owner"); + conn.execute( + "INSERT INTO ai_agent_profile_grants ( + id, profile_id, user_id, role, can_run, can_manage_skills, + can_manage_config, created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, 'owner', 1, 1, 1, ?4, ?5, 1) + ON CONFLICT(profile_id, user_id, role) + DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1, + updated_at = excluded.updated_at", + params![personal_grant_id, personal_profile_id, user_id, now, now], + )?; + + if is_admin { + let admin_grant_id = format!("grant_shared_lite_admin_{user_part}"); + conn.execute( + "INSERT INTO ai_agent_profile_grants ( + id, profile_id, user_id, role, can_run, can_manage_skills, + can_manage_config, created_at, updated_at, revision + ) + VALUES (?1, 'shared_lite', ?2, 'admin', 1, 1, 1, ?3, ?4, 1) + ON CONFLICT(profile_id, user_id, role) + DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1, + updated_at = excluded.updated_at", + params![admin_grant_id, user_id, now, now], + )?; + for (profile_id, _, _, _) in shared_chat_profiles { + let admin_chat_grant_id = format!("grant_{profile_id}_admin_{user_part}"); + conn.execute( + "INSERT INTO ai_agent_profile_grants ( + id, profile_id, user_id, role, can_run, can_manage_skills, + can_manage_config, created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, 'admin', 1, 0, 0, ?4, ?5, 1) + ON CONFLICT(profile_id, user_id, role) + DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0, + updated_at = excluded.updated_at", + params![admin_chat_grant_id, profile_id, user_id, now, now], + )?; + } + } + + list_ai_agent_profile_access_rows(&conn, user_id) + } + + fn resolve_ai_agent_profile( + &self, + user_id: &str, + is_admin: bool, + profile_id: &str, + ) -> Result, ControlPlaneError> { + let profile_id = profile_id.trim(); + if profile_id.is_empty() { + return Ok(None); + } + let access = self.ensure_ai_agent_profile_policy(user_id, is_admin)?; + Ok(access + .into_iter() + .find(|item| item.profile.id == profile_id && item.grant.can_run)) + } + + fn upsert_navigation_recent( + &self, + input: UpsertNavigationRecentInput, + ) -> Result { + let user_id = input.user_id.trim().to_string(); + let workspace_id = option_to_stored_text(input.workspace_id); + let kind = input.kind.trim().to_string(); + let source_kind = input.source_kind.trim().to_string(); + let root_uri = input.root_uri.trim().to_string(); + let relative_path = input + .relative_path + .map(|value| value.trim().trim_matches('/').to_string()) + .filter(|value| !value.is_empty()); + let document_id = input + .document_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let title = input.title.trim().to_string(); + if user_id.is_empty() + || kind.is_empty() + || source_kind.is_empty() + || root_uri.is_empty() + || title.is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "navigation recent user/kind/source/root/title 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.metadata_json)?; + let target_key = navigation_recent_target_key( + &kind, + &root_uri, + relative_path.as_deref(), + document_id.as_deref(), + )?; + let conn = self.lock_conn()?; + let now = now_text(); + conn.execute( + "INSERT INTO user_navigation_recent ( + id, user_id, workspace_id, kind, source_kind, root_uri, target_key, + relative_path, document_id, title, status, metadata_json, visited_at, + created_at, updated_at, revision + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, 1) + ON CONFLICT(user_id, kind, target_key) + DO UPDATE SET + workspace_id = excluded.workspace_id, + source_kind = excluded.source_kind, + root_uri = excluded.root_uri, + relative_path = excluded.relative_path, + document_id = excluded.document_id, + title = excluded.title, + status = 'active', + metadata_json = excluded.metadata_json, + visited_at = excluded.visited_at, + updated_at = excluded.updated_at, + revision = user_navigation_recent.revision + 1", + params![ + input.id.unwrap_or_else(|| new_id("nav_recent")), + user_id, + workspace_id, + kind, + source_kind, + root_uri, + target_key, + relative_path, + document_id, + title, + input.metadata_json, + now, + now, + now, + ], + )?; + prune_navigation_recent_for_kind(&conn, &user_id, &kind)?; + let record = conn.query_row( + "SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key, + relative_path, document_id, title, status, metadata_json, visited_at, + created_at, updated_at, revision + FROM user_navigation_recent + WHERE user_id = ?1 AND kind = ?2 AND target_key = ?3 + LIMIT 1", + params![user_id, kind, target_key], + row_to_navigation_recent, + )?; + Ok(record) + } + + fn list_navigation_recent( + &self, + user_id: &str, + kind: Option<&str>, + limit: usize, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Ok(Vec::new()); + } + let limit = limit.clamp(1, 100) as i64; + 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, + relative_path, document_id, title, status, metadata_json, visited_at, + created_at, updated_at, revision + FROM user_navigation_recent + WHERE user_id = ?1 AND kind = ?2 AND status = 'active' + ORDER BY visited_at DESC, updated_at DESC + LIMIT ?3", + )?; + let rows = stmt + .query_map(params![user_id, kind, limit], row_to_navigation_recent)? + .collect::, _>>()?; + rows + } else { + let mut stmt = conn.prepare( + "SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key, + relative_path, document_id, title, status, metadata_json, visited_at, + created_at, updated_at, revision + FROM user_navigation_recent + WHERE user_id = ?1 AND status = 'active' + ORDER BY visited_at DESC, updated_at DESC + LIMIT ?2", + )?; + let rows = stmt + .query_map(params![user_id, limit], row_to_navigation_recent)? + .collect::, _>>()?; + rows + }; + Ok(rows) + } + + fn upsert_ai_policy( + &self, + input: UpsertAiPolicyInput, + ) -> Result { + if input.user_id.is_none() && input.workspace_id.is_none() { + return Err(ControlPlaneError::InvalidInput( + "ai_policy 必须绑定 user_id 或 workspace_id".to_string(), + )); + } + serde_json::from_str::(&input.allowed_roots_json)?; + serde_json::from_str::(&input.model_policy_json)?; + serde_json::from_str::(&input.quota_json)?; + + let conn = self.lock_conn()?; + let now = now_text(); + let existing = conn + .query_row( + "SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision + FROM ai_policies + WHERE (?1 IS NULL OR user_id = ?1) + AND (?2 IS NULL OR workspace_id = ?2) + ORDER BY created_at DESC + LIMIT 1", + params![input.user_id, input.workspace_id], + row_to_ai_policy, + ) + .optional()?; + + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE ai_policies + SET allowed_roots_json = ?1, model_policy_json = ?2, quota_json = ?3, updated_at = ?4, revision = ?5 + WHERE id = ?6", + params![ + input.allowed_roots_json, + input.model_policy_json, + input.quota_json, + now, + revision, + existing.id + ], + )?; + return Ok(AiPolicyRecord { + id: existing.id, + user_id: existing.user_id, + workspace_id: existing.workspace_id, + allowed_roots_json: input.allowed_roots_json, + model_policy_json: input.model_policy_json, + quota_json: input.quota_json, + created_at: existing.created_at, + updated_at: now, + revision, + }); + } + + let record = AiPolicyRecord { + id: input.id.unwrap_or_else(|| new_id("aip")), + user_id: input.user_id, + workspace_id: input.workspace_id, + allowed_roots_json: input.allowed_roots_json, + model_policy_json: input.model_policy_json, + quota_json: input.quota_json, + created_at: now.clone(), + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT INTO ai_policies (id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.allowed_roots_json, + record.model_policy_json, + record.quota_json, + record.created_at, + record.updated_at + ], + )?; + Ok(record) + } + + fn get_ai_policy( + &self, + actor_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError> { + let actor_id = actor_id.trim(); + let workspace_id = workspace_id + .map(str::trim) + .filter(|value| !value.is_empty()); + if actor_id.is_empty() && workspace_id.is_none() { + return Ok(None); + } + let conn = self.lock_conn()?; + if let Some(workspace_id) = workspace_id { + let workspace_policy = conn + .query_row( + "SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision + FROM ai_policies + WHERE workspace_id = ?1 + ORDER BY updated_at DESC + LIMIT 1", + params![workspace_id], + row_to_ai_policy, + ) + .optional()?; + if workspace_policy.is_some() { + return Ok(workspace_policy); + } + } + if actor_id.is_empty() { + return Ok(None); + } + conn.query_row( + "SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision + FROM ai_policies + WHERE user_id = ?1 AND workspace_id IS NULL + ORDER BY updated_at DESC + LIMIT 1", + params![actor_id], + row_to_ai_policy, + ) + .optional() + .map_err(ControlPlaneError::from) + } + + fn upsert_sync_state( + &self, + input: UpsertSyncStateInput, + ) -> Result { + let workspace_id = input.workspace_id.trim().to_string(); + let remote_kind = input.remote_kind.trim().to_string(); + let status = input.status.trim().to_string(); + if workspace_id.is_empty() || remote_kind.is_empty() || status.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "sync_state workspace_id/remote_kind/status 不能为空".to_string(), + )); + } + if let Some(error_json) = &input.error_json { + serde_json::from_str::(error_json)?; + } + + let conn = self.lock_conn()?; + let now = now_text(); + let existing = conn + .query_row( + "SELECT id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision + FROM sync_state + WHERE workspace_id = ?1 AND remote_kind = ?2 + LIMIT 1", + params![workspace_id, remote_kind], + row_to_sync_state, + ) + .optional()?; + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE sync_state + SET remote_id = ?1, cursor = ?2, last_synced_at = ?3, status = ?4, error_json = ?5, updated_at = ?6, revision = ?7 + WHERE id = ?8", + params![ + input.remote_id, + input.cursor, + input.last_synced_at, + status, + input.error_json, + now, + revision, + existing.id + ], + )?; + return Ok(SyncStateRecord { + id: existing.id, + workspace_id: existing.workspace_id, + remote_kind: existing.remote_kind, + remote_id: input.remote_id, + cursor: input.cursor, + last_synced_at: input.last_synced_at, + status, + error_json: input.error_json, + updated_at: now, + revision, + }); + } + + let record = SyncStateRecord { + id: input.id.unwrap_or_else(|| new_id("sync")), + workspace_id, + remote_kind, + remote_id: input.remote_id, + cursor: input.cursor, + last_synced_at: input.last_synced_at, + status, + error_json: input.error_json, + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT INTO sync_state (id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)", + params![ + record.id, + record.workspace_id, + record.remote_kind, + record.remote_id, + record.cursor, + record.last_synced_at, + record.status, + record.error_json, + record.updated_at + ], + )?; + Ok(record) + } + + fn get_sync_state( + &self, + workspace_id: &str, + remote_kind: &str, + ) -> Result, ControlPlaneError> { + let workspace_id = workspace_id.trim(); + let remote_kind = remote_kind.trim(); + if workspace_id.is_empty() || remote_kind.is_empty() { + return Ok(None); + } + 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 + WHERE workspace_id = ?1 AND remote_kind = ?2 + LIMIT 1", + params![workspace_id, remote_kind], + row_to_sync_state, + ) + .optional() + .map_err(ControlPlaneError::from) + } + + fn upsert_ai_runtime_run( + &self, + input: UpsertAiRuntimeRunInput, + ) -> Result { + if input.user_id.trim().is_empty() + || input.session_id.trim().is_empty() + || input.run_id.trim().is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "ai runtime run user_id/session_id/run_id 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.runtime_json)?; + serde_json::from_str::(&input.payload_json)?; + + let conn = self.lock_conn()?; + let now = now_text(); + 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 + FROM ai_runtime_runs + WHERE run_id = ?1 + LIMIT 1", + params![input.run_id], + row_to_ai_runtime_run, + ) + .optional()?; + + if let Some(existing) = existing { + let revision = existing.revision + 1; + conn.execute( + "UPDATE ai_runtime_runs + SET title = ?1, profile = ?2, acp_runtime = ?3, trace_id = ?4, status = ?5, runtime_json = ?6, payload_json = ?7, deleted_at = NULL, updated_at = ?8, revision = ?9 + WHERE id = ?10", + params![ + input.title, + input.profile, + input.acp_runtime, + input.trace_id, + input.status, + input.runtime_json, + input.payload_json, + now, + revision, + existing.id + ], + )?; + return Ok(AiRuntimeRunRecord { + id: existing.id, + user_id: existing.user_id, + workspace_id: existing.workspace_id, + document_id: existing.document_id, + session_id: existing.session_id, + run_id: existing.run_id, + title: input.title, + profile: input.profile, + acp_runtime: input.acp_runtime, + trace_id: input.trace_id, + status: input.status, + runtime_json: input.runtime_json, + payload_json: input.payload_json, + deleted_at: None, + created_at: existing.created_at, + updated_at: now, + revision, + }); + } + + let record = AiRuntimeRunRecord { + id: input.id.unwrap_or_else(|| new_id("acr")), + user_id: input.user_id, + workspace_id: input.workspace_id, + document_id: input.document_id, + session_id: input.session_id, + run_id: input.run_id, + title: input.title, + profile: input.profile, + acp_runtime: input.acp_runtime, + trace_id: input.trace_id, + status: input.status, + runtime_json: input.runtime_json, + payload_json: input.payload_json, + deleted_at: None, + created_at: now.clone(), + updated_at: now, + revision: 1, + }; + conn.execute( + "INSERT 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 (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL, ?14, ?15, 1)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.document_id, + record.session_id, + record.run_id, + record.title, + record.profile, + record.acp_runtime, + record.trace_id, + record.status, + record.runtime_json, + record.payload_json, + record.created_at, + record.updated_at + ], + )?; + Ok(record) + } + + fn list_ai_runtime_runs( + &self, + user_id: &str, + workspace_id: Option<&str>, + document_id: Option<&str>, + session_id: Option<&str>, + limit: usize, + ) -> Result, ControlPlaneError> { + 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 + WHERE user_id = ?1 + AND (?2 IS NULL OR workspace_id = ?2) + AND (?3 IS NULL OR document_id = ?3) + AND (?4 IS NULL OR session_id = ?4) + AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT ?5", + )?; + let rows = stmt + .query_map( + params![user_id, workspace_id, document_id, session_id, limit as i64], + row_to_ai_runtime_run, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn find_ai_runtime_run( + &self, + user_id: &str, + run_id: &str, + ) -> Result, ControlPlaneError> { + 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 + WHERE user_id = ?1 AND run_id = ?2 AND deleted_at IS NULL + LIMIT 1", + params![user_id, run_id], + row_to_ai_runtime_run, + ) + .optional() + .map_err(ControlPlaneError::from) + } + + fn append_ai_runtime_event( + &self, + input: AppendAiRuntimeEventInput, + ) -> Result { + if input.user_id.trim().is_empty() + || input.session_id.trim().is_empty() + || input.run_id.trim().is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "ai runtime event user_id/session_id/run_id 不能为空".to_string(), + )); + } + serde_json::from_str::(&input.payload_json)?; + + let conn = self.lock_conn()?; + let record = AiRuntimeEventRecord { + id: input.id.unwrap_or_else(|| new_id("are")), + user_id: input.user_id, + workspace_id: input.workspace_id, + document_id: input.document_id, + session_id: input.session_id, + run_id: input.run_id, + profile: input.profile, + acp_runtime: input.acp_runtime, + event_type: input.event_type, + payload_json: input.payload_json, + created_at: now_text(), + }; + conn.execute( + "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 (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.document_id, + record.session_id, + record.run_id, + record.profile, + record.acp_runtime, + record.event_type, + record.payload_json, + record.created_at + ], + )?; + Ok(record) + } + + fn delete_ai_runtime_events_for_run( + &self, + user_id: &str, + run_id: &str, + ) -> Result { + 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, ControlPlaneError> { + 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 + WHERE user_id = ?1 AND run_id = ?2 + ORDER BY created_at ASC + LIMIT ?3", + )?; + let rows = stmt + .query_map( + params![user_id, run_id, limit as i64], + row_to_ai_runtime_event, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn list_ai_runtime_journal_events( + &self, + user_id: &str, + run_id: &str, + after_seq: i64, + limit: usize, + ) -> Result, ControlPlaneError> { + let conn = self.lock_conn()?; + let mut stmt = conn.prepare( + "WITH ordered AS ( + SELECT + ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS seq, + id, user_id, workspace_id, document_id, session_id, run_id, profile, + acp_runtime, event_type, payload_json, created_at + FROM ai_runtime_events + WHERE user_id = ?1 AND run_id = ?2 + ) + SELECT seq, id, user_id, workspace_id, document_id, session_id, run_id, profile, + acp_runtime, event_type, payload_json, created_at + FROM ordered + WHERE seq > ?3 + ORDER BY seq ASC + LIMIT ?4", + )?; + let rows = stmt + .query_map( + params![user_id, run_id, after_seq.max(0), limit.max(1) as i64], + row_to_ai_runtime_journal_event, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn upsert_ai_external_conversation_binding( + &self, + input: UpsertAiExternalConversationBindingInput, + ) -> Result { + let user_id = input.user_id.trim(); + let mnote_session_id = input.mnote_session_id.trim(); + let provider = input.provider.trim(); + let remote_conversation_id = input.remote_conversation_id.trim(); + if user_id.is_empty() + || mnote_session_id.is_empty() + || provider.is_empty() + || remote_conversation_id.is_empty() + { + return Err(ControlPlaneError::InvalidInput( + "external conversation binding user_id/session_id/provider/remote_id 不能为空" + .to_string(), + )); + } + serde_json::from_str::(&input.metadata_json)?; + validate_ai_external_conversation_status(&input.status)?; + + let conn = self.lock_conn()?; + let now = now_text(); + let existing = 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 + WHERE user_id = ?1 AND mnote_session_id = ?2 AND provider = ?3 + LIMIT 1", + params![user_id, mnote_session_id, provider], + row_to_ai_external_conversation_binding, + ) + .optional()?; + if let Some(existing) = existing { + let revision = existing.revision + 1; + let deleted_at = if input.status == "active" { + None + } else { + existing.deleted_at.or_else(|| Some(now.clone())) + }; + conn.execute( + "UPDATE ai_external_conversation_bindings + SET workspace_id = ?1, acp_session_id = ?2, agent_id = ?3, profile = ?4, remote_conversation_id = ?5, remote_url = ?6, status = ?7, metadata_json = ?8, updated_at = ?9, deleted_at = ?10, revision = ?11 + WHERE id = ?12", + params![ + input.workspace_id, + input.acp_session_id, + input.agent_id, + input.profile, + remote_conversation_id, + input.remote_url, + input.status, + input.metadata_json, + now, + deleted_at, + revision, + existing.id + ], + )?; + return Ok(AiExternalConversationBindingRecord { + id: existing.id, + user_id: existing.user_id, + workspace_id: input.workspace_id, + mnote_session_id: existing.mnote_session_id, + acp_session_id: input.acp_session_id, + agent_id: input.agent_id, + profile: input.profile, + provider: existing.provider, + remote_conversation_id: remote_conversation_id.to_string(), + remote_url: input.remote_url, + status: input.status, + metadata_json: input.metadata_json, + created_at: existing.created_at, + updated_at: now, + deleted_at, + revision, + }); + } + + let deleted_at = if input.status == "active" { + None + } else { + Some(now.clone()) + }; + let record = AiExternalConversationBindingRecord { + id: input.id.unwrap_or_else(|| new_id("aecb")), + user_id: user_id.to_string(), + workspace_id: input.workspace_id, + mnote_session_id: mnote_session_id.to_string(), + acp_session_id: input.acp_session_id, + agent_id: input.agent_id, + profile: input.profile, + provider: provider.to_string(), + remote_conversation_id: remote_conversation_id.to_string(), + remote_url: input.remote_url, + status: input.status, + metadata_json: input.metadata_json, + created_at: now.clone(), + updated_at: now, + deleted_at, + revision: 1, + }; + conn.execute( + "INSERT INTO ai_external_conversation_bindings (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) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)", + params![ + record.id, + record.user_id, + record.workspace_id, + record.mnote_session_id, + record.acp_session_id, + record.agent_id, + record.profile, + record.provider, + record.remote_conversation_id, + record.remote_url, + record.status, + record.metadata_json, + record.created_at, + record.updated_at, + record.deleted_at + ], + )?; + Ok(record) + } + + fn find_ai_external_conversation_binding( + &self, + user_id: &str, + workspace_id: Option<&str>, + mnote_session_id: &str, + provider: &str, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let mnote_session_id = mnote_session_id.trim(); + let provider = provider.trim(); + if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() { + return Ok(None); + } + 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 + WHERE user_id = ?1 + AND (?2 IS NULL OR workspace_id = ?2) + AND mnote_session_id = ?3 + AND provider = ?4 + LIMIT 1", + params![user_id, workspace_id, mnote_session_id, provider], + row_to_ai_external_conversation_binding, + ) + .optional() + .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, 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::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn mark_ai_external_conversation_binding_status( + &self, + user_id: &str, + workspace_id: Option<&str>, + mnote_session_id: &str, + provider: &str, + status: &str, + metadata_json: Option<&str>, + ) -> Result { + let user_id = user_id.trim(); + let mnote_session_id = mnote_session_id.trim(); + let provider = provider.trim(); + validate_ai_external_conversation_status(status)?; + let metadata_json = metadata_json.unwrap_or("{}"); + serde_json::from_str::(metadata_json)?; + if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() { + return Ok(0); + } + let conn = self.lock_conn()?; + let now = now_text(); + let deleted_at = if status == "active" { + None + } else { + Some(now.clone()) + }; + let changed = conn.execute( + "UPDATE ai_external_conversation_bindings + SET status = ?1, metadata_json = ?2, updated_at = ?3, deleted_at = COALESCE(?4, deleted_at), revision = revision + 1 + WHERE user_id = ?5 + AND (?6 IS NULL OR workspace_id = ?6) + AND mnote_session_id = ?7 + AND provider = ?8", + params![ + status, + metadata_json, + now, + deleted_at, + user_id, + workspace_id, + mnote_session_id, + provider + ], + )?; + Ok(changed) + } + + fn rename_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + title: &str, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let session_id = session_id.trim(); + let title = title.trim(); + if user_id.is_empty() || session_id.is_empty() || title.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "ai runtime session user_id/session_id/title 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let now = now_text(); + conn.execute( + "UPDATE ai_runtime_runs + SET title = ?1, updated_at = ?2, revision = revision + 1 + WHERE user_id = ?3 + AND session_id = ?4 + AND (?5 IS NULL OR workspace_id = ?5) + AND deleted_at IS NULL", + params![title, now, user_id, session_id, workspace_id], + )?; + 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 + WHERE user_id = ?1 + AND session_id = ?2 + AND (?3 IS NULL OR workspace_id = ?3) + AND deleted_at IS NULL + ORDER BY updated_at DESC", + )?; + let rows = stmt + .query_map( + params![user_id, session_id, workspace_id], + row_to_ai_runtime_run, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + + fn auto_title_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result, ControlPlaneError> { + let user_id = user_id.trim(); + let session_id = session_id.trim(); + if user_id.is_empty() || session_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "ai runtime session user_id/session_id 不能为空".to_string(), + )); + } + 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 + FROM ai_runtime_runs + WHERE user_id = ?1 + AND session_id = ?2 + AND (?3 IS NULL OR workspace_id = ?3) + AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT 1", + params![user_id, session_id, workspace_id], + row_to_ai_runtime_run, + ) + .optional()?; + let Some(existing) = existing else { + return Ok(None); + }; + let title = existing + .title + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + derive_ai_runtime_title(&existing.payload_json, &existing.session_id) + }); + let now = now_text(); + let revision = existing.revision + 1; + conn.execute( + "UPDATE ai_runtime_runs + SET title = ?1, updated_at = ?2, revision = ?3 + WHERE id = ?4", + params![title, now, revision, existing.id], + )?; + Ok(Some(AiRuntimeRunRecord { + title: Some(title), + updated_at: now, + revision, + ..existing + })) + } + + fn delete_ai_runtime_session( + &self, + user_id: &str, + session_id: &str, + workspace_id: Option<&str>, + ) -> Result { + let user_id = user_id.trim(); + let session_id = session_id.trim(); + if user_id.is_empty() || session_id.is_empty() { + return Err(ControlPlaneError::InvalidInput( + "ai runtime session user_id/session_id 不能为空".to_string(), + )); + } + let conn = self.lock_conn()?; + let changed = conn.execute( + "UPDATE ai_runtime_runs + SET deleted_at = ?1, updated_at = ?1, revision = revision + 1 + WHERE user_id = ?2 + AND session_id = ?3 + AND (?4 IS NULL OR workspace_id = ?4) + AND deleted_at IS NULL", + params![now_text(), user_id, session_id, workspace_id], + )?; + Ok(changed) + } +} + +fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str { + if permission_rank(right) > permission_rank(left) { + right + } else { + left + } +} + +fn permission_rank(permission: &str) -> u8 { + match permission { + "admin" => 3, + "write" => 2, + "read" => 1, + _ => 0, + } +} + +// libSQL 和 rusqlite 都会初始化 SQLite C API;同一单元测试进程里混跑 +// 两套 store 容易触发 libSQL 的线程模型断言。默认用独立 integration test +// 覆盖 Turso/libSQL local 行为,这组镜像测试仅用于手动深度排查。 +#[cfg(all(test, feature = "turso-unit-tests"))] +mod tests { + use super::*; + use crate::model::{ + password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput, + CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, + UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, + UpsertSyncStateInput, UpsertUserUiPreferenceInput, + }; + + fn store() -> TursoControlPlaneStore { + TursoControlPlaneStore::in_memory().expect("in-memory control plane") + } + + fn create_user(store: &TursoControlPlaneStore, id: &str) -> UserRecord { + store + .upsert_user(UpsertUserInput { + id: Some(id.to_string()), + email: Some(format!("{id}@example.com")), + username: id.to_string(), + display_name: id.to_string(), + role: None, + password_hash: None, + }) + .expect("upsert user") + } + + fn create_password_identity( + store: &TursoControlPlaneStore, + username: &str, + email: &str, + password: &str, + ) -> UserRecord { + let user = create_user(store, username); + store + .create_password_identity(CreatePasswordIdentityInput { + user_id: user.id.clone(), + email: Some(email.to_string()), + username: username.to_string(), + password: password.to_string(), + }) + .expect("create password identity"); + user + } + + #[test] + fn upsert_user_inserts_and_updates_revision() { + let store = store(); + let user = create_user(&store, "alice"); + assert_eq!(user.revision, 1); + + let updated = store + .upsert_user(UpsertUserInput { + id: Some("alice".to_string()), + email: Some("alice2@example.com".to_string()), + username: "alice".to_string(), + display_name: "Alice".to_string(), + role: Some("admin".to_string()), + password_hash: None, + }) + .expect("update user"); + + assert_eq!(updated.email.as_deref(), Some("alice2@example.com")); + assert_eq!(updated.role, "admin"); + assert_eq!(updated.revision, 2); + } + + #[test] + fn ensure_default_workspace_creates_owner_grant() { + let store = store(); + create_user(&store, "shujuan"); + + let workspace = store + .ensure_default_workspace("shujuan") + .expect("default workspace"); + assert_eq!(workspace.name, "shujuan 的空间"); + + let access = store + .resolve_access("shujuan", &workspace.root_uri) + .expect("resolve access"); + assert_eq!(access.permission, "write"); + 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(); + create_user(&store, "bob"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "bob".to_string(), + workspace_id: None, + root_uri: "local://shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "read".to_string(), + recursive: true, + capabilities: vec![], + source: "admin".to_string(), + created_by: None, + }) + .expect("read grant"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "bob".to_string(), + workspace_id: None, + root_uri: "local://shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "write".to_string(), + recursive: true, + capabilities: vec!["ai".to_string()], + source: "admin".to_string(), + created_by: None, + }) + .expect("write grant"); + + let access = store + .resolve_access("bob", "local://shared/page.md") + .expect("resolve access"); + assert_eq!(access.permission, "write"); + assert_eq!(access.grant_ids.len(), 2); + } + + #[test] + fn list_and_revoke_directory_grant_updates_resolved_access() { + let store = store(); + create_user(&store, "reader"); + let grant = store + .grant_directory_access(DirectoryGrantInput { + user_id: "reader".to_string(), + workspace_id: None, + root_uri: "local://shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "read".to_string(), + recursive: true, + capabilities: vec![], + source: "admin".to_string(), + created_by: None, + }) + .expect("read grant"); + + let grants = store + .list_directory_grants_for_actor("reader") + .expect("list grants"); + assert_eq!(grants.len(), 1); + assert_eq!(grants[0].id, grant.id); + + store + .revoke_directory_grant(&grant.id, None) + .expect("revoke grant"); + let access = store + .resolve_access("reader", "local://shared/page.md") + .expect("resolve revoked access"); + assert_eq!(access.permission, "none"); + } + + #[test] + fn resolve_access_supports_legacy_directory_grants_with_plain_path_root_uri() { + let store = store(); + create_user(&store, "legacy_reader"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "legacy_reader".to_string(), + workspace_id: None, + root_uri: "/tmp/shared".to_string(), + root_path: "/tmp/shared".to_string(), + permission: "read".to_string(), + recursive: true, + capabilities: vec![], + source: "legacy".to_string(), + created_by: None, + }) + .expect("legacy grant"); + + let access = store + .resolve_access("legacy_reader", "file:///tmp/shared/page.md") + .expect("resolve access"); + assert_eq!(access.permission, "read"); + assert_eq!(access.grant_ids.len(), 1); + } + + #[test] + fn resolve_access_does_not_match_sibling_uri_prefix() { + let store = store(); + create_user(&store, "reader"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "reader".to_string(), + workspace_id: None, + root_uri: "file:///tmp/mnote-ai-root".to_string(), + root_path: "/tmp/mnote-ai-root".to_string(), + permission: "write".to_string(), + recursive: true, + capabilities: vec!["ai".to_string()], + source: "admin".to_string(), + created_by: None, + }) + .expect("grant root"); + + let inside = store + .resolve_access("reader", "file:///tmp/mnote-ai-root/page.md") + .expect("resolve inside"); + assert_eq!(inside.permission, "write"); + + let sibling = store + .resolve_access("reader", "file:///tmp/mnote-ai-root-sibling/page.md") + .expect("resolve sibling"); + assert_eq!(sibling.permission, "none"); + assert!(sibling.grant_ids.is_empty()); + } + + #[test] + fn share_link_token_is_hashed_and_revocable() { + let store = store(); + create_user(&store, "owner"); + create_user(&store, "ws_owner"); + let workspace = store + .ensure_default_workspace("ws_owner") + .expect("workspace"); + let created = store + .create_share_link(CreateShareLinkInput { + id: None, + workspace_id: workspace.id, + resource_kind: "page".to_string(), + resource_id: "page_1".to_string(), + token: Some("plain-token".to_string()), + permission: "read".to_string(), + created_by: "ws_owner".to_string(), + expires_at: None, + }) + .expect("create share link"); + + assert_eq!(created.token, "plain-token"); + assert!(created.link.token_hash.starts_with("sha256-v1:")); + assert_ne!(created.link.token_hash, "plain-token"); + + let resolved = store + .resolve_share_link(&created.link.token_hash) + .expect("resolve share link") + .expect("active share link"); + assert_eq!(resolved.id, created.link.id); + + let listed = store + .list_share_links(&created.link.workspace_id) + .expect("list links"); + assert_eq!(listed.len(), 1); + + store + .revoke_share_link(&created.link.id) + .expect("revoke share"); + assert!(store + .resolve_share_link(&created.link.token_hash) + .expect("resolve revoked share") + .is_none()); + } + + #[test] + fn audit_log_appends_and_lists_latest_first() { + let store = store(); + create_user(&store, "owner"); + store + .append_audit(AppendAuditInput { + actor_user_id: Some("owner".to_string()), + action: "control.share.created".to_string(), + target_kind: "share_link".to_string(), + target_id: Some("share_1".to_string()), + metadata_json: "{}".to_string(), + }) + .expect("append audit"); + store + .append_audit(AppendAuditInput { + actor_user_id: Some("owner".to_string()), + action: "control.share.revoked".to_string(), + target_kind: "share_link".to_string(), + target_id: Some("share_1".to_string()), + metadata_json: "{\"reason\":\"test\"}".to_string(), + }) + .expect("append audit 2"); + + let rows = store.list_audit_log(10).expect("list audit"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].action, "control.share.revoked"); + assert_eq!(rows[1].action, "control.share.created"); + } + + #[test] + fn drain_outbox_marks_events_delivered() { + let store = store(); + store + .append_outbox(OutboxEventInput { + topic: "control".to_string(), + event_type: "control.workspace.updated".to_string(), + payload_json: "{}".to_string(), + }) + .expect("append event"); + + let first = store.drain_outbox(10).expect("first drain"); + assert_eq!(first.len(), 1); + + let second = store.drain_outbox(10).expect("second drain"); + assert!(second.is_empty()); + } + + #[test] + fn mark_outbox_delivered_marks_one_pending_event() { + let store = store(); + let first = store + .append_outbox(OutboxEventInput { + topic: "control".to_string(), + event_type: "control.grant.created".to_string(), + payload_json: "{}".to_string(), + }) + .expect("first event"); + let second = store + .append_outbox(OutboxEventInput { + topic: "control".to_string(), + event_type: "control.share.created".to_string(), + payload_json: "{}".to_string(), + }) + .expect("second event"); + + store + .mark_outbox_delivered(&first.id) + .expect("mark first delivered"); + + let pending = store.drain_outbox(10).expect("drain pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, second.id); + } + + #[test] + fn sidebar_shortcuts_are_user_scoped_and_upserted_by_target() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + let workspace = store.ensure_default_workspace("alice").expect("workspace"); + + let first = store + .upsert_sidebar_shortcut(UpsertSidebarShortcutInput { + id: None, + user_id: "alice".to_string(), + workspace_id: workspace.id.clone(), + root_uri: Some("file:///tmp/mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + target_id: "local-dir:design".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "design".to_string(), + icon: Some("folder_open".to_string()), + sort_order: 0, + metadata_json: "{}".to_string(), + }) + .expect("insert shortcut"); + + let updated = store + .upsert_sidebar_shortcut(UpsertSidebarShortcutInput { + id: None, + user_id: "alice".to_string(), + workspace_id: workspace.id.clone(), + root_uri: Some("file:///tmp/mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + target_id: "local-dir:design".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "Design".to_string(), + icon: Some("folder_open".to_string()), + sort_order: 4, + metadata_json: "{\"scope\":\"filetree\"}".to_string(), + }) + .expect("upsert shortcut"); + + assert_eq!(updated.id, first.id); + assert_eq!(updated.title, "Design"); + assert_eq!(updated.root_uri.as_deref(), Some("file:///tmp/mnote")); + assert_eq!(updated.sort_order, 4); + assert_eq!(updated.revision, first.revision + 1); + + let alice_shortcuts = store + .list_sidebar_shortcuts("alice", &workspace.id) + .expect("list alice shortcuts"); + assert_eq!(alice_shortcuts.len(), 1); + assert_eq!(alice_shortcuts[0].target_id, "local-dir:design"); + let alice_global_local_shortcuts = store + .list_sidebar_shortcuts_with_global_local("alice", "cloud-workspace") + .expect("list alice global local shortcuts"); + assert_eq!(alice_global_local_shortcuts.len(), 1); + assert_eq!(alice_global_local_shortcuts[0].workspace_id, workspace.id); + + let bob_shortcuts = store + .list_sidebar_shortcuts("bob", &workspace.id) + .expect("list bob shortcuts"); + assert!(bob_shortcuts.is_empty()); + + store + .delete_sidebar_shortcut("alice", &updated.id) + .expect("delete shortcut"); + let after_delete = store + .list_sidebar_shortcuts("alice", &workspace.id) + .expect("list deleted shortcuts"); + assert!(after_delete.is_empty()); + } + + #[test] + fn user_ui_preferences_are_scoped_upserted_and_user_isolated() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + + let first = store + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_mnt_Data1T_mnote".to_string()), + source_kind: Some("local_folder".to_string()), + scope_kind: "source_family".to_string(), + scope_id: "external_local_folder".to_string(), + key: "hideTitleHeader".to_string(), + value_json: "false".to_string(), + }) + .expect("insert preference"); + + let updated = store + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_mnt_Data1T_mnote".to_string()), + source_kind: Some("local_folder".to_string()), + scope_kind: "source_family".to_string(), + scope_id: "external_local_folder".to_string(), + key: "hideTitleHeader".to_string(), + value_json: "true".to_string(), + }) + .expect("upsert preference"); + + assert_eq!(updated.id, first.id); + assert_eq!(updated.value_json, "true"); + assert_eq!(updated.revision, first.revision + 1); + + let alice_preferences = store + .list_user_ui_preferences( + "alice", + Some("local:_mnt_Data1T_mnote"), + Some("local_folder"), + ) + .expect("list alice preferences"); + assert_eq!(alice_preferences.len(), 1); + assert_eq!(alice_preferences[0].scope_kind, "source_family"); + assert_eq!(alice_preferences[0].scope_id, "external_local_folder"); + + let bob_preferences = store + .list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder")) + .expect("list bob preferences"); + assert!(bob_preferences.is_empty()); + + let sidebar_scope = "filetree:root-hash:design-hash"; + let sidebar_value = serde_json::json!({ + "schemaVersion": 1, + "treeKind": "filetree", + "rootUri": "file:///mnt/Data1T/mnote", + "scope": "design", + "expandedRelativePaths": ["design/05-editor-mainline"], + "expandedIds": [], + "selectedId": "", + "focusedId": "", + "activeId": "", + "scrollTop": 0 + }) + .to_string(); + let sidebar_first = store + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_mnt_Data1T_mnote".to_string()), + source_kind: Some("local_folder".to_string()), + scope_kind: "sidebar_tree".to_string(), + scope_id: sidebar_scope.to_string(), + key: "sidebarTreeViewState.v1".to_string(), + value_json: sidebar_value, + }) + .expect("insert sidebar tree view state"); + let sidebar_updated = store + .upsert_user_ui_preference(UpsertUserUiPreferenceInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_mnt_Data1T_mnote".to_string()), + source_kind: Some("local_folder".to_string()), + scope_kind: "sidebar_tree".to_string(), + scope_id: sidebar_scope.to_string(), + key: "sidebarTreeViewState.v1".to_string(), + value_json: serde_json::json!({ + "schemaVersion": 1, + "treeKind": "filetree", + "rootUri": "file:///mnt/Data1T/mnote", + "scope": "design", + "expandedRelativePaths": ["design/05-editor-mainline", "design/05-editor-mainline/process"], + "expandedIds": [], + "selectedId": "", + "focusedId": "", + "activeId": "", + "scrollTop": 0 + }) + .to_string(), + }) + .expect("upsert sidebar tree view state"); + assert_eq!(sidebar_updated.id, sidebar_first.id); + assert_eq!(sidebar_updated.revision, sidebar_first.revision + 1); + + let alice_sidebar_preferences = store + .list_user_ui_preferences( + "alice", + Some("local:_mnt_Data1T_mnote"), + Some("local_folder"), + ) + .expect("list alice sidebar preferences"); + assert!(alice_sidebar_preferences.iter().any(|preference| { + preference.scope_kind == "sidebar_tree" + && preference.scope_id == sidebar_scope + && preference.key == "sidebarTreeViewState.v1" + })); + let bob_sidebar_preferences = store + .list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder")) + .expect("list bob sidebar preferences"); + assert!(bob_sidebar_preferences.is_empty()); + } + + #[test] + fn ai_agent_profile_policy_provisions_personal_and_shared_boundaries() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + store + .upsert_user(UpsertUserInput { + id: Some("admin".to_string()), + email: Some("admin@example.com".to_string()), + username: "admin".to_string(), + display_name: "admin".to_string(), + role: Some("admin".to_string()), + password_hash: None, + }) + .expect("admin user"); + + let alice_profiles = store + .ensure_ai_agent_profile_policy("alice", false) + .expect("alice profiles"); + assert_eq!(alice_profiles.len(), 11); + let alice_personal = alice_profiles + .iter() + .find(|item| item.profile.profile_kind == "personal") + .expect("alice personal profile"); + assert_eq!( + alice_personal.profile.owner_user_id.as_deref(), + Some("alice") + ); + assert!(alice_personal.grant.can_manage_skills); + let alice_shared = alice_profiles + .iter() + .find(|item| item.profile.id == "shared_lite") + .expect("alice shared profile"); + assert!(alice_shared.grant.can_run); + assert!(!alice_shared.grant.can_manage_skills); + for (profile_id, isolated_profile, display_name) in [ + ( + "shared_deepseek_chat", + "openclaw-deepseek-chat", + "DeepSeek Chat", + ), + ("shared_gemini_chat", "openclaw-gemini-chat", "Gemini Chat"), + ("shared_doubao_chat", "openclaw-doubao-chat", "豆包 Chat"), + ( + "shared_api_deepseek_flash_chat", + "api-deepseek-flash-chat", + "DeepSeek Flash Chat", + ), + ( + "shared_api_deepseek_pro_chat", + "api-deepseek-pro-chat", + "DeepSeek Pro Chat", + ), + ("shared_api_gpt_chat", "api-gpt-chat", "GPT Chat"), + ("shared_api_kimi_chat", "api-kimi-chat", "Kimi Chat"), + ( + "shared_api_gemini_chat", + "api-gemini-chat", + "Gemini API Chat", + ), + ("shared_api_grok_chat", "api-grok-chat", "Grok API Chat"), + ] { + let alice_chat = alice_profiles + .iter() + .find(|item| item.profile.id == profile_id) + .expect("alice shared chat profile"); + assert_eq!(alice_chat.profile.isolated_profile_name, isolated_profile); + assert_eq!(alice_chat.profile.display_name, display_name); + assert!(alice_chat.grant.can_run); + assert!(!alice_chat.grant.can_manage_skills); + assert!(!alice_chat.grant.can_manage_config); + } + + let bob_profiles = store + .ensure_ai_agent_profile_policy("bob", false) + .expect("bob profiles"); + let bob_personal = bob_profiles + .iter() + .find(|item| item.profile.profile_kind == "personal") + .expect("bob personal profile"); + assert_ne!(bob_personal.profile.id, alice_personal.profile.id); + assert!(store + .resolve_ai_agent_profile("bob", false, &alice_personal.profile.id) + .expect("resolve cross user") + .is_none()); + + let admin_shared = store + .resolve_ai_agent_profile("admin", true, "shared_lite") + .expect("admin shared") + .expect("admin can see shared"); + assert!(admin_shared.grant.can_manage_skills); + assert!(admin_shared.grant.can_manage_config); + for profile_id in [ + "shared_deepseek_chat", + "shared_gemini_chat", + "shared_doubao_chat", + "shared_api_deepseek_flash_chat", + "shared_api_deepseek_pro_chat", + "shared_api_gpt_chat", + "shared_api_kimi_chat", + "shared_api_gemini_chat", + "shared_api_grok_chat", + ] { + let admin_chat = store + .resolve_ai_agent_profile("admin", true, profile_id) + .expect("admin chat") + .expect("admin can see shared chat profile"); + assert!(admin_chat.grant.can_run); + assert!(!admin_chat.grant.can_manage_skills); + assert!(!admin_chat.grant.can_manage_config); + } + } + + #[test] + fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + + let first = store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "design".to_string(), + metadata_json: "{}".to_string(), + }) + .expect("insert folder recent"); + let updated = store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "Design".to_string(), + metadata_json: "{\"from\":\"test\"}".to_string(), + }) + .expect("upsert folder recent"); + assert_eq!(updated.id, first.id); + assert_eq!(updated.title, "Design"); + assert_eq!(updated.revision, first.revision + 1); + + store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "page".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some("design/Plan.md".to_string()), + document_id: Some("local-md:design~2FPlan.md".to_string()), + title: "Plan".to_string(), + metadata_json: "{}".to_string(), + }) + .expect("insert page recent"); + for index in 0..12 { + store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some(format!("folder-{index}")), + document_id: None, + title: format!("folder-{index}"), + metadata_json: "{}".to_string(), + }) + .expect("insert bounded folder recent"); + } + + let alice_folders = store + .list_navigation_recent("alice", Some("folder"), 50) + .expect("list alice folders"); + assert_eq!(alice_folders.len(), 10); + assert_eq!(alice_folders[0].kind, "folder"); + assert_eq!(alice_folders[0].title, "folder-11"); + assert!(alice_folders.iter().all(|record| record.user_id == "alice")); + assert!(!alice_folders.iter().any(|record| record.title == "Design")); + + let alice_pages = store + .list_navigation_recent("alice", Some("page"), 20) + .expect("list alice pages"); + assert_eq!(alice_pages.len(), 1); + assert_eq!( + alice_pages[0].document_id.as_deref(), + Some("local-md:design~2FPlan.md") + ); + + let bob_recent = store + .list_navigation_recent("bob", None, 20) + .expect("list bob recent"); + assert!(bob_recent.is_empty()); + } + + #[test] + fn ai_policy_upsert_returns_workspace_policy_before_user_policy() { + let store = store(); + create_user(&store, "ai_user"); + let workspace = store + .ensure_default_workspace("ai_user") + .expect("default workspace"); + + let user_policy = store + .upsert_ai_policy(UpsertAiPolicyInput { + id: None, + user_id: Some("ai_user".to_string()), + workspace_id: None, + allowed_roots_json: "[\"file:///user-root\"]".to_string(), + model_policy_json: "{\"default\":\"local\"}".to_string(), + quota_json: "{}".to_string(), + }) + .expect("user policy"); + assert_eq!(user_policy.revision, 1); + + let workspace_policy = store + .upsert_ai_policy(UpsertAiPolicyInput { + id: None, + user_id: Some("ai_user".to_string()), + workspace_id: Some(workspace.id.clone()), + allowed_roots_json: "[\"file:///workspace-root\"]".to_string(), + model_policy_json: "{\"default\":\"workspace\"}".to_string(), + quota_json: "{}".to_string(), + }) + .expect("workspace policy"); + + let resolved = store + .get_ai_policy("ai_user", Some(&workspace.id)) + .expect("get workspace policy") + .expect("workspace policy exists"); + assert_eq!(resolved.id, workspace_policy.id); + assert!(resolved.allowed_roots_json.contains("workspace-root")); + + let user_only = store + .get_ai_policy("ai_user", None) + .expect("get user policy") + .expect("user policy exists"); + assert_eq!(user_only.id, user_policy.id); + } + + #[test] + fn sync_state_upsert_tracks_cursor_status_and_revision() { + let store = store(); + create_user(&store, "sync_user"); + let workspace = store + .ensure_default_workspace("sync_user") + .expect("default workspace"); + + let first = store + .upsert_sync_state(UpsertSyncStateInput { + id: None, + workspace_id: workspace.id.clone(), + remote_kind: "local_worker".to_string(), + remote_id: Some("worker-1".to_string()), + cursor: Some("cursor-1".to_string()), + last_synced_at: None, + status: "idle".to_string(), + error_json: None, + }) + .expect("first sync state"); + assert_eq!(first.revision, 1); + + let second = store + .upsert_sync_state(UpsertSyncStateInput { + id: None, + workspace_id: workspace.id.clone(), + remote_kind: "local_worker".to_string(), + remote_id: Some("worker-1".to_string()), + cursor: Some("cursor-2".to_string()), + last_synced_at: Some("2026-05-22T00:00:00Z".to_string()), + status: "error".to_string(), + error_json: Some("{\"code\":\"test\"}".to_string()), + }) + .expect("second sync state"); + + assert_eq!(second.id, first.id); + assert_eq!(second.revision, 2); + assert_eq!(second.cursor.as_deref(), Some("cursor-2")); + + let loaded = store + .get_sync_state(&workspace.id, "local_worker") + .expect("get sync state") + .expect("sync state exists"); + assert_eq!(loaded.status, "error"); + assert_eq!(loaded.error_json.as_deref(), Some("{\"code\":\"test\"}")); + } + + #[test] + fn ai_runtime_run_upsert_lists_updates_and_events() { + let store = store(); + create_user(&store, "ai_runtime_user"); + + let first = store + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: Some("初始标题".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\":\"读取当前页面\"}".to_string(), + }) + .expect("insert runtime run"); + assert_eq!(first.revision, 1); + + let updated = store + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: Some("更新标题".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_2".to_string()), + status: "completed".to_string(), + runtime_json: "{\"status\":\"completed\"}".to_string(), + payload_json: "{\"message\":\"完成\"}".to_string(), + }) + .expect("update runtime run"); + assert_eq!(updated.id, first.id); + assert_eq!(updated.revision, 2); + + let runs = store + .list_ai_runtime_runs( + "ai_runtime_user", + Some("ws_1"), + Some("doc_1"), + Some("sess_1"), + 10, + ) + .expect("list runtime runs"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].title.as_deref(), Some("更新标题")); + + let event = store + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + 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!(event.event_type, "message.delta"); + store + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + 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: "run.completed".to_string(), + payload_json: "{\"status\":\"completed\"}".to_string(), + }) + .expect("append second runtime event"); + + let events = store + .list_ai_runtime_events("ai_runtime_user", "run_1", 10) + .expect("list runtime events"); + assert_eq!(events.len(), 2); + assert_eq!(events[0].payload_json, "{\"text\":\"hello\"}"); + + let found = store + .find_ai_runtime_run("ai_runtime_user", "run_1") + .expect("find runtime run") + .expect("runtime run exists"); + assert_eq!(found.status, "completed"); + + let journal_events = store + .list_ai_runtime_journal_events("ai_runtime_user", "run_1", 1, 10) + .expect("list runtime journal events"); + 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] + fn ai_runtime_session_management_renames_auto_titles_and_soft_deletes() { + let store = store(); + create_user(&store, "ai_runtime_user"); + store + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: None, + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: None, + status: "completed".to_string(), + runtime_json: "{\"status\":\"completed\"}".to_string(), + payload_json: "{\"message\":\"请总结这篇文档\"}".to_string(), + }) + .expect("insert runtime run"); + + let titled = store + .auto_title_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1")) + .expect("auto title") + .expect("titled run"); + assert_eq!(titled.title.as_deref(), Some("请总结这篇文档")); + + let renamed = store + .rename_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1"), "人工标题") + .expect("rename session"); + assert_eq!(renamed.len(), 1); + assert_eq!(renamed[0].title.as_deref(), Some("人工标题")); + + let deleted = store + .delete_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1")) + .expect("delete session"); + assert_eq!(deleted, 1); + + let runs = store + .list_ai_runtime_runs("ai_runtime_user", Some("ws_1"), None, Some("sess_1"), 10) + .expect("list after delete"); + assert!(runs.is_empty()); + } + + #[test] + fn ai_external_conversation_binding_is_user_scoped_and_statused() { + let store = store(); + create_user(&store, "doubao_user_a"); + create_user(&store, "doubao_user_b"); + + let binding = store + .upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput { + id: None, + user_id: "doubao_user_a".to_string(), + workspace_id: Some("ws_1".to_string()), + mnote_session_id: "chatonly_session_1".to_string(), + acp_session_id: Some("acp_session_1".to_string()), + agent_id: "chat_only".to_string(), + profile: "openclaw-doubao-chat".to_string(), + provider: "doubao-web".to_string(), + remote_conversation_id: "38428454119180290".to_string(), + remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()), + status: "active".to_string(), + metadata_json: "{\"source\":\"provider.conversation.bound\"}".to_string(), + }) + .expect("upsert external conversation binding"); + + assert_eq!(binding.status, "active"); + assert_eq!(binding.remote_conversation_id, "38428454119180290"); + + let found = store + .find_ai_external_conversation_binding( + "doubao_user_a", + Some("ws_1"), + "chatonly_session_1", + "doubao-web", + ) + .expect("find binding") + .expect("binding exists"); + assert_eq!(found.id, binding.id); + assert_eq!(found.acp_session_id.as_deref(), Some("acp_session_1")); + + let other_user = store + .find_ai_external_conversation_binding( + "doubao_user_b", + Some("ws_1"), + "chatonly_session_1", + "doubao-web", + ) + .expect("find other user binding"); + assert!(other_user.is_none()); + + let changed = store + .mark_ai_external_conversation_binding_status( + "doubao_user_a", + Some("ws_1"), + "chatonly_session_1", + "doubao-web", + "local_deleted", + Some("{\"reason\":\"mnote_session_deleted\"}"), + ) + .expect("mark local deleted"); + assert_eq!(changed, 1); + + let deleted = store + .find_ai_external_conversation_binding( + "doubao_user_a", + Some("ws_1"), + "chatonly_session_1", + "doubao-web", + ) + .expect("find deleted binding") + .expect("binding remains auditable"); + assert_eq!(deleted.status, "local_deleted"); + assert!(deleted.deleted_at.is_some()); + } + + #[test] + fn session_lookup_resolves_active_user_by_token_hash() { + let store = store(); + create_user(&store, "shujuan"); + + let session = store + .create_session(CreateSessionInput { + id: None, + user_id: "shujuan".to_string(), + token_hash: session_token_hash("raw-session-token"), + user_agent: Some("mnote-test".to_string()), + ip_hash: None, + expires_at: None, + }) + .expect("create session"); + + let resolved = store + .get_session_by_token_hash(&session.token_hash) + .expect("lookup session") + .expect("active session"); + + assert_eq!(resolved.session.user_id, "shujuan"); + assert_eq!(resolved.user.id, "shujuan"); + assert_eq!(resolved.user.email.as_deref(), Some("shujuan@example.com")); + } + + #[test] + fn revoked_session_is_not_resolved() { + let store = store(); + create_user(&store, "shujuan"); + + let session = store + .create_session(CreateSessionInput { + id: None, + user_id: "shujuan".to_string(), + token_hash: session_token_hash("revoked-token"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("create session"); + store.revoke_session(&session.id).expect("revoke session"); + + let resolved = store + .get_session_by_token_hash(&session.token_hash) + .expect("lookup session"); + assert!(resolved.is_none()); + } + + #[test] + fn password_hash_v1_is_sha256_hex_placeholder() { + assert!(password_hash_v1("secret").starts_with("sha256-v1:")); + } + + #[test] + fn authenticate_password_accepts_email_login_and_creates_session() { + let store = store(); + let test_password = ["correct", "horse"].join(" "); + create_password_identity(&store, "alice", "alice@example.com", &test_password); + + let resolved = store + .authenticate_password(AuthenticatePasswordInput { + account: "alice@example.com".to_string(), + password: test_password, + session_id: None, + token_hash: session_token_hash("alice-email-session"), + user_agent: Some("mnote-test".to_string()), + ip_hash: None, + expires_at: None, + }) + .expect("authenticate by email"); + + assert_eq!(resolved.user.username, "alice"); + assert_eq!( + resolved.session.token_hash, + session_token_hash("alice-email-session") + ); + + let lookup = store + .get_session_by_token_hash(&resolved.session.token_hash) + .expect("lookup created session") + .expect("created session"); + assert_eq!(lookup.user.id, resolved.user.id); + } + + #[test] + fn authenticate_password_accepts_username_login() { + let store = store(); + let test_password = ["sword", "fish"].join(""); + create_password_identity(&store, "bob", "bob@example.com", &test_password); + + let resolved = store + .authenticate_password(AuthenticatePasswordInput { + account: "bob".to_string(), + password: test_password, + session_id: None, + token_hash: session_token_hash("bob-username-session"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("authenticate by username"); + + assert_eq!(resolved.user.email.as_deref(), Some("bob@example.com")); + assert_eq!( + resolved.session.token_hash, + session_token_hash("bob-username-session") + ); + } + + #[test] + fn authenticate_password_rejects_wrong_password() { + let store = store(); + let right_password = ["right", "password"].join("-"); + let wrong_password = ["wrong", "password"].join("-"); + create_password_identity(&store, "chris", "chris@example.com", &right_password); + + let err = store + .authenticate_password(AuthenticatePasswordInput { + account: "chris@example.com".to_string(), + password: wrong_password, + session_id: None, + token_hash: session_token_hash("chris-failed-session"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect_err("wrong password should fail"); + + assert!(matches!(err, ControlPlaneError::Unauthorized(_))); + assert!(store + .get_session_by_token_hash(&session_token_hash("chris-failed-session")) + .expect("lookup failed session") + .is_none()); + } + + + // --- Fault injection tests --- + + #[test] + fn invalid_local_path_returns_storage_error_not_panic() { + let path = "/tmp/__mnote_nonexistent_dir__/control-plane-unknown.db"; + let result = TursoControlPlaneStore::open_local(path); + let Err(error) = result else { + panic!("invalid path should return an error"); + }; + assert!( + matches!(&error, ControlPlaneError::Storage(_)), + "expected Storage error, got {error:?}" + ); + } + + #[test] + fn turso_remote_config_rejects_missing_url() { + let result = TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::Remote, + path: None, + remote_url: None, + auth_token: Some("dummy-token".to_string()), + sync_interval: None, + }); + let Err(error) = result else { + panic!("missing URL should return error"); + }; + assert!( + matches!(&error, ControlPlaneError::InvalidInput(_)), + "expected InvalidInput for missing URL, got {error:?}" + ); + } + + #[test] + fn turso_remote_config_rejects_missing_token() { + 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 error"); + }; + assert!( + matches!(&error, ControlPlaneError::InvalidInput(_)), + "expected InvalidInput for missing token, got {error:?}" + ); + } + + #[test] + fn turso_remote_config_rejects_empty_url() { + let result = TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::Remote, + path: None, + remote_url: Some("".to_string()), + auth_token: Some("dummy-token".to_string()), + sync_interval: None, + }); + let Err(error) = result else { + panic!("empty URL should return error"); + }; + assert!( + matches!(&error, ControlPlaneError::InvalidInput(_)), + "expected InvalidInput for empty URL, got {error:?}" + ); + } + + #[test] + fn turso_remote_config_rejects_empty_token() { + let result = TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::Remote, + path: None, + remote_url: Some("libsql://example.turso.io".to_string()), + auth_token: Some("".to_string()), + sync_interval: None, + }); + let Err(error) = result else { + panic!("empty token should return error"); + }; + assert!( + matches!(&error, ControlPlaneError::InvalidInput(_)), + "expected InvalidInput for empty token, got {error:?}" + ); + } + + #[test] + fn turso_remote_replica_config_rejects_missing_path() { + let result = TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::RemoteReplica, + path: None, + remote_url: Some("libsql://example.turso.io".to_string()), + auth_token: Some("dummy-token".to_string()), + sync_interval: None, + }); + let Err(error) = result else { + panic!("missing replica path should return error"); + }; + assert!( + matches!(&error, ControlPlaneError::InvalidInput(_)), + "expected InvalidInput for missing replica path, got {error:?}" + ); + } + + #[test] + fn turso_synced_config_rejects_missing_path() { + let result = TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig { + mode: TursoControlPlaneMode::Synced, + path: None, + remote_url: Some("libsql://example.turso.io".to_string()), + auth_token: Some("dummy-token".to_string()), + sync_interval: None, + }); + let Err(error) = result else { + panic!("missing synced path should return error"); + }; + assert!( + matches!(&error, ControlPlaneError::InvalidInput(_)), + "expected InvalidInput for missing synced path, got {error:?}" + ); + } + + #[test] + fn timeout_variant_is_propagated_correctly() { + let err = ControlPlaneError::Timeout("connection timed out".to_string()); + let display = err.to_string(); + assert!( + display.starts_with("Timeout:"), + "expected Timeout: prefix, got {display}" + ); + } + + #[test] + fn rate_limit_variant_is_propagated_correctly() { + let err = ControlPlaneError::RateLimit("too many requests".to_string()); + let display = err.to_string(); + assert!( + display.starts_with("RateLimit:"), + "expected RateLimit: prefix, got {display}" + ); + } +} diff --git a/rust/crates/control-plane/tests/turso_store.rs b/rust/crates/control-plane/tests/turso_store.rs new file mode 100644 index 00000000..ebb9ed70 --- /dev/null +++ b/rust/crates/control-plane/tests/turso_store.rs @@ -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(_))); +} diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index 655223a4..c3efe7ff 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -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 { None } +#[cfg(not(test))] +fn parse_turso_sync_interval_ms() -> Option { + 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, pub acp_runtime: Arc, pub buffer_store: BufferStore, - control_plane: Arc, + control_plane: Arc, } 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 { + 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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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 { diff --git a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs index 6e1fc8bc..610ef54b 100644 --- a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs +++ b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs @@ -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) -> Self { + pub fn new(buffer_store: BufferStore, control_plane: Arc) -> 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" ))); diff --git a/rust/crates/mnote-web/src/routes/dev_seed.rs b/rust/crates/mnote-web/src/routes/dev_seed.rs new file mode 100644 index 00000000..2d746009 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/dev_seed.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +enum DevSeedOperation { + SetupWorkspace { + user_id: String, + #[serde(default)] + email: Option, + #[serde(default)] + username: Option, + #[serde(default)] + display_name: Option, + #[serde(default)] + role: Option, + #[serde(default)] + password: Option, + workspace_id: String, + workspace_name: String, + root_uri: String, + root_path: String, + #[serde(default)] + source_kind: Option, + #[serde(default)] + permission: Option, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + grant_source: Option, + #[serde(default)] + grant_created_by: Option, + }, + SeedAiRuntime { + #[serde(default)] + id: Option, + user_id: String, + #[serde(default)] + workspace_id: Option, + #[serde(default)] + document_id: Option, + session_id: String, + run_id: String, + #[serde(default)] + title: Option, + profile: String, + acp_runtime: String, + #[serde(default)] + trace_id: Option, + status: String, + #[serde(default = "empty_json_object")] + runtime_json: Value, + #[serde(default = "empty_json_object")] + payload_json: Value, + #[serde(default)] + events: Vec, + }, + ClearRuntimeEvents { + user_id: String, + run_id: String, + }, + GetAiRuntimeRun { + user_id: String, + run_id: String, + }, + ListAiRuntimeRuns { + user_id: String, + #[serde(default)] + workspace_id: Option, + #[serde(default)] + document_id: Option, + #[serde(default)] + session_id: Option, + #[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, + }, + CountAiRuntimeEvents { + user_id: String, + run_id: String, + #[serde(default)] + event_type: Option, + }, + FindExternalConversationBinding { + user_id: String, + #[serde(default)] + workspace_id: Option, + mnote_session_id: String, + provider: String, + }, + ListExternalConversationBindings { + user_id: String, + #[serde(default)] + workspace_id: Option, + 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, + 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, +} + +fn empty_json_object() -> Value { + json!({}) +} + +fn default_dev_seed_limit() -> usize { + 200 +} + +pub async fn seed( + State(state): State, + Json(request): Json, +) -> Result, 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 { + 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"); + } +} diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index a8195f00..70b093b8 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -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! { }); diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index e553269b..d8c3735f 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index bb94c56e..b23e3319 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -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", diff --git a/rust/crates/mnote-web/src/ssr/pages/admin.rs b/rust/crates/mnote-web/src/ssr/pages/admin.rs index 6571c7c2..d9dec8f4 100644 --- a/rust/crates/mnote-web/src/ssr/pages/admin.rs +++ b/rust/crates/mnote-web/src/ssr/pages/admin.rs @@ -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( {workspace_name.clone()}

- "授权记录" + "控制面" {share_grants_path.clone()}
diff --git a/scripts/TESTING_REFERENCE.md b/scripts/TESTING_REFERENCE.md index adb51762..e86fd783 100644 --- a/scripts/TESTING_REFERENCE.md +++ b/scripts/TESTING_REFERENCE.md @@ -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 中"脚本纪律"一节。 diff --git a/scripts/lib/control-plane-dev-seed.js b/scripts/lib/control-plane-dev-seed.js new file mode 100644 index 00000000..c49a48e6 --- /dev/null +++ b/scripts/lib/control-plane-dev-seed.js @@ -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, +}; diff --git a/scripts/lib/control-plane-test-env.js b/scripts/lib/control-plane-test-env.js new file mode 100644 index 00000000..c40cbe19 --- /dev/null +++ b/scripts/lib/control-plane-test-env.js @@ -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, +}; diff --git a/scripts/task-control-plane-admin-libsql-roundtrip-smoke.js b/scripts/task-control-plane-admin-libsql-roundtrip-smoke.js new file mode 100644 index 00000000..06edc8d5 --- /dev/null +++ b/scripts/task-control-plane-admin-libsql-roundtrip-smoke.js @@ -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; +}); diff --git a/scripts/task492-sidebar-starred-shortcuts-smoke.js b/scripts/task492-sidebar-starred-shortcuts-smoke.js index 2b104859..350cb653 100644 --- a/scripts/task492-sidebar-starred-shortcuts-smoke.js +++ b/scripts/task492-sidebar-starred-shortcuts-smoke.js @@ -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"], diff --git a/scripts/task494-filetree-lazy-loading-dedup-smoke.js b/scripts/task494-filetree-lazy-loading-dedup-smoke.js index 289b6ea6..b743b659 100644 --- a/scripts/task494-filetree-lazy-loading-dedup-smoke.js +++ b/scripts/task494-filetree-lazy-loading-dedup-smoke.js @@ -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"], diff --git a/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js b/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js index 71b39512..6d573ca4 100644 --- a/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js +++ b/scripts/task496-editor-open-parallel-runtime-aggregate-smoke.js @@ -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"], diff --git a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js index 75d8044d..38889963 100644 --- a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js +++ b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js @@ -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"], diff --git a/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js b/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js index 73d7310e..fa46ba0c 100644 --- a/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js +++ b/scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js @@ -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"], diff --git a/scripts/task499-sidebar-tree-view-state-smoke.js b/scripts/task499-sidebar-tree-view-state-smoke.js index 9383319b..22a1454a 100644 --- a/scripts/task499-sidebar-tree-view-state-smoke.js +++ b/scripts/task499-sidebar-tree-view-state-smoke.js @@ -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 }); } } diff --git a/scripts/task500-navigation-page-route-guard-smoke.js b/scripts/task500-navigation-page-route-guard-smoke.js index 46c09677..f4abec99 100644 --- a/scripts/task500-navigation-page-route-guard-smoke.js +++ b/scripts/task500-navigation-page-route-guard-smoke.js @@ -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 }); } } diff --git a/scripts/task512-chatonly-doubao-sync-smoke.js b/scripts/task512-chatonly-doubao-sync-smoke.js index c4f607ec..35b9310c 100644 --- a/scripts/task512-chatonly-doubao-sync-smoke.js +++ b/scripts/task512-chatonly-doubao-sync-smoke.js @@ -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`, diff --git a/scripts/task513-chatonly-provider-sync-smoke.js b/scripts/task513-chatonly-provider-sync-smoke.js index ea8e4545..dd800517 100644 --- a/scripts/task513-chatonly-provider-sync-smoke.js +++ b/scripts/task513-chatonly-provider-sync-smoke.js @@ -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`, diff --git a/scripts/task527-chatonly-api-provider-smoke.js b/scripts/task527-chatonly-api-provider-smoke.js index 4ab7e5ed..03b13680 100644 --- a/scripts/task527-chatonly-api-provider-smoke.js +++ b/scripts/task527-chatonly-api-provider-smoke.js @@ -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; diff --git a/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js b/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js index f3859a27..46a46feb 100644 --- a/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js +++ b/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js @@ -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); diff --git a/scripts/task557-page-ai-run-resume-smoke.js b/scripts/task557-page-ai-run-resume-smoke.js index fcfb6945..24a7cabc 100644 --- a/scripts/task557-page-ai-run-resume-smoke.js +++ b/scripts/task557-page-ai-run-resume-smoke.js @@ -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", diff --git a/scripts/task558-page-ai-reasonix-live-session-context-smoke.js b/scripts/task558-page-ai-reasonix-live-session-context-smoke.js index 48bb0884..02beacd2 100644 --- a/scripts/task558-page-ai-reasonix-live-session-context-smoke.js +++ b/scripts/task558-page-ai-reasonix-live-session-context-smoke.js @@ -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"); diff --git a/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js b/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js index 79f6f041..c27dce6c 100644 --- a/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js +++ b/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js @@ -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 }); diff --git a/scripts/task560-page-ai-hermes-load-replay-smoke.js b/scripts/task560-page-ai-hermes-load-replay-smoke.js index 8c2eeb7c..806b2c4a 100644 --- a/scripts/task560-page-ai-hermes-load-replay-smoke.js +++ b/scripts/task560-page-ai-hermes-load-replay-smoke.js @@ -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)); } diff --git a/scripts/task561-page-ai-session-dashboard-smoke.js b/scripts/task561-page-ai-session-dashboard-smoke.js index 83197ccc..0418da9b 100644 --- a/scripts/task561-page-ai-session-dashboard-smoke.js +++ b/scripts/task561-page-ai-session-dashboard-smoke.js @@ -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 }; diff --git a/scripts/task562-page-ai-reasonix-approval-plan-smoke.js b/scripts/task562-page-ai-reasonix-approval-plan-smoke.js index e09a356d..16c4a870 100644 --- a/scripts/task562-page-ai-reasonix-approval-plan-smoke.js +++ b/scripts/task562-page-ai-reasonix-approval-plan-smoke.js @@ -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 };