Retire Convex paths and standardize local-first Pi runtime
This commit is contained in:
@@ -1,136 +1,50 @@
|
||||
# 仓库协作指南(AGENTS)
|
||||
|
||||
## 当前主线
|
||||
## 主线(一句话)
|
||||
|
||||
- 产品:`tree-first graph kernel` + local-first 工作区;云首发同栈(单机/云服务器均可部署)。
|
||||
- 形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Lab Page AI + LightRAG + mindmap/OnlyOffice + Wolai 主题壳 + libSQL 控制面 + Vault`。
|
||||
- 数据真相:本地(或服务器上的)workspace folder;页面正文为 `.md`。
|
||||
- 控制面:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` 为默认与云首发推荐(独立部署、无需 Turso 云账号);`turso-remote` / `turso-local-replica` 仅在明确需要托管同步时使用。`sqlite` 不是 mnote-web 运行时。
|
||||
- Web:`mnote-web` 为 **3000** 唯一公开入口。kernel 持有树/边/projection/command;前端只消费稳定 projection。
|
||||
- Page AI:**Pi Lab**(`/api/page-ai/pi/*`)为唯一产品入口。
|
||||
- 知识库:LightRAG + `mnote.knowledge_rag.*` / `/api/knowledge-rag/*`。
|
||||
- 正文保存:`page.body.write` + 文件版本;AI 写文件:授权 scope + allowed roots + Pi patch/diff + watcher。
|
||||
- 架构 SSOT:`ARCHITECTURE.md`。预发瘦身清单:`design/10-review/process/22-pre-release-legacy-purge-v1.md`。产品化收口:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。
|
||||
tree-first kernel + local-first 工作区;真相 = 本地 folder + `.md`;**mnote-web:3000**;Page AI = **Pi Lab only**;控制面默认 **libsql-local**;知识库 LightRAG;Vault + `$mnote-vault`。
|
||||
SSOT:`ARCHITECTURE.md`。预发:`design/10-review/process/22-pre-release-legacy-purge-v1.md`。
|
||||
|
||||
## 云首发范围(必须保留)
|
||||
## 云首发必须保留
|
||||
|
||||
| 能力 | 入口 / 说明 |
|
||||
|------|-------------|
|
||||
| 工作区 + 树 + 文档 | kernel + FileTree/PageTree + leptos-tiptap |
|
||||
| Page AI | Pi Lab |
|
||||
| 知识库 | LightRAG |
|
||||
| 办公文档 | OnlyOffice(附件/资源进入) |
|
||||
| 密码箱 | Vault + `$mnote-vault` |
|
||||
| 控制面 | `libsql-local`(auth / membership / share / AI policy / Pi scope) |
|
||||
工作区树文档 · Pi Lab · LightRAG · OnlyOffice · Vault · libsql-local。
|
||||
勿当主链宣传:第三方 ACP 宿主、默认 debug tree shell。
|
||||
|
||||
不在首发产品面、且不得在文档中当作可选主链宣传的路径:旧 agent 网关、第三方 ACP 宿主、OpenCode 代理壳、debug tree shell(默认关闭)。清理顺序与可删文件见 purge 清单。
|
||||
## 约束
|
||||
|
||||
## 组件定位
|
||||
- 树/边/结构只在 Rust kernel;UI 不拼第二套真相。
|
||||
- 浏览器禁止新增 `setInterval` 轮询 → WS/SSE/watcher/command/MO。
|
||||
- 标题/设置/正文 → Page Aggregate;树变更 → `tree.*`。
|
||||
- AI 写文件:scope + roots + Pi patch/diff + 版本 + watcher(Phase C 流式 apply 冻结)。
|
||||
- `mnote_agent_tools/` 与 `/api/mnote/tools/*` **不可整目录误删**。
|
||||
|
||||
| 组件 | 定位 |
|
||||
|------|------|
|
||||
| `leptos-tiptap` island | 文档页默认编辑 runtime;非对象真源 |
|
||||
| Mindmap | tree-first 视图/挂件;非真相层 |
|
||||
| OnlyOffice | 独立页面编辑器;经附件/资源进入 |
|
||||
| LightRAG + Pi | 知识库与 Page AI 默认融合 |
|
||||
| Vault | 本机/服务器密码箱;AI 经 capability token 读密 |
|
||||
| Resource → File → Page Tree | 对象真源 → 主组织投影 → 导航投影 |
|
||||
|
||||
## Runtime 模块定位
|
||||
|
||||
- Browser:`rust/crates/mnote-web/browser/`
|
||||
- Sidebar:`sidebar-tree-runtime.js`(及 workspace / page tree / filetree / upload 等)
|
||||
- FileTree:`filetree-*.js`
|
||||
- Page AI host:`sidebar-page-ai-*.js`(默认 Pi Lab)
|
||||
- 文档 host:`document-editor-adapter-runtime.js`
|
||||
- Island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/`
|
||||
- SSR 注入:`layout.rs` / `tree.rs` / `web_shell.rs` — 非大型 JS 功能定位入口
|
||||
|
||||
## 目录优先级
|
||||
## 目录
|
||||
|
||||
| 路径 | 用途 |
|
||||
|------|------|
|
||||
| `rust/crates/core-protocol/` | Kernel 类型、projection 协议 |
|
||||
| `rust/crates/bridge-runtime/` | query / command / traversal |
|
||||
| `rust/crates/mnote-web/` | 3000 route、transport、projection |
|
||||
| `rust/crates/mnote-web/browser/` | 浏览器 runtime JS |
|
||||
| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island |
|
||||
| `rust/crates/mnote-vault-core/` | Vault 核心 |
|
||||
| `src/components/onlyoffice/` | OnlyOffice 静态与插件 |
|
||||
| `wolai-backend/app/` | 辅助后端 / 异步(非 3000 主入口) |
|
||||
| `recycle/` | 回收区;不作为实现依据 |
|
||||
| `reference-code/` | 对照参考;**不进部署包** |
|
||||
| `rust/crates/core-protocol` · `bridge-runtime` · `mnote-web` | 协议 / query-command / 3000 |
|
||||
| `mnote-web/browser` · `leptos-tiptap-spike` | 前端 runtime / 编辑 island |
|
||||
| `mnote-vault-core` · `src/components/onlyoffice` | Vault · OO |
|
||||
| `recycle/` · `reference-code/` | 非实现依据;**不进部署包** |
|
||||
|
||||
## 架构约束
|
||||
## 协作
|
||||
|
||||
- 树 / 页面结构 / 引用边:落 Rust kernel,禁止 UI 拼第二套真相。
|
||||
- 浏览器主链禁止新增 `setInterval` / 周期轮询;用 WS/SSE、watcher、command result、`MutationObserver`。
|
||||
- 标题 / 页面设置 / 正文:收口 Page Aggregate。
|
||||
- 新建/重命名/移动/归档:`tree.*`。
|
||||
- AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher。Phase C 流式 apply 冻结。
|
||||
- Page AI 宿主为原生 Pi Lab runtime;普通 UI 不得出现第二套 agent 切换入口。
|
||||
- Agent 工具包:`rust/crates/mnote-web/src/mnote_agent_tools/`(HTTP 面 `/api/mnote/tools/*`;Pi / LightRAG / skill / OnlyOffice live tools 依赖,**不可整目录误删**)。
|
||||
- 架构判断:`ARCHITECTURE.md` → 对应 `design/**` process/done 稿。
|
||||
- 只改任务相关文件;不覆盖用户改动。
|
||||
- design:`process/` 可执行,`done/` 完成。bugs 镜像大类。
|
||||
- CodeGraph 结构问题;`rg` 字面量。热启:`npm run dev:hot`。测:`cargo test -p mnote-web`。
|
||||
|
||||
## 设计稿目录规则
|
||||
## 账号(dev,勿混)
|
||||
|
||||
- `design/01-*` … `design/12-*`:`process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。
|
||||
- 已完成主线稿迁 `done/`;被覆盖旧稿迁 `design/old/` 并标 `[recycle]`。
|
||||
- `design/90-reference/` 只放参考资料。
|
||||
| 账号 | 角色 |
|
||||
|------|------|
|
||||
| `mnote-admin` | ops admin |
|
||||
| `mnote-e2e` | `ai_service`(非 admin) |
|
||||
| 其它 | human |
|
||||
|
||||
## Bugs 目录规则
|
||||
详情与密码:`scripts/TESTING_REFERENCE.md`。登录走标准 `/auth`(无快速登录)。
|
||||
|
||||
- 镜像 design 大类;`process/` 未修完,`done/` 已验证。
|
||||
- 壳/Sidebar/文档页体验 → `05-editor-mainline/`;projection/selection/DnD/tree → `04-tree-domain/`。
|
||||
## 指针
|
||||
|
||||
## 协作边界
|
||||
|
||||
- 只改与任务直接相关的文件;不覆盖用户已有改动;无关脏改动保持不动。
|
||||
- Subagent 为受控叶子:限定读写范围与验证命令;主控复核 diff/证据后才采纳。
|
||||
- 同一写入范围同时只允许一个 writer。
|
||||
|
||||
## CodeGraph
|
||||
|
||||
- 结构性代码问题优先 CodeGraph MCP(explore / impact)。
|
||||
- 改代码后 `codegraph sync .`;大重构或索引异常 `codegraph index . --force`。
|
||||
- 字面文本/日志字符串优先 `rg`。
|
||||
|
||||
## Reference-Code
|
||||
|
||||
- 优先 `reference-code/sidex-main`;`reference-code/vscode` 为裁剪辅助。
|
||||
- 每次只对照一个切面;不要把参考项目 UI 状态当成 MNote 新事实源。
|
||||
|
||||
## 常用命令
|
||||
|
||||
- 热启动:`npm run dev:hot` → `http://localhost:3000`
|
||||
- 测试:`cargo test -p mnote-web`
|
||||
- Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local`
|
||||
- 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote`
|
||||
|
||||
## Smoke 与测试账号
|
||||
|
||||
- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth`。
|
||||
- 禁止 smoke 用 `sqlite3` CLI 直写 control-plane;seed 走 Rust API / `scripts/lib/control-plane-dev-seed.js`。
|
||||
- 账号角色(7-76 方案 A,**勿混用**):
|
||||
| 账号 | 定位 | 密码(本地/dev) | admin 能力 |
|
||||
|------|------|------------------|------------|
|
||||
| `mnote-admin` / `mnote.admin@example.com` | **ops admin**(`/admin/*`、代签、policy) | `MnoteAdmin123!` | 是 |
|
||||
| `mnote-e2e` / `mnote.e2e@example.com` | **AI 主体** `ai_service`(私有工作区/知识库) | `MnoteE2E123!` | **否** |
|
||||
| `liaibo` 等 | 普通 human user | 各自密码 | **否** |
|
||||
- 登录走 **标准** `/auth` 表单或 `/api/auth`(已移除「测试账号快速登录」);开发对标生产。
|
||||
- Admin 列表真源:`access-policy.json` 的 `admins` + `MNOTE_ADMIN_USER_IDS`(默认仅 `mnote-admin`)。
|
||||
|
||||
## 前端测试
|
||||
|
||||
- 真实渲染:`/doko`;交互回归:浏览器自动化。
|
||||
- 主页/Sidebar/文档首屏优先复用 `scripts/task*-smoke.js`。
|
||||
|
||||
## 编码
|
||||
|
||||
- UTF-8;TS/TSX 2 空格;Python 4 空格。
|
||||
|
||||
## 密码箱 / agent skills(薄指针)
|
||||
|
||||
- 策略 SSOT:`/home/lix/.agent-infra/vault-policy.md`;skill `$mnote-vault`(`skills/mnote-vault`)。
|
||||
- Page AI pack 注册:agent tool pack 内 `skill` 模块 id `mnote-vault`(仅放 `skills/` 不够)。
|
||||
- 勿在本文件复制完整 vault 策略。
|
||||
- Vault:`vault-policy.md` · `$mnote-vault`(pack id `mnote-vault`)。
|
||||
- **知识中枢**(非 LightRAG):`/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space`
|
||||
- 查 → `INDEX.md`;更 → 事实变更时;写/多域 → `$mnote-e2e-knowledge-hub`
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
# 5-38 双向链接系统(Bidirectional Links)v1
|
||||
|
||||
> 状态:process(调研完成,待分阶段落地)
|
||||
> 日期:2026-07-29
|
||||
> 目标:避免「多处引用同一知识却各写一份」的多真源问题;在 local-first + tree-first kernel 下建立**单真源引用边 + 反链投影**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
**正向链接写在正文(Markdown / block 语义)→ 保存/索引时抽出 `references` 边 → 反链只是该边的反向投影。**
|
||||
TipTap 只负责插入/渲染;**不**在前端维护第二套链接图。
|
||||
|
||||
---
|
||||
|
||||
## 1. TipTap 更新检查(2026-07-29)
|
||||
|
||||
| 位置 | 版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `reference-code/leptos-tiptap/tiptap` | **@tiptap/\* 2.27.2** | 当前主链 bridge 依赖 |
|
||||
| npm `v2-latest` | **2.27.2** | v2 线已停在此点 |
|
||||
| npm `latest` | **3.29.2**(2026-07-28) | 主线已进 v3 很久 |
|
||||
| `reference-code/tiptap-main` 快照 | packages 约 **3.23.4**,git ~2026-05 | 参考仓库偏旧于 npm latest |
|
||||
| `@tiptap/extension-mention` | 3.29.2 与 core 对齐 | v3 线 mention;v2 仍有独立包 |
|
||||
|
||||
### 1.1 对双向链接的含义
|
||||
|
||||
- **双向链接不依赖 TipTap 3**。核心是 kernel 边 + Markdown 持久化 + 索引投影。
|
||||
- **本阶段不建议为双向链接单独升 v3**。v2→v3 是独立迁移(schema/API/extension 打包方式变化),成本远高于「补 page mention + 反链面板」。
|
||||
- 可继续参考:
|
||||
- `reference-code/leptos-tiptap`(当前 bridge / link extension)
|
||||
- `reference-code/tiptap-notion-like-registry` 的 `mention-dropdown-menu` / `link-popover`(**UI 交互**,不是用户 mention 语义)
|
||||
- `reference-code/tiptap-main` 仅作 API 对照;升 v3 时再同步拉取
|
||||
|
||||
### 1.2 可选后续(非本系统阻塞)
|
||||
|
||||
- 独立 checklist:评估 leptos-tiptap bridge 升 `@tiptap/*@3` 的 breaking 面。
|
||||
- 升版前冻结:现有 `page_reference` ↔ link mark round-trip 测试。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状盘点(已有资产 / 缺口)
|
||||
|
||||
### 2.1 已有(可复用,勿重造)
|
||||
|
||||
| 层 | 资产 | 路径/入口 |
|
||||
| --- | --- | --- |
|
||||
| 语法 | `[[page\|label]]` / `((block\|label))` | `mnote-editor-core/src/markdown.rs` |
|
||||
| 块类型 | `page_reference` / `PageReference` | core-protocol、bridge、editor actor |
|
||||
| 编辑投影 | page_ref → 段落 + `link` mark(class `mnote-page-block-link`) | `document-tiptap-conversion-runtime.js`、`block_transform.rs` |
|
||||
| 索引反链 | 扫 `](*.md)` + `[[…]]`,API 可读 | `local_search_index.rs` → `GET /api/search/local-index/backlinks` |
|
||||
| Kernel 类型 | `KernelEdgeType::References` / `BacklinksTo` | `core-protocol/src/kernel.rs` |
|
||||
| 页面选项 | `collapseBacklinks` / `showBlockRefCount` | page aggregate + UI preferences |
|
||||
| 证据边 | markdown 链接 → deterministic edges | document evidence / LightRAG 旁路(勿与笔记反链混为同一产品入口) |
|
||||
| 思源边界 | 借产品闭环,不借 `.sy` / SQL 真相 | `design/09-siyuan-reference/...` |
|
||||
|
||||
### 2.2 缺口(多真源风险点)
|
||||
|
||||
1. **编辑器层**:page_ref 降级为普通 link mark,无独立 inline atom;`[[` 输入规则 / 页面搜索插入不完整(legacy editor 仅插 `[[page]]` 占位)。
|
||||
2. **索引层**:`extract_backlinks` 存的是**字符串目标**(路径 / wikilink 文本),未统一解析为稳定 `documentId` / ObjectIdentity;重命名后易漂。
|
||||
3. **Kernel 层**:`References` / `BacklinksTo` 已声明,**未**成为 page_aggregate / 反链 UI 的正式 query 面。
|
||||
4. **UI 层**:`collapseBacklinks` 明确 **「待接线:当前 Rust 壳未挂回链面板」**;旧 local-index 反链 DOM 已故意不复活(避免与 LightRAG 资料库入口混淆)。
|
||||
5. **语义层**:E28 `@mention` 已暂停且纠偏为「人/会议」,**不是**页面引用;页面引用应走 `[[` / slash「页面」/ 转 page_reference,勿与 `@` 混用。
|
||||
6. **块引用**:`((block))` 语法与类型有,主编辑岛与稳定 blockId 持久化尚未闭环。
|
||||
|
||||
---
|
||||
|
||||
## 3. 产品语义(对齐思源 / Wolai,落 MNote 边界)
|
||||
|
||||
### 3.1 三种引用(必须分开)
|
||||
|
||||
| 种类 | 用户形态 | 持久化 | 索引边 | 优先级 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **页面引用** | `[[Title]]` / `[[path\|显示名]]` / page_reference 块 | Markdown 或 block props | `references` → page | **P0** |
|
||||
| **块引用** | `((blockId))` | 需稳定 blockId | `references` → block | P1 |
|
||||
| **外链 / 附件** | `[x](url)` / 资源 path | 普通 link / resource_ref | 不进「笔记反链」主列表(可进 resourceRefs) | 已有,保持 |
|
||||
|
||||
### 3.2 「双向」的真正含义
|
||||
|
||||
- 用户只写**正向**引用。
|
||||
- 系统维护 `source → target` 边。
|
||||
- **反链** = `target ← sources` 的投影查询,不是第二份手写列表。
|
||||
- 图谱 / 搜索 / AI 上下文 / 导图挂件 **只读同一套边**,禁止各自扫一遍 Markdown 各存一份。
|
||||
|
||||
### 3.3 真源顺序(硬规则)
|
||||
|
||||
```
|
||||
本地 .md(+ 将来稳定 block 元数据)
|
||||
↓ 解析 / 保存
|
||||
Page Aggregate blocks(含 page_reference / 将来 inline ref)
|
||||
↓ 抽出
|
||||
Reference edges(kernel 或 local-index 统一表,单一实现)
|
||||
↓ 投影
|
||||
反链面板 / search / AI tools / 可选图谱
|
||||
```
|
||||
|
||||
**禁止**:
|
||||
|
||||
- 浏览器 localStorage 存链接图
|
||||
- TipTap JSON 与 Markdown 各维护一套「官方」反链
|
||||
- LightRAG entity 边冒充笔记 page backlink(可并列展示,须分入口)
|
||||
|
||||
---
|
||||
|
||||
## 4. 目标架构
|
||||
|
||||
### 4.1 引用边最小模型
|
||||
|
||||
```text
|
||||
ReferenceEdge {
|
||||
source_document_id: String, // local-md:… 或稳定 id
|
||||
source_block_id: Option<String>,
|
||||
target_kind: page | block | resource | external,
|
||||
target_document_id: Option<String>, // 解析成功时
|
||||
target_raw: String, // 原文 token / path,解析失败仍可展示
|
||||
anchor_text: Option<String>,
|
||||
locator: Option<String>, // 行号 / offset,便于跳转
|
||||
}
|
||||
```
|
||||
|
||||
- **写入**:文档保存成功、local-index refresh、watcher 变更后 **整页替换**该 source 的出边(思源式:按 root 删再插,避免脏边)。
|
||||
- **读取**:
|
||||
- `query.backlinks_to(documentId)` → 谁链到我
|
||||
- `query.references_from(documentId)` → 我链向谁
|
||||
- 与已有 API 对齐:先让 `GET /api/search/local-index/backlinks` **成为**上述 query 的 HTTP 面,再视需要升为 kernel query 名 `search.local_index.backlinks` → 最终 `kernel.backlinks_to`。
|
||||
|
||||
### 4.2 目标解析(消灭字符串多真源)
|
||||
|
||||
解析顺序建议:
|
||||
|
||||
1. 已是 `local-md:…` / 已知 documentId
|
||||
2. 相对路径相对**当前文件** resolve → 规范化相对 root → `local-md:{encode}`
|
||||
3. wikilink `[[Title]]`:按标题 / 文件名 stem 在索引内消歧(多命中则 raw 保留 + UI 标黄)
|
||||
4. 失败:`target_document_id = null`,`target_raw` 保留,反链仍可按 raw 弱匹配(可选)
|
||||
|
||||
重命名 / 移动:走现有 tree command;索引 rebuild 时重算边(P0 可全量 rebuild;P1 增量 + transferBlockRef 类迁移可参考思源 `transferBlockRef`,**不**照搬 SQL)。
|
||||
|
||||
### 4.3 TipTap 显示层(v2 即可)
|
||||
|
||||
**不要**把 Notion registry 的「用户 Mention」直接当页面引用。
|
||||
|
||||
推荐两档:
|
||||
|
||||
| 档 | 形态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| **A(最小,先做)** | 保持 `paragraph` + `link` mark + `mnote-page-block-link`,补强 round-trip 与点击打开 | 已有主链,改动小 |
|
||||
| **B(产品完整)** | 新增 inline atom:`pageMention` / 或专用 mark attrs:`data-mnote-ref="page"` + `data-target-id` | 便于样式、未解析态、禁止拆字 |
|
||||
|
||||
输入:
|
||||
|
||||
- InputRule:`[[` 打开页面搜索(复用 local search / tree query,**不是** `@`)
|
||||
- Slash:插入「页面引用」
|
||||
- 块菜单:段落 ↔ page_reference 转换(已有 `replace_block_with_page_reference` 可扩)
|
||||
|
||||
Markdown 导出:
|
||||
|
||||
- 优先 `[[target|label]]` 或 `[label](rel/path.md)`(**工作区内统一一种**;建议 **wikilink 作规范、md link 作兼容导入**)
|
||||
- `mnote-editor-core` 已用 `[[id|label]]`,local-folder 路径型文档应导出 **相对路径** 以便 Git 可读:`[[docs/child|子页]]` 或 `[子页](docs/child.md)` — **实现前定一种 SSOT 写进本文件 §6**。
|
||||
|
||||
### 4.4 反链 UI
|
||||
|
||||
- 挂在**页面底部或右侧信息区**(Wolai/思源心智),**不要**塞回已移除的 local-index 设置页。
|
||||
- 消费:`backlinks` API + Page Aggregate 选项 `collapseBacklinks`。
|
||||
- 每条:来源标题、路径、可选 snippet、点击 `open document`(带 sourceKind/rootUri)。
|
||||
- `showBlockRefCount`:有块引用后再接线。
|
||||
|
||||
### 4.5 与 AI / LightRAG
|
||||
|
||||
| 需求 | 入口 |
|
||||
| --- | --- |
|
||||
| 「哪些笔记链到这篇」 | 笔记反链 API / kernel edges |
|
||||
| 「资料库里实体/PDF 证据」 | LightRAG / knowledge-rag |
|
||||
| Agent 写引用 | 必须写 Markdown/block 合法引用,再走同一抽取器 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 分阶段落地(可执行)
|
||||
|
||||
### Phase 0 — 契约冻结(0.5–1d)
|
||||
|
||||
- [ ] 选定工作区持久化规范:`[[rel/path|label]]` **或** `[label](rel.md)` 为写出 SSOT(导入两者都认)。
|
||||
- [ ] 固定 `ReferenceEdge` 字段与 `documentId` 解析顺序(§4.2)。
|
||||
- [ ] 明确 `@` ≠ 页面引用(文档 + slash 文案)。
|
||||
|
||||
### Phase 1 — 索引单真源(1–2d)**【优先】**
|
||||
|
||||
- [ ] 强化 `extract_backlinks`:输出结构化 edge(含 resolved id),不只是字符串列表。
|
||||
- [ ] `query_local_backlinks` 按 **resolved documentId** 聚合;兼容旧字符串。
|
||||
- [ ] 单测:A→B 的 `[[B]]` 与 `[B](b.md)` 反链一致;重命名后 refresh 更新。
|
||||
- [ ] 保持 `task452` 语义:API 可用;不恢复旧 settings DOM。
|
||||
|
||||
### Phase 2 — 反链面板(1–2d)
|
||||
|
||||
- [ ] 文档页挂载 backlinks panel(读 Phase 1 API)。
|
||||
- [ ] 接通 `collapseBacklinks`。
|
||||
- [ ] Smoke:两篇本地 md 互链,B 页可见 A。
|
||||
|
||||
### Phase 3 — 编辑器插入体验(2–3d)
|
||||
|
||||
- [ ] `[[` 触发页面搜索 + 插入规范 token / page_reference。
|
||||
- [ ] 保存后索引增量/全量更新边。
|
||||
- [ ] 未解析目标的视觉态(可选)。
|
||||
- [ ] **仍用 TipTap 2.27.2**;不夹带 v3。
|
||||
|
||||
### Phase 4 — Kernel 边对齐(按主线空档)
|
||||
|
||||
- [ ] local-index 实现升为 `KernelEdgeType::References` 的唯一写入路径(或薄封装)。
|
||||
- [ ] `BacklinksTo` 作为派生查询,不双写。
|
||||
- [ ] Page Aggregate 可附带 `backlinks` 摘要(注意体积与权限)。
|
||||
|
||||
### Phase 5 — 块引用(后置)
|
||||
|
||||
- [ ] 稳定 blockId(与 UniqueID / 导出策略一起设计)。
|
||||
- [ ] `((id))` 解析、预览、transfer on move。
|
||||
- [ ] `showBlockRefCount`。
|
||||
|
||||
### 明确不做(本 v1)
|
||||
|
||||
- 为双向链接升级 TipTap 3
|
||||
- 复制思源 `refs` SQL 表为产品真相
|
||||
- 用 LightRAG 替代笔记反链
|
||||
- 恢复旧 local-index 设置页反链 DOM
|
||||
- 把 `@` 改成页面引用
|
||||
|
||||
---
|
||||
|
||||
## 6. 待决事项(实现前二选一写死)
|
||||
|
||||
1. **写出格式 SSOT**
|
||||
- 方案 W:wikilink `[[rel/path|label]]`(贴近思源/Obsidian,editor-core 已有)
|
||||
- 方案 M:标准 md link `[label](rel.md)`(Git 友好、通用)
|
||||
- **建议**:索引与解析两者都认;**新插入默认 W**,导出可配置(默认 W)。
|
||||
|
||||
2. **page_reference 块 vs inline**
|
||||
- 块级:独占一段(当前)
|
||||
- 行内:段落中的 mention atom
|
||||
- **建议**:P0 块级 + link mark;P1 行内 atom(真正「文中引用」)。
|
||||
|
||||
3. **反链面板位置**
|
||||
- 页底固定区 vs 右侧 drawer
|
||||
- **建议**:页底折叠区(`collapseBacklinks` 已存在语义)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 参考索引
|
||||
|
||||
| 来源 | 用途 |
|
||||
| --- | --- |
|
||||
| 思源 `kernel/sql` `refs` 表 / `block_ref_query.go` | 边模型、按 root 重建、反链查询形状 |
|
||||
| 思源 conf `Backlink*` / `VirtualBlockRef` | 产品选项;虚拟引用可后置 |
|
||||
| `design/09-siyuan-reference/...` | 借鉴边界 |
|
||||
| `design/01-tree-first-graph-kernel/...` | tree + references 边 |
|
||||
| leptos-tiptap `tiptap_link.ts` | link mark bridge |
|
||||
| notion-like `mention-dropdown-menu` | 仅 suggestion UI 模式 |
|
||||
| `local_search_index.rs` `extract_backlinks` | 现抽取实现 |
|
||||
| `sidebar-page-settings-runtime.js` | collapseBacklinks 待接线文案 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准(Phase 1–2 最小闭环)
|
||||
|
||||
1. 在 root 下创建 `A.md`,正文写入指向 `B.md` 的规范引用并保存。
|
||||
2. 打开 `B`:反链面板出现 `A`(标题可点,带 local source 参数)。
|
||||
3. 删除 A 中引用并保存 / 刷新索引后,B 反链消失。
|
||||
4. 同一套数据:`GET .../local-index/backlinks` 与面板一致。
|
||||
5. 无第二套前端链接缓存;无 TipTap 3 升级。
|
||||
|
||||
---
|
||||
|
||||
## 9. 与主线关系
|
||||
|
||||
当前主线仍是 Page Aggregate / tree / local-first。双向链接应:
|
||||
|
||||
- **先**吃透已有 local-index + page_reference(Phase 1–2)
|
||||
- **再**编辑体验(Phase 3)
|
||||
- Kernel 图边与块引用跟 tree-first 空档合并,避免另起存储
|
||||
|
||||
---
|
||||
|
||||
*本文是 process 设计;落地时按 Phase 开 checklist / smoke,完成后迁 `done/`。*
|
||||
@@ -0,0 +1,193 @@
|
||||
# 7-77 mnote-e2e 知识中枢架构 v1
|
||||
|
||||
> 状态:`process` · 可执行设计(落地 skill 前的 SSOT)
|
||||
> 范围:AI 主体 `mnote-e2e` 工作区 + 多 AI / Admin 共用的**系统·项目知识中枢**
|
||||
> 非目标:替换 LightRAG;替换 vault 读密;替代全局 self-improving 记忆循环
|
||||
> 前置:Web PAT(笔记 CRUD)与 vault mnv1(读密)已分职;本设计只落**可读 Markdown 真源**
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题与目标
|
||||
|
||||
### 1.1 要解决什么
|
||||
|
||||
- 重复查 skill 路径、服务器 URL、运维约定;
|
||||
- 系统级配置散落在聊天 / 临时文件里;
|
||||
- 项目上下文与全局基建混杂,难给 **Admin 复核** 与 **多 AI 共用**。
|
||||
|
||||
### 1.2 产品定位
|
||||
|
||||
**知识中枢 = 系统重要信息 + 项目重要信息的「索引 · 导航 · 积累」层。**
|
||||
Admin 与 AI 共用同一套 Markdown 树;LightRAG 是可选增强,不是唯一入口。
|
||||
|
||||
### 1.3 边界
|
||||
|
||||
| 概念 | 本中枢关系 |
|
||||
|------|------------|
|
||||
| self-improving-for-codex | 不替代;行为记忆仍在 agent 侧 |
|
||||
| LightRAG | 可选索引 `00-system` + 显式项目;排除 inbox/archive/系统点目录 |
|
||||
| vault / mnv1 | 正文禁止密文;只写 credential 指针 |
|
||||
| Web PAT | e2e 工作区 CRUD 通道 |
|
||||
| 全局 skill / AGENTS | 工作区 `AGENTS.md` 强制「先查中枢」 |
|
||||
|
||||
### 1.4 成功标准
|
||||
|
||||
1. Admin 打开 e2e 工作区根即可浏览系统/项目知识。
|
||||
2. AI 默认先读 `INDEX.md` 再动手。
|
||||
3. 秘密只在 vault。
|
||||
4. Admin 可清 inbox、删冗余、批注待更新。
|
||||
5. 结构稳定到可做成 skill。
|
||||
|
||||
---
|
||||
|
||||
## 2. 真源与放置
|
||||
|
||||
### 2.1 工作区
|
||||
|
||||
```text
|
||||
主体:mnote-e2e(ai_service)
|
||||
根: /mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space
|
||||
鉴权:Web PAT notes.* + tree.*
|
||||
读密:mnv1(与中枢分离)
|
||||
```
|
||||
|
||||
### 2.2 中枢根 = 工作区根(无额外 00-hub 层)
|
||||
|
||||
**决策(2026-07-28)**:不再套 `00-hub/`。e2e 工作区几乎整仓即知识中枢,多一层路径长、心智重复。
|
||||
|
||||
```text
|
||||
my-space/ # ← 中枢根
|
||||
AGENTS.md # 工作区纪律(启动入口)
|
||||
README.md # 中枢是什么、怎么用
|
||||
INDEX.md # 总索引(启动必读,短)
|
||||
00-system/ # 通用 / 系统级
|
||||
10-projects/ # 分项目
|
||||
90-inbox/ # AI 草稿,Admin 晋升
|
||||
99-archive/ # 过期
|
||||
.mnote/ ai-sessions/ … # 系统目录:不进索引、不进 RAG 默认范围
|
||||
```
|
||||
|
||||
**约定**
|
||||
|
||||
| 类别 | 路径 | 是否知识树 |
|
||||
|------|------|------------|
|
||||
| 知识正文 | `INDEX.md`、`00-system/**`、`10-projects/**`、`90-inbox/**`、`99-archive/**` | 是 |
|
||||
| 系统运行时 | `.mnote/**`、`ai-sessions/**`、`.reasonix/**`、`.codegraph/**` | **否** |
|
||||
|
||||
> 实测:e2e PAT 已验证 tree 创建/重命名/删除 + `resource/read|write`;用户试探文档已清理。
|
||||
|
||||
---
|
||||
|
||||
## 3. 信息架构
|
||||
|
||||
### 3.1 分层纪律(必守)
|
||||
|
||||
| 层 | 放什么 | 不放什么 |
|
||||
|----|--------|----------|
|
||||
| **`00-system`** | **主机与基建**:Kubuntu、OrangeVPS、heyun;VPN/frp;全机 skill;系统凭证指针 | MNote 应用账号(mnote-e2e 等)、产品端口 3000/3003、PAT/mnv1 产品细则 |
|
||||
| **`10-projects/*`** | 产品与项目:身份、业务端口、架构、runbook | 把 heyun/Kubuntu 主机表重复抄一套当「项目」而不链系统层 |
|
||||
|
||||
### 3.2 `00-system/`(系统 / 主机)
|
||||
|
||||
```text
|
||||
00-system/
|
||||
INDEX.md
|
||||
01-hosts/ # Kubuntu / OrangeVPS / heyun(可达性备注)
|
||||
02-skills/ # 全机 skill 登记
|
||||
03-network/ # 代理 profile、frp 约定
|
||||
04-endpoints/ # 系统向入口(代理/面板);产品入口链到项目
|
||||
05-repos/ # 本机仓库与数据路径
|
||||
06-agents-runtime/ # 本机 agent 宿主
|
||||
07-credentials-map/# 系统凭证指针(无密文)
|
||||
08-ops-runbooks/ # 主机/网络运维
|
||||
09-governance/ # 中枢治理
|
||||
```
|
||||
|
||||
### 3.3 `10-projects/<slug>/`
|
||||
|
||||
```text
|
||||
10-projects/
|
||||
INDEX.md
|
||||
mnote-core/ # 含 identity.md、endpoints.md(产品端口)
|
||||
ai-runtime/ # 含 credentials.md(PAT/mnv1/AI 密码本指针)
|
||||
infra-ops/ # 部署视图;主机真源仍链 00-system/01-hosts
|
||||
<slug>/
|
||||
README.md INDEX.md architecture.md runbook.md
|
||||
decisions.md glossary.md open-questions.md links.md
|
||||
```
|
||||
|
||||
### 3.4 inbox / archive
|
||||
|
||||
| 区 | 写 | 默认启动读 |
|
||||
|----|----|------------|
|
||||
| `90-inbox/` | AI 草稿 | 否 |
|
||||
| `99-archive/` | 归档 | 否 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 安全与索引纪律
|
||||
|
||||
1. 禁止 password / apikey / cookie / mnpat1 / mnv1 明文。
|
||||
2. 凭证只写:subject、credentialId、用途、字段名。
|
||||
3. 启动只读:`AGENTS.md` → `INDEX.md` → 按需子 INDEX。
|
||||
4. 清单用表格 + 链接;明细按需打开。
|
||||
|
||||
---
|
||||
|
||||
## 5. 角色
|
||||
|
||||
| 角色 | 读 | 写 | 删 system 核心 |
|
||||
|------|----|----|----------------|
|
||||
| Admin | 全部 | 全部 | 可 |
|
||||
| AI(e2e PAT) | 工作区知识树 | inbox + 受控更新 catalog | 禁止擅自删 |
|
||||
| 其他用户 | 无 membership 则不可见 | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 6. 后续接线
|
||||
|
||||
- 工作区 `AGENTS.md`:先查中枢、禁密文、inbox 规则。
|
||||
- Skill:`$mnote-e2e-knowledge-hub`
|
||||
- 真源:`~/.codex/skills/mnote-e2e-knowledge-hub/`
|
||||
- Grok symlink:`~/.grok/skills/mnote-e2e-knowledge-hub`
|
||||
- 含 worker 模板:`references/worker-prompts.md` · 结构:`references/structure.md`
|
||||
- LightRAG:默认可挂 `00-system/**`;项目 opt-in;排除 inbox/archive/点目录。
|
||||
|
||||
---
|
||||
|
||||
## 7. 落地阶段
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| A | 以 e2e 根建树 + 占位 INDEX | **done** |
|
||||
| B | 填 skills/servers/identity 实表(kb2/kb3 充实) | **done**(可持续更新) |
|
||||
| C | skill + 完善 AGENTS.md | **done**(2026-07-29) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 反模式
|
||||
|
||||
- 密文进 Markdown
|
||||
- 每 AI 各建一套 hub
|
||||
- 把 `ai-sessions` 当知识
|
||||
- 镜像整个 git 仓库
|
||||
- AI 静默删 `00-system` 核心页
|
||||
- ~~多包一层无必要的 `00-hub/`~~(已否决)
|
||||
|
||||
---
|
||||
|
||||
## 9. 开放问题
|
||||
|
||||
1. skill 登记:半自动扫盘 + Admin 审?
|
||||
2. `design/**` 以 pointer 链入(推荐)还是摘要复制?
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-28 | 初稿:含 `00-hub` 层 |
|
||||
| 2026-07-28 | **改中枢根 = e2e 工作区根**,去掉 `00-hub`;并落地目录骨架 |
|
||||
| 2026-07-28 | **纠正分层**:系统级 = 主机(Kubuntu/OrangeVPS/heyun)与基建;mnote-e2e 身份/产品端口下沉 `10-projects` |
|
||||
| 2026-07-29 | 落地 skill `$mnote-e2e-knowledge-hub`(Codex 真源 + Grok 链接);阶段 B/C 收口 |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 23 · 退役产品全库清理清单 v1
|
||||
|
||||
> 状态:**in-progress → 高优先级 Waves A1–C 已落地(2026-07-28)**
|
||||
> 日期:2026-07-28
|
||||
> 目标:把 convex + Weknora + openhub + reasonix + Hermes + ACP + OpenCode + Sidex + Yuxi + legacy_next_compat + soft retirement 残留彻底扫光,像没有存在过一样。
|
||||
> 优先级:**高**(编译不红、runtime 不暴露、文档不广告)→ **中**(调试残留)→ **低**(历史叙事、死 smoke)。
|
||||
|
||||
## 1. 整体盘点(更新 2026-07-28 执行后)
|
||||
|
||||
| 品牌 | 产品面残留 | 调试/测试面 | 历史叙事 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| **Hermes** | seed→`pi`;`HERMES_HOME` 兼容读已删;`AiRuntimeOwner::RustWebPi` | soft-retire smoke 保留 exit 0 | design/old 可留 | **已收** |
|
||||
| **OpenHub / OpenCode** | 无产品入口 | soft-retire smoke | design/old | **低** |
|
||||
| **Reasonix** | seed→`pi` | `.reasonix` 目录忽略保留 | design/old | **已收** |
|
||||
| **ACP** | DB 列 `acp_*` 保留(schema);产品 runtime 无 ACP 宿主 | 极少 | | **中(列名兼容)** |
|
||||
| **next_compat / LEGACY_NEXT** | AppConfig / proxy / documents Next 链已删 | smoke 已去 env | design/done 历史稿 | **已收** |
|
||||
| **convex_retired** | 已扫完 | | | **已完** |
|
||||
| **Weknora / Yuxi / Sidex** | 极少 | | design/old | **低** |
|
||||
|
||||
## 2. 本批已执行(A1 → A2 → A3 → B → C)
|
||||
|
||||
### A1 · control-plane brand → pi ✅
|
||||
- sqlite / turso seed:`hermes`/`reasonix` → `pi`,display「我的 Pi」
|
||||
- 迁移:历史 `agent_id='hermes'` 就地 UPDATE 到 `pi`
|
||||
- fixtures / `turso_store` / `control-plane-dev-seed.js` 字面量中性化
|
||||
|
||||
### A2 · LEGACY_NEXT / documents proxy ✅
|
||||
- `AppConfig` 删除 `legacy_next_base_url` / `enable_legacy_next_compat` / `compat_next_base_path`
|
||||
- `session` / `gateway` health 与 legacy proxy 删除
|
||||
- `documents` Next proxy 栈删除
|
||||
- OnlyOffice 非 local writeback → fail-closed(`onlyoffice_cloud_writeback_unavailable`)
|
||||
- 保留 OnlyOffice **同源 server proxy** 所需 `append_path_and_query`
|
||||
|
||||
### A3 · Hermes 命名 / HERMES_HOME ✅
|
||||
- `AiRuntimeOwner::RustWebHermes` → `RustWebPi`
|
||||
- spike:`open_hermes_page_ai_drawer` → `open_page_ai_drawer`
|
||||
- `agent_profile_home`:仅 `MNOTE_AGENT_HOME` / `~/.mnote-agent`(去掉 `HERMES_HOME` / `~/.hermes`)
|
||||
- audit log:仅 `MNOTE_AGENT_TOOL_AUDIT_LOG`
|
||||
- AGENTS.md:去掉「旧 agent 网关 / OpenCode 代理壳」宣传句
|
||||
- `.env.all`:删除 `MNOTE_HERMES_API_*`
|
||||
|
||||
### B · compat 410 / 文案 / debug ✅
|
||||
- `/api/ai-agent/run` 继续 **410** `legacy_ai_agent_run_retired`(含 hermes/codex provider 负向合同)
|
||||
- compat 测试去 mock Next 上游,只断言 GONE
|
||||
- debug shell:`MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES` 默认 false(边界保持)
|
||||
|
||||
### C · smoke / 编译验证 ✅
|
||||
- 17 个 smoke 去掉 `MNOTE_WEB_*LEGACY_NEXT*`
|
||||
- `task164` 断言改为不出现 `legacy_next_compat`
|
||||
- 死 hermes/opencode smoke 已 soft-retire(Wave 10,保持)
|
||||
- 验证:
|
||||
- `cargo check -p mnote-web --tests` 无 error
|
||||
- `cargo test -p bridge-runtime --lib` 116 passed
|
||||
- `cargo test -p control-plane --lib` 37 passed
|
||||
- `cargo test -p mnote-web --lib routes::compat` 4 passed
|
||||
|
||||
## 3. 仍可后续(非阻塞)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| ACP 列名 `acp_*` | 控制面 schema 兼容;改列需独立迁移批 |
|
||||
| actor 占位拒绝 `hermes` | `mnote_tools` 仍拒绝 actor=`hermes` 占位 id(负向安全) |
|
||||
| `mnote-hermes-tool` source | document-session 历史 external write source 兼容读 |
|
||||
| `.reasonix` / `.opencode` 目录忽略 | local_folder 扫描忽略,非产品入口 |
|
||||
| design/old 历史稿 | 不阻塞产品面 |
|
||||
| 测试函数名 `hermes_tools_*` 前缀 | 历史命名,schema 已中性化 |
|
||||
|
||||
## 4. 验收 Checklist
|
||||
|
||||
- [x] `cargo check -p mnote-web --tests` 无 error
|
||||
- [x] `cargo test -p bridge-runtime --lib` 全通过
|
||||
- [x] `cargo test -p control-plane --lib` 全通过
|
||||
- [x] AGENTS.md 不再把退役 agent 宿主写成可选主链
|
||||
- [x] smoke 不再注入 `MNOTE_WEB_*LEGACY_NEXT*`
|
||||
- [x] 产品 profile home 不再读 `HERMES_HOME` / `~/.hermes`
|
||||
- [ ] (可选)ARCHITECTURE.md 全文扫 brand 叙事
|
||||
- [ ] (可选)ACP 列 rename 迁移批
|
||||
|
||||
## 5. 工具回扫命令
|
||||
|
||||
```bash
|
||||
rg -n "legacy_next|LEGACY_NEXT|HERMES_HOME|RustWebHermes|enable_legacy_next" rust scripts --glob '!**/target/**'
|
||||
rg -n "MNOTE_WEB_ENABLE_LEGACY_NEXT|MNOTE_HERMES" scripts .env.all
|
||||
cargo check -p mnote-web --tests
|
||||
```
|
||||
@@ -50,7 +50,7 @@ pub struct OnlyOfficeProxyPreparationInput {
|
||||
pub supabase_url: Option<String>,
|
||||
pub supabase_internal_url: Option<String>,
|
||||
pub onlyoffice_storage_host_override: Option<String>,
|
||||
pub convex_origin: Option<String>,
|
||||
pub extra_origin: Option<String>,
|
||||
pub supabase_anon_key: Option<String>,
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ pub fn prepare_proxy_request(
|
||||
let supa = try_parse_origin_host(input.supabase_url.as_deref());
|
||||
let supa_internal_origin = try_parse_origin_url(input.supabase_internal_url.as_deref());
|
||||
let storage_override = try_parse_origin_host(input.onlyoffice_storage_host_override.as_deref());
|
||||
let convex_origin = try_parse_origin_url(input.convex_origin.as_deref());
|
||||
let extra_origin = try_parse_origin_url(input.extra_origin.as_deref());
|
||||
|
||||
let is_supabase_path = target.path().starts_with("/storage/v1/")
|
||||
|| target.path().starts_with("/auth/v1/")
|
||||
@@ -302,7 +302,7 @@ pub fn prepare_proxy_request(
|
||||
add_allowed(hostname, port);
|
||||
}
|
||||
|
||||
if let Some(origin) = convex_origin.as_ref() {
|
||||
if let Some(origin) = extra_origin.as_ref() {
|
||||
let port = origin
|
||||
.port()
|
||||
.map(|value| value.to_string())
|
||||
@@ -792,7 +792,7 @@ mod tests {
|
||||
supabase_url: Some("https://public.example.com".into()),
|
||||
supabase_internal_url: Some("http://127.0.0.1:18000".into()),
|
||||
onlyoffice_storage_host_override: None,
|
||||
convex_origin: Some("http://127.0.0.1:3210".into()),
|
||||
extra_origin: Some("http://127.0.0.1:3210".into()),
|
||||
supabase_anon_key: Some("anon".into()),
|
||||
})
|
||||
.expect("proxy should prepare");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
-- 002-ai-runtime-store.sql
|
||||
-- MNote ACP/Hermes runtime session store.
|
||||
-- MNote AI runtime session store.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_runtime_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -591,7 +591,7 @@ fn list_ai_agent_profile_access_rows(
|
||||
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'
|
||||
WHERE p.agent_id = 'pi'
|
||||
AND p.status = 'active'
|
||||
AND g.can_run = 1
|
||||
AND (
|
||||
@@ -2002,12 +2002,24 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
}
|
||||
let conn = self.lock_conn()?;
|
||||
let now = now_text();
|
||||
// 退役 brand:历史 hermes agent_id 就地迁到 pi,避免 UNIQUE 下再插一套孤儿行。
|
||||
conn.execute(
|
||||
"UPDATE ai_agent_profiles
|
||||
SET agent_id = 'pi',
|
||||
display_name = CASE
|
||||
WHEN display_name = '我的 Hermes' THEN '我的 Pi'
|
||||
ELSE display_name
|
||||
END,
|
||||
updated_at = ?1
|
||||
WHERE agent_id = 'hermes'",
|
||||
params![now],
|
||||
)?;
|
||||
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)
|
||||
VALUES ('shared_lite', 'pi', '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],
|
||||
@@ -2093,7 +2105,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
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)
|
||||
VALUES (?1, 'pi', '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",
|
||||
@@ -2121,9 +2133,10 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
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)
|
||||
VALUES (?1, 'pi', 'personal', ?2, 'default', ?3, '我的 Pi', '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",
|
||||
DO UPDATE SET status = 'active', display_name = excluded.display_name,
|
||||
isolated_profile_name = excluded.isolated_profile_name, 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");
|
||||
@@ -4461,8 +4474,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: Some("trace_1".to_string()),
|
||||
status: "running".to_string(),
|
||||
runtime_json: "{\"status\":\"running\"}".to_string(),
|
||||
@@ -4480,8 +4493,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: Some("trace_2".to_string()),
|
||||
status: "completed".to_string(),
|
||||
runtime_json: "{\"status\":\"completed\"}".to_string(),
|
||||
@@ -4511,8 +4524,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
event_type: "message.delta".to_string(),
|
||||
payload_json: "{\"text\":\"hello\"}".to_string(),
|
||||
})
|
||||
@@ -4526,8 +4539,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
event_type: "run.completed".to_string(),
|
||||
payload_json: "{\"status\":\"completed\"}".to_string(),
|
||||
})
|
||||
@@ -4639,8 +4652,8 @@ mod tests {
|
||||
session_id: "sess_1".to_string(),
|
||||
run_id: "run_1".to_string(),
|
||||
title: None,
|
||||
profile: "reasonix".to_string(),
|
||||
acp_runtime: "reasonix".to_string(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: None,
|
||||
status: "completed".to_string(),
|
||||
runtime_json: "{\"status\":\"completed\"}".to_string(),
|
||||
@@ -4963,7 +4976,7 @@ mod tests {
|
||||
workspace_id: Some("ws_1".to_string()),
|
||||
session_id: "sess_1".to_string(),
|
||||
run_id: Some("run_2".to_string()),
|
||||
provider: "reasonix".to_string(),
|
||||
provider: "pi".to_string(),
|
||||
provider_session_id: None,
|
||||
tool_name: "fs.write".to_string(),
|
||||
allowed: false,
|
||||
|
||||
@@ -1125,7 +1125,7 @@ fn list_ai_agent_profile_access_rows(
|
||||
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'
|
||||
WHERE p.agent_id = 'pi'
|
||||
AND p.status = 'active'
|
||||
AND g.can_run = 1
|
||||
AND (
|
||||
@@ -2530,12 +2530,24 @@ impl ControlPlaneStore for TursoControlPlaneStore {
|
||||
}
|
||||
let conn = self.lock_conn()?;
|
||||
let now = now_text();
|
||||
// 退役 brand:历史 hermes agent_id 就地迁到 pi,避免 UNIQUE 下再插一套孤儿行。
|
||||
conn.execute(
|
||||
"UPDATE ai_agent_profiles
|
||||
SET agent_id = 'pi',
|
||||
display_name = CASE
|
||||
WHEN display_name = '我的 Hermes' THEN '我的 Pi'
|
||||
ELSE display_name
|
||||
END,
|
||||
updated_at = ?1
|
||||
WHERE agent_id = 'hermes'",
|
||||
params![now],
|
||||
)?;
|
||||
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)
|
||||
VALUES ('shared_lite', 'pi', '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],
|
||||
@@ -2621,7 +2633,7 @@ impl ControlPlaneStore for TursoControlPlaneStore {
|
||||
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)
|
||||
VALUES (?1, 'pi', '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",
|
||||
@@ -2649,9 +2661,10 @@ impl ControlPlaneStore for TursoControlPlaneStore {
|
||||
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)
|
||||
VALUES (?1, 'pi', 'personal', ?2, 'default', ?3, '我的 Pi', '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",
|
||||
DO UPDATE SET status = 'active', display_name = excluded.display_name,
|
||||
isolated_profile_name = excluded.isolated_profile_name, 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");
|
||||
@@ -4891,8 +4904,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: Some("trace_1".to_string()),
|
||||
status: "running".to_string(),
|
||||
runtime_json: "{\"status\":\"running\"}".to_string(),
|
||||
@@ -4910,8 +4923,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: Some("trace_2".to_string()),
|
||||
status: "completed".to_string(),
|
||||
runtime_json: "{\"status\":\"completed\"}".to_string(),
|
||||
@@ -4941,8 +4954,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
event_type: "message.delta".to_string(),
|
||||
payload_json: "{\"text\":\"hello\"}".to_string(),
|
||||
})
|
||||
@@ -4956,8 +4969,8 @@ mod tests {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
event_type: "run.completed".to_string(),
|
||||
payload_json: "{\"status\":\"completed\"}".to_string(),
|
||||
})
|
||||
@@ -5005,8 +5018,8 @@ mod tests {
|
||||
session_id: "sess_1".to_string(),
|
||||
run_id: "run_1".to_string(),
|
||||
title: None,
|
||||
profile: "reasonix".to_string(),
|
||||
acp_runtime: "reasonix".to_string(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: None,
|
||||
status: "completed".to_string(),
|
||||
runtime_json: "{\"status\":\"completed\"}".to_string(),
|
||||
@@ -5471,7 +5484,7 @@ mod tests {
|
||||
workspace_id: Some("ws_1".to_string()),
|
||||
session_id: "sess_1".to_string(),
|
||||
run_id: Some("run_2".to_string()),
|
||||
provider: "reasonix".to_string(),
|
||||
provider: "pi".to_string(),
|
||||
provider_session_id: None,
|
||||
tool_name: "fs.write".to_string(),
|
||||
allowed: false,
|
||||
|
||||
@@ -79,8 +79,8 @@ fn libsql_local_store_covers_control_plane_core_flows() {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: Some("trace_1".to_string()),
|
||||
status: "running".to_string(),
|
||||
runtime_json: "{\"status\":\"running\"}".to_string(),
|
||||
@@ -97,8 +97,8 @@ fn libsql_local_store_covers_control_plane_core_flows() {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
event_type: "message.delta".to_string(),
|
||||
payload_json: "{\"text\":\"hello\"}".to_string(),
|
||||
})
|
||||
@@ -181,8 +181,8 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
trace_id: None,
|
||||
status: "running".to_string(),
|
||||
runtime_json: "{}".to_string(),
|
||||
@@ -231,8 +231,8 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
|
||||
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(),
|
||||
profile: "pi".to_string(),
|
||||
acp_runtime: "pi".to_string(),
|
||||
event_type: "message.delta".to_string(),
|
||||
payload_json: format!("{{\"index\":{index}}}"),
|
||||
})
|
||||
|
||||
@@ -16,7 +16,8 @@ pub struct AiSession {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AiRuntimeOwner {
|
||||
RustWebHermes,
|
||||
/// mnote-web 上的 Pi Lab / agent-tools 运行时(历史名 RustWebHermes 已退役)。
|
||||
RustWebPi,
|
||||
CompatReactIsland,
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ pub struct DeleteBlock {
|
||||
/// 菜单项、快捷键、按钮的 enablement 通过 `when` 表达式对 context 求值得到。
|
||||
///
|
||||
/// 第一阶段支持 key 列表:
|
||||
/// - `workspace.sourceKind` — 工作区类型 ("local_folder" | "convex" | ...)
|
||||
/// - `workspace.sourceKind` — 工作区类型 ("local_folder")
|
||||
/// - `workspace.readonly` — bool,工作区是否只读
|
||||
/// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none")
|
||||
/// - `tree.selectionCount` — 当前选中行数 (i64)
|
||||
|
||||
@@ -53,7 +53,6 @@ pub enum KernelProjectionKind {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceSourceKind {
|
||||
LocalFolder,
|
||||
ConvexWorkspace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -897,8 +896,8 @@ mod tests {
|
||||
#[test]
|
||||
fn workspace_source_serializes_minimal_command_source_contract() {
|
||||
let source = WorkspaceSource {
|
||||
source_kind: WorkspaceSourceKind::ConvexWorkspace,
|
||||
root_uri: "convex://workspace/ws_1".into(),
|
||||
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: "file:///tmp/ws_1".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
capabilities: vec![
|
||||
WorkspaceSourceCapability::LoadSnapshot,
|
||||
@@ -908,8 +907,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&source).expect("workspace source 应可序列化");
|
||||
assert_eq!(value["sourceKind"], json!("convex_workspace"));
|
||||
assert_eq!(value["rootUri"], json!("convex://workspace/ws_1"));
|
||||
assert_eq!(value["sourceKind"], json!("local_folder"));
|
||||
assert_eq!(value["rootUri"], json!("file:///tmp/ws_1"));
|
||||
assert_eq!(value["workspaceId"], json!("ws_1"));
|
||||
assert_eq!(
|
||||
value["capabilities"],
|
||||
@@ -1026,8 +1025,8 @@ mod tests {
|
||||
}
|
||||
let value = serde_json::json!({
|
||||
"workspaceId": "ws_1",
|
||||
"sourceKind": "convex_workspace",
|
||||
"rootUri": "convex://ws",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///tmp/ws",
|
||||
"relativePath": "doc.md",
|
||||
"objectIdentity": {
|
||||
"objectKind": "page",
|
||||
|
||||
@@ -589,7 +589,7 @@ pub fn plan_page_title(
|
||||
) -> CliResult<CliJsonOutput> {
|
||||
let bridge = build_bridge_context(ctx, workspace_id, "page", "title", page_id);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.title.update".into(),
|
||||
name: "tree.node.rename".into(),
|
||||
command_id: build_command_id("page", "title", page_id),
|
||||
idempotency_key: bridge.idempotency_key.clone(),
|
||||
actor: build_actor_payload(ctx),
|
||||
@@ -644,7 +644,7 @@ pub fn plan_page_save(
|
||||
let content_value = parse_json_value(content_json, "content_json")?;
|
||||
let bridge = build_bridge_context(ctx, workspace_id, "page", "save", page_id);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.save".into(),
|
||||
name: "page.body.save".into(),
|
||||
command_id: build_command_id("page", "save", page_id),
|
||||
idempotency_key: bridge.idempotency_key.clone(),
|
||||
actor: build_actor_payload(ctx),
|
||||
@@ -699,7 +699,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul
|
||||
let content_value = parse_json_value(args.content_json, "content_json")?;
|
||||
let bridge = build_bridge_context(ctx, Some(args.workspace_id), "page", "create", args.page_id);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.create".into(),
|
||||
name: "tree.node.create".into(),
|
||||
command_id: build_command_id("page", "create", args.page_id),
|
||||
idempotency_key: bridge.idempotency_key.clone(),
|
||||
actor: build_actor_payload(ctx),
|
||||
@@ -755,7 +755,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul
|
||||
pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult<CliJsonOutput> {
|
||||
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "move", args.page_id);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.move".into(),
|
||||
name: "tree.subtree.move".into(),
|
||||
command_id: build_command_id("page", "move", args.page_id),
|
||||
idempotency_key: bridge.idempotency_key.clone(),
|
||||
actor: build_actor_payload(ctx),
|
||||
@@ -805,7 +805,7 @@ pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult<Cl
|
||||
pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResult<CliJsonOutput> {
|
||||
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "delete", args.page_id);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.delete".into(),
|
||||
name: "tree.node.archive".into(),
|
||||
command_id: build_command_id("page", "delete", args.page_id),
|
||||
idempotency_key: bridge.idempotency_key.clone(),
|
||||
actor: build_actor_payload(ctx),
|
||||
@@ -849,7 +849,7 @@ pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResul
|
||||
pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliResult<CliJsonOutput> {
|
||||
let bridge = build_bridge_context(ctx, args.workspace_id, "page", "restore", args.page_id);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.restore".into(),
|
||||
name: "tree.node.restore".into(),
|
||||
command_id: build_command_id("page", "restore", args.page_id),
|
||||
idempotency_key: bridge.idempotency_key.clone(),
|
||||
actor: build_actor_payload(ctx),
|
||||
@@ -1551,7 +1551,7 @@ fn execute_query(
|
||||
"documents.content.get"
|
||||
| "documents.meta.get"
|
||||
| "mindmaps.get"
|
||||
| "sidebar.dataset.list" => execute_retired_cloud_transport(name),
|
||||
| "sidebar.dataset.list" => reject_non_local_cli_transport(name),
|
||||
"search.documents" => execute_search_documents(transport, context, cli_ctx),
|
||||
other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))),
|
||||
}
|
||||
@@ -1565,13 +1565,13 @@ fn execute_command(
|
||||
cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
match name {
|
||||
"documents.create"
|
||||
| "documents.move"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.title.update"
|
||||
| "documents.save"
|
||||
| "mindmaps.put" => execute_retired_cloud_transport(name),
|
||||
"tree.node.create"
|
||||
| "tree.subtree.move"
|
||||
| "tree.node.archive"
|
||||
| "tree.node.restore"
|
||||
| "tree.node.rename"
|
||||
| "page.body.save"
|
||||
| "mindmaps.put" => reject_non_local_cli_transport(name),
|
||||
"insert_block" => execute_block_insert(normalized_input, context, cli_ctx),
|
||||
"blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx),
|
||||
"blocks.move" => execute_block_move(normalized_input, cli_ctx),
|
||||
@@ -1650,7 +1650,7 @@ fn execute_search_documents(
|
||||
_context: &CliOutputContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("search.documents")
|
||||
reject_non_local_cli_transport("search.documents")
|
||||
}
|
||||
|
||||
fn execute_block_insert(
|
||||
@@ -1658,7 +1658,7 @@ fn execute_block_insert(
|
||||
_context: &CliOutputContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("insert_block")
|
||||
reject_non_local_cli_transport("insert_block")
|
||||
}
|
||||
|
||||
fn execute_block_patch(
|
||||
@@ -1666,15 +1666,15 @@ fn execute_block_patch(
|
||||
_transport: &CliTransportPlan,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("blocks.patch")
|
||||
reject_non_local_cli_transport("blocks.patch")
|
||||
}
|
||||
|
||||
fn execute_block_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("blocks.move")
|
||||
reject_non_local_cli_transport("blocks.move")
|
||||
}
|
||||
|
||||
fn execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("blocks.embed")
|
||||
reject_non_local_cli_transport("blocks.embed")
|
||||
}
|
||||
|
||||
fn build_tool_target(
|
||||
@@ -1735,7 +1735,7 @@ fn load_tool_data(
|
||||
"image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })),
|
||||
"doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => {
|
||||
let _ = read_doc_target(args, target)?;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
Err(non_local_cli_transport_error(tool_name))
|
||||
}
|
||||
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
|
||||
let document_id = read_doc_target(args, target)?;
|
||||
@@ -1745,7 +1745,7 @@ fn load_tool_data(
|
||||
.or_else(|| target.and_then(|value| value.block_id.as_deref()))
|
||||
.ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?;
|
||||
let _ = (document_id, mindmap_id);
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
Err(non_local_cli_transport_error(tool_name))
|
||||
}
|
||||
other => Err(CliError::validation(format!(
|
||||
"暂不支持 tool 数据加载: {other}"
|
||||
@@ -1753,13 +1753,13 @@ fn load_tool_data(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_retired_cloud_transport(operation: &str) -> CliResult<Value> {
|
||||
Err(retired_cloud_transport_error(operation))
|
||||
fn reject_non_local_cli_transport(operation: &str) -> CliResult<Value> {
|
||||
Err(non_local_cli_transport_error(operation))
|
||||
}
|
||||
|
||||
fn retired_cloud_transport_error(operation: &str) -> CliError {
|
||||
fn non_local_cli_transport_error(operation: &str) -> CliError {
|
||||
CliError::validation(format!(
|
||||
"旧 Convex CLI 执行链已退役: {operation};请改用 local-first Rust/SQLite control-plane 路径"
|
||||
"非 local_folder 的 CLI 执行链已移除: {operation};请使用 local-first local_folder 路径"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2100,10 +2100,10 @@ mod tests {
|
||||
transport,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "documents.save");
|
||||
assert_eq!(name, "page.body.save");
|
||||
assert_eq!(command_id, "cmd_page_save_page_1");
|
||||
assert_eq!(transport.kind, "runtime_command_plan");
|
||||
assert_eq!(transport.function_name, "documents.save");
|
||||
assert_eq!(transport.function_name, "page.body.save");
|
||||
assert_eq!(
|
||||
transport.args_json,
|
||||
json!({
|
||||
@@ -2149,9 +2149,9 @@ mod tests {
|
||||
CliOperationOutput::Command {
|
||||
name, transport, ..
|
||||
} => {
|
||||
assert_eq!(name, "documents.create");
|
||||
assert_eq!(name, "tree.node.create");
|
||||
assert_eq!(transport.kind, "runtime_command_plan");
|
||||
assert_eq!(transport.function_name, "documents.create");
|
||||
assert_eq!(transport.function_name, "tree.node.create");
|
||||
assert_eq!(transport.args_json["workspaceId"], json!("ws_1"));
|
||||
assert_eq!(transport.args_json["parentId"], json!("parent_1"));
|
||||
}
|
||||
@@ -2160,7 +2160,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_move_json_contract_uses_documents_move() {
|
||||
fn page_move_json_contract_uses_tree_subtree_move() {
|
||||
let output = plan_page_move(
|
||||
&CliContext::default(),
|
||||
&PageMoveArgs {
|
||||
@@ -2176,9 +2176,9 @@ mod tests {
|
||||
CliOperationOutput::Command {
|
||||
name, transport, ..
|
||||
} => {
|
||||
assert_eq!(name, "documents.move");
|
||||
assert_eq!(name, "tree.subtree.move");
|
||||
assert_eq!(transport.kind, "runtime_command_plan");
|
||||
assert_eq!(transport.function_name, "documents.move");
|
||||
assert_eq!(transport.function_name, "tree.subtree.move");
|
||||
assert_eq!(transport.args_json["sortOrder"], json!(3));
|
||||
}
|
||||
_ => panic!("expected command output"),
|
||||
|
||||
@@ -806,7 +806,7 @@ import {
|
||||
? urlFromCaller
|
||||
: new URL(`/documents/${encodeURIComponent(descriptor.documentId)}`, window.location.origin);
|
||||
if (!url.searchParams.get('workspaceId') && descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId);
|
||||
if (descriptor.sourceKind && descriptor.sourceKind !== 'convex_workspace') url.searchParams.set('sourceKind', descriptor.sourceKind);
|
||||
if (descriptor.sourceKind) url.searchParams.set('sourceKind', descriptor.sourceKind);
|
||||
if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri);
|
||||
pushUrlState(url);
|
||||
};
|
||||
|
||||
@@ -468,7 +468,7 @@ async function uploadLocalFolderAsset(file, plan, context) {
|
||||
|
||||
async function uploadMediaAsset(file, plan, context) {
|
||||
document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true');
|
||||
throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。');
|
||||
throw new Error('仅支持本地文件夹上传。');
|
||||
}
|
||||
|
||||
async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
isFileTreePageRow,
|
||||
localFilePathFromAssetId,
|
||||
normalizeFileTreePageRenameTitle,
|
||||
openConvexAssetFromFileTree,
|
||||
openLocalAssetFromFileTree,
|
||||
openEditorAttachmentDetail,
|
||||
openEditorAttachmentDownload,
|
||||
openEditorAttachmentEditTab,
|
||||
@@ -592,19 +592,19 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
}
|
||||
if (isAsset && action === 'new-window') {
|
||||
recordFileTreeAction('new-window', detail);
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
|
||||
void openLocalAssetFromFileTree({ ...detail, openTarget: 'new-window' });
|
||||
return;
|
||||
}
|
||||
if (isAsset && action === 'open-edit-mode') {
|
||||
withOfficeEditModeGuard(async function() {
|
||||
recordFileTreeAction('open-edit-mode', detail);
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' });
|
||||
void openLocalAssetFromFileTree({ ...detail, openTarget: 'edit-mode' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isAsset && action === 'open-right') {
|
||||
recordFileTreeAction('open-right', detail);
|
||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'side' });
|
||||
void openLocalAssetFromFileTree({ ...detail, openTarget: 'side' });
|
||||
return;
|
||||
}
|
||||
if (action === 'open-right') {
|
||||
|
||||
@@ -381,7 +381,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function openConvexAssetFromFileTree(detail) {
|
||||
async function openLocalAssetFromFileTree(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (!assetId) return;
|
||||
var openTarget = String(detail && detail.openTarget || '').trim().toLowerCase();
|
||||
@@ -520,11 +520,11 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
return;
|
||||
}
|
||||
document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
|
||||
await window.mnote.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。');
|
||||
await window.mnote.alert('仅支持本地文件夹附件;请通过 local_folder 资源打开。');
|
||||
}
|
||||
|
||||
window.addEventListener('tree.asset.open', function(event) {
|
||||
void openConvexAssetFromFileTree(event.detail || {});
|
||||
void openLocalAssetFromFileTree(event.detail || {});
|
||||
});
|
||||
|
||||
|
||||
@@ -541,7 +541,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
isMindmapAssetDetail,
|
||||
localFilePathFromAssetId,
|
||||
navigateToMindmapObject,
|
||||
openConvexAssetFromFileTree,
|
||||
openLocalAssetFromFileTree,
|
||||
openLocalOfficeFileInActiveTab,
|
||||
openLocalResourceInActiveTab,
|
||||
readFileTreeObjectIdentity,
|
||||
|
||||
@@ -1073,7 +1073,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const openLocalResourceInActiveTab = (...args) => sidebarFileTreeOpen.openLocalResourceInActiveTab(...args);
|
||||
const readFileTreeObjectIdentity = (...args) => sidebarFileTreeOpen.readFileTreeObjectIdentity(...args);
|
||||
const fetchCurrentOnlyOfficeUserId = (...args) => sidebarFileTreeOpen.fetchCurrentOnlyOfficeUserId(...args);
|
||||
const openConvexAssetFromFileTree = (...args) => sidebarFileTreeOpen.openConvexAssetFromFileTree(...args);
|
||||
const openLocalAssetFromFileTree = (...args) => sidebarFileTreeOpen.openLocalAssetFromFileTree(...args);
|
||||
|
||||
var sidebarFileTreeUpload = null;
|
||||
const fileTreeRowsForUploadPreflight = (...args) => sidebarFileTreeUpload.fileTreeRowsForUploadPreflight(...args);
|
||||
@@ -2071,7 +2071,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
}
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true');
|
||||
throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。');
|
||||
throw new Error('仅支持本地文件夹上传。');
|
||||
}
|
||||
appendUploadedAssetRow(payload.asset, plan.targetDocumentId);
|
||||
if (options && options.insertIntoEditor) {
|
||||
@@ -2219,7 +2219,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
isFileTreePageRow,
|
||||
localFilePathFromAssetId,
|
||||
normalizeFileTreePageRenameTitle,
|
||||
openConvexAssetFromFileTree,
|
||||
openLocalAssetFromFileTree,
|
||||
openEditorAttachmentDetail: (...args) => openEditorAttachmentDetail(...args),
|
||||
openEditorAttachmentDownload: (...args) => openEditorAttachmentDownload(...args),
|
||||
openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args),
|
||||
|
||||
@@ -223,8 +223,6 @@
|
||||
|
||||
// Prefer WebSocket transport when available
|
||||
var requestedTransport = bootstrap.transport || 'tree-live-sse';
|
||||
if (requestedTransport === 'convex-command-log-sse') requestedTransport = 'tree-live-sse';
|
||||
if (requestedTransport === 'convex-command-log-ws') requestedTransport = 'tree-live-ws';
|
||||
|
||||
var preferWs = requestedTransport === 'tree-live-ws' && 'WebSocket' in window;
|
||||
if (preferWs) {
|
||||
|
||||
@@ -37,7 +37,6 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) {
|
||||
const hasDocument = Boolean(getFileTreeRowDocumentId(item));
|
||||
const hasAsset = Boolean(getFileTreeRowAssetId(item));
|
||||
const localSource = context.sourceKind === "local_folder";
|
||||
const convexSource = context.sourceKind === "convex_workspace";
|
||||
const canCreateFolder = localSource;
|
||||
const canCreatePage = rowKind === "root" || rowKind === "folder" || rowKind === "document";
|
||||
const canRename =
|
||||
@@ -63,11 +62,11 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) {
|
||||
}),
|
||||
createFileTreeMenuItem("newFolder", "新建文件夹", {
|
||||
disabled: !canCreateFolder,
|
||||
reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||
reason: "仅 local_folder 支持新建文件夹",
|
||||
}),
|
||||
createFileTreeMenuItem("upload", "上传/导入", {
|
||||
disabled: true,
|
||||
reason: localSource ? "外部文件请拖入 Explorer" : "legacy cloud 上传 executor 尚未接入",
|
||||
reason: localSource ? "外部文件请拖入 Explorer" : "仅 local_folder 支持上传",
|
||||
}),
|
||||
createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }),
|
||||
createFileTreeMenuItem("collapseAll", "全部折叠"),
|
||||
@@ -95,7 +94,7 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) {
|
||||
}));
|
||||
items.push(createFileTreeMenuItem("newFolder", "新建文件夹", {
|
||||
disabled: !canCreateFolder,
|
||||
reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||
reason: "仅 local_folder 支持新建文件夹",
|
||||
}));
|
||||
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
||||
disabled: !canPaste,
|
||||
|
||||
@@ -765,21 +765,7 @@ function startTreeShellRuntime() {
|
||||
scheduleRefresh();
|
||||
return true;
|
||||
}
|
||||
if (sourceKind === "convex_workspace") {
|
||||
for (const item of deletableItems) {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
await sendCommand({
|
||||
action: "delete",
|
||||
workspaceId,
|
||||
documentId,
|
||||
});
|
||||
applyRemovedDocumentLocally(documentId);
|
||||
}
|
||||
if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId))) {
|
||||
scheduleRefresh();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// non-local sources removed; only local_folder path above
|
||||
postToHost("tree.filetree.delete", {
|
||||
workspaceId,
|
||||
rowIds: deletableItems.map((item) => item.rowId),
|
||||
@@ -823,21 +809,8 @@ function startTreeShellRuntime() {
|
||||
copy: fileTreeClipboard.action === "copy",
|
||||
});
|
||||
if (!accepted) return;
|
||||
if (sourceKind === "convex_workspace") {
|
||||
const sourceRowIds = filterRedundantFileTreeRowIds(fileTreeClipboard.rowIds);
|
||||
for (const rowId of sourceRowIds) {
|
||||
const sourceItem = fileTreeRowById.get(rowId);
|
||||
const documentId = getFileTreeRowDocumentId(sourceItem);
|
||||
if (!documentId) continue;
|
||||
await sendCommand({
|
||||
action: fileTreeClipboard.action === "copy" ? "copy" : "move",
|
||||
workspaceId,
|
||||
documentId,
|
||||
parentId: target.documentId || null,
|
||||
targetParentId: target.documentId || null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
}
|
||||
if (false) {
|
||||
// paste: local_folder only
|
||||
if (fileTreeClipboard.action === "cut") {
|
||||
fileTreeClipboard = { action: null, rowIds: [] };
|
||||
}
|
||||
@@ -1944,7 +1917,7 @@ function startTreeShellRuntime() {
|
||||
target: { documentId },
|
||||
payload: { documentId },
|
||||
});
|
||||
if (sourceKind === "convex_workspace" && applyCreatedDocumentLocally(result, parentId, title)) {
|
||||
if (sourceKind === "local_folder" && applyCreatedDocumentLocally(result, parentId, title)) {
|
||||
if (mode === "filetree") {
|
||||
beginInlineRename("filetree", `doc:${documentId}`);
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{error, warn};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
@@ -27,17 +27,11 @@ pub struct AppConfig {
|
||||
pub service_version: String,
|
||||
pub bind_addr: String,
|
||||
pub public_bind_addr: String,
|
||||
pub legacy_next_base_url: Option<String>,
|
||||
pub enable_legacy_next_compat: bool,
|
||||
pub enable_debug_shell_routes: bool,
|
||||
pub enable_editor_actor: bool,
|
||||
pub enable_page_ai_pi_lab: bool,
|
||||
pub compat_next_base_path: String,
|
||||
pub convex_url: Option<String>,
|
||||
pub convex_admin_key: Option<String>,
|
||||
|
||||
pub allow_dev_fixtures: bool,
|
||||
pub query_fixtures_json: Option<String>,
|
||||
pub mutation_fixtures_json: Option<String>,
|
||||
pub dev_user_id: String,
|
||||
pub dev_user_name: String,
|
||||
pub dev_user_email: String,
|
||||
@@ -56,33 +50,17 @@ impl AppConfig {
|
||||
.unwrap_or_else(|_| "127.0.0.1:0".into()),
|
||||
public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND")
|
||||
.unwrap_or_else(|_| "127.0.0.1:3000".into()),
|
||||
legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL")
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false),
|
||||
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false),
|
||||
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
|
||||
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
|
||||
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
||||
.unwrap_or_else(|_| "/api/compat/next".into()),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false),
|
||||
query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
mutation_fixtures_json: env::var("MNOTE_WEB_MUTATION_FIXTURES_JSON")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
dev_user_id: env::var("DEV_USER_ID")
|
||||
.ok()
|
||||
.or_else(|| read_env_or_dotenv("DEV_USER_ID"))
|
||||
@@ -367,15 +345,6 @@ async fn log_failed_response(request: Request, next: Next) -> Response {
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if error_code == "convex_retired" {
|
||||
warn!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status,
|
||||
error_code = %error_code,
|
||||
"退役 legacy cloud/Convex 兼容路径被请求"
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
@@ -384,7 +353,6 @@ async fn log_failed_response(request: Request, next: Next) -> Response {
|
||||
"mnote-web 请求返回服务端错误"
|
||||
);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
@@ -399,17 +367,11 @@ mod tests {
|
||||
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: true,
|
||||
enable_page_ai_pi_lab: true,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 职责:
|
||||
//! - 持有 per-document `EditorBlockDocument` 内存态
|
||||
//! - 接收 EditorCommand,在内存中 apply,产生 diff
|
||||
//! - Convex 持久化仍由 block.rs 通过 execute_page_body_save 完成
|
||||
//! - 正文持久化由 block.rs 通过 execute_page_body_save 完成(local-first)
|
||||
//!
|
||||
//! 不是 agent runtime:不维护会话、不做意图解析、不调模型(遵从 7-12 禁止项)。
|
||||
|
||||
@@ -270,7 +270,7 @@ impl EditorRuntimeActor {
|
||||
Ok((content, delta))
|
||||
}
|
||||
|
||||
/// 从内存态生成 legacy content(用于构建 Convex save payload)。
|
||||
/// 从内存态生成 legacy content(用于构建 save payload)。
|
||||
pub fn legacy_content_for_save(&self, document_id: &str) -> Result<Value, WebError> {
|
||||
let documents = self
|
||||
.documents
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::api_access_token::{bearer_mnpat1, verify_pat_token};
|
||||
use crate::routes::vault::{bearer_mnv1, verify_agent_vault_token};
|
||||
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
|
||||
use axum::extract::Request;
|
||||
use axum::middleware::Next;
|
||||
@@ -10,6 +11,7 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons
|
||||
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
|
||||
|
||||
// 7-76:Bearer mnpat1.* 优先于 cookie(PAT 请求忽略 cookie 并权)。
|
||||
// 注意:PAT 不能访问密码箱数据面(vault.rs require_authenticated 会拒绝)。
|
||||
if let Some(token) = bearer_mnpat1(context.auth.authorization.as_deref()) {
|
||||
if let Ok(verified) = verify_pat_token(token) {
|
||||
if let Some(actor) = crate::context::stable_actor_id(&verified.subject_user_id) {
|
||||
@@ -29,9 +31,29 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons
|
||||
}
|
||||
}
|
||||
|
||||
// 12-3 E2: Authorization Bearer mnext1.* → actor(仅当尚未 PAT / 仍 anonymous)
|
||||
// 7-76 / 12-2:Bearer mnv1.* → agent vault capability(读密 list/get/resolve…)
|
||||
// 仅当尚未被 mnpat1 绑定(避免 PAT 伪装成 vault token 混权)。
|
||||
if context.auth.auth_method != "pat"
|
||||
&& (context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty())
|
||||
{
|
||||
if let Some(token) = bearer_mnv1(context.auth.authorization.as_deref()) {
|
||||
if let Ok(claims) = verify_agent_vault_token(token) {
|
||||
if let Some(actor) = crate::context::stable_actor_id(&claims.actor) {
|
||||
context.auth.actor_id = actor;
|
||||
context.auth.actor_type = "ai_service".into();
|
||||
context.auth.auth_method = "vault_token".into();
|
||||
context.auth.scopes = claims.scope.clone();
|
||||
context.auth.session_id = Some(format!("mnv1:{}", claims.jti));
|
||||
context.auth.cookie_header = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 12-3 E2: Authorization Bearer mnext1.* → actor(仅当尚未 PAT / vault / 仍 anonymous)
|
||||
if context.auth.auth_method != "pat"
|
||||
&& context.auth.auth_method != "vault_token"
|
||||
&& (context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty())
|
||||
{
|
||||
if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) {
|
||||
if let Ok(claims) = verify_extension_token(token) {
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts;
|
||||
use crate::routes::ensure_local_workspace_access;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -230,7 +230,7 @@ async fn create_artifact_node(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -247,9 +247,9 @@ async fn create_artifact_node(
|
||||
"to": artifact_document_id,
|
||||
"kind": "ai_artifact_reference"
|
||||
},
|
||||
"result": execution.result,
|
||||
"artifacts": execution.artifacts,
|
||||
"artifactError": execution.artifact_error
|
||||
"result": execution,
|
||||
"artifacts": None::<serde_json::Value>,
|
||||
"artifactError": None::<String>
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::mnote_agent_tools::doc::{
|
||||
required_arg,
|
||||
};
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts;
|
||||
use bridge_runtime::{
|
||||
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -1155,7 +1155,7 @@ async fn execute_page_body_save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -1166,9 +1166,9 @@ async fn execute_page_body_save(
|
||||
"commandName": "page.body.save",
|
||||
"commandId": command_id,
|
||||
"changedBlocks": changed_blocks,
|
||||
"result": execution.result,
|
||||
"artifacts": execution.artifacts,
|
||||
"artifactError": execution.artifact_error
|
||||
"result": execution,
|
||||
"artifacts": None::<serde_json::Value>,
|
||||
"artifactError": None::<String>
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1229,7 +1229,7 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -1240,9 +1240,9 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
|
||||
"commandName": "page.body.save",
|
||||
"commandId": command_id,
|
||||
"changedBlocks": changed_blocks,
|
||||
"result": execution.result,
|
||||
"artifacts": execution.artifacts,
|
||||
"artifactError": execution.artifact_error
|
||||
"result": execution,
|
||||
"artifacts": None::<serde_json::Value>,
|
||||
"artifactError": None::<String>
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ pub async fn doc_fetch(
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
|
||||
// 本地文件路径检测:直接读取授权 root 内的 .md 文件,不经过 Convex。
|
||||
// 本地文件路径检测:直接读取授权 root 内的 .md 文件。
|
||||
let is_local_file = document_id.starts_with('/')
|
||||
|| document_id.starts_with("./")
|
||||
|| document_id.starts_with("file://");
|
||||
@@ -253,7 +253,7 @@ pub async fn doc_fetch(
|
||||
{
|
||||
"local_fs"
|
||||
} else {
|
||||
"convex"
|
||||
"local_folder"
|
||||
};
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
@@ -1637,11 +1637,7 @@ pub async fn doc_markdown_edit(
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
(
|
||||
blocks_to_markdown(&blocks, true),
|
||||
if is_local_workspace {
|
||||
"local_folder"
|
||||
} else {
|
||||
"convex"
|
||||
},
|
||||
"local_folder",
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1749,7 +1745,7 @@ pub async fn doc_markdown_edit(
|
||||
String::from("无操作已应用")
|
||||
};
|
||||
|
||||
// 5. 写回(本地文件直接 fs::write,Convex 文档通过 block ops apply)
|
||||
// 5. 写回(本地文件直接 fs::write;否则走 local page body / block ops)
|
||||
let apply_result = if let Some(ref path) = local_file_path {
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
json!({"written": false, "dryRun": true, "path": path.display().to_string()})
|
||||
@@ -1900,7 +1896,7 @@ pub async fn doc_markdown_edit(
|
||||
validate_only: false,
|
||||
};
|
||||
let execution =
|
||||
crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
crate::routes::command_support::reject_non_local_runtime_command_with_artifacts(
|
||||
state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -1911,9 +1907,9 @@ pub async fn doc_markdown_edit(
|
||||
"commandName": "page.body.save",
|
||||
"commandId": command_id,
|
||||
"changedBlocks": changed_blocks,
|
||||
"result": execution.result,
|
||||
"artifacts": execution.artifacts,
|
||||
"artifactError": execution.artifact_error
|
||||
"result": execution,
|
||||
"artifacts": None::<serde_json::Value>,
|
||||
"artifactError": None::<String>
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::mnote_agent_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -306,7 +306,7 @@ async fn page_command(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -316,9 +316,9 @@ async fn page_command(
|
||||
let mut result = json!({
|
||||
"commandName": command_name,
|
||||
"commandId": command_id,
|
||||
"result": execution.result,
|
||||
"artifacts": execution.artifacts,
|
||||
"artifactError": execution.artifact_error
|
||||
"result": execution,
|
||||
"artifacts": None::<serde_json::Value>,
|
||||
"artifactError": None::<String>
|
||||
});
|
||||
merge_result_extra(&mut result, result_extra);
|
||||
Ok(result)
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
reject_non_local_runtime_query, resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
@@ -89,7 +89,7 @@ async fn execute_bridge_query(
|
||||
workspace_id: &str,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await
|
||||
reject_non_local_runtime_query(config, context, Some(workspace_id), query).await
|
||||
}
|
||||
|
||||
fn require_resolved_workspace_id(
|
||||
@@ -184,17 +184,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::legacy_cloud_guard::{
|
||||
execute_retired_command_plan, execute_retired_command_plan_with_artifacts,
|
||||
RetiredCloudCommandExecution,
|
||||
};
|
||||
use crate::transport::local_only::local_required;
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||
RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn runtime_context(
|
||||
@@ -64,57 +60,24 @@ pub fn build_runtime_command_plan(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
/// 非 local_folder 的 runtime command 一律拒绝(无 cloud 执行链)。
|
||||
pub async fn reject_non_local_runtime_command(
|
||||
_config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
_effective_workspace_id: Option<&str>,
|
||||
_command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_command_plan(context, effective_workspace_id, command)?;
|
||||
execute_retired_command_plan(config, context, &plan).await
|
||||
Err(local_required(context, "command_local_required"))
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
state: &AppState,
|
||||
/// 历史 with_artifacts 形态;永远 local_required,无产物/无 cloud 执行。
|
||||
pub async fn reject_non_local_runtime_command_with_artifacts(
|
||||
_state: &AppState,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<RetiredCloudCommandExecution, WebError> {
|
||||
let runtime_context = runtime_context(context, effective_workspace_id);
|
||||
let runtime_input = RuntimeInput::Command {
|
||||
context: runtime_context.clone(),
|
||||
command: command.clone(),
|
||||
};
|
||||
let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(runtime_input)
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))?
|
||||
else {
|
||||
return Err(WebError::internal("runtime command 未返回 command plan").with_context(context));
|
||||
};
|
||||
|
||||
let execution = execute_retired_command_plan_with_artifacts(
|
||||
state.config(),
|
||||
context,
|
||||
&runtime_context,
|
||||
&command,
|
||||
&plan,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Push stream delta notification via broadcast for WebSocket/SSE push consumers
|
||||
let workspace_id = effective_workspace_id
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| context.workspace.workspace_id.clone());
|
||||
let delta = json!({
|
||||
"kind": "command_committed",
|
||||
"commandName": command.name,
|
||||
"commandId": command.command_id,
|
||||
"workspaceId": workspace_id,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
});
|
||||
let _ = state.stream_delta_tx.send(delta);
|
||||
|
||||
Ok(execution)
|
||||
_effective_workspace_id: Option<&str>,
|
||||
_command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
Err(local_required(context, "command_local_required"))
|
||||
}
|
||||
|
||||
pub fn build_tree_target(
|
||||
|
||||
@@ -59,32 +59,41 @@ mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
fn test_config() -> AppConfig {
|
||||
AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[],"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[],"domain_events":[],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
environment: "dev".into(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(test_config()))
|
||||
}
|
||||
|
||||
async fn post_ai_agent_run(body: &'static str) -> axum::http::Response<Body> {
|
||||
build_app(AppState::new(test_config()))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai-agent/run")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -104,37 +113,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_ai_agent_run_returns_legacy_retired_guard() {
|
||||
let response = build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
environment: "dev".into(),
|
||||
}))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai-agent/run")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"online"}}}"#))
|
||||
.expect("request"),
|
||||
let response = post_ai_agent_run(
|
||||
r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"online"}}}"#,
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
assert_eq!(
|
||||
@@ -161,37 +143,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_agent_provider_returns_legacy_retired_guard() {
|
||||
let response = build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:9".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
environment: "dev".into(),
|
||||
}))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai-agent/run")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"codex"}}}"#))
|
||||
.expect("request"),
|
||||
let response = post_ai_agent_run(
|
||||
r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"codex"}}}"#,
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
assert_eq!(
|
||||
@@ -209,58 +164,13 @@ mod tests {
|
||||
assert!(text.contains("legacy_ai_agent_run_retired"));
|
||||
}
|
||||
|
||||
/// 退役 brand provider 必须 410,且不得静默落到任何旁路执行。
|
||||
#[tokio::test]
|
||||
async fn explicit_retired_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() {
|
||||
let next_app = axum::Router::new().route(
|
||||
"/api/ai-agent/run",
|
||||
axum::routing::post(|| async move {
|
||||
(
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/event-stream; charset=utf-8",
|
||||
)],
|
||||
"event: assistant_message\ndata: {\"text\":\"hello from next ai\"}\n\n",
|
||||
async fn explicit_retired_hermes_provider_returns_gone() {
|
||||
let response = post_ai_agent_run(
|
||||
r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"hermes"}}}"#,
|
||||
)
|
||||
.into_response()
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind next");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, next_app).await.expect("serve next");
|
||||
});
|
||||
|
||||
let response = build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some(format!("http://{}", addr)),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
environment: "dev".into(),
|
||||
}))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/ai-agent/run")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"hermes"}}}"#))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::GONE);
|
||||
assert_eq!(
|
||||
@@ -275,8 +185,7 @@ mod tests {
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("hermes"));
|
||||
assert!(!text.contains("hello from next ai"));
|
||||
|
||||
server.abort();
|
||||
assert!(text.contains("legacy_ai_agent_run_retired"));
|
||||
assert!(text.contains("Pi Lab"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,17 +518,11 @@ mod tests {
|
||||
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,
|
||||
enable_page_ai_pi_lab: false,
|
||||
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(),
|
||||
|
||||
@@ -3,14 +3,14 @@ use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self, BufferKey, BufferStore};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_legacy_cloud,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts,
|
||||
reject_non_local_runtime_command,
|
||||
reject_non_local_runtime_command_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
|
||||
reject_non_local_runtime_query, reject_non_local_documents_meta,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
@@ -22,8 +22,6 @@ use bridge_runtime::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -187,7 +185,6 @@ pub struct DocumentEmptyTrashRequest {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
|
||||
|
||||
@@ -207,14 +204,8 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(
|
||||
execution: &crate::transport::legacy_cloud_guard::RetiredCloudCommandExecution,
|
||||
) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
fn execution_artifacts_json(_execution: &Value) -> Value {
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn stamp_documents_headers(headers: &mut HeaderMap) {
|
||||
@@ -226,312 +217,6 @@ fn stamp_documents_headers(headers: &mut HeaderMap) {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
let trimmed = value.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../..")
|
||||
.join(".env.all");
|
||||
let content = fs::read_to_string(root).ok()?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.starts_with('#') || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if k.trim() != key {
|
||||
continue;
|
||||
}
|
||||
let trimmed = v.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn next_documents_base_url() -> String {
|
||||
read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV)
|
||||
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn should_proxy_via_next(_context: &RequestContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn build_next_proxy_headers(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
) -> reqwest::header::HeaderMap {
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
||||
let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(header_value) = HeaderValue::from_str(value) else {
|
||||
return;
|
||||
};
|
||||
headers.insert(header_name, header_value);
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(cookie) = context.auth.cookie_header.as_deref() {
|
||||
insert(&mut headers, "cookie", cookie);
|
||||
}
|
||||
if let Some(authorization) = context.auth.authorization.as_deref() {
|
||||
insert(&mut headers, "authorization", authorization);
|
||||
}
|
||||
insert(&mut headers, "x-request-id", &context.trace.request_id);
|
||||
insert(&mut headers, "x-trace-id", &context.trace.trace_id);
|
||||
insert(
|
||||
&mut headers,
|
||||
"x-mnote-source-channel",
|
||||
&context.source.channel,
|
||||
);
|
||||
insert(
|
||||
&mut headers,
|
||||
"x-mnote-source-client",
|
||||
&context.source.client,
|
||||
);
|
||||
insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id);
|
||||
insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type);
|
||||
if let Some(session_id) = context.auth.session_id.as_deref() {
|
||||
insert(&mut headers, "x-mnote-session-id", session_id);
|
||||
}
|
||||
if let Some(workspace_id) = effective_workspace_id {
|
||||
insert(&mut headers, "x-mnote-workspace-id", workspace_id);
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
async fn send_next_documents_request(
|
||||
context: &RequestContext,
|
||||
request: reqwest::RequestBuilder,
|
||||
phase: &'static str,
|
||||
) -> Result<Value, WebError> {
|
||||
let response = request.send().await.map_err(|error| {
|
||||
let base = if error.is_timeout() {
|
||||
WebError::gateway_timeout_code(
|
||||
"next_proxy_timeout",
|
||||
format!("Next compat 请求超时: {error}"),
|
||||
)
|
||||
} else {
|
||||
WebError::service_unavailable_code(
|
||||
"next_proxy_unavailable",
|
||||
format!("Next compat 请求失败: {error}"),
|
||||
)
|
||||
};
|
||||
base.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"next_proxy_bad_response",
|
||||
format!("Next compat 响应读取失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||
})?;
|
||||
|
||||
let payload = serde_json::from_str::<Value>(&text).map_err(|_| {
|
||||
let snippet: String = text.chars().take(180).collect();
|
||||
WebError::bad_gateway_code(
|
||||
"next_proxy_bad_response",
|
||||
format!("Next compat 返回了非 JSON 内容: {snippet}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
let message = payload
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| payload.get("message").and_then(Value::as_str))
|
||||
.unwrap_or("Next compat 文档接口请求失败");
|
||||
let web_error = match status {
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
|
||||
WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message)
|
||||
}
|
||||
reqwest::StatusCode::NOT_FOUND => {
|
||||
WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message)
|
||||
}
|
||||
_ => WebError::bad_gateway_code("next_proxy_error", message),
|
||||
};
|
||||
return Err(web_error
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "next")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string()));
|
||||
}
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn proxy_next_documents_meta(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let base_url = next_documents_base_url();
|
||||
let mut url =
|
||||
reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| {
|
||||
WebError::internal(format!("Next compat meta URL 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_meta_url")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
url.query_pairs_mut().append_pair("documentId", document_id);
|
||||
if let Some(workspace_id) = effective_workspace_id {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("workspaceId", workspace_id);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_meta_client")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let payload = send_next_documents_request(
|
||||
context,
|
||||
client
|
||||
.get(url)
|
||||
.headers(build_next_proxy_headers(context, effective_workspace_id)),
|
||||
"next_proxy_meta",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(payload.get("doc").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
async fn proxy_next_documents_content(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let base_url = next_documents_base_url();
|
||||
let mut url =
|
||||
reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| {
|
||||
WebError::internal(format!("Next compat content URL 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_content_url")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
url.query_pairs_mut().append_pair("documentId", document_id);
|
||||
if let Some(workspace_id) = effective_workspace_id {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("workspaceId", workspace_id);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_content_client")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let payload = send_next_documents_request(
|
||||
context,
|
||||
client
|
||||
.get(url)
|
||||
.headers(build_next_proxy_headers(context, effective_workspace_id)),
|
||||
"next_proxy_content",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json!({
|
||||
"content": payload.get("content").cloned().unwrap_or(Value::Null),
|
||||
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn proxy_next_documents_save(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
body: &DocumentSaveRequest,
|
||||
) -> Result<Value, WebError> {
|
||||
let base_url = next_documents_base_url();
|
||||
let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| {
|
||||
WebError::internal(format!("Next compat save URL 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_save_url")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "next_proxy_save_client")
|
||||
.with_header("x-upstream-service", "next")
|
||||
})?;
|
||||
|
||||
let payload = send_next_documents_request(
|
||||
context,
|
||||
client
|
||||
.post(url)
|
||||
.headers(build_next_proxy_headers(context, effective_workspace_id))
|
||||
.json(&json!({
|
||||
"documentId": body.document_id,
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"writeIntentId": body.write_intent_id,
|
||||
"saveOperationId": body.save_operation_id,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||
"blockCount": body.block_count,
|
||||
})),
|
||||
"next_proxy_save",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json!({
|
||||
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": payload
|
||||
.get("conflictDetectionKey")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null),
|
||||
"ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn load_document_meta_result(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
@@ -546,15 +231,7 @@ pub async fn load_document_meta_result(
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(context) {
|
||||
return proxy_next_documents_meta(
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
&document_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
fetch_documents_meta_via_legacy_cloud(
|
||||
reject_non_local_documents_meta(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -577,15 +254,7 @@ pub async fn load_document_content_result(
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(context) {
|
||||
return proxy_next_documents_content(
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
&document_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
execute_runtime_query_via_legacy_cloud(
|
||||
reject_non_local_runtime_query(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -789,11 +458,6 @@ pub async fn save(
|
||||
}
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||
if should_proxy_via_next(&context) {
|
||||
let result =
|
||||
proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "page.body.save".into(),
|
||||
command_id: format!("document_save_{}", context.trace.request_id),
|
||||
@@ -836,19 +500,19 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
let mut result = execution.result;
|
||||
let mut result = execution;
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert("executedCommand".into(), json!("page.body.save"));
|
||||
map.insert("canonicalCommand".into(), json!("page.body.save"));
|
||||
map.insert("compatRoute".into(), json!("/api/documents/save"));
|
||||
if let Some(artifact_error) = execution.artifact_error {
|
||||
if let Some(artifact_error) = None::<String> {
|
||||
map.insert("artifactError".into(), json!(artifact_error));
|
||||
}
|
||||
}
|
||||
@@ -868,7 +532,7 @@ pub async fn purge(
|
||||
);
|
||||
}
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "documents.purge".into(),
|
||||
name: "tree.node.purge".into(),
|
||||
command_id: format!("document_purge_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
@@ -893,13 +557,13 @@ pub async fn purge(
|
||||
"documentId": document_id,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web documents purge compat".into()),
|
||||
refs: vec!["mnote-web-documents-compat".into()],
|
||||
reason: Some("mnote-web tree node purge".into()),
|
||||
refs: vec!["mnote-web-tree-purge".into()],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let result =
|
||||
execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?;
|
||||
reject_non_local_runtime_command(state.config(), &context, None, command).await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -943,13 +607,13 @@ pub async fn empty_trash(
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web tree trash empty workspace".into()),
|
||||
refs: vec![
|
||||
"mnote-web-documents-trash-compat".into(),
|
||||
"mnote-web-tree-trash".into(),
|
||||
"tree.trash.emptyWorkspace".into(),
|
||||
],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
Some(workspace_id),
|
||||
@@ -957,8 +621,8 @@ pub async fn empty_trash(
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let mut result = execution.result;
|
||||
let artifact_error = None::<String>;
|
||||
let mut result = execution;
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert(
|
||||
"canonicalCommand".into(),
|
||||
@@ -979,7 +643,6 @@ pub async fn empty_trash(
|
||||
"meta": {
|
||||
"commandName": "tree.trash.emptyWorkspace",
|
||||
"canonicalCommand": "tree.trash.emptyWorkspace",
|
||||
"compatCommandName": "documents.emptyTrashByWorkspace",
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
},
|
||||
@@ -1062,7 +725,7 @@ pub async fn title(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1070,8 +733,8 @@ pub async fn title(
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let result = execution.result;
|
||||
let artifact_error = None::<String>;
|
||||
let result = execution;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
@@ -1166,7 +829,7 @@ pub async fn options(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1174,8 +837,8 @@ pub async fn options(
|
||||
)
|
||||
.await?;
|
||||
let artifacts = execution_artifacts_json(&execution);
|
||||
let artifact_error = execution.artifact_error.clone();
|
||||
let result = execution.result;
|
||||
let artifact_error = None::<String>;
|
||||
let result = execution;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_documents_headers(&mut headers);
|
||||
Ok((
|
||||
@@ -1212,91 +875,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"updated_at": "2026-04-18T09:30:00Z",
|
||||
"can_edit": true,
|
||||
"disable_download": false,
|
||||
"disable_copy": false,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_heading_numbers": true,
|
||||
"show_toc": true,
|
||||
"show_structure": true,
|
||||
"protect_editing": false,
|
||||
"show_word_count": true,
|
||||
"collapse_backlinks": false,
|
||||
"page_font": "default",
|
||||
"layout_density": "normal",
|
||||
"hide_child_pages": false,
|
||||
"show_block_ref_count": true,
|
||||
"embed_default_block_id": "heading_1",
|
||||
"word_count": 42,
|
||||
"character_count": 128,
|
||||
"block_count": 3,
|
||||
"todo_total": 1,
|
||||
"todo_done": 0
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "章节一" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateTitle": {
|
||||
"ok": true,
|
||||
"title": "服务端页面(改名)"
|
||||
},
|
||||
"documents:updateOptions": {
|
||||
"ok": true,
|
||||
"show_toc": false
|
||||
},
|
||||
"documents:updateContent": {
|
||||
"ok": true,
|
||||
"updated_at": "2026-04-18T09:45:00Z",
|
||||
"revision": 8,
|
||||
"conflict_detection_key": "doc_1:8"
|
||||
},
|
||||
"documents:emptyTrashByWorkspace": {
|
||||
"ok": true,
|
||||
"deletedCount": 2
|
||||
},
|
||||
"bridgeLogs:recordCommandLog": {
|
||||
"ok": true,
|
||||
"id": "clog_fixture"
|
||||
},
|
||||
"bridgeLogs:recordDomainEvent": {
|
||||
"ok": true,
|
||||
"id": "evt_fixture"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -1329,8 +912,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_auth_cookie_does_not_force_next_proxy() {
|
||||
fn stale_third_party_auth_cookie_does_not_change_request_identity_source() {
|
||||
let mut headers = HeaderMap::new();
|
||||
// 残留第三方 cookie 名不得成为身份真源;真源仍是 mnote_session。
|
||||
headers.insert(
|
||||
"cookie",
|
||||
HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"),
|
||||
@@ -1341,7 +925,8 @@ mod tests {
|
||||
&headers,
|
||||
);
|
||||
|
||||
assert!(!super::should_proxy_via_next(&context));
|
||||
assert_ne!(context.auth.actor_id.as_str(), "jwt-demo");
|
||||
assert!(context.auth.session_id.is_none() || context.auth.session_id.as_deref() != Some("jwt-demo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1775,141 +1775,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_shell",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "编辑器壳页面",
|
||||
"updated_at": "2026-04-18T11:22:33Z",
|
||||
"can_edit": true,
|
||||
"show_structure": true
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "编辑器壳页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "第一节" }]
|
||||
}
|
||||
],
|
||||
"revision": 9,
|
||||
"conflict_detection_key": "doc_shell:9",
|
||||
"page_subtree": {
|
||||
"projection_id": "kernel_projection:page_tree:doc_shell",
|
||||
"projection": "page_tree",
|
||||
"root_node_id": "doc_shell",
|
||||
"root_node": {
|
||||
"id": "doc_shell",
|
||||
"parent_node_id": null,
|
||||
"node_type": "page",
|
||||
"block_id": null,
|
||||
"anchor_block_id": null,
|
||||
"depth": 0,
|
||||
"metadata": {
|
||||
"title": "编辑器壳页面",
|
||||
"text_snippet": "页面首段",
|
||||
"block_type": null,
|
||||
"heading_level": null,
|
||||
"numbering": null,
|
||||
"child_count": 2,
|
||||
"order": 0,
|
||||
"path": ["编辑器壳页面"]
|
||||
}
|
||||
},
|
||||
"subtree": {
|
||||
"root_node_id": "doc_shell",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "doc_shell",
|
||||
"parent_node_id": null,
|
||||
"node_type": "page",
|
||||
"block_id": null,
|
||||
"anchor_block_id": null,
|
||||
"depth": 0,
|
||||
"metadata": {
|
||||
"title": "编辑器壳页面",
|
||||
"text_snippet": "页面首段",
|
||||
"block_type": null,
|
||||
"heading_level": null,
|
||||
"numbering": null,
|
||||
"child_count": 2,
|
||||
"order": 0,
|
||||
"path": ["编辑器壳页面"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_heading_1",
|
||||
"parent_node_id": "doc_shell",
|
||||
"node_type": "section",
|
||||
"block_id": "heading_1",
|
||||
"anchor_block_id": "heading_1",
|
||||
"depth": 1,
|
||||
"metadata": {
|
||||
"title": "第一节",
|
||||
"text_snippet": "第一节 页面首段",
|
||||
"block_type": "heading",
|
||||
"heading_level": 1,
|
||||
"numbering": "1",
|
||||
"child_count": 1,
|
||||
"order": 0,
|
||||
"path": ["编辑器壳页面", "第一节"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_para_1",
|
||||
"parent_node_id": "node_heading_1",
|
||||
"node_type": "content_node",
|
||||
"block_id": "paragraph_1",
|
||||
"anchor_block_id": null,
|
||||
"depth": 2,
|
||||
"metadata": {
|
||||
"title": null,
|
||||
"text_snippet": "这是正文第一段",
|
||||
"block_type": "paragraph",
|
||||
"heading_level": null,
|
||||
"numbering": null,
|
||||
"child_count": 0,
|
||||
"order": 1,
|
||||
"path": ["编辑器壳页面", "第一节", "这是正文第一段"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"outline": [
|
||||
{
|
||||
"id": "outline_heading_1",
|
||||
"node_id": "node_heading_1",
|
||||
"anchor_block_id": "heading_1",
|
||||
"title": "第一节",
|
||||
"level": 1,
|
||||
"numbering": "1"
|
||||
}
|
||||
],
|
||||
"evidence": [],
|
||||
"stats": {
|
||||
"block_count": 3,
|
||||
"heading_count": 1,
|
||||
"evidence_count": 0,
|
||||
"max_depth": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -384,17 +384,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -294,17 +294,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -392,7 +386,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_projection_routes_short_circuit_local_folder_without_convex_dataset() {
|
||||
async fn tree_projection_routes_short_circuit_local_folder_without_cloud_dataset() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-kernel-projection-{}",
|
||||
std::process::id()
|
||||
|
||||
@@ -437,10 +437,11 @@ pub fn pat_is_installed(config: &AppConfig, token_id: &str) -> bool {
|
||||
pub fn install_status_for_pat(config: &AppConfig, token_id: &str, jti: &str) -> Value {
|
||||
let env = environment_name(config);
|
||||
let base = public_base_url(config);
|
||||
let state = load_install_state();
|
||||
let entry = state.environments.get(&env);
|
||||
let meta = entry.and_then(|e| find_pat_meta_by_token(e, token_id, jti));
|
||||
let installed = meta.is_some();
|
||||
// 统一经 helper 判定,与列表 / install 一致,并消除 dead_code 警告
|
||||
let meta = current_pat_install_for_token(config, token_id, jti);
|
||||
let installed = pat_is_installed(config, token_id)
|
||||
|| (!jti.is_empty() && jti != token_id && pat_is_installed(config, jti))
|
||||
|| meta.is_some();
|
||||
let subject = meta
|
||||
.as_ref()
|
||||
.map(|m| m.subject_user_id.clone())
|
||||
@@ -450,7 +451,6 @@ pub fn install_status_for_pat(config: &AppConfig, token_id: &str, jti: &str) ->
|
||||
} else {
|
||||
display_env_path(&user_agent_env_path(&subject, &env))
|
||||
};
|
||||
// 已无共享 active 槽;installed 即该用户本机已配置
|
||||
json!({
|
||||
"installed": installed,
|
||||
"active": installed,
|
||||
@@ -1015,17 +1015,11 @@ mod tests {
|
||||
service_version: "test".into(),
|
||||
bind_addr: bind.into(),
|
||||
public_bind_addr: bind.into(),
|
||||
legacy_next_base_url: None,
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: false,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev".into(),
|
||||
dev_user_name: "dev".into(),
|
||||
dev_user_email: "dev@test".into(),
|
||||
|
||||
@@ -500,17 +500,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
|
||||
@@ -11111,17 +11111,11 @@ mod tests {
|
||||
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: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
|
||||
@@ -101,7 +101,7 @@ fn document_for_target_row(
|
||||
}
|
||||
|
||||
pub async fn upload(Extension(context): Extension<RequestContext>) -> Response {
|
||||
retired_convex_media_response(&context, "upload", None)
|
||||
retired_media_response(&context, "upload", None)
|
||||
}
|
||||
|
||||
pub async fn filetree_upload_target_preflight(
|
||||
@@ -164,10 +164,10 @@ pub async fn sign(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
retired_convex_media_response(&context, "sign", asset_id)
|
||||
retired_media_response(&context, "sign", asset_id)
|
||||
}
|
||||
|
||||
fn retired_convex_media_response(
|
||||
fn retired_media_response(
|
||||
context: &RequestContext,
|
||||
operation: &str,
|
||||
asset_id: Option<&str>,
|
||||
@@ -176,9 +176,9 @@ fn retired_convex_media_response(
|
||||
StatusCode::GONE,
|
||||
Json(json!({
|
||||
"ok": false,
|
||||
"code": "mnote_media_convex_retired",
|
||||
"error": "旧 Convex Files media route 已退役",
|
||||
"message": "旧 /api/media Convex Files 上传与签名链已退役;local-first 附件请使用 /api/local-folder/assets/upload 与 /api/local-folder/files/open。",
|
||||
"code": "mnote_media_local_required",
|
||||
"error": "media 仅支持 local-folder 附件路径",
|
||||
"message": "/api/media 云上传链已移除;请使用 /api/local-folder/assets/upload 与 /api/local-folder/files/open。",
|
||||
"operation": operation,
|
||||
"assetId": asset_id,
|
||||
"replacement": {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::command_support::reject_non_local_runtime_command_with_artifacts;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
|
||||
read_local_mindmap_data, write_local_mindmap_data,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
||||
fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud,
|
||||
execute_runtime_query_against_data, reject_non_local_runtime_query,
|
||||
reject_non_local_documents_meta, reject_non_local_query_data,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
@@ -124,18 +124,12 @@ async fn resolve_mindmap_workspace_id(
|
||||
// 思维导图 runtime 的历史请求体不一定带 workspaceId;
|
||||
// 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。
|
||||
let meta =
|
||||
fetch_documents_meta_via_legacy_cloud(state.config(), context, None, document_id).await?;
|
||||
reject_non_local_documents_meta(state.config(), context, None, document_id).await?;
|
||||
Ok(read_workspace_id_from_meta(&meta))
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(
|
||||
execution: &crate::transport::legacy_cloud_guard::RetiredCloudCommandExecution,
|
||||
) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
fn execution_artifacts_json(_execution: &Value) -> Value {
|
||||
Value::Null
|
||||
}
|
||||
|
||||
pub async fn get_mindmap(
|
||||
@@ -179,7 +173,7 @@ pub async fn get_mindmap(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
|
||||
let query_name = resolve_query_name(¶ms);
|
||||
let result = execute_runtime_query_via_legacy_cloud(
|
||||
let result = reject_non_local_runtime_query(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -361,7 +355,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -375,14 +369,14 @@ pub async fn apply_mindmap_command(
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"commandName": "mindmaps.put",
|
||||
"result": execution.result,
|
||||
"result": execution,
|
||||
"artifacts": execution_artifacts_json(&execution),
|
||||
"artifactError": execution.artifact_error,
|
||||
"artifactError": None::<String>,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let current = fetch_query_data_via_legacy_cloud(
|
||||
let current = reject_non_local_query_data(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -443,7 +437,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -460,9 +454,9 @@ pub async fn apply_mindmap_command(
|
||||
"applied": applied.applied,
|
||||
"errors": applied.errors,
|
||||
"projectionRevision": body.projection_revision,
|
||||
"result": execution.result,
|
||||
"result": execution,
|
||||
"artifacts": execution_artifacts_json(&execution),
|
||||
"artifactError": execution.artifact_error,
|
||||
"artifactError": None::<String>,
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -482,23 +476,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{"documents:getMeta":{"id":"doc_1","workspace_id":"ws_demo","title":"页面"},"mindmaps:get":{"data":{"data":{"text":"KMIND","uid":"root"},"children":[]},"revision":1}}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{"mindmaps:put":{"ok":true,"document_id":"doc_1","mindmap_id":"mind_1","updated_at":"2026-05-12T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -356,62 +356,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
serde_json::json!({
|
||||
"sidebar:datasetList": {
|
||||
"active_workspace_id": "ws_demo",
|
||||
"documents": [
|
||||
{
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "页面",
|
||||
"parent_id": null,
|
||||
"sort_order": 0
|
||||
}
|
||||
],
|
||||
"media_assets": [],
|
||||
"mindmap_assets": [
|
||||
{
|
||||
"id": "mind_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"document_id": "doc_1",
|
||||
"asset_type": "mindmap",
|
||||
"file_name": "思维导图.json",
|
||||
"mime_type": "application/json"
|
||||
}
|
||||
],
|
||||
"table_assets": [],
|
||||
"trashed_documents": [],
|
||||
"trashed_media_assets": [],
|
||||
"trashed_mindmap_assets": [],
|
||||
"trashed_table_assets": [],
|
||||
"mindmap_docs": [],
|
||||
"mindmap_asset_children": {}
|
||||
},
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "页面"
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [],
|
||||
"revision": 1,
|
||||
"conflict_detection_key": "doc_1:1",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -425,36 +374,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
serde_json::json!({
|
||||
"mindmaps:get": {
|
||||
"ok": true,
|
||||
"source": "compat-blob",
|
||||
"revision": 7,
|
||||
"data": {
|
||||
"data": {"uid": "root", "text": "KMIND"},
|
||||
"children": [
|
||||
{"data": {"uid": "topic", "text": "二级节点"}, "children": []}
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"document_id": "doc_1",
|
||||
"mindmap_id": "mind_1"
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -143,30 +143,16 @@ fn local_agent_audit_record_tool_write(
|
||||
|
||||
/// 从 profile 本地 config.yaml 读取禁用的 mnote tools。
|
||||
/// 路径:`$MNOTE_AGENT_HOME/profiles/{profile}/config.yaml` 中 `mnote.tools.disabled`。
|
||||
/// 兼容读取:`HERMES_HOME` / `~/.hermes`(仅 profile 配置路径,非产品面)。
|
||||
/// 默认回落 `~/.mnote-agent`(不再读取 `HERMES_HOME` / `~/.hermes`)。
|
||||
fn agent_profile_home() -> PathBuf {
|
||||
env::var("MNOTE_AGENT_HOME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::var("HERMES_HOME")
|
||||
env::var("HOME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.or_else(|| {
|
||||
env::var("HOME").ok().and_then(|home| {
|
||||
let preferred = PathBuf::from(&home).join(".mnote-agent");
|
||||
if preferred.exists() {
|
||||
return Some(preferred);
|
||||
}
|
||||
let legacy = PathBuf::from(&home).join(".hermes");
|
||||
if legacy.exists() {
|
||||
return Some(legacy);
|
||||
}
|
||||
Some(preferred)
|
||||
})
|
||||
.map(|home| PathBuf::from(home).join(".mnote-agent"))
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
|
||||
}
|
||||
@@ -1118,13 +1104,6 @@ fn audit_log_path() -> PathBuf {
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
// 兼容旧 env 名
|
||||
env::var("MNOTE_HERMES_TOOL_AUDIT_LOG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from("tmp").join("mnote-agent-tool-audit.jsonl"))
|
||||
}
|
||||
|
||||
@@ -1394,61 +1373,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"can_edit": true,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_toc": true,
|
||||
"block_count": 1
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "章节一" }]
|
||||
},
|
||||
{
|
||||
"id": "p_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第一段" }]
|
||||
},
|
||||
{
|
||||
"id": "p_2",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第二段" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
||||
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
||||
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -1462,44 +1391,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
json!({
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "复杂块阻断页面",
|
||||
"can_edit": true,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_toc": true,
|
||||
"block_count": 6
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "复杂块阻断页面",
|
||||
"content": content,
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
||||
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
||||
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -40,7 +40,7 @@ mod tree;
|
||||
mod tree_view_state;
|
||||
mod ui_debug;
|
||||
pub(crate) mod ui_preferences;
|
||||
mod vault;
|
||||
pub(crate) mod vault;
|
||||
pub(crate) mod vault_extension_token;
|
||||
mod vault_path;
|
||||
mod vault_store;
|
||||
@@ -893,17 +893,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
@@ -1056,7 +1050,7 @@ mod tests {
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json body");
|
||||
assert_eq!(payload["code"], "mnote_media_convex_retired");
|
||||
assert_eq!(payload["code"], "mnote_media_local_required");
|
||||
assert_eq!(payload["operation"], operation);
|
||||
assert_eq!(
|
||||
payload["replacement"]["upload"],
|
||||
|
||||
@@ -367,17 +367,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::onlyoffice_bridge;
|
||||
use crate::app::AppConfig;
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{
|
||||
@@ -921,7 +920,7 @@ pub async fn proxy(
|
||||
onlyoffice_storage_host_override: env_or_dotenv(
|
||||
"NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE",
|
||||
),
|
||||
convex_origin: None,
|
||||
extra_origin: None,
|
||||
supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
.or_else(|| env_or_dotenv("SUPABASE_ANON_KEY")),
|
||||
})
|
||||
@@ -1417,41 +1416,23 @@ pub async fn callback(
|
||||
Err(error) => onlyoffice_callback_failure(error),
|
||||
};
|
||||
}
|
||||
match proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/callback",
|
||||
uri.query(),
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => (
|
||||
// local-first:非 local asset 写回已退役 Next 代理;只服务 local-folder callback。
|
||||
let _ = (state, uri, body);
|
||||
(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_unavailable",
|
||||
"message": error.message(),
|
||||
"code": "onlyoffice_cloud_writeback_unavailable",
|
||||
"message": "OnlyOffice 非 local asset 写回已退役;请使用 local-folder 路径(rootUri/path 或 local-file asset)。",
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(error) => (
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_failed",
|
||||
"message": error.message(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn forcesave(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
State(_state): State<AppState>,
|
||||
_uri: Uri,
|
||||
Query(query): Query<OnlyOfficeForcesaveQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let asset_id = query
|
||||
@@ -1470,104 +1451,14 @@ pub async fn forcesave(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
|
||||
})?;
|
||||
let response = proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/forcesave",
|
||||
uri.query(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED {
|
||||
return WebError::new(
|
||||
// local-first:forcesave 的 Next 写回链已退役;非 local 路径显式不可用。
|
||||
Err(WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
"onlyoffice_cloud_writeback_unavailable",
|
||||
format!(
|
||||
"OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"
|
||||
"OnlyOffice forcesave 仅支持 local-folder 主链;云/兼容写回已退役: assetId={asset_id}, key={key}"
|
||||
),
|
||||
);
|
||||
}
|
||||
error
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option<String> {
|
||||
config
|
||||
.legacy_next_base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
async fn proxy_legacy_onlyoffice_json(
|
||||
config: &AppConfig,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> Result<Response, WebError> {
|
||||
let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| {
|
||||
WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
"OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链",
|
||||
)
|
||||
})?;
|
||||
let target = append_path_and_query(&base, path, query);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!(
|
||||
"OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut request = client
|
||||
.post(target)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
} else {
|
||||
request = request.body("{}");
|
||||
}
|
||||
let upstream = request.send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回请求失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = upstream.status();
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
|
||||
let bytes = upstream.bytes().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回响应读取失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, content_type);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
|
||||
let mut target = format!(
|
||||
"{}/{}",
|
||||
base.trim_end_matches('/'),
|
||||
path.trim_start_matches('/')
|
||||
);
|
||||
if let Some(query) = query.filter(|value| !value.is_empty()) {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
target
|
||||
}
|
||||
|
||||
fn request_body_bytes(
|
||||
@@ -1613,6 +1504,19 @@ fn strip_hop_by_hop_headers(headers: &mut HeaderMap) {
|
||||
}
|
||||
}
|
||||
|
||||
fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
|
||||
let mut target = format!(
|
||||
"{}/{}",
|
||||
base.trim_end_matches('/'),
|
||||
path.trim_start_matches('/')
|
||||
);
|
||||
if let Some(query) = query.filter(|value| !value.is_empty()) {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
target
|
||||
}
|
||||
|
||||
async fn proxy_onlyoffice_path(
|
||||
upstream_prefix: &str,
|
||||
upstream_path: &str,
|
||||
@@ -2067,23 +1971,16 @@ mod tests {
|
||||
assert!(html.contains("window.__MNOTE_ONLYOFFICE_BRIDGE__"));
|
||||
}
|
||||
|
||||
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
|
||||
fn test_state() -> AppState {
|
||||
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,
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
@@ -2091,7 +1988,7 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_legacy_json_server(
|
||||
async fn spawn_mock_download_server(
|
||||
response_body: &'static str,
|
||||
) -> (String, oneshot::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
@@ -2104,7 +2001,7 @@ mod tests {
|
||||
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
|
||||
let _ = tx.send(request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/octet-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
@@ -2114,9 +2011,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_without_legacy_next_fails_explicitly() {
|
||||
async fn onlyoffice_callback_without_local_asset_fails_explicitly() {
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
"/api/onlyoffice/callback?assetId=asset_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
@@ -2155,9 +2052,9 @@ mod tests {
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
|
||||
let (download_url, _captured) = spawn_mock_download_server("new docx bytes").await;
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
root.display()
|
||||
@@ -2205,7 +2102,7 @@ mod tests {
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
|
||||
let (download_url, _captured) = spawn_mock_download_server("new docx bytes").await;
|
||||
let session_id = format!("mnote-oo-local-callback-{}", std::process::id());
|
||||
let token = format!("token-{session_id}");
|
||||
let registered =
|
||||
@@ -2222,7 +2119,7 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
session_id,
|
||||
@@ -2267,7 +2164,7 @@ mod tests {
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
let target = root.join("Page").join("report.docx");
|
||||
fs::write(&target, b"old").expect("write old docx");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("status six bytes").await;
|
||||
let (download_url, _captured) = spawn_mock_download_server("status six bytes").await;
|
||||
let session_id = format!("mnote-oo-local-callback-six-{}", std::process::id());
|
||||
let token = format!("token-{session_id}");
|
||||
let registered =
|
||||
@@ -2284,7 +2181,7 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
session_id,
|
||||
@@ -2334,7 +2231,7 @@ mod tests {
|
||||
fs::create_dir_all(root.join("Page")).expect("create page");
|
||||
fs::create_dir_all(&outside).expect("create outside");
|
||||
fs::write(outside.join("report.docx"), b"outside").expect("write outside");
|
||||
let (download_url, _captured) = spawn_legacy_json_server("should not write").await;
|
||||
let (download_url, _captured) = spawn_mock_download_server("should not write").await;
|
||||
let session_id = format!("mnote-oo-local-callback-escape-{}", std::process::id());
|
||||
let token = format!("token-{session_id}");
|
||||
let registered =
|
||||
@@ -2351,7 +2248,7 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=..%2F{}%2Freport.docx",
|
||||
session_id,
|
||||
@@ -2426,7 +2323,7 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(registered.status(), StatusCode::OK);
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
format!(
|
||||
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&sessionId={}&token={}&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
|
||||
session_id,
|
||||
@@ -2458,46 +2355,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
|
||||
let response = callback(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
HeaderMap::new(),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
session_id: None,
|
||||
token: None,
|
||||
root_uri: None,
|
||||
path: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"key": "doc_key",
|
||||
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
|
||||
assert!(request.contains(r#""status":2"#));
|
||||
assert!(request.contains(r#""key":"doc_key""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() {
|
||||
async fn onlyoffice_forcesave_without_local_writeback_is_not_noop_success() {
|
||||
let response = forcesave(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
@@ -2516,38 +2376,10 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable");
|
||||
assert_eq!(payload["code"], "onlyoffice_cloud_writeback_unavailable");
|
||||
assert_ne!(payload["via"], "mnote-web-rust-noop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) =
|
||||
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await;
|
||||
let response = forcesave(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeForcesaveQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
key: Some("doc_key".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["via"], "forcesave");
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() {
|
||||
let response = page(Query(OnlyOfficePageQuery {
|
||||
@@ -2626,7 +2458,7 @@ mod tests {
|
||||
async fn onlyoffice_callback_rejects_invalid_jwt_when_secret_configured() {
|
||||
std::env::set_var("ONLYOFFICE_JWT_SECRET", "test-onlyoffice-jwt-secret");
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
State(test_state()),
|
||||
"/api/onlyoffice/callback?assetId=asset_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
@@ -2679,5 +2511,5 @@ mod tests {
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -9218,17 +9218,11 @@ mod tests {
|
||||
service_version: "test".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: true,
|
||||
enable_page_ai_pi_lab: true,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -583,23 +583,9 @@ fn agent_profile_home() -> PathBuf {
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var("HERMES_HOME")
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.or_else(|| {
|
||||
std::env::var("HOME").ok().and_then(|home| {
|
||||
let preferred = PathBuf::from(&home).join(".mnote-agent");
|
||||
if preferred.exists() {
|
||||
return Some(preferred);
|
||||
}
|
||||
let legacy = PathBuf::from(&home).join(".hermes");
|
||||
if legacy.exists() {
|
||||
return Some(legacy);
|
||||
}
|
||||
Some(preferred)
|
||||
})
|
||||
.map(|home| PathBuf::from(home).join(".mnote-agent"))
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
|
||||
}
|
||||
@@ -716,55 +702,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"can_edit": true,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_toc": true,
|
||||
"block_count": 2
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "p_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第一段" }]
|
||||
},
|
||||
{
|
||||
"id": "p_2",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第二段" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
||||
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
||||
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::legacy_cloud_guard::execute_retired_query_plan;
|
||||
use crate::transport::local_only::local_required;
|
||||
use bridge_runtime::{
|
||||
build_query_request, execute_runtime_input, execute_runtime_query, BridgeContext,
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
|
||||
@@ -154,24 +154,24 @@ pub fn build_documents_meta_query_plan(
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_query_data_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
/// 非 local_folder 的 query data 拉取已移除。
|
||||
pub async fn reject_non_local_query_data(
|
||||
_config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
_effective_workspace_id: Option<&str>,
|
||||
_query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_query_plan(context, effective_workspace_id, query)?;
|
||||
execute_retired_query_plan(config, context, &plan).await
|
||||
Err(local_required(context, "query_local_required"))
|
||||
}
|
||||
|
||||
pub async fn fetch_documents_meta_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
/// 非 local_folder 的 documents.meta 已移除。
|
||||
pub async fn reject_non_local_documents_meta(
|
||||
_config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
_effective_workspace_id: Option<&str>,
|
||||
_document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?;
|
||||
execute_retired_query_plan(config, context, &plan).await
|
||||
Err(local_required(context, "query_local_required"))
|
||||
}
|
||||
|
||||
pub fn execute_runtime_query_against_data(
|
||||
@@ -188,14 +188,12 @@ pub fn execute_runtime_query_against_data(
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_query_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
/// 非 local_folder 的 runtime query 一律拒绝。
|
||||
pub async fn reject_non_local_runtime_query(
|
||||
_config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
_effective_workspace_id: Option<&str>,
|
||||
_query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let data =
|
||||
fetch_query_data_via_legacy_cloud(config, context, effective_workspace_id, query.clone())
|
||||
.await?;
|
||||
execute_runtime_query_against_data(context, effective_workspace_id, query, data)
|
||||
Err(local_required(context, "query_local_required"))
|
||||
}
|
||||
|
||||
@@ -2,20 +2,17 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts, runtime_context,
|
||||
reject_non_local_runtime_command_with_artifacts, runtime_context,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, execute_local_tree_command,
|
||||
};
|
||||
use crate::transport::legacy_cloud_guard::{
|
||||
execute_retired_mutation_by_name, execute_retired_query_by_name,
|
||||
persist_runtime_command_artifacts,
|
||||
};
|
||||
use crate::transport::local_only::local_required;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -26,6 +23,23 @@ use time::{Duration, OffsetDateTime};
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_RESOURCE_TRASH_TRANSPORT: &str = "x-mnote-resource-trash-transport";
|
||||
|
||||
async fn reject_cloud_query(
|
||||
context: &RequestContext,
|
||||
error_phase: &'static str,
|
||||
) -> Result<Value, WebError> {
|
||||
Err(local_required(context, error_phase))
|
||||
}
|
||||
|
||||
async fn reject_cloud_mutation(
|
||||
context: &RequestContext,
|
||||
error_phase: &'static str,
|
||||
) -> Result<Value, WebError> {
|
||||
Err(local_required(context, error_phase))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaBatchRequest {
|
||||
@@ -115,38 +129,6 @@ async fn current_user_id(
|
||||
return Ok(user_id.to_string());
|
||||
}
|
||||
|
||||
let has_auth_cookie = context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.map(|value| {
|
||||
value.contains("__convexAuthJWT=") || value.contains("mnote_web_convex_token=")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if has_auth_cookie {
|
||||
if let Ok(user) = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"users:currentUser",
|
||||
json!({}),
|
||||
context.workspace.workspace_id.as_deref(),
|
||||
"resource_trash_current_user",
|
||||
)
|
||||
.await
|
||||
{
|
||||
for key in ["_id", "id"] {
|
||||
if let Some(user_id) = user
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(user_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state.config().allow_dev_fixtures {
|
||||
return Ok(state.config().dev_user_id.clone());
|
||||
}
|
||||
@@ -190,7 +172,7 @@ fn now_iso_like() -> String {
|
||||
}
|
||||
|
||||
async fn record_media_empty_trash_artifacts(
|
||||
state: &AppState,
|
||||
_state: &AppState,
|
||||
context: &RequestContext,
|
||||
user_id: &str,
|
||||
workspace_id: &str,
|
||||
@@ -270,20 +252,13 @@ async fn record_media_empty_trash_artifacts(
|
||||
}),
|
||||
};
|
||||
let runtime_context = runtime_context(context, Some(workspace_id));
|
||||
if let Some(artifacts) = build_runtime_command_artifact_plan(
|
||||
&runtime_context,
|
||||
&command,
|
||||
&plan,
|
||||
result,
|
||||
&now_iso_like(),
|
||||
) {
|
||||
persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
|
||||
}
|
||||
// cloud artifact persist 已移除;local-first 不写 cloud command log。
|
||||
let _ = (runtime_context, command, plan, result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_resource_resync_artifacts(
|
||||
state: &AppState,
|
||||
_state: &AppState,
|
||||
context: &RequestContext,
|
||||
actor_id: &str,
|
||||
workspace_id: &str,
|
||||
@@ -371,34 +346,17 @@ async fn record_resource_resync_artifacts(
|
||||
}),
|
||||
};
|
||||
let runtime_context = runtime_context(context, Some(workspace_id));
|
||||
if let Some(artifacts) = build_runtime_command_artifact_plan(
|
||||
&runtime_context,
|
||||
&command,
|
||||
&plan,
|
||||
result,
|
||||
&now_iso_like(),
|
||||
) {
|
||||
persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
|
||||
}
|
||||
// cloud artifact persist 已移除;local-first 不写 cloud command log。
|
||||
let _ = (runtime_context, command, plan, result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_document_workspace_id(
|
||||
state: &AppState,
|
||||
_state: &AppState,
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
_document_id: &str,
|
||||
) -> Option<String> {
|
||||
execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"documents:getMeta",
|
||||
json!({
|
||||
"id": document_id,
|
||||
"includeDeleted": true,
|
||||
}),
|
||||
context.workspace.workspace_id.as_deref(),
|
||||
"resource_trash_document_workspace",
|
||||
)
|
||||
reject_cloud_query(context, "resource_trash_local_required")
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|meta| {
|
||||
@@ -412,22 +370,12 @@ async fn fetch_document_workspace_id(
|
||||
}
|
||||
|
||||
async fn fetch_table_meta(
|
||||
state: &AppState,
|
||||
_state: &AppState,
|
||||
context: &RequestContext,
|
||||
user_id: &str,
|
||||
table_id: &str,
|
||||
_user_id: &str,
|
||||
_table_id: &str,
|
||||
) -> Option<Value> {
|
||||
execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"tables:get",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"tableId": table_id,
|
||||
}),
|
||||
context.workspace.workspace_id.as_deref(),
|
||||
"resource_trash_table_meta",
|
||||
)
|
||||
reject_cloud_query(context, "resource_trash_local_required")
|
||||
.await
|
||||
.ok()
|
||||
.filter(|value| value.is_object())
|
||||
@@ -490,22 +438,12 @@ fn annotate_resource_lifecycle_result(
|
||||
}
|
||||
|
||||
async fn fetch_media_asset_meta(
|
||||
state: &AppState,
|
||||
_state: &AppState,
|
||||
context: &RequestContext,
|
||||
user_id: &str,
|
||||
asset_id: &str,
|
||||
_user_id: &str,
|
||||
_asset_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let assets = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"mediaAssets:listByIds",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"ids": [asset_id],
|
||||
}),
|
||||
context.workspace.workspace_id.as_deref(),
|
||||
"media_asset_meta",
|
||||
)
|
||||
let assets = reject_cloud_query(context, "resource_trash_local_required")
|
||||
.await?;
|
||||
Ok(assets
|
||||
.as_array()
|
||||
@@ -618,7 +556,7 @@ pub async fn media_batch(
|
||||
);
|
||||
// 先执行 tree runtime command;成功后再做 legacy media store patch,
|
||||
// 避免 patch 已生效而 tree 失败时调用方仍看到成功(与 restore/delete 一致)。
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -626,49 +564,21 @@ pub async fn media_batch(
|
||||
)
|
||||
.await?;
|
||||
if action == "rename" {
|
||||
let new_name = require_id(
|
||||
let _new_name = require_id(
|
||||
&context,
|
||||
body.new_name.as_deref().unwrap_or_default(),
|
||||
"newName",
|
||||
)?;
|
||||
execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:patchById",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"id": asset_id,
|
||||
"patch": {
|
||||
"file_name": new_name,
|
||||
},
|
||||
}),
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
"media_batch_rename_patch",
|
||||
)
|
||||
reject_cloud_mutation(&context, "")
|
||||
.await?;
|
||||
}
|
||||
if action == "move" {
|
||||
let target_document_id = require_id(
|
||||
let _target_document_id = require_id(
|
||||
&context,
|
||||
body.target_document_id.as_deref().unwrap_or_default(),
|
||||
"targetDocumentId",
|
||||
)?;
|
||||
execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:patchById",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"id": asset_id,
|
||||
"patch": {
|
||||
"document_id": target_document_id,
|
||||
},
|
||||
}),
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
"media_batch_move_patch",
|
||||
)
|
||||
reject_cloud_mutation(&context, "")
|
||||
.await?;
|
||||
}
|
||||
updated += 1;
|
||||
@@ -718,14 +628,14 @@ pub async fn media_purge(
|
||||
"assetId": asset_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
command,
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, execution.result))
|
||||
Ok(ok_response(&context, execution))
|
||||
}
|
||||
|
||||
pub async fn media_empty_trash(
|
||||
@@ -735,19 +645,7 @@ pub async fn media_empty_trash(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let user_id = current_user_id(&state, &context).await?;
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:emptyTrashByWorkspace",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"workspaceId": workspace_id,
|
||||
"expiredDeletedAt": force_expired_at(),
|
||||
}),
|
||||
Some(workspace_id),
|
||||
None,
|
||||
"media_empty_trash",
|
||||
)
|
||||
let result = reject_cloud_mutation(&context, "")
|
||||
.await?;
|
||||
let _ =
|
||||
match record_media_empty_trash_artifacts(&state, &context, &user_id, workspace_id, &result)
|
||||
@@ -818,7 +716,7 @@ pub async fn mindmap_delete(
|
||||
"mindmapId": mindmap_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -827,7 +725,7 @@ pub async fn mindmap_delete(
|
||||
.await?;
|
||||
Ok(ok_response(
|
||||
&context,
|
||||
annotate_resource_lifecycle_result(execution.result, "tree.resource.archive", "mindmap"),
|
||||
annotate_resource_lifecycle_result(execution, "tree.resource.archive", "mindmap"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -903,7 +801,7 @@ pub async fn mindmap_trash_action(
|
||||
"mindmapId": mindmap_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -912,7 +810,7 @@ pub async fn mindmap_trash_action(
|
||||
.await?;
|
||||
Ok(ok_response(
|
||||
&context,
|
||||
annotate_resource_lifecycle_result(execution.result, command_name, "mindmap"),
|
||||
annotate_resource_lifecycle_result(execution, command_name, "mindmap"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -922,17 +820,7 @@ pub async fn mindmap_empty_trash(
|
||||
Json(body): Json<WorkspaceTrashRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mindmaps:emptyTrashByWorkspace",
|
||||
json!({
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
Some(workspace_id),
|
||||
None,
|
||||
"mindmap_empty_trash",
|
||||
)
|
||||
let result = reject_cloud_mutation(&context, "")
|
||||
.await?;
|
||||
let _ = record_resource_resync_artifacts(
|
||||
&state,
|
||||
@@ -986,22 +874,7 @@ pub async fn table_create(
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
})?;
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"tables:create",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"title": body.title,
|
||||
"schema": body.schema.unwrap_or_else(|| json!({})),
|
||||
"snapshot": body.snapshot,
|
||||
}),
|
||||
Some(&workspace_id),
|
||||
None,
|
||||
"table_create",
|
||||
)
|
||||
let result = reject_cloud_mutation(&context, "")
|
||||
.await?;
|
||||
let table_id = result
|
||||
.get("id")
|
||||
@@ -1093,7 +966,7 @@ async fn table_action(
|
||||
"tableId": table_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
let execution = reject_non_local_runtime_command_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -1103,7 +976,7 @@ async fn table_action(
|
||||
let _ = error_phase;
|
||||
Ok(ok_response(
|
||||
&context,
|
||||
annotate_resource_lifecycle_result(execution.result, command_name, "table"),
|
||||
annotate_resource_lifecycle_result(execution, command_name, "table"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1114,19 +987,7 @@ pub async fn table_empty_trash(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let user_id = current_user_id(&state, &context).await?;
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"tables:emptyTrashByWorkspace",
|
||||
json!({
|
||||
"userId": user_id,
|
||||
"workspaceId": workspace_id,
|
||||
"expiredDeletedAt": force_expired_at(),
|
||||
}),
|
||||
Some(workspace_id),
|
||||
None,
|
||||
"table_empty_trash",
|
||||
)
|
||||
let result = reject_cloud_mutation(&context, "")
|
||||
.await?;
|
||||
let _ = record_resource_resync_artifacts(
|
||||
&state,
|
||||
@@ -1165,42 +1026,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {"id": "doc_1", "workspace_id": "ws_demo"},
|
||||
"mediaAssets:listByIds": [{"id": "asset_1", "workspace_id": "ws_demo", "document_id": "doc_1"}],
|
||||
"tables:get": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1"}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"mediaAssets:patchById": {"ok": true},
|
||||
"mediaAssets:purgeById": {"ok": true, "deleted": 1},
|
||||
"mediaAssets:emptyTrashByWorkspace": {"ok": true, "deleted": 2},
|
||||
"bridgeLogs:recordCommandLog": {"ok": true},
|
||||
"bridgeLogs:recordDomainEvent": {"ok": true},
|
||||
"mindmaps:softDelete": {"ok": true, "moved": 1, "deleted_at": "2026-05-15T00:00:00Z"},
|
||||
"mindmaps:restore": {"ok": true, "updated_at": "2026-05-15T00:00:00Z"},
|
||||
"mindmaps:purge": {"ok": true},
|
||||
"mindmaps:emptyTrashByWorkspace": {"ok": true, "deletedCount": 3},
|
||||
"tables:create": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1", "title": "表格"},
|
||||
"tables:remove": {"success": true, "deleted_at": "2026-05-15T00:00:00Z"},
|
||||
"tables:restore": {"success": true},
|
||||
"tables:purge": {"success": true},
|
||||
"tables:emptyTrashByWorkspace": {"ok": true, "deleted": 4}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -1489,7 +1319,6 @@ mod tests {
|
||||
|
||||
assert_eq!(moved["result"]["moved"], 1);
|
||||
assert_eq!(moved["result"]["canonicalCommand"], "tree.resource.move");
|
||||
assert_ne!(moved["result"]["canonicalCommand"], "documents.move");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
||||
execute_runtime_query_against_data, reject_non_local_runtime_query,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::web_shell::{escape_script_json, load_sidebar_tree_html};
|
||||
@@ -628,7 +628,7 @@ async fn load_search_results_with_filters(
|
||||
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
|
||||
}),
|
||||
};
|
||||
match execute_runtime_query_via_legacy_cloud(
|
||||
match reject_non_local_runtime_query(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
@@ -768,17 +768,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -893,23 +887,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_documents_does_not_return_builtin_fixture_when_convex_unavailable() {
|
||||
async fn search_documents_does_not_return_builtin_fixture_when_local_required() {
|
||||
let app = build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
@@ -1511,17 +1499,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -9,7 +9,6 @@ use control_plane::session_token_hash;
|
||||
use serde::Serialize;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
const COOKIE_MNOTE_SESSION: &str = "mnote_session";
|
||||
const COOKIE_ACTOR_EMAIL: &str = "mnote_actor_email";
|
||||
const COOKIE_ACTOR_NAME: &str = "mnote_actor_name";
|
||||
@@ -22,7 +21,6 @@ pub struct RuntimeConfigResponse {
|
||||
pub public_entry: String,
|
||||
pub tree_renderer_family: &'static str,
|
||||
pub document_editor_host: &'static str,
|
||||
pub legacy_next_compat_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -46,7 +44,6 @@ pub async fn runtime_config(State(state): State<AppState>) -> Response {
|
||||
public_entry: state.config().public_bind_addr.clone(),
|
||||
tree_renderer_family: "rust_family",
|
||||
document_editor_host: "leptos_tiptap_island",
|
||||
legacy_next_compat_enabled: state.config().enable_legacy_next_compat,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -162,23 +159,9 @@ fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String {
|
||||
/// 仅用于 **展示** email/name 补全;**绝不**作为 user_id / 鉴权真源。
|
||||
/// 无签名校验:任意客户端可伪造 JWT payload 中的展示字段。
|
||||
/// 身份仍以 control-plane session / header actor 为准(见上方 get_session 分支)。
|
||||
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
|
||||
let token = context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.and_then(|cookies| raw_cookie_value(cookies, COOKIE_CONVEX_AUTH_JWT))?;
|
||||
let payload_segment = token.split('.').nth(1)?;
|
||||
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
|
||||
let payload: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
|
||||
keys.iter().find_map(|key| {
|
||||
payload
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
fn jwt_cookie_claim(_context: &RequestContext, _keys: &[&str]) -> Option<String> {
|
||||
// 身份仅 control-plane session / header;不再从第三方 JWT cookie 补全。
|
||||
None
|
||||
}
|
||||
|
||||
fn encoded_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
|
||||
@@ -235,17 +218,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -280,7 +257,8 @@ mod tests {
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["treeRendererFamily"], "rust_family");
|
||||
assert!(payload.get("legacyNextBaseUrl").is_none());
|
||||
assert!(payload.get("convexAdminKey").is_none());
|
||||
assert!(payload.get("adminKey").is_none());
|
||||
assert!(payload.get("cloudAdminKey").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -302,7 +280,8 @@ mod tests {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["userId"], "dev-user");
|
||||
assert!(payload.get("convexAdminKey").is_none());
|
||||
assert!(payload.get("adminKey").is_none());
|
||||
assert!(payload.get("cloudAdminKey").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -328,7 +307,8 @@ mod tests {
|
||||
assert_eq!(payload["userId"], "user_real");
|
||||
assert_eq!(payload["actorType"], "user");
|
||||
assert_eq!(payload["authMode"], "forwardedActor");
|
||||
assert!(payload.get("convexAdminKey").is_none());
|
||||
assert!(payload.get("adminKey").is_none());
|
||||
assert!(payload.get("cloudAdminKey").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -338,17 +318,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -408,17 +382,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -489,17 +457,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -575,7 +537,6 @@ mod tests {
|
||||
assert_eq!(payload["userId"], "user_real");
|
||||
assert_eq!(payload["actorType"], "user");
|
||||
assert_eq!(payload["authMode"], "forwardedActor");
|
||||
assert_ne!(payload["code"], "legacy_next_compat_disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -597,7 +558,6 @@ mod tests {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["userId"], "dev-user");
|
||||
assert_ne!(payload["code"], "legacy_next_compat_disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -619,6 +579,5 @@ mod tests {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["userId"], "dev-user");
|
||||
assert_ne!(payload["code"], "legacy_next_compat_disabled");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, fetch_query_data_via_legacy_cloud,
|
||||
execute_runtime_query_against_data, reject_non_local_query_data,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||
@@ -38,7 +38,7 @@ pub async fn load_sidebar_dataset(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
fetch_query_data_via_legacy_cloud(
|
||||
reject_non_local_query_data(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
|
||||
@@ -369,7 +369,7 @@ fn delta_matches_workspace(delta: &Value, subscription_workspace: Option<&str>)
|
||||
|
||||
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
|
||||
let mut next = query.clone();
|
||||
// Convex bridgeLogs 的 cursor 是“向更旧记录翻页”,不是 live tail 的起点;
|
||||
// bridgeLogs 的 cursor 是“向更旧记录翻页”,不是 live tail 的起点;
|
||||
// 实时轮询必须始终查最新窗口,再用 current_cursor 在 Rust 侧比较增量。
|
||||
next.cursor = None;
|
||||
next
|
||||
@@ -414,17 +414,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":"cursor_demo","has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
reject_non_local_runtime_query, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
@@ -635,7 +635,7 @@ pub async fn load_stream_overview(
|
||||
.with_context(context)
|
||||
},
|
||||
)?;
|
||||
let overview = execute_runtime_query_via_legacy_cloud(
|
||||
let overview = reject_non_local_runtime_query(
|
||||
config,
|
||||
context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -690,7 +690,7 @@ pub async fn load_stream_snapshot(
|
||||
}
|
||||
};
|
||||
|
||||
let overview = execute_runtime_query_via_legacy_cloud(
|
||||
let overview = reject_non_local_runtime_query(
|
||||
config,
|
||||
context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -821,7 +821,7 @@ mod tests {
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "documents.emptyTrashByWorkspace",
|
||||
"command_name": "tree.trash.emptyWorkspace",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "resync_required",
|
||||
|
||||
@@ -2,8 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
|
||||
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
|
||||
@@ -12,10 +11,10 @@ use crate::routes::local_folder_source::{
|
||||
local_workspace_id_from_root_uri, LocalAccessMode,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
reject_non_local_documents_meta, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name;
|
||||
use crate::transport::local_only::local_required;
|
||||
use crate::tree_shell::filetree_renderer::{
|
||||
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
|
||||
};
|
||||
@@ -137,69 +136,6 @@ impl TreeCommandEnvelopeContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TreeCompatAliasCatalogEntry {
|
||||
pub compat_command: &'static str,
|
||||
pub preferred_command: &'static str,
|
||||
pub source_kind: &'static str,
|
||||
pub retained_for: &'static str,
|
||||
pub retirement_condition: &'static str,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub const TREE_DOCUMENT_COMPAT_ALIAS_CATALOG: &[TreeCompatAliasCatalogEntry] = &[
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.create",
|
||||
preferred_command: "tree.node.create",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.create",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.title.update",
|
||||
preferred_command: "tree.node.rename",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.rename",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.move",
|
||||
preferred_command: "tree.subtree.move",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.subtree.move",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.delete",
|
||||
preferred_command: "tree.node.archive",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.archive",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.restore",
|
||||
preferred_command: "tree.node.restore",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.restore",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.purge",
|
||||
preferred_command: "tree.node.purge",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.purge",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.copy_tree",
|
||||
preferred_command: "tree.subtree.copy",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.subtree.copy",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TreeCommandRequest {
|
||||
Create {
|
||||
@@ -323,10 +259,9 @@ fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
|
||||
|
||||
fn normalize_tree_source_kind(raw: Option<&str>) -> &'static str {
|
||||
match raw.map(str::trim).unwrap_or("") {
|
||||
"local_folder" | "local-folder" | "local" => "local_folder",
|
||||
"convex_workspace" | "convex" | "" => "convex_workspace",
|
||||
// 未知 kind 一律回落到 convex_workspace,避免客户端自报 kind 与 root_uri 脱钩
|
||||
_ => "convex_workspace",
|
||||
"local_folder" | "local-folder" | "local" | "" => "local_folder",
|
||||
// 未知 kind 不再回落到云源;统一 local_folder(调用方仍须提供 rootUri)
|
||||
_ => "local_folder",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,32 +270,17 @@ fn build_workspace_source_wire(
|
||||
workspace_id: &str,
|
||||
envelope_context: &TreeCommandEnvelopeContext,
|
||||
) -> bridge_runtime::RuntimeSourceWire {
|
||||
// 不信任请求体自报的任意 source_kind:白名单归一化后再与 root_uri 对齐。
|
||||
// 仅 local_folder;root_uri 必须由上游鉴权提供,禁止伪造云 URI。
|
||||
let source_kind = normalize_tree_source_kind(envelope_context.source_kind.as_deref()).to_string();
|
||||
let root_uri = match source_kind.as_str() {
|
||||
"local_folder" => {
|
||||
// local_folder 必须带已由上游鉴权的 root_uri;无则不回落到 convex 假 URI
|
||||
envelope_context
|
||||
let root_uri = envelope_context
|
||||
.root_uri
|
||||
.as_ref()
|
||||
.map(|u| u.trim().to_string())
|
||||
.filter(|u| !u.is_empty() && !u.contains(".."))
|
||||
}
|
||||
_ => Some(
|
||||
envelope_context
|
||||
.root_uri
|
||||
.clone()
|
||||
.filter(|u| {
|
||||
let t = u.trim();
|
||||
!t.is_empty() && (t.starts_with("convex://") || t.starts_with("workspace:"))
|
||||
})
|
||||
.unwrap_or_else(|| format!("convex://workspace/{workspace_id}")),
|
||||
),
|
||||
};
|
||||
let capabilities =
|
||||
if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" {
|
||||
.filter(|u| !u.is_empty() && !u.contains(".."));
|
||||
let capabilities = if envelope_context.source_capabilities.is_empty() {
|
||||
vec![
|
||||
"load-snapshot".into(),
|
||||
"watch".into(),
|
||||
"preflight-command".into(),
|
||||
"execute-command".into(),
|
||||
"resolve-page-aggregate".into(),
|
||||
@@ -955,7 +875,7 @@ fn build_tree_shell_html(
|
||||
let source_kind = projection
|
||||
.get("sourceKind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("convex_workspace");
|
||||
.unwrap_or("local_folder");
|
||||
let root_uri = projection
|
||||
.get("rootUri")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1990,7 +1910,7 @@ fn create_command_wire(
|
||||
}
|
||||
TreeCommandRequest::CreateFolder { .. } => Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
"convex_workspace 暂不支持 folder create capability",
|
||||
"仅 local_folder 支持 folder create capability",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate")),
|
||||
@@ -2194,7 +2114,7 @@ fn create_command_wire(
|
||||
}
|
||||
TreeCommandRequest::DropFiles { .. } => Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
"convex_workspace 外部文件 drop 需要走上传 preflight / object storage executor",
|
||||
"外部文件 drop 需要走 local-folder 上传 preflight",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate")),
|
||||
@@ -2280,7 +2200,7 @@ async fn resolve_tree_create_workspace_id(
|
||||
|
||||
if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let parent_meta =
|
||||
fetch_documents_meta_via_legacy_cloud(state.config(), context, None, parent_id).await?;
|
||||
reject_non_local_documents_meta(state.config(), context, None, parent_id).await?;
|
||||
if let Some(workspace_id) = parent_meta
|
||||
.get("workspace_id")
|
||||
.or_else(|| parent_meta.get("workspaceId"))
|
||||
@@ -2292,33 +2212,8 @@ async fn resolve_tree_create_workspace_id(
|
||||
}
|
||||
}
|
||||
|
||||
let bootstrap = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"workspaces:ensureDefaultWorkspace",
|
||||
json!({
|
||||
"fallbackName": context.auth.actor_id,
|
||||
"workspaceIdIfCreate": generate_tree_document_id(),
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
"tree_command_workspace_bootstrap",
|
||||
)
|
||||
.await?;
|
||||
bootstrap
|
||||
.get("activeWorkspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_gateway_code(
|
||||
"workspace_bootstrap_bad_response",
|
||||
"workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_workspace_bootstrap")
|
||||
})
|
||||
// 云 workspace bootstrap 已移除;无 local context 时直接要求 local_folder。
|
||||
Err(local_required(context, "tree_command_workspace_required"))
|
||||
}
|
||||
|
||||
fn operation_resource_relative_path(value: &Value, key: &str) -> Option<String> {
|
||||
@@ -2505,7 +2400,7 @@ pub async fn tree_command(
|
||||
.with_header("x-error-phase", "tree_command_validate"));
|
||||
}
|
||||
};
|
||||
let requested_workspace_id = match &request {
|
||||
let _requested_workspace_id = match &request {
|
||||
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
|
||||
TreeCommandRequest::CreateFolder { workspace_id, .. } => workspace_id.as_deref(),
|
||||
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
|
||||
@@ -2659,77 +2554,13 @@ pub async fn tree_command(
|
||||
}),
|
||||
));
|
||||
}
|
||||
let effective_workspace_id = match &request {
|
||||
TreeCommandRequest::Create { parent_id, .. } => {
|
||||
resolve_tree_create_workspace_id(
|
||||
&state,
|
||||
&context,
|
||||
requested_workspace_id,
|
||||
parent_id.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
TreeCommandRequest::CreateFolder { .. } => {
|
||||
// 非 local_folder 命令一律拒绝
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
"convex_workspace 暂不支持 folder create capability",
|
||||
"local_required",
|
||||
"tree command 仅支持 local_folder(sourceKind=local_folder + rootUri)",
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header("x-error-phase", "tree_command_validate"));
|
||||
}
|
||||
_ => resolve_effective_workspace_id(&context, requested_workspace_id, true)?
|
||||
.expect("workspace_required 已确保存在"),
|
||||
};
|
||||
let needs_move_preflight = matches!(&request, TreeCommandRequest::Move { .. });
|
||||
let mut command_wire = create_command_wire(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
request,
|
||||
&envelope_context,
|
||||
)?;
|
||||
if needs_move_preflight {
|
||||
command_wire.preflight_data =
|
||||
Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?);
|
||||
}
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
command_wire,
|
||||
)
|
||||
.await?;
|
||||
let response_document_id = execution
|
||||
.result
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or(requested_document_id);
|
||||
let artifacts = execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
.and_then(|artifacts| serde_json::to_value(artifacts).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let artifact_error = execution
|
||||
.artifact_error
|
||||
.as_ref()
|
||||
.map(|message| Value::String(message.clone()))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
Ok(json_response(
|
||||
&context,
|
||||
json!({
|
||||
"workspaceId": effective_workspace_id,
|
||||
"action": action,
|
||||
"documentId": response_document_id,
|
||||
"parentId": requested_parent_id,
|
||||
"title": requested_title,
|
||||
"sortOrder": requested_sort_order,
|
||||
"updatedAt": execution.result.get("updated_at").cloned().unwrap_or(Value::Null),
|
||||
"execution": execution.result,
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
}),
|
||||
))
|
||||
.with_header("x-error-phase", "tree_command_local_required"));
|
||||
}
|
||||
|
||||
pub async fn reduce_tree_shell_runtime(
|
||||
@@ -2752,7 +2583,7 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
||||
TreeCommandRequest, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG,
|
||||
TreeCommandRequest,
|
||||
};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
@@ -2769,17 +2600,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
|
||||
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -2953,8 +2778,6 @@ mod tests {
|
||||
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
|
||||
@@ -3144,17 +2967,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -3890,17 +3707,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -4428,11 +4239,11 @@ mod tests {
|
||||
assert_eq!(create_wire.name, "tree.node.create");
|
||||
assert_eq!(
|
||||
create_wire.source.source_kind.as_deref(),
|
||||
Some("convex_workspace")
|
||||
Some("local_folder")
|
||||
);
|
||||
assert_eq!(
|
||||
create_wire.source.root_uri.as_deref(),
|
||||
Some("convex://workspace/ws_demo")
|
||||
None
|
||||
);
|
||||
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
|
||||
assert!(create_wire
|
||||
@@ -4477,8 +4288,8 @@ mod tests {
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
let envelope_context = TreeCommandEnvelopeContext {
|
||||
source_kind: Some("convex_workspace".into()),
|
||||
root_uri: Some("convex://workspace/ws_demo".into()),
|
||||
source_kind: Some("local_folder".into()),
|
||||
root_uri: Some("file:///tmp/ws_demo".into()),
|
||||
source_capabilities: vec![
|
||||
"load-snapshot".into(),
|
||||
"preflight-command".into(),
|
||||
@@ -4511,11 +4322,11 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
rename_wire.source.source_kind.as_deref(),
|
||||
Some("convex_workspace")
|
||||
Some("local_folder")
|
||||
);
|
||||
assert_eq!(
|
||||
rename_wire.source.root_uri.as_deref(),
|
||||
Some("convex://workspace/ws_demo")
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
rename_wire.payload["targetNodeId"],
|
||||
@@ -4557,18 +4368,10 @@ mod tests {
|
||||
&TreeCommandEnvelopeContext::default(),
|
||||
)
|
||||
.expect("tree create wire");
|
||||
let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.create".into(),
|
||||
..tree_create_wire.clone()
|
||||
};
|
||||
let tree_create_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire)
|
||||
.expect("tree create plan");
|
||||
let compat_create_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
|
||||
.expect("compat create plan");
|
||||
assert_eq!(tree_create_plan.function_name, "tree.node.create");
|
||||
assert_eq!(compat_create_plan.function_name, "documents.create");
|
||||
|
||||
let tree_rename_wire = create_command_wire(
|
||||
&context,
|
||||
@@ -4581,18 +4384,10 @@ mod tests {
|
||||
&TreeCommandEnvelopeContext::default(),
|
||||
)
|
||||
.expect("tree rename wire");
|
||||
let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.title.update".into(),
|
||||
..tree_rename_wire.clone()
|
||||
};
|
||||
let tree_rename_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire)
|
||||
.expect("tree rename plan");
|
||||
let compat_rename_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
|
||||
.expect("compat rename plan");
|
||||
assert_eq!(tree_rename_plan.function_name, "tree.node.rename");
|
||||
assert_eq!(compat_rename_plan.function_name, "documents.title.update");
|
||||
|
||||
let tree_move_wire = create_command_wire(
|
||||
&context,
|
||||
@@ -4606,44 +4401,8 @@ mod tests {
|
||||
&TreeCommandEnvelopeContext::default(),
|
||||
)
|
||||
.expect("tree move wire");
|
||||
let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
|
||||
name: "documents.move".into(),
|
||||
..tree_move_wire.clone()
|
||||
};
|
||||
let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire)
|
||||
.expect("tree move plan");
|
||||
let compat_move_plan =
|
||||
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
|
||||
.expect("compat move plan");
|
||||
assert_eq!(tree_move_plan.function_name, "tree.subtree.move");
|
||||
assert_eq!(compat_move_plan.function_name, "documents.move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
|
||||
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
|
||||
assert!(aliases
|
||||
.iter()
|
||||
.all(|entry| entry.compat_command.starts_with("documents.")));
|
||||
assert!(aliases
|
||||
.iter()
|
||||
.all(|entry| entry.preferred_command.starts_with("tree.")));
|
||||
assert!(aliases
|
||||
.iter()
|
||||
.all(|entry| entry.source_kind == "convex_workspace"));
|
||||
assert!(aliases
|
||||
.iter()
|
||||
.all(|entry| entry.retained_for.contains("legacy cloud")));
|
||||
assert!(aliases
|
||||
.iter()
|
||||
.all(|entry| entry.retirement_condition.contains("emit tree.")));
|
||||
assert!(aliases.iter().any(|entry| {
|
||||
entry.compat_command == "documents.delete"
|
||||
&& entry.preferred_command == "tree.node.archive"
|
||||
}));
|
||||
assert!(aliases.iter().any(|entry| {
|
||||
entry.compat_command == "documents.copy_tree"
|
||||
&& entry.preferred_command == "tree.subtree.copy"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ fn resolve_scope(
|
||||
root_uri: &str,
|
||||
scope: &str,
|
||||
) -> Result<TreeViewStateScope, WebError> {
|
||||
let source_kind = normalize_or_default(source_kind, "convex_workspace");
|
||||
let source_kind = normalize_or_default(source_kind, "local_folder");
|
||||
let tree_kind = normalize_tree_kind(context, tree_kind)?;
|
||||
let root_uri = root_uri.trim().to_string();
|
||||
let scope = normalize_or_default(scope, "root");
|
||||
|
||||
@@ -348,7 +348,7 @@ fn page_preference_scope(
|
||||
) -> Result<PagePreferenceScope, WebError> {
|
||||
let source_kind = source_kind.trim();
|
||||
let source_kind = if source_kind.is_empty() {
|
||||
"convex_workspace"
|
||||
"local_folder"
|
||||
} else {
|
||||
source_kind
|
||||
};
|
||||
|
||||
@@ -87,17 +87,73 @@ fn actor_id(context: &RequestContext) -> String {
|
||||
}
|
||||
|
||||
fn require_authenticated(context: &RequestContext) -> Result<String, WebError> {
|
||||
// 7-76:Web PAT(mnpat1)与密码箱分职 — 禁止用笔记 PAT 读密/管箱。
|
||||
// 读密通道:浏览器 session,或 agent vault token(mnv1.*)。
|
||||
if crate::routes::api_access_token::is_pat_auth(context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"vault_via_web_pat_forbidden",
|
||||
"Web PAT(mnpat1)不能访问密码箱;请使用浏览器会话,或签发 vault token(mnv1)后调用",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
let id = actor_id(context);
|
||||
if id == "anonymous" {
|
||||
return Err(WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"vault_auth_required",
|
||||
"密码箱需要登录会话",
|
||||
));
|
||||
"密码箱需要登录会话或 vault token(mnv1)",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// agent vault token(auth_method=vault_token)时强制 scope;session/extension 不限。
|
||||
fn require_vault_agent_scope(context: &RequestContext, need: &str) -> Result<(), WebError> {
|
||||
if context.auth.auth_method != "vault_token" {
|
||||
return Ok(());
|
||||
}
|
||||
let need = need.trim();
|
||||
let ok = context.auth.scopes.iter().any(|s| {
|
||||
let s = s.trim();
|
||||
s == "*"
|
||||
|| s == need
|
||||
|| s == format!("vault.{need}")
|
||||
|| (need == "list" && (s == "get" || s == "vault.get")) // get 蕴含 list 选型
|
||||
|| (need == "get" && (s == "list" || s == "vault.list")) // list 常可看 L0 详情
|
||||
});
|
||||
if ok {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"vault_scope_denied",
|
||||
format!("vault token 缺少 scope: {need}"),
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
/// 从 Authorization 提取 mnv1 token。
|
||||
pub fn bearer_mnv1(authorization: Option<&str>) -> Option<&str> {
|
||||
let raw = authorization?
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| authorization?.strip_prefix("bearer "))?
|
||||
.trim();
|
||||
if raw.starts_with("mnv1.") {
|
||||
Some(raw)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 mnv1 vault capability token。
|
||||
pub fn verify_agent_vault_token(token: &str) -> Result<mnote_vault_core::VaultTokenClaims, WebError> {
|
||||
let key = mnote_vault_core::load_or_create_hmac_key(&mnote_vault_core::default_hmac_key_path())
|
||||
.map_err(WebError::from)?;
|
||||
mnote_vault_core::verify_token(&key, token).map_err(WebError::from)
|
||||
}
|
||||
|
||||
fn require_root_uri(root_uri: Option<&str>) -> Result<&str, WebError> {
|
||||
root_uri
|
||||
.map(str::trim)
|
||||
@@ -529,6 +585,7 @@ pub async fn list(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "list")?;
|
||||
let root_uri = require_root_uri(query.root_uri.as_deref())?;
|
||||
let root = resolve_read_root(&state, &context, root_uri).await?;
|
||||
let status = parse_status(query.status.as_deref())?;
|
||||
@@ -594,6 +651,7 @@ pub async fn get_item(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "get")?;
|
||||
let root_uri = require_root_uri(query.root_uri.as_deref())?;
|
||||
let root = resolve_read_root(&state, &context, root_uri).await?;
|
||||
let record = vault_store::get_credential(&root, &id)?;
|
||||
@@ -1190,6 +1248,7 @@ pub async fn resolve_item(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
let actor = require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "resolve")?;
|
||||
let map = body.as_object().ok_or_else(|| {
|
||||
WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象")
|
||||
})?;
|
||||
@@ -1254,6 +1313,7 @@ pub async fn list_ai(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "list")?;
|
||||
let status = parse_status(query.status.as_deref())?;
|
||||
// 尽力修复旧数据分组(失败不阻断列表)
|
||||
let repair = repair_ai_folder_paths_best_effort();
|
||||
@@ -1347,6 +1407,7 @@ pub async fn get_ai_item(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "get")?;
|
||||
let result = get_ai_vault_item(&id)?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
@@ -1359,6 +1420,7 @@ pub async fn resolve_ai_item(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
let actor = require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "resolve")?;
|
||||
let map = body.as_object().ok_or_else(|| {
|
||||
WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象")
|
||||
})?;
|
||||
@@ -1405,6 +1467,7 @@ pub async fn login_ai_item(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
let actor = require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "login")?;
|
||||
let force = body
|
||||
.get("forceRefresh")
|
||||
.or_else(|| body.get("force_refresh"))
|
||||
@@ -1927,6 +1990,15 @@ pub async fn list_local_agent_vault_tokens(
|
||||
axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
// 本机安装列表仅浏览器 session / admin;Web PAT 不走此面
|
||||
if crate::routes::api_access_token::is_pat_auth(&context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"vault_via_web_pat_forbidden",
|
||||
"Web PAT 不能查看本机 vault 安装列表",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -2081,6 +2153,7 @@ pub async fn put_ai_session(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
require_vault_enabled()?;
|
||||
let actor = require_authenticated(&context)?;
|
||||
require_vault_agent_scope(&context, "session")?;
|
||||
let cookie = body
|
||||
.get("cookieHeader")
|
||||
.or_else(|| body.get("cookie_header"))
|
||||
|
||||
@@ -3,10 +3,6 @@ use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self};
|
||||
use crate::error::WebError;
|
||||
use crate::page_aggregate::PageAggregate;
|
||||
use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
@@ -15,7 +11,6 @@ use crate::routes::local_folder_source::{
|
||||
load_local_folder_page_tree_scope_snapshot_with_reveal,
|
||||
load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
||||
};
|
||||
@@ -34,7 +29,6 @@ use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use control_plane::UpsertNavigationRecentInput;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
@@ -2108,7 +2102,7 @@ pub async fn office_preview_vendor_asset(
|
||||
.join(relative);
|
||||
let bytes = std::fs::read(&path).map_err(|error| {
|
||||
WebError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"office_preview_vendor_asset_unavailable",
|
||||
format!("Office preview vendor asset 不可用,请先运行 npm install: {error}"),
|
||||
)
|
||||
@@ -2391,7 +2385,7 @@ pub async fn pdfjs_asset(Path(asset_path): Path<String>) -> Result<Response, Web
|
||||
.join(relative);
|
||||
let bytes = std::fs::read(&path).map_err(|error| {
|
||||
WebError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"pdfjs_asset_unavailable",
|
||||
format!("PDF.js asset 不可用,请先运行 npm install: {error}"),
|
||||
)
|
||||
@@ -3084,11 +3078,22 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
_workspace_id: Option<&str>,
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<PageAggregate, WebError> {
|
||||
if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
|
||||
let kind = source_kind.map(str::trim).filter(|value| !value.is_empty());
|
||||
// local-md: 文档在未显式传 sourceKind 时仍走 local_folder
|
||||
let is_local = kind == Some("local_folder")
|
||||
|| (kind.is_none() && document_id.trim().starts_with("local-md:"));
|
||||
if !is_local {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_required",
|
||||
"Page Aggregate 仅支持 local_folder(sourceKind=local_folder + rootUri)",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
|
||||
let root_uri = root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -3114,50 +3119,10 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
state,
|
||||
context,
|
||||
&mut aggregate,
|
||||
source_kind,
|
||||
Some("local_folder"),
|
||||
Some(root_uri_for_preferences.as_str()),
|
||||
)?;
|
||||
return Ok(aggregate);
|
||||
}
|
||||
|
||||
let meta = load_document_meta_result(
|
||||
state,
|
||||
context,
|
||||
DocumentMetaQuery {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: workspace_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let content = load_document_content_result(
|
||||
state,
|
||||
context,
|
||||
DocumentContentQuery {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: workspace_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let projection = execute_runtime_query_against_data(
|
||||
context,
|
||||
workspace_id,
|
||||
RuntimeQueryEnvelopeWire {
|
||||
name: "page.aggregate.get".into(),
|
||||
payload: json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
},
|
||||
json!({
|
||||
"meta": meta,
|
||||
"content": content,
|
||||
}),
|
||||
)?;
|
||||
|
||||
serde_json::from_value::<PageAggregate>(projection).map_err(|error| {
|
||||
WebError::internal(format!("Page Aggregate projection 反序列化失败: {error}"))
|
||||
})
|
||||
Ok(aggregate)
|
||||
}
|
||||
|
||||
fn annotate_local_attachment_refs_authorization(
|
||||
@@ -3611,50 +3576,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"updated_at": "2026-04-18T09:30:00Z",
|
||||
"can_edit": true,
|
||||
"word_count": 42,
|
||||
"character_count": 128,
|
||||
"block_count": 3
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["editor_1"],
|
||||
"blocks": [{
|
||||
"blockId": "editor_1",
|
||||
"blockType": "paragraph",
|
||||
"contentNodes": [{
|
||||
"payload": {"type": "text", "text": "来自 editorDocument 的正文"},
|
||||
"attrs": {}
|
||||
}],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -3669,37 +3595,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"updated_at": "2026-04-18T09:30:00Z",
|
||||
"can_edit": true,
|
||||
"word_count": 42,
|
||||
"character_count": 128,
|
||||
"block_count": 3
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -3785,23 +3685,17 @@ mod tests {
|
||||
.expect("grant control-plane read");
|
||||
}
|
||||
|
||||
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
|
||||
fn app_without_local_source() -> axum::Router {
|
||||
build_app(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: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: Some("http://127.0.0.1:9".into()),
|
||||
convex_admin_key: Some("test-admin-key".into()),
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
@@ -3891,8 +3785,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_local_markdown_without_source_kind_does_not_fall_back_to_convex() {
|
||||
let response = app_with_unreachable_convex_without_fixture()
|
||||
async fn document_shell_local_markdown_without_source_kind_uses_local_inference() {
|
||||
let response = app_without_local_source()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/documents/local-md:docs~2FPlan.md?resourceTab=primary%3A%3Aresource%3Afile%3Afile%3A%2F%2F%2Ftmp%3Adocs%2FPlan.pdf")
|
||||
@@ -3910,7 +3804,7 @@ mod tests {
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("convex_retired")
|
||||
Some("local_required")
|
||||
);
|
||||
let location = response
|
||||
.headers()
|
||||
@@ -3934,17 +3828,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -4532,8 +4420,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
|
||||
let response = app_with_unreachable_convex_without_fixture()
|
||||
async fn page_aggregate_endpoint_errors_without_local_source() {
|
||||
let response = app_without_local_source()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
|
||||
@@ -4543,13 +4431,13 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
|
||||
if response.status() != StatusCode::BAD_REQUEST {
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
panic!(
|
||||
"expected SERVICE_UNAVAILABLE, got {status}: {}",
|
||||
"expected BAD_REQUEST, got {status}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
}
|
||||
@@ -4558,35 +4446,23 @@ mod tests {
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("convex_retired")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-phase")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("convex_query_retired")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-upstream-service")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("legacy-cloud-retired")
|
||||
Some("local_required")
|
||||
);
|
||||
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "convex_retired");
|
||||
assert_eq!(payload["code"], "local_required");
|
||||
assert!(payload.get("schema").is_none());
|
||||
assert!(payload.get("result").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_errors_without_convex_or_fixture() {
|
||||
let response = app_with_unreachable_convex_without_fixture()
|
||||
async fn document_shell_errors_without_local_source() {
|
||||
let response = app_without_local_source()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/documents/doc_1?workspaceId=ws_demo")
|
||||
@@ -4598,13 +4474,13 @@ mod tests {
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
|
||||
if response.status() != StatusCode::BAD_REQUEST {
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
panic!(
|
||||
"expected SERVICE_UNAVAILABLE, got {status}: {}",
|
||||
"expected BAD_REQUEST, got {status}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
}
|
||||
@@ -4613,7 +4489,7 @@ mod tests {
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("convex_retired")
|
||||
Some("local_required")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
@@ -4621,7 +4497,7 @@ mod tests {
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "convex_retired");
|
||||
assert_eq!(payload["code"], "local_required");
|
||||
assert!(!text.contains("mnote.page_aggregate.v1"));
|
||||
assert!(!text.contains("data-mnote-dev-fixture"));
|
||||
assert!(!text.contains("data-page-aggregate-snapshot"));
|
||||
@@ -4703,17 +4579,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -4772,17 +4642,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -4931,17 +4795,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
@@ -4995,7 +4853,7 @@ mod tests {
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"sourceKind": "convex_workspace",
|
||||
"sourceKind": "local_folder",
|
||||
"documentId": "doc_1",
|
||||
"updates": {
|
||||
"showHeadingNumbers": true,
|
||||
@@ -5014,7 +4872,7 @@ mod tests {
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=convex_workspace&documentId=doc_1")
|
||||
.uri("/api/ui/preferences/effective?workspaceId=ws_demo&sourceKind=local_folder&documentId=doc_1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -5487,17 +5345,11 @@ mod tests {
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
enable_page_ai_pi_lab: false,
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
|
||||
allow_dev_fixtures: false,
|
||||
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(),
|
||||
|
||||
@@ -338,7 +338,7 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.asset.open"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openConvexAssetFromFileTree"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openLocalAssetFromFileTree"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildMindmapOpenPath"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("isMindmapAssetDetail"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("^思维导图"));
|
||||
@@ -985,7 +985,7 @@ mod tests {
|
||||
assert!(
|
||||
!SIDEBAR_TREE_RUNTIME_JS
|
||||
.contains("targetUrl.searchParams.set('sourceKind', 'convex_workspace')"),
|
||||
"默认来源菜单不能跳到已退出 dev 主链的 convex_workspace"
|
||||
"默认来源菜单只能走 local_folder,不得设置已退役的云 workspace sourceKind"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1011,7 +1011,7 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
TREE_LIVE_CONTROLLER_JS.contains("body.getAttribute('data-mnote-source-kind')"),
|
||||
"根入口由 SSR body 暴露 local_folder 时,tree live 不能误走 retired Convex WS/SSE"
|
||||
"根入口由 SSR body 暴露 local_folder 时,tree live 必须走 local-folder-events,不得走云 WS/SSE"
|
||||
);
|
||||
assert!(
|
||||
TREE_LIVE_CONTROLLER_JS.contains("bootstrap.transport === 'local-folder-events'"),
|
||||
@@ -1151,7 +1151,7 @@ mod tests {
|
||||
assert!(
|
||||
LOCAL_UPLOAD_RUNTIME_JS
|
||||
.contains("var imageUrl = localOpenUrl || fallbackUrl || markdownHref;"),
|
||||
"图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中退役 Convex 兼容路径"
|
||||
"图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中错误路径"
|
||||
);
|
||||
assert!(
|
||||
LOCAL_UPLOAD_RUNTIME_JS.contains("setImage({ src: imageUrl"),
|
||||
|
||||
@@ -1 +1 @@
|
||||
pub mod legacy_cloud_guard;
|
||||
pub mod local_only;
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, retired_command_transport_function_name,
|
||||
retired_query_transport_function_name, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan,
|
||||
RuntimeQueryExecutionPlan,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RetiredCloudCommandExecution {
|
||||
pub result: Value,
|
||||
pub artifacts: Option<RuntimeCommandArtifactPlan>,
|
||||
pub artifact_error: Option<String>,
|
||||
}
|
||||
|
||||
fn retired_error(context: &RequestContext, phase: &'static str) -> WebError {
|
||||
WebError::service_unavailable_code(
|
||||
"convex_retired",
|
||||
"旧 cloud/Convex 兼容运行时已退役;请使用 local-first Rust/SQLite control-plane 路径",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
.with_header("x-upstream-service", "legacy-cloud-retired")
|
||||
}
|
||||
|
||||
fn load_query_fixture(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
if !config.allow_dev_fixtures {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(raw) = config.query_fixtures_json.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let fixtures: Value = serde_json::from_str(trimmed).map_err(|error| {
|
||||
WebError::internal(format!("MNOTE_WEB_QUERY_FIXTURES_JSON 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "fixture_parse")
|
||||
})?;
|
||||
|
||||
let Some(map) = fixtures.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(fixture) = map.get(plan.function_name.as_str()) {
|
||||
return Ok(Some(fixture.clone()));
|
||||
}
|
||||
Ok(retired_query_transport_function_name(&plan.query_name)
|
||||
.ok()
|
||||
.and_then(|legacy_name| map.get(legacy_name))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn load_mutation_fixture_by_name(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
function_name: &str,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
if !config.allow_dev_fixtures {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(raw) = config.mutation_fixtures_json.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let fixtures: Value = serde_json::from_str(trimmed).map_err(|error| {
|
||||
WebError::internal(format!("MNOTE_WEB_MUTATION_FIXTURES_JSON 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "fixture_parse")
|
||||
})?;
|
||||
|
||||
Ok(fixtures
|
||||
.as_object()
|
||||
.and_then(|map| map.get(function_name))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn load_mutation_fixture(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
if let Some(fixture) =
|
||||
load_mutation_fixture_by_name(config, context, plan.function_name.as_str())?
|
||||
{
|
||||
return Ok(Some(fixture));
|
||||
}
|
||||
if let Some(legacy_name) = resource_lifecycle_transport_function_name(plan) {
|
||||
if let Some(fixture) = load_mutation_fixture_by_name(config, context, legacy_name)? {
|
||||
return Ok(Some(fixture));
|
||||
}
|
||||
}
|
||||
retired_command_transport_function_name(&plan.command_name)
|
||||
.ok()
|
||||
.map(|legacy_name| load_mutation_fixture_by_name(config, context, legacy_name))
|
||||
.transpose()
|
||||
.map(|fixture| fixture.flatten())
|
||||
}
|
||||
|
||||
fn resource_lifecycle_transport_function_name(
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Option<&'static str> {
|
||||
let action = match plan.command_name.as_str() {
|
||||
"tree.resource.archive" => "archive",
|
||||
"tree.resource.restore" => "restore",
|
||||
"tree.resource.purge" => "purge",
|
||||
"tree.resource.rename" => "rename",
|
||||
_ => return None,
|
||||
};
|
||||
let resource_kind = plan
|
||||
.args_json
|
||||
.get("resourceLifecyclePlan")
|
||||
.and_then(|value| value.get("resourceKind"))
|
||||
.or_else(|| plan.args_json.get("resourceKind"))
|
||||
.and_then(Value::as_str)?;
|
||||
match (action, resource_kind) {
|
||||
("archive" | "restore" | "rename", "file" | "media") => Some("mediaAssets:patchById"),
|
||||
("purge", "file" | "media") => Some("mediaAssets:purgeById"),
|
||||
("archive", "mindmap") => Some("mindmaps:softDelete"),
|
||||
("restore", "mindmap") => Some("mindmaps:restore"),
|
||||
("purge", "mindmap") => Some("mindmaps:purge"),
|
||||
("archive", "table") => Some("tables:remove"),
|
||||
("restore", "table") => Some("tables:restore"),
|
||||
("purge", "table") => Some("tables:purge"),
|
||||
("rename", "table") => Some("tables:update"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_retired_query_plan(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
) -> Result<Value, WebError> {
|
||||
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"transport_query_unsupported",
|
||||
format!("mnote-web transport 暂不支持 query: {}", plan.function_name),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "plan_validation"));
|
||||
}
|
||||
|
||||
if let Some(fixture) = load_query_fixture(config, context, plan)? {
|
||||
return Ok(fixture);
|
||||
}
|
||||
|
||||
Err(retired_error(context, "convex_query_retired"))
|
||||
}
|
||||
|
||||
pub async fn execute_sidebar_dataset_query(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_retired_query_plan(config, context, plan).await
|
||||
}
|
||||
|
||||
pub async fn execute_retired_query_by_name(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
function_name: &str,
|
||||
args: Value,
|
||||
workspace_id: Option<&str>,
|
||||
error_phase: &'static str,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = RuntimeQueryExecutionPlan {
|
||||
query_name: function_name.to_string(),
|
||||
function_name: function_name.to_string(),
|
||||
workspace_id: workspace_id.map(ToOwned::to_owned),
|
||||
request_id: context.trace.request_id.clone(),
|
||||
trace_id: context.trace.trace_id.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
payload_json: args.to_string(),
|
||||
args_json: args,
|
||||
};
|
||||
execute_retired_query_plan(config, context, &plan)
|
||||
.await
|
||||
.map_err(|error| error.with_header("x-error-phase", error_phase))
|
||||
}
|
||||
|
||||
pub async fn execute_retired_command_plan(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<Value, WebError> {
|
||||
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"transport_command_unsupported",
|
||||
format!(
|
||||
"mnote-web transport 暂不支持 command: {}",
|
||||
plan.function_name
|
||||
),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "plan_validation"));
|
||||
}
|
||||
|
||||
if let Some(fixture) = load_mutation_fixture(config, context, plan)? {
|
||||
return Ok(fixture);
|
||||
}
|
||||
|
||||
Err(retired_error(context, "convex_mutation_retired"))
|
||||
}
|
||||
|
||||
pub async fn execute_retired_mutation_by_name(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
function_name: &str,
|
||||
args: Value,
|
||||
_workspace_id: Option<&str>,
|
||||
_idempotency_key: Option<&str>,
|
||||
error_phase: &'static str,
|
||||
) -> Result<Value, WebError> {
|
||||
if let Some(fixture) = load_mutation_fixture_by_name(config, context, function_name)? {
|
||||
return Ok(fixture);
|
||||
}
|
||||
|
||||
let _ = args;
|
||||
Err(retired_error(context, error_phase))
|
||||
}
|
||||
|
||||
pub async fn persist_runtime_command_artifacts(
|
||||
_config: &AppConfig,
|
||||
_context: &RequestContext,
|
||||
_artifacts: &RuntimeCommandArtifactPlan,
|
||||
) -> Result<(), WebError> {
|
||||
// Convex 已退役。Rust 侧仍会把 artifact plan 返回给调用方和 realtime
|
||||
// consumer;这里保持 no-op,避免兼容路径因为历史持久化层退役而失败。
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_retired_command_plan_with_artifacts(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
runtime_context: &bridge_runtime::RuntimeBridgeContextWire,
|
||||
command: &bridge_runtime::RuntimeCommandEnvelopeWire,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<RetiredCloudCommandExecution, WebError> {
|
||||
let result = execute_retired_command_plan(config, context, plan).await?;
|
||||
let now = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
|
||||
let artifacts =
|
||||
build_runtime_command_artifact_plan(runtime_context, command, plan, &result, &now);
|
||||
let artifact_error = if let Some(artifacts) = artifacts.as_ref() {
|
||||
persist_runtime_command_artifacts(config, context, artifacts)
|
||||
.await
|
||||
.err()
|
||||
.map(|error| error.message().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(RetiredCloudCommandExecution {
|
||||
result,
|
||||
artifacts,
|
||||
artifact_error,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
|
||||
/// local-first only:非 local_folder 请求统一拒绝(无 cloud/兼容执行链)。
|
||||
pub fn local_required(context: &RequestContext, phase: &'static str) -> WebError {
|
||||
WebError::bad_request_code(
|
||||
"local_required",
|
||||
"仅支持 local_folder(sourceKind=local_folder + rootUri)",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", phase)
|
||||
}
|
||||
@@ -1164,7 +1164,7 @@ fn apply_ok_feedback<E>(
|
||||
apply_feedback_result(setter, result.map(|_| ok_message));
|
||||
}
|
||||
|
||||
fn open_hermes_page_ai_drawer() {
|
||||
fn open_page_ai_drawer() {
|
||||
let Some(win) = window() else {
|
||||
return;
|
||||
};
|
||||
@@ -1214,7 +1214,7 @@ fn request_ai_edit_bridge(
|
||||
set_ai_bridge_message,
|
||||
set_command_feedback,
|
||||
);
|
||||
open_hermes_page_ai_drawer();
|
||||
open_page_ai_drawer();
|
||||
}
|
||||
|
||||
fn normalize_layout_density(value: Option<String>) -> String {
|
||||
|
||||
@@ -78,8 +78,8 @@ async function seedAiRuntime(requestContext, baseUrl, options) {
|
||||
session_id: options.sessionId,
|
||||
run_id: options.runId,
|
||||
title: options.title,
|
||||
profile: options.profile || "reasonix",
|
||||
acp_runtime: options.acpRuntime || options.profile || "reasonix",
|
||||
profile: options.profile || "pi",
|
||||
acp_runtime: options.acpRuntime || options.profile || "pi",
|
||||
trace_id: options.traceId,
|
||||
status: options.status || "running",
|
||||
runtime_json: options.runtimeJson || {},
|
||||
|
||||
@@ -293,7 +293,6 @@ async function main() {
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
RUST_LOG: process.env.RUST_LOG || "warn",
|
||||
};
|
||||
const child = spawn(BINARY, [], {
|
||||
|
||||
@@ -64,8 +64,6 @@ function startGateway(port) {
|
||||
...buildDocumentFixtureEnv(),
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
};
|
||||
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: "/mnt/Data1T/mnote/rust",
|
||||
|
||||
@@ -15,8 +15,6 @@ function buildFixtureEnv(port) {
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
|
||||
"documents:getMeta": {
|
||||
id: "doc_auth",
|
||||
|
||||
@@ -124,7 +124,7 @@ async function main() {
|
||||
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
|
||||
|
||||
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!bodyText.includes("legacy_next_compat_disabled"), "本地文件夹主入口不能落入 legacy Next fallback");
|
||||
assert(!bodyText.includes("legacy_next_compat"), "本地文件夹主入口不能出现已退役的 legacy Next 文案");
|
||||
const readmeRow = page.locator('.tree-row[data-row-id="local:markdown:README.md"]');
|
||||
const docsRow = page.locator('.tree-row[data-row-id="local:folder:docs"]');
|
||||
await readmeRow.waitFor({
|
||||
@@ -206,7 +206,7 @@ async function main() {
|
||||
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const finalUrl = new URL(page.url());
|
||||
assert(
|
||||
finalUrl.pathname === "/" && !page.url().includes("legacy_next_compat_disabled"),
|
||||
finalUrl.pathname === "/" && !page.url().includes("legacy_next_compat"),
|
||||
"回到主入口后应仍由 Rust Web 主壳承载",
|
||||
);
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ function buildFixtureEnv(port) {
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
|
||||
"documents:getMeta": {
|
||||
id: "doc_1",
|
||||
|
||||
@@ -82,7 +82,6 @@ async function main() {
|
||||
...process.env,
|
||||
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_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -99,7 +99,6 @@ async function main() {
|
||||
...process.env,
|
||||
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_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -31,8 +31,6 @@ function startGateway(port) {
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -85,7 +85,6 @@ async function main() {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
...controlPlaneEnv,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
|
||||
@@ -120,7 +120,6 @@ async function main() {
|
||||
...process.env,
|
||||
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_LOCAL_ACCESS_POLICY_FILE: policyFile,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -85,7 +85,6 @@ async function main() {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
...controlPlaneEnv,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
|
||||
@@ -161,7 +161,6 @@ async function main() {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
...controlPlaneEnv,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
|
||||
@@ -134,7 +134,6 @@ async function main() {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
|
||||
...controlPlaneEnv,
|
||||
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
|
||||
@@ -46,8 +46,6 @@ function startGateway(port, devHot) {
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
};
|
||||
delete env.MNOTE_WEB_DEV_HOT_RELOAD;
|
||||
if (devHot) env.MNOTE_WEB_DEV_HOT_RELOAD = "1";
|
||||
|
||||
@@ -41,8 +41,6 @@ function startGateway(port) {
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
};
|
||||
const child = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: "/mnt/Data1T/mnote/rust",
|
||||
|
||||
@@ -114,7 +114,6 @@ async function main() {
|
||||
...process.env,
|
||||
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_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -117,7 +117,6 @@ async function startLocalServer(dataRoot) {
|
||||
...process.env,
|
||||
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_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
Reference in New Issue
Block a user