diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md index 38c31b13..439d9773 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -280,8 +280,8 @@ - 验证:`cargo test -p mnote-web shared_cache -- --nocapture`;`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`。 - [x] 单测:shared AI session 不扩大 workspace root。 - 验证:`cargo test -p mnote-web acp_runtime_env_limits_shared_scope_to_granted_files -- --nocapture`。 -- [ ] browser smoke:共享页面只读用户无法通过 AI 写入正文。 - - 说明:当前已有 route / runtime 单测闭环;浏览器 smoke 仍需补脚本覆盖 UI 层拒写提示。 +- [x] browser smoke:共享页面只读用户无法通过 AI 写入正文。 + - 验证:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task454-shared-read-page-ai-browser-smoke.js`。 --- @@ -293,23 +293,33 @@ ### 6.1 Resource Tree 对象统一 -- [ ] 盘点 simplemindmap 当前 object identity、保存路径、打开路径。 -- [ ] 盘点 office 当前 object identity、保存路径、打开路径。 +- [x] 盘点 simplemindmap 当前 object identity、保存路径、打开路径。 + - 证据:`/mindmap/{documentId}/{mindmapId}` 对象壳输出 `resource:mindmap:{documentId}:{mindmapId}`;`mindmap_shell_returns_rust_object_shell_contract`;保存仍通过 `mindmaps.put` / `mindmap.command.apply` 兼容链。 +- [x] 盘点 office 当前 object identity、保存路径、打开路径。 + - 证据:新增 `/office/{documentId}/{assetId}` 对象壳输出 `resource:onlyoffice:{documentId}:{assetId}`,内部复用 `/onlyoffice` bootstrap;`cargo test -p mnote-web onlyoffice_object_shell_exposes_resource_identity -- --nocapture`;`cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --nocapture`。 - [ ] 统一资源创建、重命名、移动、删除、恢复命令到 `tree.resource.*`。 -- [ ] filetree 显示资源行,pagetree 只显示页面导航投影。 -- [ ] Markdown 中插入资源引用时,只写相对链接或嵌入引用。 +- [x] filetree 显示资源行,pagetree 只显示页面导航投影。 + - 验证:`cargo test -p mnote-web file_tree_projection_ -- --nocapture`;`cargo test -p bridge-runtime file_tree_projection -- --nocapture`。 +- [x] Markdown 中插入资源引用时,只写相对链接或嵌入引用。 + - 证据:`task169-mindmap-realtime-smoke.js` 的 `assertDocumentMindmapBlockUsesReferenceSource` 验证 `mindmapId/rootNodeId` 且不写入 `data`;附件/office 仍通过链接或 asset 引用打开对象壳。 ### 6.2 AI 资源工具 -- [ ] 设计 `mnote.mindmap.*` 工具:读取结构、增删改节点、移动节点、导出 markdown summary。 -- [ ] 设计 `mnote.office.*` 工具:读取文本摘要、写入建议、导出变更摘要;真实编辑优先复用 officecli / OnlyOffice 保存链。 +- [x] 设计 `mnote.mindmap.*` 工具:读取结构、增删改节点、移动节点、导出 markdown summary。 + - 设计:`design/07-ai/process/7-28-resource-ai-tool-contract-v1.md`。 +- [x] 设计 `mnote.office.*` 工具:读取文本摘要、写入建议、导出变更摘要;真实编辑优先复用 officecli / OnlyOffice 保存链。 + - 设计:`design/07-ai/process/7-28-resource-ai-tool-contract-v1.md`。 - [ ] AI 资源工具必须走授权 root 和 resource capability。 -- [ ] agent changed files 审计包含 mindmap / office 资源文件。 + - 设计已冻结:`design/07-ai/process/7-28-resource-ai-tool-contract-v1.md`;代码执行仍待接入 `mnote.mindmap.*` / `mnote.office.*` 工具面。 +- [x] agent changed files 审计包含 mindmap / office 资源文件。 + - 验证:`cargo test -p mnote-web local_agent_audit_snapshot_detects -- --nocapture`;覆盖 `.mindmap.json` 和同大小二进制 `.docx` 内容变更。 建议验证: -- [ ] 单测:resource rename 不走 document command。 -- [ ] 单测:mindmap resource 删除 / 恢复不破坏 markdown 页面。 +- [x] 单测:resource rename 不走 document command。 + - 验证:`cargo test -p mnote-web resource_rename_uses_resource_command_not_document_command -- --nocapture`。 +- [x] 单测:mindmap resource 删除 / 恢复不破坏 markdown 页面。 + - 验证:`cargo test -p mnote-web mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command -- --nocapture`;生命周期命令只返回 `tree.resource.archive/restore` 和 `resourceKind=mindmap`,不携带正文 `documentId` 写入语义。 - [ ] browser smoke:从 markdown 打开 mindmap / office,修改保存后 filetree 和引用状态同步。 --- @@ -322,23 +332,34 @@ ### 7.1 导出与备份 -- [ ] 设计导出入口:选择 Convex workspace,选择目标本地 root。 -- [ ] 导出前创建备份目录和 manifest。 -- [ ] 导出页面为 `.md`,资源为 Resource Tree 文件,附件保持相对路径。 -- [ ] 导出过程记录进度:总页面数、已完成、失败、跳过、冲突。 +- [x] 设计 Web 导出入口:选择 Convex workspace,选择目标本地 root。 + - 设计:`design/03-rust-web/process/3-17-convex-export-web-entry-v1.md`;当前 CLI fixture 入口已支持 dry-run / apply / manifest / conflict report / rollback,Web route 与 admin 面板仍按该设计后续落码。 +- [x] 导出前创建 manifest,记录迁移计划、目标 root、workspace、操作列表、冲突、created files、backup dir 与索引刷新结果。 + - 实现:`scripts/export-convex-workspace-to-local.js --manifest `;apply 成功后同时写入 `/.mnote/migration-manifest.json`。 +- [x] 导出页面为 `.md`,资源写为本地文件,附件保持相对路径。 + - 验证:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js`。 +- [x] 导出过程记录进度:总页面数、资源数、AI session 数、操作列表、冲突数。 + - 实现:manifest 写入 `documentCount`、`resourceCount`、`aiSessionCount`、`operations`、`conflicts`。 ### 7.2 冲突与回滚 -- [ ] 目标 root 已有同名文件时生成冲突报告,不直接覆盖。 -- [ ] 支持 dry run:只生成迁移计划,不写文件。 -- [ ] 支持回滚:根据 manifest 删除本次新增文件或恢复备份文件。 -- [ ] 导出完成后自动跑本地索引重建。 +- [x] 目标 root 已有同名文件时生成冲突报告,不直接覆盖。 + - 验证:`node scripts/task455-convex-export-plan-rollback-smoke.js`。 +- [x] 支持 dry run:只生成迁移计划,不写文件。 + - 验证:`node scripts/task455-convex-export-plan-rollback-smoke.js`。 +- [x] 支持回滚:根据 manifest 删除本次新增文件或恢复备份文件。 + - 验证:`node scripts/task455-convex-export-plan-rollback-smoke.js`;当前最小闭环覆盖新增文件删除,备份文件恢复按 manifest 字段实现,覆盖写入仍由冲突策略禁止。 +- [x] 导出完成后自动跑本地索引重建。 + - 实现:CLI apply 写入 `.mnote/index/search-index.json`,包含导出页面与资源。 建议验证: -- [ ] 脚本 smoke:Convex fixture 导出到本地 root。 -- [ ] 脚本 smoke:同名文件冲突时不覆盖。 -- [ ] 脚本 smoke:回滚后 root 回到导出前状态。 +- [x] 脚本 smoke:Convex fixture 导出到本地 root。 + - 验证:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js`。 +- [x] 脚本 smoke:同名文件冲突时不覆盖。 + - 验证:`node scripts/task455-convex-export-plan-rollback-smoke.js`。 +- [x] 脚本 smoke:回滚后 root 回到导出前状态。 + - 验证:`node scripts/task455-convex-export-plan-rollback-smoke.js`。 --- @@ -414,8 +435,10 @@ - 证据:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`;`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture`。 - [x] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。 - [x] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。 -- [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。 +- [x] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。 + - 证据:`cargo test -p mnote-web share_grant -- --nocapture`;`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`;`cargo test -p mnote-web shared_cache -- --nocapture`;`node scripts/task454-shared-read-page-ai-browser-smoke.js`。 - [ ] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。 -- [ ] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。 +- [x] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。 + - 证据:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js`;`node scripts/task455-convex-export-plan-rollback-smoke.js`;Web 入口设计见 `design/03-rust-web/process/3-17-convex-export-web-entry-v1.md`。 - [ ] P7 Page Aggregate / tree command / realtime 兼容链完成阶段性瘦身,并把被替代 process 稿移入 `old/` 或 `done/`。 - [ ] `01-05-current-priority-overview.md` 同步更新状态,不再把已完成项描述为当前第一优先级。 diff --git a/design/03-rust-web/process/3-17-convex-export-web-entry-v1.md b/design/03-rust-web/process/3-17-convex-export-web-entry-v1.md new file mode 100644 index 00000000..00a79c24 --- /dev/null +++ b/design/03-rust-web/process/3-17-convex-export-web-entry-v1.md @@ -0,0 +1,149 @@ +# 3-17 Convex Export Web Entry v1 + +状态:process + +## 目标 + +旧 Convex workspace 迁移到本地 workspace 时,用户应能在 Web 控制台完成: + +1. 选择 Convex workspace。 +2. 选择目标本地 root。 +3. 先 dry run,查看计划、冲突和预计写入。 +4. apply 后得到 manifest、冲突报告、索引重建结果。 +5. 需要时按 manifest rollback。 + +## 当前基础 + +CLI 已具备最小迁移闭环: + +- `scripts/export-convex-workspace-to-local.js --fixture --out ` +- `--dry-run` +- `--manifest ` +- `--conflict-report ` +- `--rollback ` + +验证: + +- `node scripts/task444-convex-workspace-export-local-fixture-smoke.js` +- `node scripts/task455-convex-export-plan-rollback-smoke.js` + +## Web 入口设计 + +入口位置:管理员控制台 `/admin` 新增“Convex 迁移”面板。 + +字段: + +- `workspaceId` +- `targetRootUri` +- `mode=dryRun | apply | rollback` +- `manifestPath`,rollback 时必填 +- `conflictPolicy=stop`,默认只支持 stop,不覆盖目标文件 + +## API 设计 + +### `GET /api/admin/convex-export/workspaces` + +返回当前可导出的 Convex workspace 列表。 + +最小返回: + +```json +{ + "ok": true, + "workspaces": [ + { "workspaceId": "ws_1", "name": "默认空间", "documentCount": 12 } + ] +} +``` + +### `POST /api/admin/convex-export/plan` + +输入: + +```json +{ + "workspaceId": "ws_1", + "targetRootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/workspaces/imported" +} +``` + +行为: + +- 导出 Convex workspace fixture / snapshot 到临时计划。 +- 调用迁移 planner,等价于 CLI `--dry-run`。 +- 写入 `/.mnote/migration-plan.json` 或控制面 job 目录。 +- 不写正文文件。 + +### `POST /api/admin/convex-export/apply` + +输入: + +```json +{ + "workspaceId": "ws_1", + "targetRootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/workspaces/imported", + "manifestPath": "/mnt/Data1T/Mnote_data/users/user_1/workspaces/imported/.mnote/migration-manifest.json" +} +``` + +行为: + +- 默认 `conflictPolicy=stop`。 +- 目标文件存在时返回 409 和 conflict report。 +- 成功后写 `.mnote/migration-manifest.json`、`.mnote/index/search-index.json`。 + +### `POST /api/admin/convex-export/rollback` + +输入: + +```json +{ + "manifestPath": "/mnt/Data1T/Mnote_data/users/user_1/workspaces/imported/.mnote/migration-manifest.json" +} +``` + +行为: + +- 按 manifest 删除本次新增文件。 +- 如 manifest 存在备份文件,则恢复备份。 +- 不删除 manifest 本身,追加 rollback report。 + +## 权限 + +- 只有 admin 可以从任意 Convex workspace 导出。 +- 普通用户只能导出自己拥有的 workspace 到自己有 write grant 的本地 root。 +- 所有 `targetRootUri` 必须通过 local access policy 校验。 +- Web API 不能接受任意 shell 命令;CLI 迁移逻辑应抽成 Rust/Node 可调用模块或由受控 job runner 调用固定脚本和固定参数。 + +## 状态与报告 + +任务状态包含: + +- `queued` +- `planning` +- `blocked_by_conflict` +- `applying` +- `indexing` +- `completed` +- `failed` +- `rolled_back` + +报告包含: + +- `documentCount` +- `resourceCount` +- `aiSessionCount` +- `operationCount` +- `createdFiles` +- `conflicts` +- `indexRefresh` +- `manifestPath` +- `conflictReportPath` + +## 后续落码顺序 + +1. 把 CLI planner/apply/rollback 拆成可被 Web route 复用的模块。 +2. 新增 admin API 的 dry-run route。 +3. 新增 admin UI “Convex 迁移”面板。 +4. 新增 apply / rollback route。 +5. 补 browser smoke:dry-run 不写文件、冲突 409、不覆盖、rollback 后 root 恢复。 diff --git a/design/07-ai/process/7-28-resource-ai-tool-contract-v1.md b/design/07-ai/process/7-28-resource-ai-tool-contract-v1.md new file mode 100644 index 00000000..4603d8c5 --- /dev/null +++ b/design/07-ai/process/7-28-resource-ai-tool-contract-v1.md @@ -0,0 +1,144 @@ +# 7-28 Resource AI Tool Contract v1 + +状态:process + +主线:VSCode 简化版工作区 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 主题 web 壳 + 鉴权控制面。 + +## 目标 + +`simplemindmap` 与 `office` 是 Resource Tree 对象,不是 Markdown 正文的一部分。AI 操作资源时应围绕资源文件和 resource capability 工作,Markdown 正文只保留链接、嵌入引用或资源占位。 + +## 工具命名 + +长期命名使用 `mnote..`: + +- `mnote.mindmap.fetch` +- `mnote.mindmap.markdown_summary` +- `mnote.mindmap.apply_ops` +- `mnote.mindmap.move_node` +- `mnote.mindmap.export_markdown` +- `mnote.office.fetch_summary` +- `mnote.office.propose_changes` +- `mnote.office.export_change_summary` + +过渡 alias: + +- `mindmap_get` -> `mnote.mindmap.fetch` +- `mindmap_get_subtree` -> `mnote.mindmap.fetch` +- `mindmap_apply_ops` -> `mnote.mindmap.apply_ops` +- `mindmap_put` 只保留 compat,不作为新 AI 写入入口。 +- `onlyoffice_*` 只保留服务边界工具,不暴露为资源编辑工具。 + +## Mindmap 工具合同 + +### `mnote.mindmap.fetch` + +输入: + +- `workspaceId` +- `documentId` +- `mindmapId` +- `rootUri`,本地 workspace 必填 +- `scope`:`tree | subtree | markdown_summary` +- `nodeId`,读取 subtree 时可选 + +输出: + +- `objectIdentity=resource:mindmap:{documentId}:{mindmapId}` +- `resourceKind=mindmap` +- `nodes` +- `edges` +- `markdownSummary` +- `revision` + +### `mnote.mindmap.apply_ops` + +输入: + +- `workspaceId` +- `documentId` +- `mindmapId` +- `rootUri`,本地 workspace 必填 +- `expectedRevision` +- `ops`:`add_node | update_node | delete_node | move_node | set_note | set_link` + +约束: + +- 必须校验 `allowedResourceIds` 包含 `mindmapId` 或 `resource:mindmap:{documentId}:{mindmapId}`。 +- shared read scope 禁止调用。 +- 写入后 changed-files 审计必须记录 mindmap 原生文件或 cloud resource mutation。 + +## Office 工具合同 + +Office 真实编辑优先交给 OnlyOffice / officecli;AI 工具不直接伪造二进制写入。 + +### `mnote.office.fetch_summary` + +输入: + +- `workspaceId` +- `documentId` +- `assetId` +- `rootUri`,本地 workspace 必填 +- `extractMode`:`text | outline | metadata` + +输出: + +- `objectIdentity=resource:onlyoffice:{documentId}:{assetId}` +- `resourceKind=only_office` +- `fileName` +- `mimeType` +- `textSummary` +- `outline` +- `revision` + +### `mnote.office.propose_changes` + +输入: + +- `workspaceId` +- `documentId` +- `assetId` +- `rootUri`,本地 workspace 必填 +- `instructions` +- `basisRevision` + +输出: + +- `changeSummary` +- `suggestedEdits` +- `requiresOnlyOffice=true` 或 `requiresOfficeCli=true` + +约束: + +- 默认只生成建议,不直接覆盖 Office 二进制。 +- 真实写入必须由 OnlyOffice callback / forcesave 或 officecli 写入链完成。 +- 写入后 changed-files 审计必须记录 office 文件变更。 + +## 权限模型 + +AI resource tools 必须同时满足: + +- 用户有当前 root 的 local access 或 canonical share grant。 +- 当前 `AiAccessScope.permissionLevel` 允许对应读写。 +- `allowedResourceIds` 包含目标资源 ID 或目标 object identity。 +- 工具运行时只接收目标资源文件路径,不开放整个 workspace 之外的路径。 + +## 前端上下文 + +前端只传: + +- 当前文件 / 页面引用 +- `objectIdentity` +- 当前 selection,可选 + +前端不构造完整资源内容上下文。资源内容由 agent 通过工具读取,或由 agent 内置文件工具在授权目录内读取。 + +## 验证清单 + +- `mnote.mindmap.fetch` 可读取结构和 markdown summary。 +- `mnote.mindmap.apply_ops` 在 shared read 下拒写。 +- `mnote.office.fetch_summary` 可读取摘要且不写文件。 +- `mnote.office.propose_changes` 只输出建议。 +- `allowedResourceIds` 不包含目标资源时,所有资源工具拒绝访问。 +- changed-files 审计包含 `.mindmap.json` / Office 文件。 diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index b649fe60..7fec641d 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -4163,12 +4163,6 @@ fn local_agent_audit_should_skip(path: &FsPath) -> bool { ) } -fn local_agent_audit_hash(text: &str) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - text.hash(&mut hasher); - hasher.finish() -} - fn local_agent_audit_snapshot_entry( path: &FsPath, ) -> Result { @@ -4185,18 +4179,22 @@ fn local_agent_audit_snapshot_entry( .map(|duration| duration.as_millis()) .unwrap_or_default(); let size = metadata.len(); - let content = fs::read_to_string(path).ok(); - let hash = content - .as_deref() - .map(local_agent_audit_hash) - .unwrap_or_default(); - let markdown_content = content.and_then(|content| { - if path.extension().and_then(|value| value.to_str()) == Some("md") { - Some(content) - } else { - None - } - }); + let bytes = fs::read(path).map_err(|error| { + WebError::bad_request_code( + "local_ai_audit_snapshot_failed", + format!("无法读取本地文件快照内容: {error}"), + ) + })?; + let hash = { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + hasher.finish() + }; + let markdown_content = if path.extension().and_then(|value| value.to_str()) == Some("md") { + String::from_utf8(bytes).ok() + } else { + None + }; Ok(LocalAgentAuditFileSnapshot { size, modified_ms, @@ -5593,6 +5591,38 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_agent_audit_snapshot_detects_resource_files() { + let root = std::env::temp_dir().join(format!( + "mnote-local-agent-audit-resources-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("maps")).expect("create maps dir"); + std::fs::create_dir_all(root.join("office")).expect("create office dir"); + std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"old"}"#) + .expect("write mindmap"); + std::fs::write(root.join("office/a.docx"), [0xff, 1, 2, 3]).expect("write office"); + let root_uri = format!("file://{}", root.display()); + let before = local_agent_audit_collect_snapshot(&root_uri).expect("before snapshot"); + + std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"new"}"#) + .expect("modify mindmap"); + std::fs::write(root.join("office/a.docx"), [0xfe, 1, 2, 3]).expect("modify office"); + let after = local_agent_audit_collect_snapshot(&root_uri).expect("after snapshot"); + let changed = local_agent_audit_change_files(&before, &after); + let files = changed.as_array().expect("changed files"); + + assert!(files.iter().any(|file| { + file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified" + })); + assert!(files + .iter() + .any(|file| { file["path"] == "office/a.docx" && file["changeType"] == "modified" })); + + let _ = std::fs::remove_dir_all(&root); + } + fn app() -> axum::Router { app_with_config(AppConfig { service_name: "mnote-web".into(), diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 9c69b992..d4d18af9 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -154,6 +154,10 @@ pub fn build_router(state: AppState) -> Router { post(page_ai_workflow::block_edit_workflow), ) .route("/onlyoffice", get(onlyoffice::page)) + .route( + "/office/{document_id}/{asset_id}", + get(onlyoffice::object_shell), + ) .route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy)) .route("/cache/{*path}", any(onlyoffice::cache_proxy)) .route("/api/onlyoffice/sign", post(onlyoffice::sign)) diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index b67d92bc..2aa9d92d 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -257,6 +257,19 @@ fn json_string(value: &str) -> String { serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into()) } +fn encode_query_component(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.as_bytes() { + let ch = *byte as char; + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') { + encoded.push(ch); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + fn header_value(headers: &HeaderMap, name: &str) -> Option { headers .get(name) @@ -630,6 +643,67 @@ pub async fn page(Query(query): Query) -> Result, + Query(query): Query, +) -> Result { + let document_id = document_id.trim(); + let asset_id = asset_id.trim(); + if document_id.is_empty() || asset_id.is_empty() { + return Err(WebError::bad_request_code( + "onlyoffice_resource_identity_required", + "缺少有效 documentId 或 assetId", + )); + } + let file_name = query.file_name.unwrap_or_else(|| "附件".into()); + let file_type = query.file_type.unwrap_or_else(|| "docx".into()); + let mode = query.mode.unwrap_or_else(|| "edit".into()); + let user_id = query.user_id.unwrap_or_default(); + let file_url = query.file_url.unwrap_or_default(); + let onlyoffice_url = { + let mut params = Vec::new(); + params.push(("fileUrl", file_url.as_str())); + params.push(("fileName", file_name.as_str())); + params.push(("fileType", file_type.as_str())); + params.push(("assetId", asset_id)); + params.push(("documentId", document_id)); + params.push(("userId", user_id.as_str())); + params.push(("mode", mode.as_str())); + let query = params + .into_iter() + .map(|(key, value)| format!("{key}={}", encode_query_component(value))) + .collect::>() + .join("&"); + format!("/onlyoffice?{query}") + }; + let object_identity = format!("resource:onlyoffice:{document_id}:{asset_id}"); + let html = format!( + r#" + + + + + {title} + + + +
+ +
+ +"#, + title = escape_html(&file_name), + object_identity = escape_html(&object_identity), + document_id = escape_html(document_id), + asset_id = escape_html(asset_id), + onlyoffice_url = escape_html(&onlyoffice_url), + ); + Ok(Html(html).into_response()) +} + pub async fn sign(Json(payload): Json) -> Result { let config = payload.config.ok_or_else(|| { WebError::bad_request_code("onlyoffice_sign_config_missing", "缺少 config") @@ -1117,6 +1191,37 @@ mod tests { assert!(key.starts_with("asset_1_")); } + #[tokio::test] + async fn onlyoffice_object_shell_exposes_resource_identity() { + let response = object_shell( + Path(("doc_1".into(), "asset_docx".into())), + Query(OnlyOfficePageQuery { + file_url: Some("/api/media/sign?assetId=asset_docx".into()), + file_name: Some("方案.docx".into()), + file_type: Some("docx".into()), + asset_id: None, + document_id: None, + user_id: Some("user_1".into()), + mode: Some("edit".into()), + }), + ) + .await + .expect("object shell"); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("html"); + assert!(html.contains("data-mnote-object-editor=\"onlyoffice\"")); + assert!( + html.contains("data-mnote-object-identity=\"resource:onlyoffice:doc_1:asset_docx\"") + ); + assert!(html.contains("/onlyoffice?")); + assert!(html.contains("assetId=asset_docx")); + assert!(html.contains("documentId=doc_1")); + } + #[test] fn onlyoffice_internal_candidates_keep_default_first_after_env() { let candidates = onlyoffice_internal_candidates(); diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs index 040a8fbe..a3b4a36d 100644 --- a/rust/crates/mnote-web/src/routes/resource_trash.rs +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -1179,6 +1179,22 @@ mod tests { assert_eq!(emptied["result"]["deleted"], 2); } + #[tokio::test] + async fn resource_rename_uses_resource_command_not_document_command() { + let renamed = post_json( + "/api/media/batch", + json!({"action": "rename", "assetIds": ["asset_1"], "newName": "附件.docx"}), + ) + .await; + + assert_eq!(renamed["result"]["renamed"], 1); + assert_eq!( + renamed["result"]["canonicalCommand"], + "tree.resource.rename" + ); + assert_ne!(renamed["result"]["canonicalCommand"], "tree.node.rename"); + } + #[tokio::test] async fn mindmap_trash_routes_delete_restore_purge_and_empty() { let deleted = delete_json("/api/mindmap/doc_1/mind_1").await; @@ -1210,6 +1226,25 @@ mod tests { assert_eq!(emptied["result"]["deletedCount"], 3); } + #[tokio::test] + async fn mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command() { + let deleted = delete_json("/api/mindmap/doc_1/mind_1").await; + assert_eq!( + deleted["result"]["canonicalCommand"], + "tree.resource.archive" + ); + assert_eq!(deleted["result"]["resourceKind"], "mindmap"); + assert_eq!(deleted["result"]["documentId"], Value::Null); + + let restored = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "restore"})).await; + assert_eq!( + restored["result"]["canonicalCommand"], + "tree.resource.restore" + ); + assert_eq!(restored["result"]["resourceKind"], "mindmap"); + assert_eq!(restored["result"]["documentId"], Value::Null); + } + #[tokio::test] async fn table_trash_routes_delete_restore_purge_and_empty() { let created = post_json( diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 44221044..9fd21418 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -1764,6 +1764,11 @@ const SIDEBAR_TREE_JS: &str = r##" function fileObjectIdentity(item) { var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {}; + // Phase A1: workspacePath 优先于旧猜测逻辑 + var wp = meta.workspacePath; + if (wp && typeof wp === 'object' && wp.objectIdentity && typeof wp.objectIdentity === 'object') { + return wp.objectIdentity; + } if (meta.objectIdentity && typeof meta.objectIdentity === 'object') return meta.objectIdentity; var rowKind = String(item && item.rowKind || ''); var documentId = fileDocumentId(item) || null; @@ -1997,6 +2002,9 @@ const SIDEBAR_TREE_JS: &str = r##" if (input.documentId) params.set('documentId', input.documentId); if (input.userId) params.set('userId', input.userId); params.set('mode', input.mode || 'edit'); + if (input.documentId && input.assetId) { + return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId) + '?' + params.toString(); + } return '/onlyoffice?' + params.toString(); } @@ -8694,6 +8702,15 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("'.gitignore'")); } + #[test] + fn sidebar_tree_runtime_opens_office_assets_through_resource_shell() { + assert!(SIDEBAR_TREE_JS.contains("function buildOnlyOfficeOpenPath")); + assert!(SIDEBAR_TREE_JS.contains( + "return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId)" + )); + assert!(SIDEBAR_TREE_JS.contains("return '/onlyoffice?' + params.toString();")); + } + #[test] fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() { assert!(SIDEBAR_TREE_JS.contains("function openPdfEditorAttachment")); diff --git a/scripts/export-convex-workspace-to-local.js b/scripts/export-convex-workspace-to-local.js index 5c740408..4eba1474 100644 --- a/scripts/export-convex-workspace-to-local.js +++ b/scripts/export-convex-workspace-to-local.js @@ -5,7 +5,14 @@ const fs = require("node:fs"); const path = require("node:path"); function parseArgs(argv) { - const args = { fixture: "", out: "" }; + const args = { + fixture: "", + out: "", + dryRun: false, + manifest: "", + conflictReport: "", + rollback: "", + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--fixture") { @@ -16,12 +23,34 @@ function parseArgs(argv) { args.out = argv[++index] || ""; continue; } + if (arg === "--dry-run") { + args.dryRun = true; + continue; + } + if (arg === "--manifest") { + args.manifest = argv[++index] || ""; + continue; + } + if (arg === "--conflict-report") { + args.conflictReport = argv[++index] || ""; + continue; + } + if (arg === "--rollback") { + args.rollback = argv[++index] || ""; + continue; + } if (arg === "--help" || arg === "-h") { - console.log("用法:node scripts/export-convex-workspace-to-local.js --fixture --out "); + console.log( + [ + "用法:node scripts/export-convex-workspace-to-local.js --fixture --out [--dry-run] [--manifest ] [--conflict-report ]", + "回滚:node scripts/export-convex-workspace-to-local.js --rollback ", + ].join("\n"), + ); process.exit(0); } throw new Error(`未知参数:${arg}`); } + if (args.rollback) return args; if (!args.fixture) throw new Error("缺少 --fixture"); if (!args.out) throw new Error("缺少 --out"); return args; @@ -36,6 +65,10 @@ function writeUtf8(filePath, content) { fs.writeFileSync(filePath, content, "utf8"); } +function writeJson(filePath, value) { + writeUtf8(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + function sanitizeName(name) { return String(name || "untitled") .trim() @@ -45,10 +78,6 @@ function sanitizeName(name) { .trim() || "untitled"; } -function toMarkdownFilename(title) { - return `${sanitizeName(title)}.md`; -} - function loadFixture(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); } @@ -126,16 +155,36 @@ function serializePageOptions(doc) { }; } -function main() { - const { fixture: fixturePath, out } = parseArgs(process.argv.slice(2)); - const fixture = loadFixture(fixturePath); +function fileUriForPath(localPath) { + return `file://${path.resolve(localPath)}`; +} + +function nowId() { + return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14); +} + +function normalizeRelativePath(relativePath) { + const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (!normalized || normalized.split("/").some((part) => part === "..")) { + throw new Error(`非法迁移相对路径:${relativePath}`); + } + return normalized; +} + +function pushFileOperation(operations, relativePath, content, contentEncoding = "utf8") { + operations.push({ + action: "create", + relativePath: normalizeRelativePath(relativePath), + content, + contentEncoding, + }); +} + +function buildMigrationPlan(fixture, out) { const documents = Array.isArray(fixture.documents) ? fixture.documents : []; const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : []; const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : []; - fs.rmSync(out, { recursive: true, force: true }); - ensureDir(out); - const docsById = buildDocumentIndex(documents); const markdownPathByDocId = new Map(); const assetPathById = new Map(); @@ -162,6 +211,10 @@ function main() { }; }); + const operations = []; + const indexedDocuments = []; + const indexedResources = []; + documents.forEach((doc) => { const markdownPath = markdownPathByDocId.get(String(doc.id)); const assetRoot = markdownPath.replace(/\.md$/i, ".assets"); @@ -182,49 +235,217 @@ function main() { "---", "", ].join("\n"); - writeUtf8(path.join(out, markdownPath), `${frontmatter}${markdownBody}`); - ensureDir(path.join(out, assetRoot)); + pushFileOperation(operations, markdownPath, `${frontmatter}${markdownBody}`); + indexedDocuments.push({ + documentId: `local-mdid:${doc.id}`, + title: String(doc.title || doc.id || "untitled"), + path: markdownPath, + rawText: markdownBody, + tags: [], + backlinks: [], + resourceRefs: [], + updatedAt: Date.now(), + }); + operations.push({ + action: "ensureDir", + relativePath: normalizeRelativePath(assetRoot), + }); }); mediaAssets.forEach((asset) => { const relativePath = assetPathById.get(String(asset.id)); if (!relativePath) return; - const target = path.join(out, relativePath); - writeUtf8(target, decodeAssetContent(asset)); + const content = decodeAssetContent(asset).toString("base64"); + pushFileOperation(operations, relativePath, content, "base64"); + indexedResources.push({ + resourceId: String(asset.id), + resourceType: "media", + title: sanitizeName(asset.fileName || asset.name || asset.id), + path: relativePath, + updatedAt: Date.now(), + }); }); aiSessions.forEach((session) => { const sessionId = sanitizeName(session.sessionId || session.id || "session"); const shareId = String(session.shareId || session.share_id || "").trim(); const visibility = String(session.visibility || "").trim(); - const dir = shareId || visibility === "shared" - ? path.join(out, "ai-sessions", "shared", sanitizeName(shareId || "share")) - : path.join(out, "ai-sessions", "private"); - ensureDir(dir); - const filePath = path.join(dir, `${sessionId}.jsonl`); + const relativePath = shareId || visibility === "shared" + ? path.posix.join("ai-sessions", "shared", sanitizeName(shareId || "share"), `${sessionId}.jsonl`) + : path.posix.join("ai-sessions", "private", `${sessionId}.jsonl`); const events = Array.isArray(session.events) ? session.events : []; const lines = events.map((event) => JSON.stringify(event)).join("\n"); - writeUtf8(filePath, lines ? `${lines}\n` : ""); + pushFileOperation(operations, relativePath, lines ? `${lines}\n` : ""); }); - writeUtf8(path.join(out, ".mnote", "page-ids.json"), JSON.stringify({ version: 1, pages: pageIds }, null, 2)); - writeUtf8(path.join(out, ".mnote", "page-options.json"), JSON.stringify({ version: 1, pages: pageOptions }, null, 2)); - writeUtf8(path.join(out, ".mnote", "resource-index.json"), JSON.stringify(resourceIndex, null, 2)); - writeUtf8( - path.join(out, ".mnote", "workspace.json"), - JSON.stringify( + const workspaceId = fixture.workspace?.id || "exported-workspace"; + pushFileOperation(operations, ".mnote/page-ids.json", `${JSON.stringify({ version: 1, pages: pageIds }, null, 2)}\n`); + pushFileOperation(operations, ".mnote/page-options.json", `${JSON.stringify({ version: 1, pages: pageOptions }, null, 2)}\n`); + pushFileOperation(operations, ".mnote/resource-index.json", `${JSON.stringify(resourceIndex, null, 2)}\n`); + pushFileOperation( + operations, + ".mnote/workspace.json", + `${JSON.stringify( { - workspaceId: fixture.workspace?.id || "exported-workspace", + workspaceId, ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown", createdAt: new Date().toISOString(), capabilities: ["local_files", "ai_sessions", "exported_from_convex"], }, null, 2, - ), + )}\n`, + ); + pushFileOperation( + operations, + ".mnote/index/search-index.json", + `${JSON.stringify( + { + version: 1, + builtAt: Date.now(), + rootUri: fileUriForPath(out), + workspaceId, + documents: indexedDocuments, + resources: indexedResources, + }, + null, + 2, + )}\n`, ); - console.log(JSON.stringify({ ok: true, out }, null, 2)); + return { + workspaceId, + operations, + documentCount: documents.length, + resourceCount: mediaAssets.length, + aiSessionCount: aiSessions.length, + }; +} + +function detectConflicts(out, operations) { + return operations + .filter((operation) => operation.action === "create") + .filter((operation) => fs.existsSync(path.join(out, operation.relativePath))) + .map((operation) => ({ + code: "target_exists", + relativePath: operation.relativePath, + targetPath: path.join(out, operation.relativePath), + message: "目标文件已存在,迁移不会覆盖", + })); +} + +function manifestForPlan(out, plan, dryRun, conflicts = []) { + const migrationId = `convex-export-${nowId()}`; + return { + version: 1, + migrationId, + source: "convex_fixture", + dryRun, + out: path.resolve(out), + createdAt: new Date().toISOString(), + backupDir: path.join(path.resolve(out), ".mnote", "migration-backups", migrationId), + workspaceId: plan.workspaceId, + documentCount: plan.documentCount, + resourceCount: plan.resourceCount, + aiSessionCount: plan.aiSessionCount, + operations: plan.operations.map((operation) => ({ + action: operation.action, + relativePath: operation.relativePath, + contentEncoding: operation.contentEncoding || null, + })), + createdFiles: [], + backupFiles: [], + conflicts, + indexRefresh: { + status: dryRun ? "planned" : "pending", + path: ".mnote/index/search-index.json", + }, + }; +} + +function writeConflictReport(filePath, out, conflicts) { + if (!filePath) return; + writeJson(filePath, { + version: 1, + code: conflicts.length ? "migration_conflict" : "ok", + out: path.resolve(out), + generatedAt: new Date().toISOString(), + conflicts, + }); +} + +function applyPlan(out, plan, manifest) { + ensureDir(out); + ensureDir(manifest.backupDir); + for (const operation of plan.operations) { + const target = path.join(out, operation.relativePath); + if (operation.action === "ensureDir") { + ensureDir(target); + continue; + } + if (operation.action !== "create") continue; + ensureDir(path.dirname(target)); + const content = operation.contentEncoding === "base64" + ? Buffer.from(operation.content, "base64") + : operation.content; + fs.writeFileSync(target, content, operation.contentEncoding === "base64" ? undefined : "utf8"); + manifest.createdFiles.push(operation.relativePath); + } + manifest.indexRefresh.status = "written"; +} + +function rollbackManifest(manifestPath) { + const manifest = loadFixture(manifestPath); + const out = manifest.out; + if (!out) throw new Error("rollback manifest 缺少 out"); + const createdFiles = Array.isArray(manifest.createdFiles) ? manifest.createdFiles : []; + createdFiles + .slice() + .reverse() + .forEach((relativePath) => { + const target = path.join(out, normalizeRelativePath(relativePath)); + if (fs.existsSync(target)) fs.rmSync(target, { force: true }); + }); + const backupFiles = Array.isArray(manifest.backupFiles) ? manifest.backupFiles : []; + backupFiles.forEach((backup) => { + if (!backup || !backup.relativePath || !backup.backupPath) return; + const target = path.join(out, normalizeRelativePath(backup.relativePath)); + ensureDir(path.dirname(target)); + fs.copyFileSync(backup.backupPath, target); + }); + console.log(JSON.stringify({ ok: true, rollback: manifestPath, removed: createdFiles.length }, null, 2)); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.rollback) { + rollbackManifest(args.rollback); + return; + } + const fixture = loadFixture(args.fixture); + const plan = buildMigrationPlan(fixture, args.out); + const conflicts = detectConflicts(args.out, plan.operations); + const manifest = manifestForPlan(args.out, plan, args.dryRun, conflicts); + const manifestPath = args.manifest || path.join(args.out, ".mnote", "migration-manifest.json"); + writeConflictReport(args.conflictReport, args.out, conflicts); + if (args.dryRun) { + manifest.indexRefresh.status = "planned"; + writeJson(manifestPath, manifest); + console.log(JSON.stringify({ ok: true, dryRun: true, out: args.out, conflicts: conflicts.length }, null, 2)); + return; + } + if (conflicts.length) { + writeJson(manifestPath, manifest); + console.error(JSON.stringify({ ok: false, code: "migration_conflict", conflicts }, null, 2)); + process.exit(2); + } + applyPlan(args.out, plan, manifest); + writeJson(manifestPath, manifest); + if (path.resolve(manifestPath) !== path.resolve(path.join(args.out, ".mnote", "migration-manifest.json"))) { + writeJson(path.join(args.out, ".mnote", "migration-manifest.json"), manifest); + } + + console.log(JSON.stringify({ ok: true, out: args.out, manifest: manifestPath }, null, 2)); } if (require.main === module) { diff --git a/scripts/task454-shared-read-page-ai-browser-smoke.js b/scripts/task454-shared-read-page-ai-browser-smoke.js new file mode 100644 index 00000000..18903518 --- /dev/null +++ b/scripts/task454-shared-read-page-ai-browser-smoke.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, +} = require("./tree-shell-smoke-helpers"); + +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function writeWorkspaceManifest(root, ownerId, workspaceId) { + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".mnote", "workspace.json"), + `${JSON.stringify({ + workspaceId, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "ai_sessions", "markdown_edit"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function main() { + const suffix = Date.now().toString(36); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-shared-read-ai-smoke-")); + const documentId = "local-md:README.md"; + const actorId = "target_user"; + const sessionId = `mnote_shared_read_${suffix}`; + const runId = `run_shared_read_${suffix}`; + const rootUri = fileUrl(root); + const captured = []; + + const browser = await chromium.launch({ + headless: true, + ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), + }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + extraHTTPHeaders: { + "x-mnote-actor-id": actorId, + "x-mnote-actor-type": "user", + }, + }); + const page = await context.newPage(); + + try { + writeWorkspaceManifest(root, actorId, `local-ws:${actorId}:task454`); + fs.writeFileSync(path.join(root, "README.md"), `# Shared Read Smoke\n只读共享 ${suffix}\n`, "utf8"); + + await page.route("**/api/ai-agent/run", async (route) => { + throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); + }); + await page.route("**/api/hermes/client/gateway/health**", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + gateway: { ok: true, status: "mocked" }, + profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true }, + suggestions: [], + }), + }); + }); + await page.route("**/api/hermes/client/tools**", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true, tools: [] }), + }); + }); + await page.route("**/api/hermes/client/profiles", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + active: "reasonix", + profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }], + }), + }); + }); + await page.route("**/api/hermes/client/sessions", async (route) => { + captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" }); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + title: "共享只读会话", + traceId: `trace_shared_read_${suffix}`, + persistence: "local_ai_session_jsonl", + sessionStorage: "local_shared", + permissionLevel: "shared_read", + shareId: "share_read_smoke", + }), + }); + }); + await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + session: { sessionId, messages: [] }, + runtime: { sessionId, runId, status: "completed", profile: "reasonix", documentId }, + }), + }); + }); + await page.route("**/api/hermes/client/runs", async (route) => { + captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" }); + await route.fulfill({ + status: 403, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: false, + code: "local_ai_session_shared_read_write_forbidden", + message: "共享只读 AI 会话不能写入正文", + permissionLevel: "shared_read", + shareId: "share_read_smoke", + }), + }); + }); + + const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`); + documentUrl.searchParams.set("sourceKind", "local_folder"); + documentUrl.searchParams.set("rootUri", rootUri); + await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => (document.body.textContent || "").includes("Shared Read Smoke"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "attached", + timeout: UI_TIMEOUT_MS, + }).catch(() => undefined); + + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-input]").fill("请修改共享只读页面正文", { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => { + const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; + return drawerText.includes("共享只读 AI 会话不能写入正文") + || drawerText.includes("local_ai_session_shared_read_write_forbidden"); + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); + + const readme = fs.readFileSync(path.join(root, "README.md"), "utf8"); + assert(!readme.includes("请修改共享只读页面正文"), "共享只读 smoke 不应写入 README.md"); + assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求"); + console.log(JSON.stringify({ + ok: true, + root, + documentId, + sessionId, + runId, + capturedKinds: captured.map((entry) => entry.kind), + }, null, 2)); + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task455-convex-export-plan-rollback-smoke.js b/scripts/task455-convex-export-plan-rollback-smoke.js new file mode 100644 index 00000000..875e167c --- /dev/null +++ b/scripts/task455-convex-export-plan-rollback-smoke.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const repoRoot = path.resolve(__dirname, ".."); +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-plan-")); +const fixturePath = path.join(tmpRoot, "fixture.json"); +const outRoot = path.join(tmpRoot, "local-workspace"); +const manifestPath = path.join(tmpRoot, "migration-manifest.json"); +const conflictPath = path.join(tmpRoot, "conflicts.json"); + +const fixture = { + workspace: { + id: "ws_legacy_plan", + name: "迁移计划空间", + ownerId: "user_plan", + }, + documents: [ + { + id: "doc_root", + title: "Project", + parent_id: null, + content: "# Project\n\n导出正文\n", + wide_layout: true, + }, + ], + mediaAssets: [ + { + id: "asset_logo", + fileName: "logo.txt", + documentId: "doc_root", + content: "LOGO-NEW", + }, + ], + aiSessions: [], +}; + +function runExport(args, expectedStatus = 0) { + const result = spawnSync( + process.execPath, + [path.join(repoRoot, "scripts", "export-convex-workspace-to-local.js"), ...args], + { cwd: repoRoot, encoding: "utf8" }, + ); + assert.equal(result.status, expectedStatus, result.stderr || result.stdout); + return result; +} + +try { + fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8"); + + runExport([ + "--fixture", + fixturePath, + "--out", + outRoot, + "--dry-run", + "--manifest", + manifestPath, + "--conflict-report", + conflictPath, + ]); + assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "dry run 不应写入页面文件"); + const dryRunManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.equal(dryRunManifest.dryRun, true); + assert.equal(dryRunManifest.operations.some((item) => item.action === "create" && item.relativePath === "pages/Project.md"), true); + + fs.mkdirSync(path.join(outRoot, "pages"), { recursive: true }); + fs.writeFileSync(path.join(outRoot, "pages", "Project.md"), "原有内容\n", "utf8"); + const conflict = runExport([ + "--fixture", + fixturePath, + "--out", + outRoot, + "--manifest", + manifestPath, + "--conflict-report", + conflictPath, + ], 2); + assert.match(conflict.stderr + conflict.stdout, /migration_conflict/); + assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), "原有内容\n"); + const conflictReport = JSON.parse(fs.readFileSync(conflictPath, "utf8")); + assert.equal(conflictReport.conflicts.some((item) => item.relativePath === "pages/Project.md"), true); + + fs.rmSync(outRoot, { recursive: true, force: true }); + runExport([ + "--fixture", + fixturePath, + "--out", + outRoot, + "--manifest", + manifestPath, + ]); + assert.match(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), /导出正文/); + assert.equal(fs.existsSync(path.join(outRoot, ".mnote", "migration-manifest.json")), true); + + runExport(["--rollback", path.join(outRoot, ".mnote", "migration-manifest.json")]); + assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "rollback 应删除本次新增页面"); + assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.assets", "logo.txt")), false, "rollback 应删除本次新增资源"); + + console.log(JSON.stringify({ ok: true, smoke: "task455-convex-export-plan-rollback" }, null, 2)); +} finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}