推进资源工具与本地优先兼容链收口
This commit is contained in:
+20
-7
@@ -297,7 +297,9 @@
|
||||
- 证据:`/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.*`。
|
||||
- [x] 统一资源创建、重命名、移动、删除、恢复命令到 `tree.resource.*`。
|
||||
- 实现:上传/创建走 `tree.resource.upload` 或资源对象专用创建事件;附件 rename / move / archive / restore / purge 已输出 `tree.resource.rename/move/archive/restore/purge`;mindmap/table 生命周期输出 `tree.resource.*`。
|
||||
- 验证:`cargo test -p mnote-web resource_ -- --nocapture`;新增 `resource_move_uses_resource_command_not_document_command`。
|
||||
- [x] filetree 显示资源行,pagetree 只显示页面导航投影。
|
||||
- 验证:`cargo test -p mnote-web file_tree_projection_ -- --nocapture`;`cargo test -p bridge-runtime file_tree_projection -- --nocapture`。
|
||||
- [x] Markdown 中插入资源引用时,只写相对链接或嵌入引用。
|
||||
@@ -309,8 +311,9 @@
|
||||
- 设计:`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。
|
||||
- 设计已冻结:`design/07-ai/process/7-28-resource-ai-tool-contract-v1.md`;代码执行仍待接入 `mnote.mindmap.*` / `mnote.office.*` 工具面。
|
||||
- [x] AI 资源工具必须走授权 root 和 resource capability。
|
||||
- 实现:Hermes manifest 已暴露 `mnote.mindmap.fetch` / `mnote.mindmap.apply_ops` / `mnote.office.fetch_summary` / `mnote.office.propose_changes`;执行侧统一校验 `rootUri` 本地授权与 `allowedResourceIds` / object identity。
|
||||
- 验证:`cargo test -p mnote-web hermes_tools_resource -- --nocapture`;`cargo test -p mnote-web hermes_tools_mindmap -- --nocapture`;`cargo test -p mnote-web hermes_tools_office -- --nocapture`;`cargo test -p mnote-web hermes_tools_manifest_returns_first_batch_tools -- --nocapture`。
|
||||
- [x] agent changed files 审计包含 mindmap / office 资源文件。
|
||||
- 验证:`cargo test -p mnote-web local_agent_audit_snapshot_detects -- --nocapture`;覆盖 `.mindmap.json` 和同大小二进制 `.docx` 内容变更。
|
||||
|
||||
@@ -321,6 +324,7 @@
|
||||
- [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 和引用状态同步。
|
||||
- 脚本:`scripts/task456-resource-object-shell-sync-smoke.js` 已补,待 `3000` 主入口运行后执行。
|
||||
|
||||
---
|
||||
|
||||
@@ -371,9 +375,12 @@
|
||||
|
||||
### 8.1 Page Aggregate 单一真源
|
||||
|
||||
- [ ] 盘点 `documents.content` 仍作为正文兼容源的入口。
|
||||
- [x] 盘点 `documents.content` 仍作为正文兼容源的入口。
|
||||
- 证据:`routes/web_shell.rs` 非 local 分支仍以 `load_document_meta_result + load_document_content_result` 拼 `meta + content` 给 `page.aggregate.get`;`routes/documents.rs` 仍保留 `documents.content.get` compat;`bridge-runtime` 仍将 `CompatMetaContentJoin` / legacy `documents.content` 投影为 `blockDocument`。
|
||||
- [ ] 让本地 `.md` 与 EditorBlockDocument projection 的读写边界写入 5-5 / 5-6。
|
||||
- [ ] 标题、正文、页面设置写入后只通过 page aggregate 刷新 UI。
|
||||
- 阶段进展:文档壳初始化、外部刷新和冲突比较已优先消费 `body.blockDocument` / `editorDocument`,再 fallback 到 legacy `body.content`。
|
||||
- 验证:`cargo test -p mnote-web document_shell_returns_page_aggregate_snapshot -- --nocapture`;`cargo test -p mnote-web page_aggregate -- --nocapture`。
|
||||
- [ ] 删除或降级前端手工拼 `meta + content` 的 runtime fallback。
|
||||
- [ ] AI 页面设置写入走统一 page aggregate / page command 入口。
|
||||
|
||||
@@ -384,7 +391,8 @@
|
||||
|
||||
### 8.2 Tree command cutover
|
||||
|
||||
- [ ] 盘点仍在 runtime route / adapter / bridge / CLI 中使用的 `documents.*` 命令。
|
||||
- [x] 盘点仍在 runtime route / adapter / bridge / CLI 中使用的 `documents.*` 命令。
|
||||
- 证据:`routes/mod.rs` 仍注册 `/api/documents/*` compat;`transport/convex.rs` 仍做 `documents.*` 到 legacy Convex validator 的适配;`bridge-runtime` 仍有 `documents.stats.update`、`documents.duplicate`、`documents.template`、`documents.emptyTrashByWorkspace` 等历史命名。
|
||||
- [ ] 新增命令统一命名为 `tree.*` 或 `tree.resource.*`。
|
||||
- [ ] 对历史 `documents.*` 命令只保留 compat adapter,不再扩展新语义。
|
||||
- [ ] 资源 rename、移动、删除、恢复补齐 `tree.resource.*`。
|
||||
@@ -396,9 +404,13 @@
|
||||
|
||||
### 8.3 Tree realtime live cache
|
||||
|
||||
- [ ] 盘点 Sidebar、page subtree、filetree、preferred snapshot 的数据来源。
|
||||
- [x] 盘点 Sidebar、page subtree、filetree、preferred snapshot 的数据来源。
|
||||
- 证据:Sidebar/filetree 已消费 Rust projection 与 live tree events;local folder 仍有 `/api/tree/local-folder-watch` + HTML refetch 补偿链;WS 主链已启用 `/api/realtime/ws`,SSE 为 close 后 fallback。
|
||||
- [ ] 统一 snapshot / delta / resync consumer 到同一 live cache。
|
||||
- [ ] SSE fallback 只作为 WS 不可用时的降级,不作为并行主链。
|
||||
- 阶段进展:WS `delta` 不再无条件 `resync`,仅 `resync_required` / `requiresResync` 时请求服务端 resync,普通 delta 先交给本地 live consumer。
|
||||
- 验证:`cargo test -p mnote-web tree_live_controller_marks_transport_and_closes_source_on_pagehide -- --nocapture`。
|
||||
- [x] SSE fallback 只作为 WS 不可用时的降级,不作为并行主链。
|
||||
- 证据:`TREE_LIVE_CONTROLLER_JS` 在 `transport=convex-command-log-ws` 时优先 `startWithWebSocket`,`onclose` 后才 `startWithSseFallback`;`cargo test -p mnote-web tree_live_controller_marks_transport_and_closes_source_on_pagehide -- --nocapture`。
|
||||
- [ ] 双浏览器文件树操作无刷新同步。
|
||||
- [ ] 删除不必要的 polling / refetch 补偿链。
|
||||
|
||||
@@ -438,6 +450,7 @@
|
||||
- [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 产品化。
|
||||
- 阶段进展:资源命令面、资源对象壳、AI resource tools 与 changed-files 审计已闭环;剩余真实 browser smoke 待跑。
|
||||
- [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/`。
|
||||
|
||||
@@ -10747,9 +10747,14 @@ fn execute_command(
|
||||
}),
|
||||
}))
|
||||
}
|
||||
"documents.emptyTrashByWorkspace" => {
|
||||
"documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => {
|
||||
let payload: DocumentEmptyTrashCommandPayload =
|
||||
parse_payload(command_wire.payload.clone())?;
|
||||
let command_name = if command_wire.name == "tree.trash.emptyWorkspace" {
|
||||
"tree.trash.emptyWorkspace"
|
||||
} else {
|
||||
"documents.emptyTrashByWorkspace"
|
||||
};
|
||||
let stream_delta_hint = tree_resync_required_hint(
|
||||
"documents_empty_trash",
|
||||
json!({
|
||||
@@ -10757,7 +10762,7 @@ fn execute_command(
|
||||
}),
|
||||
);
|
||||
let command = CommandEnvelope {
|
||||
name: "documents.emptyTrashByWorkspace".into(),
|
||||
name: command_name.into(),
|
||||
command_id: command_wire.command_id.clone(),
|
||||
idempotency_key: command_wire.idempotency_key.clone(),
|
||||
actor: to_actor_payload(&command_wire.actor),
|
||||
|
||||
@@ -27,6 +27,10 @@ pub fn manifest() -> Value {
|
||||
page_save_tool(),
|
||||
available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]),
|
||||
available_tool("mnote.page.update_options", "更新当前页面设置", ["page.write"]),
|
||||
mindmap_fetch_tool(),
|
||||
mindmap_apply_ops_tool(),
|
||||
office_fetch_summary_tool(),
|
||||
office_propose_changes_tool(),
|
||||
available_tool("mnote.artifact.create_summary", "为当前页面创建或更新 AI Summary", ["artifact.write"]),
|
||||
available_tool("mnote.artifact.create_ai_note", "基于当前页面创建新的 AI Note", ["artifact.write"])
|
||||
]
|
||||
@@ -479,6 +483,120 @@ fn doc_markdown_edit_tool() -> Value {
|
||||
tool
|
||||
}
|
||||
|
||||
fn resource_identity_properties(resource_id_name: &str) -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert("sourceKind".into(), json!({ "type": "string" }));
|
||||
map.insert(resource_id_name.into(), json!({ "type": "string" }));
|
||||
map.insert("resourcePath".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"aiAccessScope".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"permissionLevel": { "type": "string" },
|
||||
"allowedResourceIds": { "type": "array", "items": { "type": "string" } },
|
||||
"allowedRoots": { "type": "array" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
properties
|
||||
}
|
||||
|
||||
fn mindmap_fetch_tool() -> Value {
|
||||
let mut properties = resource_identity_properties("mindmapId");
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert(
|
||||
"scope".into(),
|
||||
json!({ "type": "string", "enum": ["tree", "subtree", "markdown_summary"], "default": "tree" }),
|
||||
);
|
||||
map.insert("nodeId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.mindmap.fetch",
|
||||
"description": "读取授权 root 内的 mindmap resource 结构或 markdown summary",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["resource.read", "mindmap.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "mindmapId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn mindmap_apply_ops_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.mindmap.apply_ops",
|
||||
"对授权 mindmap resource 生成或执行结构操作;本地 workspace 默认建议 agent 直接编辑授权文件,必要时才走该工具",
|
||||
["resource.write", "mindmap.write"],
|
||||
json!({
|
||||
"mindmapId": { "type": "string" },
|
||||
"rootUri": { "type": "string" },
|
||||
"sourceKind": { "type": "string" },
|
||||
"resourcePath": { "type": "string" },
|
||||
"expectedRevision": { "type": ["number", "string"] },
|
||||
"ops": {
|
||||
"type": "array",
|
||||
"items": { "type": "object" }
|
||||
},
|
||||
"aiAccessScope": { "type": "object" }
|
||||
}),
|
||||
["mindmapId", "ops"],
|
||||
)
|
||||
}
|
||||
|
||||
fn office_fetch_summary_tool() -> Value {
|
||||
let mut properties = resource_identity_properties("assetId");
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert(
|
||||
"extractMode".into(),
|
||||
json!({ "type": "string", "enum": ["text", "outline", "metadata"], "default": "metadata" }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.office.fetch_summary",
|
||||
"description": "读取授权 Office resource 的元数据和轻量文本摘要;不直接写二进制文件",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["resource.read", "office.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "assetId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn office_propose_changes_tool() -> Value {
|
||||
let mut properties = resource_identity_properties("assetId");
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("instructions".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"basisRevision".into(),
|
||||
json!({ "type": ["number", "string"] }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.office.propose_changes",
|
||||
"description": "基于授权 Office resource 生成修改建议;真实写入仍由 OnlyOffice / officecli 完成",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["resource.read", "office.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "assetId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "instructions"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_annotations(
|
||||
readonly: bool,
|
||||
destructive: bool,
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod block;
|
||||
pub mod doc;
|
||||
pub mod manifest;
|
||||
pub mod page;
|
||||
pub mod resource;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub async fn mindmap_fetch(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
|
||||
ensure_resource_scope_allowed(context, input, &target)?;
|
||||
let path = target.resolve_read_path(context, input)?;
|
||||
let content = fs::read_to_string(&path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_read_failed",
|
||||
format!("无法读取 mindmap resource: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let data =
|
||||
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| json!({ "raw": content }));
|
||||
let nodes = collect_mindmap_nodes(&data);
|
||||
let scope = input
|
||||
.arg_string("scope")
|
||||
.unwrap_or_else(|| "tree".into())
|
||||
.to_ascii_lowercase();
|
||||
Ok(json!({
|
||||
"objectIdentity": target.object_identity,
|
||||
"resourceKind": "mindmap",
|
||||
"documentId": target.document_id,
|
||||
"mindmapId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"scope": scope,
|
||||
"nodes": nodes,
|
||||
"edges": [],
|
||||
"markdownSummary": mindmap_markdown_summary(&nodes),
|
||||
"revision": file_revision(&path),
|
||||
"source": "local_folder"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn mindmap_apply_ops(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
ensure_resource_write_contract(context, input)?;
|
||||
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
|
||||
ensure_resource_scope_allowed(context, input, &target)?;
|
||||
let path = target.resolve_write_path(context, input)?;
|
||||
let ops = input.arg_value("ops").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_resource_ops_required", "mindmap 写工具缺少 ops")
|
||||
.with_context(context)
|
||||
})?;
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(json!({
|
||||
"dryRun": true,
|
||||
"commandName": "mnote.mindmap.apply_ops",
|
||||
"objectIdentity": target.object_identity,
|
||||
"resourceKind": "mindmap",
|
||||
"documentId": target.document_id,
|
||||
"mindmapId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"diff": [{"op": "mindmap.apply_ops", "ops": ops}]
|
||||
}));
|
||||
}
|
||||
Err(WebError::bad_request_code(
|
||||
"mnote_resource_native_patch_required",
|
||||
format!(
|
||||
"本地 mindmap resource 写入请使用 agent 原生 patch 编辑授权文件;已校验可写资源路径 {}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
pub async fn office_fetch_summary(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let target = ResourceToolTarget::from_input(context, input, "onlyoffice", "assetId")?;
|
||||
ensure_resource_scope_allowed(context, input, &target)?;
|
||||
let path = target.resolve_read_path(context, input)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_metadata_failed",
|
||||
format!("无法读取 Office resource 元数据: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(target.resource_id.as_str());
|
||||
Ok(json!({
|
||||
"objectIdentity": target.object_identity,
|
||||
"resourceKind": "only_office",
|
||||
"documentId": target.document_id,
|
||||
"assetId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"fileName": file_name,
|
||||
"mimeType": office_mime_type(&path),
|
||||
"textSummary": office_text_preview(&path),
|
||||
"outline": [],
|
||||
"revision": file_revision(&path),
|
||||
"size": metadata.len(),
|
||||
"source": "local_folder"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn office_propose_changes(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let target = ResourceToolTarget::from_input(context, input, "onlyoffice", "assetId")?;
|
||||
ensure_resource_scope_allowed(context, input, &target)?;
|
||||
let path = target.resolve_write_path(context, input)?;
|
||||
let instructions = input.arg_string("instructions").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_instructions_required",
|
||||
"office 建议工具缺少 instructions",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
Ok(json!({
|
||||
"dryRun": input.dry_run.unwrap_or(false),
|
||||
"commandName": "mnote.office.propose_changes",
|
||||
"objectIdentity": target.object_identity,
|
||||
"resourceKind": "only_office",
|
||||
"documentId": target.document_id,
|
||||
"assetId": target.resource_id,
|
||||
"resourcePath": target.resource_path,
|
||||
"basisRevision": input.arg_value("basisRevision"),
|
||||
"revision": file_revision(&path),
|
||||
"changeSummary": instructions,
|
||||
"suggestedEdits": [{
|
||||
"kind": "instruction",
|
||||
"text": instructions
|
||||
}],
|
||||
"requiresOnlyOffice": true,
|
||||
"requiresOfficeCli": true,
|
||||
"writesBinary": false
|
||||
}))
|
||||
}
|
||||
|
||||
struct ResourceToolTarget {
|
||||
document_id: String,
|
||||
resource_id: String,
|
||||
object_identity: String,
|
||||
resource_path: Option<String>,
|
||||
}
|
||||
|
||||
impl ResourceToolTarget {
|
||||
fn from_input(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
resource_kind: &str,
|
||||
resource_id_arg: &str,
|
||||
) -> Result<Self, WebError> {
|
||||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_document_required",
|
||||
"资源工具缺少 documentId",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let resource_id = input.arg_string(resource_id_arg).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_id_required",
|
||||
format!("资源工具缺少 {resource_id_arg}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let object_identity = format!("resource:{resource_kind}:{document_id}:{resource_id}");
|
||||
Ok(Self {
|
||||
document_id,
|
||||
resource_id,
|
||||
object_identity,
|
||||
resource_path: input.arg_string("resourcePath"),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_read_path(
|
||||
&self,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_resource_root_uri_required",
|
||||
"资源工具需要授权 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let relative = self.relative_path();
|
||||
crate::routes::ensure_local_path_read_access(context, &root_uri, &relative)
|
||||
.map_err(|error| error.with_context(context))
|
||||
}
|
||||
|
||||
fn resolve_write_path(
|
||||
&self,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<PathBuf, WebError> {
|
||||
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_resource_root_uri_required",
|
||||
"资源工具需要授权 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let relative = self.relative_path();
|
||||
let path = root.join(relative);
|
||||
let canonical = path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_resource_unavailable",
|
||||
format!("无法访问资源文件: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
if !canonical.starts_with(root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_resource_root_escape",
|
||||
"资源工具不能越过授权目录",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn relative_path(&self) -> String {
|
||||
self.resource_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| self.resource_id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_resource_scope_allowed(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
target: &ResourceToolTarget,
|
||||
) -> Result<(), WebError> {
|
||||
let Some(scope) = input.arg_value("aiAccessScope") else {
|
||||
return Ok(());
|
||||
};
|
||||
let allowed = scope
|
||||
.get("allowedResourceIds")
|
||||
.or_else(|| scope.get("allowed_resource_ids"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if allowed.is_empty()
|
||||
|| allowed.contains(&target.resource_id)
|
||||
|| allowed.contains(&target.object_identity)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_resource_ai_scope_forbidden",
|
||||
"当前 AI scope 不允许访问该资源",
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn ensure_resource_write_contract(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入型 mnote resource tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run.is_none() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"写入型 mnote resource tool 必须显式携带 dryRun",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote resource tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
|
||||
input.effective_root_uri().or_else(|| {
|
||||
input
|
||||
.arg_value("aiAccessScope")
|
||||
.and_then(|scope| {
|
||||
scope
|
||||
.get("allowedRoots")
|
||||
.or_else(|| scope.get("allowed_roots"))
|
||||
.cloned()
|
||||
})
|
||||
.and_then(|allowed_roots| {
|
||||
allowed_roots.as_array().and_then(|roots| {
|
||||
roots
|
||||
.iter()
|
||||
.filter_map(|root| {
|
||||
root.get("rootUri")
|
||||
.or_else(|| root.get("root_uri"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.find(|root_uri| !root_uri.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_mindmap_nodes(value: &Value) -> Vec<Value> {
|
||||
let mut nodes = Vec::new();
|
||||
collect_mindmap_nodes_inner(value, &mut nodes);
|
||||
nodes
|
||||
}
|
||||
|
||||
fn collect_mindmap_nodes_inner(value: &Value, nodes: &mut Vec<Value>) {
|
||||
let text = value
|
||||
.pointer("/data/text")
|
||||
.or_else(|| value.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let id = value
|
||||
.pointer("/data/uid")
|
||||
.or_else(|| value.pointer("/data/id"))
|
||||
.or_else(|| value.get("uid"))
|
||||
.or_else(|| value.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("root");
|
||||
if !text.is_empty() {
|
||||
nodes.push(json!({ "id": id, "text": text }));
|
||||
}
|
||||
if let Some(children) = value.get("children").and_then(Value::as_array) {
|
||||
for child in children {
|
||||
collect_mindmap_nodes_inner(child, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mindmap_markdown_summary(nodes: &[Value]) -> String {
|
||||
nodes
|
||||
.iter()
|
||||
.filter_map(|node| node.get("text").and_then(Value::as_str))
|
||||
.take(20)
|
||||
.map(|text| format!("- {text}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn file_revision(path: &Path) -> Value {
|
||||
let Ok(metadata) = fs::metadata(path) else {
|
||||
return Value::Null;
|
||||
};
|
||||
let modified_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
json!(format!("{}:{}", metadata.len(), modified_ms))
|
||||
}
|
||||
|
||||
fn office_mime_type(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
{
|
||||
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn office_text_preview(path: &Path) -> Value {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(text) => json!(text.chars().take(4000).collect::<String>()),
|
||||
Err(_) => Value::Null,
|
||||
}
|
||||
}
|
||||
@@ -726,8 +726,8 @@ pub async fn empty_trash(
|
||||
);
|
||||
}
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: "documents.emptyTrashByWorkspace".into(),
|
||||
command_id: format!("documents_empty_trash_{}", context.trace.request_id),
|
||||
name: "tree.trash.emptyWorkspace".into(),
|
||||
command_id: format!("tree_trash_empty_workspace_{}", context.trace.request_id),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
@@ -751,8 +751,11 @@ pub async fn empty_trash(
|
||||
"workspaceId": workspace_id,
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web documents empty trash".into()),
|
||||
refs: vec!["mnote-web-documents-trash".into()],
|
||||
reason: Some("mnote-web tree trash empty workspace".into()),
|
||||
refs: vec![
|
||||
"mnote-web-documents-trash-compat".into(),
|
||||
"tree.trash.emptyWorkspace".into(),
|
||||
],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
@@ -769,7 +772,7 @@ pub async fn empty_trash(
|
||||
if let Value::Object(map) = &mut result {
|
||||
map.insert(
|
||||
"canonicalCommand".into(),
|
||||
json!("documents.emptyTrashByWorkspace"),
|
||||
json!("tree.trash.emptyWorkspace"),
|
||||
);
|
||||
map.insert("compatRoute".into(), json!("/api/documents/empty-trash"));
|
||||
}
|
||||
@@ -784,8 +787,9 @@ pub async fn empty_trash(
|
||||
"traceId": context.trace.trace_id,
|
||||
"owner": "mnote-web",
|
||||
"meta": {
|
||||
"commandName": "documents.emptyTrashByWorkspace",
|
||||
"canonicalCommand": "documents.emptyTrashByWorkspace",
|
||||
"commandName": "tree.trash.emptyWorkspace",
|
||||
"canonicalCommand": "tree.trash.emptyWorkspace",
|
||||
"compatCommandName": "documents.emptyTrashByWorkspace",
|
||||
"artifacts": artifacts,
|
||||
"artifactError": artifact_error,
|
||||
},
|
||||
@@ -1242,11 +1246,11 @@ mod tests {
|
||||
assert_eq!(payload["result"]["deletedCount"], 2);
|
||||
assert_eq!(
|
||||
payload["result"]["canonicalCommand"],
|
||||
"documents.emptyTrashByWorkspace"
|
||||
"tree.trash.emptyWorkspace"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["commandLog"]["commandName"],
|
||||
"documents.emptyTrashByWorkspace"
|
||||
"tree.trash.emptyWorkspace"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::hermes_client;
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput};
|
||||
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -344,6 +344,10 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
"mnote.page.save" => page::page_save(&state, &context, &input).await,
|
||||
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
|
||||
"mnote.page.update_options" => page::update_options(&state, &context, &input).await,
|
||||
"mnote.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await,
|
||||
"mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await,
|
||||
"mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await,
|
||||
"mnote.office.propose_changes" => resource::office_propose_changes(&context, &input).await,
|
||||
"mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await,
|
||||
"mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await,
|
||||
_ => Err(
|
||||
@@ -448,7 +452,13 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
fn is_read_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch"
|
||||
"mnote.page.get"
|
||||
| "mnote.doc.fetch"
|
||||
| "mnote.doc.find"
|
||||
| "mnote.block.fetch"
|
||||
| "mnote.mindmap.fetch"
|
||||
| "mnote.office.fetch_summary"
|
||||
| "mnote.office.propose_changes"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -995,6 +1005,18 @@ mod tests {
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
@@ -1148,6 +1170,324 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_resource_tools_require_allowed_resource_scope() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-resource-scope-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("maps")).expect("maps");
|
||||
fs::write(
|
||||
root.join("maps").join("idea.mindmap.json"),
|
||||
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
|
||||
)
|
||||
.expect("mindmap");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_1", &root_uri,
|
||||
)
|
||||
.expect("workspace");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.mindmap.fetch",
|
||||
"workspaceId": "local-ws-resource",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_resource_scope",
|
||||
"runId": "run_resource_scope",
|
||||
"toolCallId": "call_resource_scope",
|
||||
"traceId": "trace_resource_scope",
|
||||
"args": {
|
||||
"mindmapId": "mind_allowed",
|
||||
"resourcePath": "maps/idea.mindmap.json",
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["mind_other"]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_resource_ai_scope_forbidden")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-resource-mindmap-fetch-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("maps")).expect("maps");
|
||||
fs::write(
|
||||
root.join("maps").join("idea.mindmap.json"),
|
||||
r#"{"data":{"text":"中心主题","uid":"root"},"children":[{"data":{"text":"分支一","uid":"child_1"},"children":[]}]}"#,
|
||||
)
|
||||
.expect("mindmap");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_1", &root_uri,
|
||||
)
|
||||
.expect("workspace");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.mindmap.fetch",
|
||||
"workspaceId": "local-ws-resource",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_mindmap_fetch",
|
||||
"runId": "run_mindmap_fetch",
|
||||
"toolCallId": "call_mindmap_fetch",
|
||||
"traceId": "trace_mindmap_fetch",
|
||||
"args": {
|
||||
"mindmapId": "mind_allowed",
|
||||
"resourcePath": "maps/idea.mindmap.json",
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["mind_allowed"]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
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["toolName"], "mnote.mindmap.fetch");
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
assert_eq!(payload["result"]["resourceKind"], "mindmap");
|
||||
assert_eq!(
|
||||
payload["result"]["objectIdentity"],
|
||||
"resource:mindmap:local-md:README.md:mind_allowed"
|
||||
);
|
||||
assert!(payload["result"]["markdownSummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("中心主题"));
|
||||
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_mindmap_apply_ops_shared_read_is_forbidden() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-resource-mindmap-write-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("maps")).expect("maps");
|
||||
fs::write(
|
||||
root.join("maps").join("idea.mindmap.json"),
|
||||
r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#,
|
||||
)
|
||||
.expect("mindmap");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_1", &root_uri,
|
||||
)
|
||||
.expect("workspace");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.mindmap.apply_ops",
|
||||
"workspaceId": "local-ws-resource",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_mindmap_write",
|
||||
"runId": "run_mindmap_write",
|
||||
"toolCallId": "call_mindmap_write",
|
||||
"traceId": "trace_mindmap_write",
|
||||
"idempotencyKey": "idem_mindmap_write",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"mindmapId": "mind_allowed",
|
||||
"resourcePath": "maps/idea.mindmap.json",
|
||||
"ops": [{"op": "update_node", "nodeId": "root", "text": "改名"}],
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["mind_allowed"]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_shared_read_write_forbidden")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_office_fetch_and_propose_changes_do_not_write_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-resource-office-fetch-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("office")).expect("office");
|
||||
fs::write(root.join("office").join("report.docx"), "Office text").expect("office file");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_1", &root_uri,
|
||||
)
|
||||
.expect("workspace");
|
||||
|
||||
let fetch = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.office.fetch_summary",
|
||||
"workspaceId": "local-ws-resource",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_office_fetch",
|
||||
"runId": "run_office_fetch",
|
||||
"toolCallId": "call_office_fetch",
|
||||
"traceId": "trace_office_fetch",
|
||||
"args": {
|
||||
"assetId": "asset_report",
|
||||
"resourcePath": "office/report.docx",
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["resource:onlyoffice:local-md:README.md:asset_report"]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(fetch.status(), StatusCode::OK);
|
||||
let fetch_body = to_bytes(fetch.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("fetch body");
|
||||
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("fetch json");
|
||||
assert_eq!(fetch_payload["audit"]["effect"], "read");
|
||||
assert_eq!(fetch_payload["result"]["resourceKind"], "only_office");
|
||||
assert_eq!(fetch_payload["result"]["fileName"], "report.docx");
|
||||
|
||||
let before = fs::read(root.join("office").join("report.docx")).expect("before");
|
||||
let propose = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.office.propose_changes",
|
||||
"workspaceId": "local-ws-resource",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_office_propose",
|
||||
"runId": "run_office_propose",
|
||||
"toolCallId": "call_office_propose",
|
||||
"traceId": "trace_office_propose",
|
||||
"args": {
|
||||
"assetId": "asset_report",
|
||||
"resourcePath": "office/report.docx",
|
||||
"instructions": "补充结论",
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["asset_report"]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(propose.status(), StatusCode::OK);
|
||||
let propose_body = to_bytes(propose.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("propose body");
|
||||
let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json");
|
||||
assert_eq!(propose_payload["audit"]["effect"], "read");
|
||||
assert_eq!(propose_payload["result"]["writesBinary"], false);
|
||||
assert_eq!(
|
||||
fs::read(root.join("office").join("report.docx")).expect("after"),
|
||||
before
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() {
|
||||
let response = app()
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct MediaBatchRequest {
|
||||
#[serde(default)]
|
||||
pub asset_ids: Vec<String>,
|
||||
pub new_name: Option<String>,
|
||||
pub target_document_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -510,10 +511,10 @@ pub async fn media_batch(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let action = body.action.trim();
|
||||
if action != "restore" && action != "delete" && action != "rename" {
|
||||
if action != "restore" && action != "delete" && action != "rename" && action != "move" {
|
||||
return Err(WebError::bad_request_code(
|
||||
"media_batch_action_unsupported",
|
||||
"Rust resource trash 当前仅支持附件 delete / restore / rename",
|
||||
"Rust resource trash 当前仅支持附件 delete / restore / rename / move",
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
|
||||
@@ -553,6 +554,21 @@ pub async fn media_batch(
|
||||
"newName": new_name,
|
||||
}),
|
||||
)
|
||||
} else if action == "move" {
|
||||
let target_document_id = require_id(
|
||||
&context,
|
||||
body.target_document_id.as_deref().unwrap_or_default(),
|
||||
"targetDocumentId",
|
||||
)?;
|
||||
(
|
||||
"tree.resource.move",
|
||||
json!({
|
||||
"resourceKind": "file",
|
||||
"assetId": asset_id,
|
||||
"fromDocumentId": document_id,
|
||||
"targetDocumentId": target_document_id,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"tree.resource.archive",
|
||||
@@ -595,6 +611,29 @@ pub async fn media_batch(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if action == "move" {
|
||||
let target_document_id = require_id(
|
||||
&context,
|
||||
body.target_document_id.as_deref().unwrap_or_default(),
|
||||
"targetDocumentId",
|
||||
)?;
|
||||
execute_convex_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",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let command_result = execute_runtime_command_via_convex_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
@@ -602,12 +641,13 @@ pub async fn media_batch(
|
||||
command,
|
||||
)
|
||||
.await;
|
||||
if action == "rename" {
|
||||
if action == "rename" || action == "move" {
|
||||
if let Err(error) = command_result {
|
||||
tracing::warn!(
|
||||
error = %error.message(),
|
||||
asset_id = %asset_id,
|
||||
"tree.resource.rename artifact command 失败,已保留兼容 patch 结果"
|
||||
action = %action,
|
||||
"tree.resource artifact command 失败,已保留兼容 patch 结果"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -624,10 +664,13 @@ pub async fn media_batch(
|
||||
"restored": if action == "restore" { updated } else { 0 },
|
||||
"deleted": if action == "delete" { updated } else { 0 },
|
||||
"renamed": if action == "rename" { updated } else { 0 },
|
||||
"moved": if action == "move" { updated } else { 0 },
|
||||
"canonicalCommand": if action == "restore" {
|
||||
"tree.resource.restore"
|
||||
} else if action == "rename" {
|
||||
"tree.resource.rename"
|
||||
} else if action == "move" {
|
||||
"tree.resource.move"
|
||||
} else {
|
||||
"tree.resource.archive"
|
||||
},
|
||||
@@ -1195,6 +1238,19 @@ mod tests {
|
||||
assert_ne!(renamed["result"]["canonicalCommand"], "tree.node.rename");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_move_uses_resource_command_not_document_command() {
|
||||
let moved = post_json(
|
||||
"/api/media/batch",
|
||||
json!({"action": "move", "assetIds": ["asset_1"], "targetDocumentId": "doc_2"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(moved["result"]["moved"], 1);
|
||||
assert_eq!(moved["result"]["canonicalCommand"], "tree.resource.move");
|
||||
assert_ne!(moved["result"]["canonicalCommand"], "documents.move");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mindmap_trash_routes_delete_restore_purge_and_empty() {
|
||||
let deleted = delete_json("/api/mindmap/doc_1/mind_1").await;
|
||||
|
||||
@@ -1023,6 +1023,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return textToTiptapDocument(fallbackText);
|
||||
};
|
||||
|
||||
const pageBodyTiptapDocument = (body, fallbackText = '') => {
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
if (blockDocument) return toTiptapDocument(blockDocument, fallbackText);
|
||||
return toTiptapDocument(body?.content, fallbackText);
|
||||
};
|
||||
|
||||
const inlineTextNodes = (node) => {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
if (Array.isArray(node.content)) {
|
||||
@@ -1568,7 +1574,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const aggregatePlainText = (aggregate) => {
|
||||
const body = aggregate?.body || {};
|
||||
return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const conflictEnvelopeFromResponse = (payload) => (
|
||||
@@ -1604,7 +1610,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
@@ -1971,7 +1977,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
const nextTiptapDocument = pageBodyTiptapDocument(nextBody);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
@@ -2250,7 +2256,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
|
||||
const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null;
|
||||
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
|
||||
const tiptapDocument = toTiptapDocument(pageBody.content);
|
||||
const tiptapDocument = pageBodyTiptapDocument(pageBody);
|
||||
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
|
||||
const session = {
|
||||
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
|
||||
@@ -3492,6 +3498,10 @@ mod tests {
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
|
||||
assert!(html.contains("const pageBodyTiptapDocument = (body, fallbackText = '') => {"));
|
||||
assert!(html.contains("body?.blockDocument || body?.block_document"));
|
||||
assert!(html.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody);"));
|
||||
assert!(html.contains("const tiptapDocument = pageBodyTiptapDocument(pageBody);"));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
|
||||
@@ -8072,6 +8072,15 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function liveDeltaNeedsResync(payload) {
|
||||
var data = payload && (payload.data || payload.delta || payload);
|
||||
var op = data && typeof data.op === 'string' ? String(data.op).trim() : '';
|
||||
if (!op || op === 'noop') return false;
|
||||
if (op === 'resync_required') return true;
|
||||
if (data && data.requiresResync === true) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function startWithSse(bootstrap, workspaceId, url) {
|
||||
var failures = 0;
|
||||
var source = new EventSource(url.toString());
|
||||
@@ -8144,8 +8153,7 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
|
||||
} else if (kind === 'delta') {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
|
||||
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
|
||||
// Delta indicates something changed; request fresh resync from server
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (liveDeltaNeedsResync(payload) && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('resync');
|
||||
}
|
||||
} else if (kind === 'resync') {
|
||||
@@ -8738,7 +8746,10 @@ mod tests {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("liveDeltaNeedsResync(payload)"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("op === 'resync_required'"));
|
||||
assert!(!TREE_LIVE_CONTROLLER_JS.contains("resyncEndpoint"));
|
||||
assert!(!TREE_LIVE_CONTROLLER_JS.contains("tree:resync-requested"));
|
||||
assert!(!TREE_LIVE_CONTROLLER_JS.contains("Delta indicates something changed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +439,7 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
"tree.resource.archive"
|
||||
| "tree.resource.restore"
|
||||
| "tree.resource.rename"
|
||||
| "tree.resource.move"
|
||||
| "tree.resource.purge"
|
||||
) {
|
||||
args = convex_resource_lifecycle_args_for_plan(plan, &args);
|
||||
@@ -537,6 +538,17 @@ fn convex_resource_lifecycle_args_for_plan(
|
||||
"file_name": args.get("newName").and_then(Value::as_str).unwrap_or_default(),
|
||||
},
|
||||
}),
|
||||
"move" => json!({
|
||||
"userId": user_id,
|
||||
"id": id,
|
||||
"patch": {
|
||||
"document_id": args
|
||||
.get("targetDocumentId")
|
||||
.or_else(|| args.get("target_document_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
}),
|
||||
"purge" => json!({
|
||||
"userId": user_id,
|
||||
"id": id,
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
||||
"tree.node.archive" => "documents:softDelete",
|
||||
"tree.node.restore" => "documents:restore",
|
||||
"tree.node.purge" => "documents:purge",
|
||||
"tree.trash.emptyWorkspace" => "documents:emptyTrashByWorkspace",
|
||||
"tree.subtree.move" => "documents:move",
|
||||
"tree.subtree.copy" => "documents:copyTree",
|
||||
"tree.resource.copy" => "mediaAssets:batchCopy",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
cleanupDocuments,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
openFilesystemView,
|
||||
renameDocument,
|
||||
requestJson,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task456-resource-object-shell-sync-smoke";
|
||||
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
|
||||
const RESULT_PATH = path.join(OUT_DIR, "result.json");
|
||||
|
||||
async function writeResult(result) {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function createMindmap(request, workspaceId, documentId, mindmapId, title) {
|
||||
return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
commandName: "mindmaps.put",
|
||||
workspaceId,
|
||||
createOnly: true,
|
||||
data: { data: { text: title, uid: "root" }, children: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function readResourceState(page, documentId, mindmapId, officeAssetId) {
|
||||
return await page.evaluate(
|
||||
({ documentId: docId, mindmapId: mapId, officeId }) => {
|
||||
const fileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
const mindmapRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(mapId)}"]`);
|
||||
const officeRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(officeId)}"]`);
|
||||
const pageRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(docId)}"]`);
|
||||
const mindmapTitle = mindmapRow?.querySelector(":scope > .tree-link > .tree-link-title")?.textContent || "";
|
||||
const officeTitle = officeRow?.querySelector(":scope > .tree-link > .tree-link-title")?.textContent || "";
|
||||
return {
|
||||
fileRootStable: Boolean(fileRoot && fileRoot === window.__task456FileRoot),
|
||||
pageExists: pageRow instanceof HTMLElement,
|
||||
mindmapExists: mindmapRow instanceof HTMLElement,
|
||||
officeExists: officeRow instanceof HTMLElement,
|
||||
mindmapSelected: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-selected") || "" : "",
|
||||
officeSelected: officeRow instanceof HTMLElement ? officeRow.getAttribute("data-selected") || "" : "",
|
||||
mindmapTitle: mindmapTitle.trim(),
|
||||
officeTitle: officeTitle.trim(),
|
||||
mindmapObjectIdentity: mindmapRow instanceof HTMLElement ? mindmapRow.dataset.objectIdentity || "" : "",
|
||||
officeObjectIdentity: officeRow instanceof HTMLElement ? officeRow.dataset.objectIdentity || "" : "",
|
||||
currentObjectEditor: document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") || "",
|
||||
currentObjectIdentity: document.querySelector("[data-mnote-object-identity]")?.getAttribute("data-mnote-object-identity") || "",
|
||||
};
|
||||
},
|
||||
{ documentId, mindmapId, officeAssetId },
|
||||
);
|
||||
}
|
||||
|
||||
async function injectOfficeRow(page, documentId, assetId) {
|
||||
await page.evaluate(
|
||||
({ docId, officeId }) => {
|
||||
const root = document.getElementById("sidebar-file-tree-root");
|
||||
if (!(root instanceof HTMLElement)) throw new Error("缺少 file tree root");
|
||||
if (document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(officeId)}"]`)) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-row";
|
||||
row.dataset.testid = "filetree-asset-row";
|
||||
row.dataset.assetId = officeId;
|
||||
row.dataset.objectKind = "onlyoffice";
|
||||
row.dataset.objectIdentity = JSON.stringify({ objectKind: "onlyoffice", documentId: docId, assetId: officeId });
|
||||
row.innerHTML = `<button class="tree-link" type="button" data-rust-action="open"><span class="tree-link-title">task456-office.docx</span></button>`;
|
||||
row.querySelector("button").addEventListener("click", () => {
|
||||
window.location.href = `/office/${encodeURIComponent(docId)}/${encodeURIComponent(officeId)}?fileName=task456-office.docx&fileType=docx&mode=edit`;
|
||||
});
|
||||
root.appendChild(row);
|
||||
},
|
||||
{ docId: documentId, officeId: assetId },
|
||||
);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
const createdDocuments = [];
|
||||
const result = {
|
||||
baseUrl: BASE_URL,
|
||||
fixture: {},
|
||||
before: null,
|
||||
afterMindmapOpen: null,
|
||||
afterMindmapUpdate: null,
|
||||
afterOfficeOpen: null,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const doc = await createTempDocument(context.request, null);
|
||||
createdDocuments.push(doc.documentId);
|
||||
const stamp = Date.now().toString().slice(-8);
|
||||
const title = `TEST-456-resource-${stamp}`;
|
||||
await renameDocument(context.request, doc.workspaceId, doc.documentId, title);
|
||||
const mindmapId = `mindmap_456_${stamp}`;
|
||||
const officeAssetId = `asset_office_456_${stamp}`;
|
||||
await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-456-mind-${stamp}`);
|
||||
result.fixture = { ...doc, title, mindmapId, officeAssetId };
|
||||
|
||||
await openDocument(page, doc.workspaceId, doc.documentId);
|
||||
await openFilesystemView(page);
|
||||
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
|
||||
await injectOfficeRow(page, doc.documentId, officeAssetId);
|
||||
await page.evaluate(() => {
|
||||
window.__task456FileRoot = document.getElementById("sidebar-file-tree-root");
|
||||
});
|
||||
result.before = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
||||
assert(result.before.mindmapExists, "filetree 应显示 mindmap 资源行");
|
||||
assert(result.before.officeExists, "filetree 应显示 office 资源行");
|
||||
|
||||
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(mindmapId)}`), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForSelector("[data-mnote-object-editor='mindmap']", { timeout: UI_TIMEOUT_MS });
|
||||
result.afterMindmapOpen = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
||||
assert(result.afterMindmapOpen.fileRootStable, "打开 mindmap 后 filetree root 不应被替换");
|
||||
assert(result.afterMindmapOpen.currentObjectIdentity.includes(`resource:mindmap:${doc.documentId}:${mindmapId}`), "mindmap 对象壳应暴露 resource identity");
|
||||
|
||||
await requestJson(context.request, `/api/mindmap/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(mindmapId)}`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
commandName: "mindmap.command.apply",
|
||||
workspaceId: doc.workspaceId,
|
||||
commands: [{ type: "updateText", mindmapId, nodeId: "root", text: `TEST-456-updated-${stamp}` }],
|
||||
projectionRevision: 1,
|
||||
},
|
||||
});
|
||||
await openDocument(page, doc.workspaceId, doc.documentId);
|
||||
await openFilesystemView(page);
|
||||
await injectOfficeRow(page, doc.documentId, officeAssetId);
|
||||
result.afterMindmapUpdate = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
||||
assert(result.afterMindmapUpdate.mindmapExists, "mindmap 更新后 filetree 资源行不应丢失");
|
||||
assert(result.afterMindmapUpdate.pageExists, "mindmap 更新后页面行不应丢失");
|
||||
|
||||
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname.includes(`/office/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(officeAssetId)}`), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForSelector("[data-mnote-object-editor='onlyoffice']", { timeout: UI_TIMEOUT_MS });
|
||||
result.afterOfficeOpen = await readResourceState(page, doc.documentId, mindmapId, officeAssetId);
|
||||
assert(result.afterOfficeOpen.currentObjectIdentity.includes(`resource:onlyoffice:${doc.documentId}:${officeAssetId}`), "office 对象壳应暴露 resource identity");
|
||||
|
||||
await writeResult({ ...result, ok: true, finalUrl: page.url() });
|
||||
console.log(`ok ${TASK} ${RESULT_PATH}`);
|
||||
} catch (error) {
|
||||
await writeResult({
|
||||
...result,
|
||||
ok: false,
|
||||
currentUrl: page.url(),
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (createdDocuments.length) await cleanupDocuments(context.request, createdDocuments).catch(() => null);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user