4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
+731 -14
View File
@@ -34,7 +34,7 @@ use index_fts::{
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, VecDeque};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::env;
use std::sync::atomic::{AtomicU64, Ordering};
use storage_convex_bridge::{
@@ -122,6 +122,8 @@ pub struct RuntimeCommandEnvelopeWire {
pub source: RuntimeSourceWire,
pub target: Option<RuntimeTargetWire>,
pub payload: Value,
#[serde(default)]
pub preflight_data: Option<Value>,
pub reason: Option<String>,
pub refs: Vec<String>,
pub dry_run: bool,
@@ -606,6 +608,190 @@ struct DocumentMoveCommandPayload {
sort_order: i64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMovePreflightDocument {
id: String,
workspace_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct DocumentMoveSnapshotDocument {
id: String,
#[serde(default, alias = "workspace_id")]
workspace_id: Option<String>,
#[serde(default, alias = "parent_id")]
parent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMovePreflightPayload {
source_document: DocumentMovePreflightDocument,
target_parent_document: Option<DocumentMovePreflightDocument>,
#[serde(default)]
target_ancestor_ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMoveSnapshotSidebarPayload {
#[serde(default)]
documents: Vec<DocumentMoveSnapshotDocument>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentMoveSnapshotPayload {
#[serde(default)]
documents: Vec<DocumentMoveSnapshotDocument>,
#[serde(default, alias = "sidebarSnapshot")]
sidebar_snapshot: Option<DocumentMoveSnapshotSidebarPayload>,
}
fn derive_document_move_preflight_from_snapshot(
payload: &DocumentMoveCommandPayload,
snapshot: &DocumentMoveSnapshotPayload,
) -> Result<DocumentMovePreflightPayload, BridgeError> {
let documents = if !snapshot.documents.is_empty() {
snapshot.documents.clone()
} else if let Some(sidebar_snapshot) = snapshot.sidebar_snapshot.as_ref() {
sidebar_snapshot.documents.clone()
} else {
return Err(BridgeError::validation(
"move preflightData 缺少 documents 快照",
));
};
let document_by_id: HashMap<String, DocumentMoveSnapshotDocument> = documents
.into_iter()
.map(|document| (document.id.clone(), document))
.collect();
let source_document =
document_by_id
.get(payload.document_id.as_str())
.ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?;
let target_parent_document = payload
.parent_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|parent_id| {
document_by_id
.get(parent_id)
.cloned()
.ok_or_else(|| BridgeError::validation("目标父页面不存在或无权限"))
})
.transpose()?;
let mut target_ancestor_ids = Vec::new();
let mut cursor = target_parent_document
.as_ref()
.and_then(|document| document.parent_id.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let mut depth = 0;
while let Some(parent_id) = cursor {
if depth >= 256 {
break;
}
target_ancestor_ids.push(parent_id.clone());
cursor = document_by_id
.get(parent_id.as_str())
.and_then(|document| document.parent_id.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
depth += 1;
}
Ok(DocumentMovePreflightPayload {
source_document: DocumentMovePreflightDocument {
id: source_document.id.clone(),
workspace_id: source_document.workspace_id.clone(),
},
target_parent_document: target_parent_document.map(|document| DocumentMovePreflightDocument {
id: document.id,
workspace_id: document.workspace_id,
}),
target_ancestor_ids,
})
}
fn resolve_document_move_preflight(
payload: &DocumentMoveCommandPayload,
preflight_data: Option<&Value>,
) -> Result<Option<DocumentMovePreflightPayload>, BridgeError> {
let Some(raw_preflight) = preflight_data else {
return Ok(None);
};
if let Ok(preflight) =
serde_json::from_value::<DocumentMovePreflightPayload>(raw_preflight.clone())
{
return Ok(Some(preflight));
}
let snapshot = serde_json::from_value::<DocumentMoveSnapshotPayload>(raw_preflight.clone())
.map_err(|error| BridgeError::validation(format!("move preflightData 非法: {error}")))?;
derive_document_move_preflight_from_snapshot(payload, &snapshot).map(Some)
}
fn validate_document_move_legality(
payload: &DocumentMoveCommandPayload,
preflight_data: Option<&Value>,
) -> Result<(), BridgeError> {
let Some(parent_id) = payload.parent_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
if parent_id == payload.document_id {
return Err(BridgeError::validation("不能把页面移动到自身下面"));
}
let Some(preflight) = resolve_document_move_preflight(payload, preflight_data)? else {
return Ok(());
};
if preflight.source_document.id.trim() == parent_id {
return Err(BridgeError::validation("不能把页面移动到自身下面"));
}
if preflight
.target_ancestor_ids
.iter()
.any(|ancestor_id| ancestor_id.trim() == payload.document_id)
{
return Err(BridgeError::validation("不能把页面移动到自己的后代下面"));
}
if let Some(target_parent_document) = preflight.target_parent_document.as_ref() {
let source_workspace_id = preflight
.source_document
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let target_workspace_id = target_parent_document
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if source_workspace_id.is_some()
&& target_workspace_id.is_some()
&& source_workspace_id != target_workspace_id
{
return Err(BridgeError::validation("暂不支持跨工作空间移动页面"));
}
}
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DocumentDeleteCommandPayload {
@@ -5557,10 +5743,15 @@ fn execute_command(
}),
}))
}
"documents.embed" => {
"documents.embed" | "tree.node.embed" => {
let payload: DocumentEmbedCommandPayload = parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.embed" {
"tree.node.embed"
} else {
"documents.embed"
};
let command = CommandEnvelope {
name: "documents.embed".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),
@@ -5571,9 +5762,7 @@ fn execute_command(
workspace_id: payload.workspace_id.clone(),
revision: payload.revision,
content_json: serde_json::to_string(&payload.content).map_err(|error| {
BridgeError::validation(format!(
"documents.embed content 序列化失败: {error}"
))
BridgeError::validation(format!("{command_name} content 序列化失败: {error}"))
})?,
conflict_detection_key: payload.conflict_detection_key.clone(),
},
@@ -5818,6 +6007,7 @@ fn execute_command(
}
"documents.move" | "tree.subtree.move" => {
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?;
let command_name = if command_wire.name == "tree.subtree.move" {
"tree.subtree.move"
} else {
@@ -5854,11 +6044,16 @@ fn execute_command(
}),
}))
}
"documents.delete" => {
"documents.delete" | "tree.node.archive" => {
let payload: DocumentDeleteCommandPayload =
parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.archive" {
"tree.node.archive"
} else {
"documents.delete"
};
let command = CommandEnvelope {
name: "documents.delete".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),
@@ -5886,11 +6081,16 @@ fn execute_command(
}),
}))
}
"documents.restore" => {
"documents.restore" | "tree.node.restore" => {
let payload: DocumentRestoreCommandPayload =
parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.restore" {
"tree.node.restore"
} else {
"documents.restore"
};
let command = CommandEnvelope {
name: "documents.restore".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),
@@ -6017,10 +6217,15 @@ fn execute_command(
}),
}))
}
"documents.purge" => {
"documents.purge" | "tree.node.purge" => {
let payload: DocumentPurgeCommandPayload = parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.purge" {
"tree.node.purge"
} else {
"documents.purge"
};
let command = CommandEnvelope {
name: "documents.purge".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),
@@ -6048,11 +6253,16 @@ fn execute_command(
}),
}))
}
"documents.copy_tree" => {
"documents.copy_tree" | "tree.subtree.copy" => {
let payload: DocumentCopyTreeCommandPayload =
parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.subtree.copy" {
"tree.subtree.copy"
} else {
"documents.copy_tree"
};
let command = CommandEnvelope {
name: "documents.copy_tree".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),
@@ -6426,6 +6636,7 @@ mod tests {
"type": "paragraph",
},
}),
preflight_data: None,
reason: Some("替换块快照".into()),
refs: vec![],
dry_run: false,
@@ -6732,6 +6943,7 @@ mod tests {
},
"createOnly": true,
}),
preflight_data: None,
reason: Some("保存导图".into()),
refs: vec!["task-032".into()],
dry_run: false,
@@ -7229,6 +7441,7 @@ mod tests {
},
"conflictDetectionKey": "doc_1:4"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-055".into()],
dry_run: false,
@@ -7326,6 +7539,7 @@ mod tests {
],
"conflictDetectionKey": "doc_1:6"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-save-fallback".into()],
dry_run: false,
@@ -7427,6 +7641,7 @@ mod tests {
],
"conflictDetectionKey": "doc_1:7"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-save-prefer-editor".into()],
dry_run: false,
@@ -7489,6 +7704,7 @@ mod tests {
],
"conflictDetectionKey": "doc_1:8"
}),
preflight_data: None,
reason: Some("保存正文".into()),
refs: vec!["task-save-content-only".into()],
dry_run: false,
@@ -7540,6 +7756,7 @@ mod tests {
"blockId": "block_1",
"targetDocumentId": "doc_2",
}),
preflight_data: None,
reason: Some("移动块".into()),
refs: vec![],
dry_run: false,
@@ -7594,6 +7811,7 @@ mod tests {
"targetDocumentId": "doc_2",
"targetBlockId": "anchor_1",
}),
preflight_data: None,
reason: Some("嵌入块".into()),
refs: vec![],
dry_run: false,
@@ -7653,6 +7871,7 @@ mod tests {
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
}),
preflight_data: None,
reason: Some("嵌入页面".into()),
refs: vec![],
dry_run: false,
@@ -7683,6 +7902,504 @@ mod tests {
}
}
#[test]
fn tree_lifecycle_command_aliases_keep_tree_command_names() {
let cases = [
(
"tree.node.archive",
"documents:softDelete",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
}),
json!({
"id": "doc_1",
}),
),
(
"tree.node.restore",
"documents:restore",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
}),
json!({
"id": "doc_1",
}),
),
(
"tree.node.purge",
"documents:purge",
json!({
"documentId": "doc_1",
}),
json!({
"id": "doc_1",
}),
),
];
for (command_name, function_name, payload, args_json) in cases {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: command_name.into(),
command_id: format!("cmd_{command_name}"),
idempotency_key: Some(format!("idem_{command_name}")),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload,
preflight_data: None,
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("command plan should build");
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, function_name);
assert_eq!(plan.command_name, command_name);
assert_eq!(plan.args_json, args_json);
}
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
}
}
}
#[test]
fn tree_subtree_move_command_rejects_self_target_via_preflight() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_self".into(),
idempotency_key: Some("idem_tree_move_self".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"parentId": "doc_1",
"sortOrder": 0,
"movePreflight": {
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetAncestorIds": []
}
}),
preflight_data: Some(json!({
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetAncestorIds": []
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("self move should be rejected");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "不能把页面移动到自身下面");
}
#[test]
fn tree_subtree_move_command_rejects_descendant_target_via_preflight() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_descendant".into(),
idempotency_key: Some("idem_tree_move_descendant".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"parentId": "child_1",
"sortOrder": 0,
"movePreflight": {
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "child_1",
"workspaceId": "ws_1",
"parentId": "doc_1"
},
"targetAncestorIds": ["doc_1"]
}
}),
preflight_data: Some(json!({
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "child_1",
"workspaceId": "ws_1",
"parentId": "doc_1"
},
"targetAncestorIds": ["doc_1"]
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("descendant move should be rejected");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
}
#[test]
fn tree_subtree_move_command_rejects_cross_workspace_target_via_preflight() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_cross_workspace".into(),
idempotency_key: Some("idem_tree_move_cross_workspace".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"parentId": "parent_remote",
"sortOrder": 0,
"movePreflight": {
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "parent_remote",
"workspaceId": "ws_2",
"parentId": null
},
"targetAncestorIds": []
}
}),
preflight_data: Some(json!({
"sourceDocument": {
"id": "doc_1",
"workspaceId": "ws_1",
"parentId": null
},
"targetParentDocument": {
"id": "parent_remote",
"workspaceId": "ws_2",
"parentId": null
},
"targetAncestorIds": []
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("cross-workspace move should be rejected");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "暂不支持跨工作空间移动页面");
}
#[test]
fn tree_subtree_move_command_rejects_descendant_target_via_snapshot_documents() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_desc_snapshot".into(),
idempotency_key: Some("idem_tree_move_desc_snapshot".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"parentId": "child_1",
"sortOrder": 0
}),
preflight_data: Some(json!({
"documents": [
{
"id": "doc_1",
"workspace_id": "ws_1",
"parent_id": null
},
{
"id": "child_1",
"workspace_id": "ws_1",
"parent_id": "doc_1"
}
]
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("descendant move should be rejected from snapshot documents");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "不能把页面移动到自己的后代下面");
}
#[test]
fn tree_subtree_move_command_rejects_missing_target_parent_via_snapshot_documents() {
let error = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.move".into(),
command_id: "cmd_tree_move_missing_parent_snapshot".into(),
idempotency_key: Some("idem_tree_move_missing_parent_snapshot".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"parentId": "missing_parent",
"sortOrder": 0
}),
preflight_data: Some(json!({
"documents": [
{
"id": "doc_1",
"workspace_id": "ws_1",
"parent_id": null
}
]
})),
reason: Some("树命令切流".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect_err("missing parent should be rejected from snapshot documents");
assert_eq!(error.kind, BridgeErrorKind::Validation);
assert_eq!(error.message, "目标父页面不存在或无权限");
}
#[test]
fn tree_embed_and_copy_aliases_keep_tree_command_names() {
let embed_plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.node.embed".into(),
command_id: "cmd_tree_embed_1".into(),
idempotency_key: Some("idem_tree_embed".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_2".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_2",
"workspaceId": "ws_1",
"revision": 5,
"content": [{ "id": "block_1", "type": "pageReference" }],
"conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
}),
preflight_data: None,
reason: Some("树命令嵌入页面".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("embed plan should build");
match embed_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.command_name, "tree.node.embed");
assert_eq!(
plan.args_json,
json!({
"id": "doc_2",
"content": [{ "id": "block_1", "type": "pageReference" }],
"expectedRevision": 5,
"conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
})
);
}
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
}
let copy_plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "tree.subtree.copy".into(),
command_id: "cmd_tree_copy_1".into(),
idempotency_key: Some("idem_tree_copy".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("parent_1".into()),
block_id: None,
}),
payload: json!({
"items": [
{
"documentId": "doc_1",
"recursive": true,
}
],
"targetParentId": "parent_1",
}),
preflight_data: None,
reason: Some("树命令复制子树".into()),
refs: vec![],
dry_run: false,
validate_only: false,
},
})
.expect("copy plan should build");
match copy_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:copyTree");
assert_eq!(plan.command_name, "tree.subtree.copy");
assert_eq!(
plan.args_json,
json!({
"items": [
{
"documentId": "doc_1",
"recursive": true,
}
],
"targetParentId": "parent_1",
})
);
}
RuntimeExecutionPlan::Query(_) => panic!("expected command plan"),
RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"),
}
}
#[test]
fn mindmap_get_tool_plan_uses_mindmaps_get_query() {
let plan = execute_runtime_input(RuntimeInput::Tool {