use adapter_onlyoffice::{ prepare_callback, prepare_forcesave, prepare_proxy_request, resolve_session, sign_config, OnlyOfficeCallbackPreparationInput, OnlyOfficeForcesavePreparationInput, OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput, }; use core_domain::Timestamp; use core_protocol::editor::{EditorBlockDocumentTiptapBridge, TiptapNode}; use core_protocol::{ default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, BlockProps, CommandEnvelope, ContentNode, ContentNodePayload, DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand, EditorDeleteBlock, EditorInsertBlockAfter, EditorMoveBlock, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge, KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities, MindmapKernelCommand, MindmapKernelEdge, MindmapKernelNode, MindmapKernelProjection, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner, MindmapProjectionSource, MindmapSummary, MindmapTreeNode, MoveBlock, PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, WorkspaceSource, }; use event_log::DomainEventRecord; use index_fts::{ can_rebuild_from_events, evaluate_search_documents, rebuild_from_events, IndexCursor, MinimalWorkspaceProjector, SearchDocumentsDataset, SearchDocumentsEvaluation, SearchDocumentsRequest, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::env; use std::sync::atomic::{AtomicU64, Ordering}; static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1); static MINDMAP_UID_COUNTER: AtomicU64 = AtomicU64::new(1); #[derive(Clone, PartialEq, Eq)] pub struct BridgeContext { pub deployment_id: Option, pub project_id: Option, pub request_id: String, pub trace_id: String, pub actor_type: String, pub actor_id: String, pub session_id: Option, pub workspace_id: Option, pub tenant_id: Option, pub auth_token: Option, pub source_channel: String, pub source_client: String, pub idempotency_key: Option, pub validate_only: bool, pub dry_run: bool, } impl std::fmt::Debug for BridgeContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BridgeContext") .field("deployment_id", &self.deployment_id) .field("project_id", &self.project_id) .field("request_id", &self.request_id) .field("trace_id", &self.trace_id) .field("actor_type", &self.actor_type) .field("actor_id", &self.actor_id) .field("session_id", &self.session_id) .field("workspace_id", &self.workspace_id) .field("tenant_id", &self.tenant_id) .field( "auth_token", &self.auth_token.as_ref().map(|_| ""), ) .field("source_channel", &self.source_channel) .field("source_client", &self.source_client) .field("idempotency_key", &self.idempotency_key) .field("validate_only", &self.validate_only) .field("dry_run", &self.dry_run) .finish() } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum BridgeErrorKind { Validation, Unauthorized, Conflict, NotFound, Transport, Rejected, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgeError { pub kind: BridgeErrorKind, pub message: String, } pub type BridgeResult = Result; impl BridgeError { pub fn validation(message: impl Into) -> Self { Self { kind: BridgeErrorKind::Validation, message: message.into(), } } pub fn transport(message: impl Into) -> Self { Self { kind: BridgeErrorKind::Transport, message: message.into(), } } pub fn not_found(message: impl Into) -> Self { Self { kind: BridgeErrorKind::NotFound, message: message.into(), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RetiredMutationRequest { pub function_name: String, pub deployment_id: Option, pub project_id: Option, pub workspace_id: Option, pub request_id: String, pub trace_id: String, pub idempotency_key: Option, pub actor_id: String, pub payload_json: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RetiredQueryRequest { pub function_name: String, pub deployment_id: Option, pub project_id: Option, pub workspace_id: Option, pub request_id: String, pub trace_id: String, pub actor_id: String, pub payload_json: String, } fn retired_bridge_error() -> BridgeError { BridgeError::validation( "旧 Convex 兼容桥已退役;请改用 local-first Rust/SQLite control-plane 路径", ) } pub fn build_query_request( context: &BridgeContext, query: &QueryEnvelope, ) -> BridgeResult { legacy_query_function_name(&query.name)?; Ok(RetiredQueryRequest { function_name: query.name.clone(), deployment_id: context.deployment_id.clone(), project_id: context.project_id.clone(), workspace_id: context.workspace_id.clone(), request_id: context.request_id.clone(), trace_id: context.trace_id.clone(), actor_id: context.actor_id.clone(), payload_json: json!({ "name": query.name }).to_string(), }) } pub fn build_write_request( context: &BridgeContext, command: &CommandEnvelope, ) -> BridgeResult { legacy_command_function_name(&command.name)?; Ok(RetiredMutationRequest { function_name: command.name.clone(), deployment_id: context.deployment_id.clone(), project_id: context.project_id.clone(), workspace_id: context.workspace_id.clone(), request_id: context.request_id.clone(), trace_id: context.trace_id.clone(), idempotency_key: context.idempotency_key.clone(), actor_id: context.actor_id.clone(), payload_json: json!({ "name": command.name, "commandId": command.command_id, }) .to_string(), }) } fn legacy_query_function_name(name: &str) -> BridgeResult<&'static str> { match name { "documents.content.get" => Ok("documents:getContent"), "documents.meta.get" => Ok("documents:getMeta"), "blocks.get" => Ok("blocks:getById"), "mindmaps.get" => Ok("mindmaps:get"), "search.documents" => Ok("search:documents"), "search_blocks" => Ok("documents:searchBlocks"), "sidebar.dataset.list" => Ok("sidebar:datasetList"), "bridge.request.get" => Ok("bridgeLogs:listByRequest"), "bridge.trace.get" => Ok("bridgeLogs:listByTrace"), "bridge.command.get" => Ok("bridgeLogs:listByCommand"), "bridge.workspace.overview" => Ok("bridgeLogs:listWorkspaceOverview"), _ => Err(retired_bridge_error()), } } pub fn retired_query_transport_function_name(name: &str) -> BridgeResult<&'static str> { legacy_query_function_name(name) } fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> { match name { "documents.title.update" => Ok("documents:updateTitle"), "page.head.updateTitle" | "tree.node.rename" => Ok("documents:updateTitle"), "documents.save" => Ok("documents:updateContent"), "page.body.save" => Ok("documents:updateContent"), "documents.options.update" | "page.layout.updateOptions" => Ok("documents:updateOptions"), "documents.stats.update" => Ok("documents:updateStats"), "documents.create" | "tree.node.create" => Ok("documents:createWithParentReference"), "documents.move" | "tree.node.move" | "tree.subtree.move" => Ok("documents:move"), "documents.delete" | "tree.node.archive" => Ok("documents:softDelete"), "documents.restore" | "tree.node.restore" => Ok("documents:restore"), "documents.purge" | "tree.node.purge" => Ok("documents:purge"), "documents.copy_tree" | "tree.subtree.copy" => Ok("documents:copyTree"), "documents.duplicate" => Ok("documents:duplicateWithMindmaps"), "documents.embed" | "tree.node.embed" => Ok("documents:updateContent"), "documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => { Ok("documents:emptyTrashByWorkspace") } "insert_block" | "blocks.patch" => Ok("documents:updateContent"), "blocks.move" => Ok("blocks:move"), "blocks.embed" => Ok("blocks:insert"), "mindmaps.put" => Ok("mindmaps:put"), "mindmap.command.apply" => Ok("mindmaps:applyCommand"), "media.assets.replace_storage" => Ok("mediaAssets:replaceStorageFromUpload"), "tree.filetree.drop.preflight" => Ok("tree:fileTreeDropPreflight"), "tree.filetree.delete.preflight" => Ok("tree:fileTreeDeletePreflight"), "tree.filetree.paste.preflight" => Ok("tree:fileTreePastePreflight"), "tree.filetree.upload-target.preflight" => Ok("tree:fileTreeUploadTargetPreflight"), "tree.resource.copy" => Ok("mediaAssets:batchCopy"), "tree.resource.move" => Ok("mediaAssets:batchMove"), "tree.resource.upload" => Ok("mediaAssets:createWithStorage"), "tree.resource.archive" => Ok("treeResource:archive"), "tree.resource.restore" => Ok("treeResource:restore"), "tree.resource.purge" => Ok("treeResource:purge"), "tree.resource.rename" => Ok("treeResource:rename"), _ => Err(retired_bridge_error()), } } pub fn retired_command_transport_function_name(name: &str) -> BridgeResult<&'static str> { legacy_command_function_name(name) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum RuntimeInput { Query { context: RuntimeBridgeContextWire, query: RuntimeQueryEnvelopeWire, #[serde(default)] data: Option, }, Command { context: RuntimeBridgeContextWire, command: RuntimeCommandEnvelopeWire, }, CommandArtifact { context: RuntimeBridgeContextWire, command: RuntimeCommandEnvelopeWire, plan: RuntimeCommandExecutionPlan, result: Value, now: String, }, Tool { context: RuntimeBridgeContextWire, tool: RuntimeToolInvocationWire, #[serde(default)] data: Option, }, } #[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeBridgeContextWire { pub deployment_id: Option, pub project_id: Option, pub workspace_id: Option, pub request_id: String, pub trace_id: String, pub actor: RuntimeActorWire, pub source: RuntimeSourceWire, pub tenant_id: Option, pub auth_token: Option, pub idempotency_key: Option, pub validate_only: bool, pub dry_run: bool, } impl std::fmt::Debug for RuntimeBridgeContextWire { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RuntimeBridgeContextWire") .field("deployment_id", &self.deployment_id) .field("project_id", &self.project_id) .field("workspace_id", &self.workspace_id) .field("request_id", &self.request_id) .field("trace_id", &self.trace_id) .field("actor", &self.actor) .field("source", &self.source) .field("tenant_id", &self.tenant_id) .field( "auth_token", &self.auth_token.as_ref().map(|_| ""), ) .field("idempotency_key", &self.idempotency_key) .field("validate_only", &self.validate_only) .field("dry_run", &self.dry_run) .finish() } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeActorWire { pub actor_type: String, pub actor_id: String, pub session_id: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeSourceWire { pub channel: String, pub client: String, #[serde(default)] pub source_kind: Option, #[serde(default)] pub root_uri: Option, #[serde(default)] pub workspace_id: Option, #[serde(default)] pub capabilities: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeTargetWire { pub workspace_id: Option, pub page_id: Option, pub block_id: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeQueryEnvelopeWire { pub name: String, pub payload: Value, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeCommandEnvelopeWire { pub name: String, pub command_id: String, pub idempotency_key: Option, pub actor: RuntimeActorWire, pub source: RuntimeSourceWire, pub target: Option, pub payload: Value, #[serde(default)] pub preflight_data: Option, pub reason: Option, pub refs: Vec, pub dry_run: bool, pub validate_only: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WorkspaceSourceWatch { pub source_kind: String, pub active: bool, } pub trait WorkspaceSourceAdapter { fn source(&self) -> &WorkspaceSource; fn load_snapshot( &self, projection: KernelProjectionKind, ) -> Result; fn watch(&self) -> Result; fn preflight_command(&self, command: &RuntimeCommandEnvelopeWire) -> Result; fn execute_command(&self, command: &RuntimeCommandEnvelopeWire) -> Result; fn resolve_page_aggregate(&self, page_id: &str) -> Result; } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeToolInvocationWire { pub tool: String, pub kind: String, pub mode: Option, pub args_json: Value, pub target: Option, pub reason: Option, #[serde(default)] pub refs: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapEmptyTrashToolPayload { workspace_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapOutlineItemPayload { title: String, level: u32, page: u32, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapOutlineToolPayload { document_id: String, mindmap_id: String, root_title: String, page_link_pattern: String, outline: Vec, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeSuccess { pub ok: bool, pub plan: RuntimeExecutionPlan, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeArtifactSuccess { pub ok: bool, pub artifacts: Option, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeFailure { pub ok: bool, pub error: RuntimeErrorPayload, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] pub struct RuntimeErrorPayload { pub kind: String, pub message: String, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum RuntimeExecutionPlan { Query(RuntimeQueryExecutionPlan), Command(RuntimeCommandExecutionPlan), Tool(RuntimeToolExecutionPlan), } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeQueryResult { pub result: Value, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeQueryExecutionPlan { pub query_name: String, pub function_name: String, pub workspace_id: Option, pub request_id: String, pub trace_id: String, pub actor_id: String, pub payload_json: String, pub args_json: Value, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeCommandExecutionPlan { pub command_name: String, pub command_id: String, pub function_name: String, pub workspace_id: Option, pub request_id: String, pub trace_id: String, pub actor_id: String, pub idempotency_key: Option, pub source: Value, pub payload_json: String, pub args_json: Value, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeCommandArtifactPlan { pub command_log: RuntimeCommandLogArtifactPlan, pub domain_event: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub domain_events: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeCommandLogArtifactPlan { pub workspace_id: String, pub id: String, pub request_id: String, pub trace_id: String, pub command_id: String, pub command_name: String, pub actor_id: String, pub actor_type: String, pub source_channel: String, pub source_client: String, pub status: String, pub target_page_id: Option, pub target_block_id: Option, pub payload: Value, pub payload_summary: String, pub refs: Vec, pub idempotency_key: Option, pub error: Option, pub created_at: String, pub finished_at: Option, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeDomainEventArtifactPlan { pub workspace_id: String, pub id: String, pub request_id: String, pub trace_id: String, pub command_id: String, pub command_log_id: String, pub event_type: String, pub aggregate_type: String, pub aggregate_id: String, pub event_version: i64, pub status: String, pub actor_type: String, pub payload: Value, pub created_at: String, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeToolExecutionPlan { pub tool_name: String, pub invocation_kind: String, pub execution_mode: String, pub effect: String, pub toolset_id: String, pub requires_confirmation: bool, pub request_id: String, pub trace_id: String, pub actor_id: String, pub validate_only: bool, pub dry_run: bool, pub payload_json: String, pub args_json: Value, pub target: Option, pub steps: Vec, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeToolPlanStep { pub kind: String, pub name: String, pub function_name: Option, pub description: String, pub args_json: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BlockGetQueryPayload { block_id: String, workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BlockPatchCommandPayload { document_id: String, workspace_id: Option, block_id: String, next_block: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BlockMoveCommandPayload { source_document_id: String, block_id: String, target_document_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BlockEmbedCommandPayload { source_document_id: String, block_id: String, target_document_id: String, target_block_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentContentQueryPayload { document_id: String, workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct PageAggregateQueryPayload { document_id: String, workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapGetQueryPayload { document_id: String, mindmap_id: String, workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SidebarDatasetQueryPayload { workspace_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelGetNodeQueryPayload { node_id: String, workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelGetSubtreeQueryPayload { root_node_id: String, workspace_id: Option, depth: Option, include_edges: Option, node_types: Option>, edge_types: Option>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelListChildrenQueryPayload { parent_node_id: String, workspace_id: Option, node_types: Option>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelListEdgesQueryPayload { node_id: String, workspace_id: Option, edge_types: Option>, direction: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelTraverseGraphQueryPayload { start_node_id: String, workspace_id: Option, edge_types: Option>, max_depth: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelProjectViewQueryPayload { projection: KernelProjectionKind, workspace_id: Option, root_node_id: Option, depth: Option, include_content: Option, include_edges: Option, node_types: Option>, edge_types: Option>, query: Option, max_results: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SearchDocumentsQueryPayload { query: String, workspace_id: String, page_id: Option, limit: Option, cursor: Option, title_only: Option, exact: Option, include_ocr: Option, time_range: Option, time_field: Option, custom_range_from: Option, custom_range_to: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct SearchRecentQueryPayload { workspace_id: String, limit: Option, cursor: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BridgeRequestQueryPayload { workspace_id: String, request_id: String, command_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BridgeTraceQueryPayload { workspace_id: String, trace_id: String, command_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BridgeCommandQueryPayload { workspace_id: String, command_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BridgeWorkspaceOverviewQueryPayload { workspace_id: String, limit: Option, cursor: Option, command_status: Option, event_status: Option, target_page_id: Option, target_block_id: Option, aggregate_type: Option, aggregate_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentTitleCommandPayload { document_id: String, title: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentSaveCommandPayload { document_id: String, workspace_id: Option, revision: Option, #[serde(default)] editor_document: Option, #[serde(default)] content: Value, #[serde(default)] tiptap_document: Option, conflict_detection_key: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentOptionsCommandPayload { document_id: String, options: DocumentOptionsPatchPayload, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentStatsCommandPayload { document_id: String, workspace_id: Option, stats: DocumentStatsPatchPayload, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentStatsPatchPayload { word_count: i64, character_count: i64, block_count: i64, todo_total: i64, todo_done: i64, } #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] struct DocumentOptionsPatchPayload { wide_layout: Option, small_text: Option, show_heading_numbers: Option, show_toc: Option, show_structure: Option, protect_editing: Option, show_word_count: Option, collapse_backlinks: Option, page_font: Option, layout_density: Option, hide_child_pages: Option, show_block_ref_count: Option, embed_default_block_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MediaAssetReplaceStorageCommandPayload { asset_id: String, document_id: String, workspace_id: Option, storage_id: String, user_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentEmbedCommandPayload { document_id: String, workspace_id: Option, revision: Option, #[serde(default)] content: Option, conflict_detection_key: Option, source_document_id: String, target_document_id: String, anchor_block_id: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelCreateNodeCommandPayload { node: KernelNode, position: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelUpdateNodeCommandPayload { node_id: String, metadata: Option, content: Option, refs: Option, audit: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelMoveSubtreeCommandPayload { subtree: KernelSubtreeRef, new_parent_node_id: Option, sort_order: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelAttachEdgeCommandPayload { edge: KernelEdge, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KernelDetachEdgeCommandPayload { edge_id: Option, from_node_id: Option, to_node_id: Option, edge_type: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapPutCommandPayload { document_id: String, mindmap_id: String, data: Value, create_only: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapCommandApplyPayload { document_id: String, mindmap_id: String, #[serde(default)] workspace_id: Option, #[serde(default)] commands: Vec, #[serde(default)] projection_revision: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapDeleteCommandPayload { document_id: String, mindmap_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapRestoreCommandPayload { document_id: String, mindmap_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapPurgeCommandPayload { document_id: String, mindmap_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapEmptyTrashCommandPayload { workspace_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentCreateCommandPayload { document_id: String, workspace_id: String, parent_id: Option, title: String, access_scope: String, content: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentMoveCommandPayload { document_id: String, parent_id: Option, sort_order: i64, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentMovePreflightDocument { id: String, workspace_id: Option, } #[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] struct DocumentMoveSnapshotDocument { id: String, #[serde(default, alias = "workspace_id")] workspace_id: Option, #[serde(default, alias = "parent_id")] parent_id: Option, #[serde(default, alias = "sort_order")] sort_order: Option, #[serde(default, alias = "created_at")] created_at: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct DocumentMoveOrderPatch { document_id: String, parent_id: Option, sort_order: i64, moved: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct DocumentMoveOrderPlan { document_id: String, from_parent_id: Option, to_parent_id: Option, requested_sort_order: i64, normalized_sort_order: i64, patches: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentMovePreflightPayload { source_document: DocumentMovePreflightDocument, target_parent_document: Option, #[serde(default)] target_ancestor_ids: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentMoveSnapshotSidebarPayload { #[serde(default)] documents: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentMoveSnapshotPayload { #[serde(default)] documents: Vec, #[serde(default, alias = "sidebarSnapshot")] sidebar_snapshot: Option, } fn derive_document_move_preflight_from_snapshot( payload: &DocumentMoveCommandPayload, snapshot: &DocumentMoveSnapshotPayload, ) -> Result { 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 = 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 normalize_parent_id(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn document_move_sort_key(document: &DocumentMoveSnapshotDocument) -> (i64, String, String) { ( normalize_document_move_sort_order(document.sort_order).unwrap_or(i64::MAX), document.created_at.clone().unwrap_or_default(), document.id.clone(), ) } fn normalize_document_move_sort_order(value: Option) -> Option { value .filter(|number| number.is_finite()) .map(|number| number.floor() as i64) } fn clamp_document_move_index(sort_order: i64, max: usize) -> usize { if sort_order < 0 { return 0; } (sort_order as usize).min(max) } fn append_document_move_order_patches( patches: &mut Vec, ordered: &[DocumentMoveSnapshotDocument], parent_id: Option, moved_document_id: &str, ) { for (index, document) in ordered.iter().enumerate() { let sort_order = index as i64; let moved = document.id == moved_document_id; if normalize_parent_id(&document.parent_id) == parent_id && normalize_document_move_sort_order(document.sort_order) == Some(sort_order) && !moved { continue; } patches.push(DocumentMoveOrderPatch { document_id: document.id.clone(), parent_id: parent_id.clone(), sort_order, moved, }); } } fn build_document_move_order_plan_from_snapshot( payload: &DocumentMoveCommandPayload, snapshot: &DocumentMoveSnapshotPayload, ) -> Result { 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 = documents .iter() .cloned() .map(|document| (document.id.clone(), document)) .collect(); let source_document = document_by_id .get(payload.document_id.as_str()) .cloned() .ok_or_else(|| BridgeError::validation("源页面不存在或无权限"))?; let from_parent_id = normalize_parent_id(&source_document.parent_id); let to_parent_id = normalize_parent_id(&payload.parent_id); let mut sibling_by_parent = BTreeMap::, Vec>::new(); for document in documents { sibling_by_parent .entry(normalize_parent_id(&document.parent_id)) .or_default() .push(document); } for siblings in sibling_by_parent.values_mut() { siblings.sort_by_key(document_move_sort_key); } let mut patches = Vec::new(); let normalized_sort_order; if from_parent_id == to_parent_id { let mut siblings = sibling_by_parent .remove(&to_parent_id) .unwrap_or_default() .into_iter() .filter(|document| document.id != payload.document_id) .collect::>(); let position = clamp_document_move_index(payload.sort_order, siblings.len()); normalized_sort_order = position as i64; siblings.insert(position, source_document); append_document_move_order_patches( &mut patches, &siblings, to_parent_id.clone(), payload.document_id.as_str(), ); } else { let old_siblings = sibling_by_parent .remove(&from_parent_id) .unwrap_or_default() .into_iter() .filter(|document| document.id != payload.document_id) .collect::>(); append_document_move_order_patches( &mut patches, &old_siblings, from_parent_id.clone(), payload.document_id.as_str(), ); let mut new_siblings = sibling_by_parent .remove(&to_parent_id) .unwrap_or_default() .into_iter() .filter(|document| document.id != payload.document_id) .collect::>(); let position = clamp_document_move_index(payload.sort_order, new_siblings.len()); normalized_sort_order = position as i64; new_siblings.insert(position, source_document); append_document_move_order_patches( &mut patches, &new_siblings, to_parent_id.clone(), payload.document_id.as_str(), ); } Ok(DocumentMoveOrderPlan { document_id: payload.document_id.clone(), from_parent_id, to_parent_id, requested_sort_order: payload.sort_order, normalized_sort_order, patches, }) } fn resolve_document_move_order_plan( payload: &DocumentMoveCommandPayload, preflight_data: Option<&Value>, ) -> Result, BridgeError> { let Some(raw_preflight) = preflight_data else { return Ok(None); }; let snapshot = match serde_json::from_value::(raw_preflight.clone()) { Ok(snapshot) if !snapshot.documents.is_empty() || snapshot.sidebar_snapshot.is_some() => { snapshot } _ => return Ok(None), }; build_document_move_order_plan_from_snapshot(payload, &snapshot).map(Some) } fn document_move_write_operation( workspace_id: Option<&str>, plan: Option<&DocumentMoveOrderPlan>, ) -> Value { let Some(plan) = plan else { return Value::Null; }; json!({ "family": "tree", "schema": "mnote.tree.write_operation", "schemaVersion": 1, "operation": "tree.subtree.move.write", "workspaceId": workspace_id, "documentId": plan.document_id, "fromParentId": plan.from_parent_id, "toParentId": plan.to_parent_id, "requestedSortOrder": plan.requested_sort_order, "normalizedSortOrder": plan.normalized_sort_order, "patches": plan.patches, }) } fn resolve_document_move_preflight( payload: &DocumentMoveCommandPayload, preflight_data: Option<&Value>, ) -> Result, BridgeError> { let Some(raw_preflight) = preflight_data else { return Ok(None); }; if let Ok(preflight) = serde_json::from_value::(raw_preflight.clone()) { return Ok(Some(preflight)); } let snapshot = serde_json::from_value::(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 { document_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentRestoreCommandPayload { document_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentDuplicateCommandPayload { source_document_id: String, new_document_id: String, title: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentTemplateCommandPayload { document_id: String, is_template: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentEmptyTrashCommandPayload { workspace_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentPurgeCommandPayload { document_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentCopyTreeItemPayload { document_id: String, recursive: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentCopyTreeCommandPayload { items: Vec, target_parent_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ResourceTransferCommandPayload { asset_ids: Vec, target_document_id: String, target_sub_path: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ResourceUploadCommandPayload { asset_id: String, workspace_id: String, target_document_id: String, target_sub_path: Option, file_name: Option, file_size: Option, mime_type: Option, asset_type: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ResourceLifecycleCommandPayload { resource_kind: Option, asset_id: Option, document_id: Option, mindmap_id: Option, table_id: Option, new_name: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeDropPreflightCommandPayload { copy: bool, #[serde(default)] source_capabilities: Vec, #[serde(default)] target_capabilities: Vec, target_document_id: Option, target_row_id: Option, focused_row_id: Option, active_document_id: Option, #[serde(default)] row_ids: Vec, #[serde(default)] rows: Vec, #[serde(default)] target_children: Vec, #[serde(default)] document_parents: Vec, conflict_policy: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeDeletePreflightCommandPayload { #[serde(default)] row_ids: Vec, #[serde(default)] rows: Vec, #[serde(default)] document_parents: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreePastePreflightCommandPayload { target_document_id: Option, focused_row_id: Option, active_document_id: Option, #[serde(default)] row_ids: Vec, #[serde(default)] rows: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeUploadTargetPreflightCommandPayload { workspace_id: Option, target_document_id: Option, target_row_id: Option, focused_row_id: Option, active_document_id: Option, #[serde(default)] rows: Vec, #[serde(default)] document_workspaces: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeDropPreflightRow { row_id: String, row_kind: String, document_id: Option, asset_id: Option, asset_document_id: Option, asset_type: Option, storage_path: Option, title: Option, #[serde(rename = "operationProfile")] _operation_profile: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeDropPreflightTargetChild { row_kind: String, document_id: Option, asset_id: Option, title: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeDropPreflightDocumentParent { document_id: String, parent_id: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeUploadTargetDocumentWorkspace { document_id: String, workspace_id: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreeDropPlan { #[serde(skip_serializing_if = "is_true")] allowed: bool, #[serde(skip_serializing_if = "Option::is_none")] blocked_reason: Option, #[serde(skip_serializing_if = "is_false")] requires_confirmation: bool, #[serde(skip_serializing_if = "Vec::is_empty")] conflicts: Vec, copy: bool, target_document_id: String, target_mindmap_id: Option, target_sub_path: Option, row_ids: Vec, doc_ids: Vec, top_level_doc_ids: Vec, copyable_asset_ids: Vec, source_asset_document_ids: Vec, #[serde(skip_serializing_if = "Option::is_none")] document_transfer_plan: Option, #[serde(skip_serializing_if = "Option::is_none")] resource_transfer_plan: Option, } fn is_true(value: &bool) -> bool { *value } fn is_false(value: &bool) -> bool { !*value } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreeDropConflict { row_kind: String, source_row_id: String, source_document_id: Option, source_asset_id: Option, existing_document_id: Option, existing_asset_id: Option, target_document_id: String, title: String, policy: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreeDeletePlan { row_ids: Vec, doc_ids: Vec, asset_ids: Vec, asset_document_ids: Vec, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreePastePlan { target_document_id: String, target_mindmap_id: Option, target_sub_path: Option, row_ids: Vec, doc_items: Vec, copyable_asset_ids: Vec, resource_transfer_plan: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreeUploadTargetPlan { workspace_id: String, target_document_id: String, target_mindmap_id: Option, target_sub_path: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreeDropDocumentTransferPlan { action: String, target_parent_id: String, document_ids: Vec, top_level_document_ids: Vec, copy_items: Vec, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct DocumentCopyTreeItemPlan { document_id: String, recursive: bool, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct ResourceTransferPlan { action: String, asset_ids: Vec, target_document_id: String, target_sub_path: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct ResourceUploadPlan { action: String, asset_id: String, workspace_id: String, target_document_id: String, target_sub_path: Option, file_name: Option, file_size: Option, mime_type: Option, asset_type: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct ResourceLifecyclePlan { action: String, resource_kind: String, asset_id: Option, document_id: Option, mindmap_id: Option, table_id: Option, new_name: Option, } fn normalize_resource_asset_ids(raw_asset_ids: &[String]) -> Vec { let mut asset_ids = Vec::new(); for raw in raw_asset_ids { let asset_id = raw.trim(); if asset_id.is_empty() || asset_ids.iter().any(|existing| existing == asset_id) { continue; } asset_ids.push(asset_id.to_string()); } asset_ids } fn sanitize_resource_target_sub_path(raw: Option<&str>) -> Option { let segments = raw? .replace('\\', "/") .split('/') .map(str::trim) .filter(|segment| !segment.is_empty() && *segment != "." && *segment != "..") .map(|segment| segment.replace(['\\', '/'], "_")) .take(8) .collect::>(); if segments.is_empty() { None } else { Some(segments.join("/")) } } fn build_resource_transfer_plan( action: &str, payload: &ResourceTransferCommandPayload, ) -> Result { let asset_ids = normalize_resource_asset_ids(&payload.asset_ids); if asset_ids.is_empty() { return Err(BridgeError::validation("resource command 缺少 assetIds")); } let target_document_id = payload.target_document_id.trim(); if target_document_id.is_empty() { return Err(BridgeError::validation( "resource command 缺少 targetDocumentId", )); } Ok(ResourceTransferPlan { action: action.into(), asset_ids, target_document_id: target_document_id.into(), target_sub_path: sanitize_resource_target_sub_path(payload.target_sub_path.as_deref()), }) } fn normalize_optional_resource_string(raw: Option<&str>) -> Option { let value = raw?.trim(); if value.is_empty() { None } else { Some(value.to_string()) } } fn normalize_resource_asset_type(raw: Option<&str>) -> String { match raw.unwrap_or("file").trim() { "image" => "image".into(), "video" => "video".into(), "audio" => "audio".into(), "mindmap" => "mindmap".into(), "table" => "table".into(), _ => "file".into(), } } fn build_resource_upload_plan( payload: &ResourceUploadCommandPayload, ) -> Result { let asset_id = payload.asset_id.trim(); if asset_id.is_empty() { return Err(BridgeError::validation("resource upload 缺少 assetId")); } let workspace_id = payload.workspace_id.trim(); if workspace_id.is_empty() { return Err(BridgeError::validation("resource upload 缺少 workspaceId")); } let target_document_id = payload.target_document_id.trim(); if target_document_id.is_empty() { return Err(BridgeError::validation( "resource upload 缺少 targetDocumentId", )); } Ok(ResourceUploadPlan { action: "upload".into(), asset_id: asset_id.into(), workspace_id: workspace_id.into(), target_document_id: target_document_id.into(), target_sub_path: sanitize_resource_target_sub_path(payload.target_sub_path.as_deref()), file_name: normalize_optional_resource_string(payload.file_name.as_deref()) .map(|value| value.replace(['\\', '/'], "_")), file_size: payload.file_size.filter(|value| *value >= 0), mime_type: normalize_optional_resource_string(payload.mime_type.as_deref()), asset_type: normalize_resource_asset_type(payload.asset_type.as_deref()), }) } fn normalize_resource_lifecycle_kind(payload: &ResourceLifecycleCommandPayload) -> String { if payload .table_id .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) { return "table".into(); } if payload .mindmap_id .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) { return "mindmap".into(); } match payload .resource_kind .as_deref() .map(str::trim) .unwrap_or("file") { "table" | "online-table" => "table".into(), "mindmap" | "map" => "mindmap".into(), _ => "file".into(), } } fn normalize_required_resource_id( raw: Option<&str>, field: &'static str, ) -> Result { raw.map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| BridgeError::validation(format!("resource command 缺少 {field}"))) } fn build_resource_lifecycle_plan( action: &str, payload: &ResourceLifecycleCommandPayload, ) -> Result { let resource_kind = normalize_resource_lifecycle_kind(payload); let mut plan = ResourceLifecyclePlan { action: action.into(), resource_kind, asset_id: None, document_id: None, mindmap_id: None, table_id: None, new_name: normalize_optional_resource_string(payload.new_name.as_deref()) .map(|value| value.replace(['\\', '/'], "_")), }; match plan.resource_kind.as_str() { "file" => { plan.asset_id = Some(normalize_required_resource_id( payload.asset_id.as_deref(), "assetId", )?); } "mindmap" => { plan.document_id = Some(normalize_required_resource_id( payload.document_id.as_deref(), "documentId", )?); plan.mindmap_id = Some(normalize_required_resource_id( payload.mindmap_id.as_deref(), "mindmapId", )?); } "table" => { plan.table_id = Some(normalize_required_resource_id( payload.table_id.as_deref(), "tableId", )?); } _ => unreachable!("resource kind 已规范化"), } if action == "rename" && plan.new_name.is_none() { return Err(BridgeError::validation("resource rename 缺少 newName")); } Ok(plan) } fn retired_resource_lifecycle_transport_function( action: &str, resource_kind: &str, ) -> Result<&'static str, BridgeError> { match (action, resource_kind) { ("archive", "file") | ("restore", "file") | ("rename", "file") => { Ok("mediaAssets:patchById") } ("purge", "file") => Ok("mediaAssets:purgeById"), ("archive", "mindmap") => Ok("mindmaps:softDelete"), ("restore", "mindmap") => Ok("mindmaps:restore"), ("purge", "mindmap") => Ok("mindmaps:purge"), ("archive", "table") => Ok("tables:remove"), ("restore", "table") => Ok("tables:restore"), ("purge", "table") => Ok("tables:purge"), ("rename", "table") => Ok("tables:update"), ("rename", "mindmap") => Err(BridgeError::validation( "tree.resource.rename 暂不支持 mindmap 资源", )), _ => Err(BridgeError::validation(format!( "不支持的 resource lifecycle command: {action}/{resource_kind}" ))), } } fn resource_lifecycle_event_type(action: &str) -> &'static str { match action { "archive" => "tree.resource.archived", "restore" => "tree.resource.restored", "purge" => "tree.resource.purged", "rename" => "tree.resource.renamed", _ => "tree.resource.changed", } } fn resource_lifecycle_stream_delta_hint(plan: &ResourceLifecyclePlan) -> Value { match plan.action.as_str() { "archive" | "purge" => tree_stream_delta_hint( "remove_asset", json!({ "assetId": plan .asset_id .as_deref() .or(plan.mindmap_id.as_deref()) .or(plan.table_id.as_deref()) .unwrap_or(""), }), ), _ => tree_stream_delta_hint( "asset_result", json!({ "assetField": "asset", }), ), } } fn normalize_optional_filetree_string(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn filetree_drop_row_document_id(row: &FileTreeDropPreflightRow) -> Option { normalize_optional_filetree_string(&row.document_id) } fn extract_filetree_drop_mindmap_id_from_storage_path(raw: Option<&str>) -> Option { let normalized = raw?.replace('\\', "/"); let prefix = "mindmaps/"; if let Some(rest) = normalized.strip_prefix(prefix) { return rest .split('/') .next() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); } let marker = "/mindmaps/"; let idx = normalized.find(marker)?; normalized[idx + marker.len()..] .split('/') .next() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn resolve_filetree_drop_mindmap_target_id( row: Option<&FileTreeDropPreflightRow>, ) -> Option { let row = row?; let row_kind = row.row_kind.trim(); let asset_type = row.asset_type.as_deref().map(str::trim).unwrap_or_default(); if row_kind == "asset-folder" && asset_type == "mindmap" { return normalize_optional_filetree_string(&row.asset_id); } if row_kind == "asset" { return extract_filetree_drop_mindmap_id_from_storage_path(row.storage_path.as_deref()); } None } fn ordered_unique_filetree_row_ids(row_ids: &[String]) -> Vec { let mut result = Vec::new(); for raw in row_ids { let row_id = raw.trim(); if row_id.is_empty() || result.iter().any(|existing| existing == row_id) { continue; } result.push(row_id.to_string()); } result } fn collect_filetree_doc_ancestors( document_id: &str, parent_by_document_id: &HashMap>, ) -> Vec { let mut ancestors = Vec::new(); let mut cursor = parent_by_document_id .get(document_id) .and_then(|parent_id| parent_id.clone()); let mut depth = 0; while let Some(parent_id) = cursor { if depth >= 256 || ancestors.iter().any(|existing| existing == &parent_id) { break; } ancestors.push(parent_id.clone()); cursor = parent_by_document_id .get(parent_id.as_str()) .and_then(|parent_id| parent_id.clone()); depth += 1; } ancestors } fn filter_top_level_filetree_doc_ids( doc_ids: &[String], parent_by_document_id: &HashMap>, ) -> Vec { let doc_set = doc_ids .iter() .cloned() .collect::>(); doc_ids .iter() .filter(|doc_id| { !collect_filetree_doc_ancestors(doc_id, parent_by_document_id) .iter() .any(|ancestor_id| doc_set.contains(ancestor_id)) }) .cloned() .collect() } fn is_filetree_drop_real_asset(row: &FileTreeDropPreflightRow) -> bool { normalize_optional_filetree_string(&row.asset_id).is_some() && normalize_optional_filetree_string(&row.storage_path).is_some() } fn normalize_filetree_capability_set( capabilities: &[String], ) -> std::collections::BTreeSet { capabilities .iter() .map(|capability| capability.trim().to_ascii_lowercase()) .filter(|capability| !capability.is_empty()) .collect() } fn filetree_capabilities_include_any( capabilities: &std::collections::BTreeSet, expected: &[&str], ) -> bool { expected .iter() .any(|capability| capabilities.contains(*capability)) } fn validate_filetree_drop_capabilities( payload: &FileTreeDropPreflightCommandPayload, ) -> Result<(), BridgeError> { let source_capabilities = normalize_filetree_capability_set(&payload.source_capabilities); let target_capabilities = normalize_filetree_capability_set(&payload.target_capabilities); if !target_capabilities.is_empty() && !filetree_capabilities_include_any(&target_capabilities, &["drop", "write"]) { return Err(BridgeError::validation("目标位置是只读,不能拖放到这里")); } if payload.copy { if !source_capabilities.is_empty() && !filetree_capabilities_include_any(&source_capabilities, &["copy", "read"]) { return Err(BridgeError::validation("来源是只读,不能复制这些对象")); } return Ok(()); } if !source_capabilities.is_empty() && !filetree_capabilities_include_any(&source_capabilities, &["move", "write"]) { return Err(BridgeError::validation("来源是只读,不能移动这些对象")); } Ok(()) } fn normalize_filetree_title(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn filetree_drop_conflict_policy(payload: &FileTreeDropPreflightCommandPayload) -> String { normalize_optional_filetree_string(&payload.conflict_policy).unwrap_or_else(|| "prompt".into()) } fn collect_filetree_drop_conflicts( payload: &FileTreeDropPreflightCommandPayload, selected_rows: &[&FileTreeDropPreflightRow], target_document_id: &str, ) -> Vec { let policy = filetree_drop_conflict_policy(payload); if policy != "prompt" || payload.target_children.is_empty() { return Vec::new(); } let mut conflicts = Vec::new(); for row in selected_rows { let Some(source_title) = normalize_filetree_title(&row.title) else { continue; }; let row_kind = row.row_kind.trim(); if row_kind != "doc" && row_kind != "asset" { continue; } for child in &payload.target_children { if child.row_kind.trim() != row_kind { continue; } let Some(child_title) = normalize_filetree_title(&child.title) else { continue; }; if child_title != source_title { continue; } conflicts.push(FileTreeDropConflict { row_kind: row_kind.to_string(), source_row_id: row.row_id.trim().to_string(), source_document_id: normalize_optional_filetree_string(&row.document_id), source_asset_id: normalize_optional_filetree_string(&row.asset_id), existing_document_id: normalize_optional_filetree_string(&child.document_id), existing_asset_id: normalize_optional_filetree_string(&child.asset_id), target_document_id: target_document_id.to_string(), title: source_title.clone(), policy: policy.clone(), }); break; } } conflicts } fn validate_filetree_drop_doc_legality( plan: &FileTreeDropPlan, parent_by_document_id: &HashMap>, ) -> Result<(), BridgeError> { if plan.copy { return Ok(()); } for doc_id in &plan.top_level_doc_ids { if doc_id == &plan.target_document_id { return Err(BridgeError::validation("不能把页面移动到自身或后代下面")); } let target_ancestors = collect_filetree_doc_ancestors(plan.target_document_id.as_str(), parent_by_document_id); if target_ancestors .iter() .any(|ancestor_id| ancestor_id == doc_id) { return Err(BridgeError::validation("不能把页面移动到自身或后代下面")); } } Ok(()) } fn build_filetree_delete_plan( payload: &FileTreeDeletePreflightCommandPayload, ) -> Result { let row_by_id: HashMap = payload .rows .iter() .filter_map(|row| { let row_id = row.row_id.trim(); if row_id.is_empty() { None } else { Some((row_id.to_string(), row.clone())) } }) .collect(); let row_ids = ordered_unique_filetree_row_ids(&payload.row_ids) .into_iter() .filter(|row_id| row_by_id.contains_key(row_id.as_str())) .collect::>(); let selected_rows = row_ids .iter() .filter_map(|row_id| row_by_id.get(row_id.as_str())) .collect::>(); let mut doc_candidates = Vec::new(); let mut asset_candidates = Vec::new(); let mut asset_document_by_id = HashMap::::new(); for row in selected_rows { let row_kind = row.row_kind.trim(); if row_kind == "doc" || row_kind == "index" { if let Some(document_id) = filetree_drop_row_document_id(row) { doc_candidates.push(document_id); } continue; } if (row_kind == "asset" || row_kind == "asset-folder") && normalize_optional_filetree_string(&row.asset_id).is_some() { let asset_id = normalize_optional_filetree_string(&row.asset_id).unwrap_or_default(); if asset_id.is_empty() { continue; } asset_candidates.push(asset_id.clone()); if let Some(document_id) = normalize_optional_filetree_string(&row.asset_document_id) .or_else(|| filetree_drop_row_document_id(row)) { asset_document_by_id.insert(asset_id, document_id); } } } let parent_by_document_id: HashMap> = payload .document_parents .iter() .filter_map(|entry| { let document_id = entry.document_id.trim(); if document_id.is_empty() { return None; } Some(( document_id.to_string(), normalize_optional_filetree_string(&entry.parent_id), )) }) .collect(); let doc_ids = filter_top_level_filetree_doc_ids(&doc_candidates, &parent_by_document_id); let doc_id_set = doc_ids .iter() .cloned() .collect::>(); let mut asset_ids = Vec::new(); let mut asset_document_ids = Vec::new(); for asset_id in asset_candidates { if asset_ids.iter().any(|existing| existing == &asset_id) { continue; } let owner_document_id = asset_document_by_id.get(asset_id.as_str()).cloned(); if let Some(document_id) = owner_document_id.as_ref() { if doc_id_set.contains(document_id) || collect_filetree_doc_ancestors(document_id, &parent_by_document_id) .iter() .any(|ancestor_id| doc_id_set.contains(ancestor_id)) { continue; } } asset_ids.push(asset_id.clone()); if let Some(owner_document_id) = owner_document_id { if !asset_document_ids .iter() .any(|existing| existing == &owner_document_id) { asset_document_ids.push(owner_document_id); } } } if doc_ids.is_empty() && asset_ids.is_empty() { return Err(BridgeError::validation("没有可删除的对象")); } Ok(FileTreeDeletePlan { row_ids, doc_ids, asset_ids, asset_document_ids, }) } fn upsert_filetree_paste_doc_item( doc_items: &mut Vec, document_id: String, recursive: bool, ) { if let Some(existing) = doc_items .iter_mut() .find(|item| item.document_id == document_id) { existing.recursive = existing.recursive || recursive; return; } doc_items.push(DocumentCopyTreeItemPlan { document_id, recursive, }); } fn build_filetree_paste_plan( payload: &FileTreePastePreflightCommandPayload, ) -> Result { let row_by_id: HashMap = payload .rows .iter() .filter_map(|row| { let row_id = row.row_id.trim(); if row_id.is_empty() { None } else { Some((row_id.to_string(), row.clone())) } }) .collect(); let focused_row = normalize_optional_filetree_string(&payload.focused_row_id) .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); let target_document_id = normalize_optional_filetree_string(&payload.target_document_id) .or_else(|| focused_row.as_ref().and_then(filetree_drop_row_document_id)) .or_else(|| normalize_optional_filetree_string(&payload.active_document_id)) .ok_or_else(|| BridgeError::validation("请选择一个目标页面后再粘贴"))?; let row_ids = ordered_unique_filetree_row_ids(&payload.row_ids) .into_iter() .filter(|row_id| row_by_id.contains_key(row_id.as_str())) .collect::>(); let selected_rows = row_ids .iter() .filter_map(|row_id| row_by_id.get(row_id.as_str())) .collect::>(); let mut doc_items = Vec::new(); let mut copyable_asset_ids = Vec::new(); for row in selected_rows { let row_kind = row.row_kind.trim(); if row_kind == "doc" { if let Some(document_id) = filetree_drop_row_document_id(row) { upsert_filetree_paste_doc_item(&mut doc_items, document_id, true); } continue; } if row_kind == "index" { if let Some(document_id) = filetree_drop_row_document_id(row) { upsert_filetree_paste_doc_item(&mut doc_items, document_id, false); } continue; } if row_kind == "asset" && is_filetree_drop_real_asset(row) { if let Some(asset_id) = normalize_optional_filetree_string(&row.asset_id) { if !copyable_asset_ids .iter() .any(|existing| existing == &asset_id) { copyable_asset_ids.push(asset_id); } } } } if doc_items.is_empty() && copyable_asset_ids.is_empty() { return Err(BridgeError::validation( "没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)", )); } let target_mindmap_id = resolve_filetree_drop_mindmap_target_id(focused_row.as_ref()); let target_sub_path = target_mindmap_id .as_deref() .map(|mindmap_id| format!("mindmaps/{mindmap_id}")); let resource_transfer_plan = if copyable_asset_ids.is_empty() { None } else { Some(ResourceTransferPlan { action: "copy".into(), asset_ids: copyable_asset_ids.clone(), target_document_id: target_document_id.clone(), target_sub_path: target_sub_path.clone(), }) }; Ok(FileTreePastePlan { target_document_id, target_mindmap_id, target_sub_path, row_ids, doc_items, copyable_asset_ids, resource_transfer_plan, }) } fn build_filetree_upload_target_plan( payload: &FileTreeUploadTargetPreflightCommandPayload, ) -> Result { let row_by_id: HashMap = payload .rows .iter() .filter_map(|row| { let row_id = row.row_id.trim(); if row_id.is_empty() { None } else { Some((row_id.to_string(), row.clone())) } }) .collect(); let target_row = normalize_optional_filetree_string(&payload.target_row_id) .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); let focused_row = normalize_optional_filetree_string(&payload.focused_row_id) .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); let target_document_id = normalize_optional_filetree_string(&payload.target_document_id) .or_else(|| target_row.as_ref().and_then(filetree_drop_row_document_id)) .or_else(|| focused_row.as_ref().and_then(filetree_drop_row_document_id)) .or_else(|| normalize_optional_filetree_string(&payload.active_document_id)) .ok_or_else(|| BridgeError::validation("请选择一个目标页面后再拖入文件"))?; let target_mindmap_id = resolve_filetree_drop_mindmap_target_id(target_row.as_ref().or(focused_row.as_ref())); let target_sub_path = target_mindmap_id .as_deref() .map(|mindmap_id| format!("mindmaps/{mindmap_id}")); let workspace_by_document_id: HashMap> = payload .document_workspaces .iter() .filter_map(|entry| { let document_id = entry.document_id.trim(); if document_id.is_empty() { return None; } Some(( document_id.to_string(), normalize_optional_filetree_string(&entry.workspace_id), )) }) .collect(); let workspace_id = workspace_by_document_id .get(target_document_id.as_str()) .and_then(|workspace_id| workspace_id.clone()) .or_else(|| normalize_optional_filetree_string(&payload.workspace_id)) .ok_or_else(|| BridgeError::validation("无法识别当前工作区,上传失败"))?; Ok(FileTreeUploadTargetPlan { workspace_id, target_document_id, target_mindmap_id, target_sub_path, }) } fn build_filetree_drop_plan( payload: &FileTreeDropPreflightCommandPayload, ) -> Result { validate_filetree_drop_capabilities(payload)?; let row_by_id: HashMap = payload .rows .iter() .filter_map(|row| { let row_id = row.row_id.trim(); if row_id.is_empty() { None } else { Some((row_id.to_string(), row.clone())) } }) .collect(); let target_row = normalize_optional_filetree_string(&payload.target_row_id) .and_then(|row_id| row_by_id.get(row_id.as_str()).cloned()); let target_document_id = normalize_optional_filetree_string(&payload.target_document_id) .or_else(|| target_row.as_ref().and_then(filetree_drop_row_document_id)) .or_else(|| { normalize_optional_filetree_string(&payload.focused_row_id).and_then(|row_id| { row_by_id .get(row_id.as_str()) .and_then(filetree_drop_row_document_id) }) }) .or_else(|| normalize_optional_filetree_string(&payload.active_document_id)) .ok_or_else(|| BridgeError::validation("无法识别拖拽目标页面"))?; let row_ids = ordered_unique_filetree_row_ids(&payload.row_ids) .into_iter() .filter(|row_id| row_by_id.contains_key(row_id.as_str())) .collect::>(); let selected_rows = row_ids .iter() .filter_map(|row_id| row_by_id.get(row_id.as_str())) .collect::>(); let mut doc_ids = Vec::new(); let mut copyable_asset_ids = Vec::new(); let mut source_asset_document_ids = Vec::new(); for row in &selected_rows { let row_kind = row.row_kind.trim(); if row_kind == "doc" { if let Some(document_id) = filetree_drop_row_document_id(row) { doc_ids.push(document_id); } continue; } if row_kind == "asset" && is_filetree_drop_real_asset(row) { if let Some(asset_id) = normalize_optional_filetree_string(&row.asset_id) { if !copyable_asset_ids .iter() .any(|existing| existing == &asset_id) { copyable_asset_ids.push(asset_id); } } let source_document_id = normalize_optional_filetree_string(&row.asset_document_id) .or_else(|| filetree_drop_row_document_id(row)); if let Some(source_document_id) = source_document_id { if !source_asset_document_ids .iter() .any(|existing| existing == &source_document_id) { source_asset_document_ids.push(source_document_id); } } } } if doc_ids.is_empty() && copyable_asset_ids.is_empty() { return Err(BridgeError::validation( "没有可拖拽的对象(虚拟附件暂不支持)", )); } let parent_by_document_id: HashMap> = payload .document_parents .iter() .filter_map(|entry| { let document_id = entry.document_id.trim(); if document_id.is_empty() { return None; } Some(( document_id.to_string(), normalize_optional_filetree_string(&entry.parent_id), )) }) .collect(); let top_level_doc_ids = filter_top_level_filetree_doc_ids(&doc_ids, &parent_by_document_id); let target_mindmap_id = resolve_filetree_drop_mindmap_target_id(target_row.as_ref()); let target_sub_path = target_mindmap_id .as_deref() .map(|mindmap_id| format!("mindmaps/{mindmap_id}")); let conflicts = collect_filetree_drop_conflicts(payload, &selected_rows, target_document_id.as_str()); let document_transfer_plan = if doc_ids.is_empty() { None } else { Some(FileTreeDropDocumentTransferPlan { action: if payload.copy { "copy" } else { "move" }.into(), target_parent_id: target_document_id.clone(), document_ids: doc_ids.clone(), top_level_document_ids: top_level_doc_ids.clone(), copy_items: doc_ids .iter() .map(|document_id| DocumentCopyTreeItemPlan { document_id: document_id.clone(), recursive: true, }) .collect(), }) }; let resource_transfer_plan = if copyable_asset_ids.is_empty() { None } else { Some(ResourceTransferPlan { action: if payload.copy { "copy" } else { "move" }.into(), asset_ids: copyable_asset_ids.clone(), target_document_id: target_document_id.clone(), target_sub_path: target_sub_path.clone(), }) }; let plan = FileTreeDropPlan { allowed: true, blocked_reason: None, requires_confirmation: !conflicts.is_empty(), conflicts, copy: payload.copy, target_document_id, target_mindmap_id, target_sub_path, row_ids, doc_ids, top_level_doc_ids, copyable_asset_ids, source_asset_document_ids, document_transfer_plan, resource_transfer_plan, }; validate_filetree_drop_doc_legality(&plan, &parent_by_document_id)?; Ok(plan) } fn tree_stream_delta_hint(kind: &str, args: Value) -> Value { json!({ "family": "tree", "kind": kind, "args": args, }) } fn tree_document_result_hint(document_field: &str) -> Value { tree_stream_delta_hint( "document_result", json!({ "documentField": document_field, }), ) } fn tree_result_document_hint() -> Value { tree_stream_delta_hint("result_document", json!({})) } fn tree_domain_event_hint(event_type: &str) -> Value { json!({ "family": "tree", "eventType": event_type, }) } fn tree_command_protocol_hint( command_name: &str, preferred_command_name: &str, compat_command_name: &str, ) -> Value { json!({ "family": "tree", "owner": "rust-runtime-kernel", "preferredCommandName": preferred_command_name, "compatCommandName": compat_command_name, "deprecatedAlias": command_name == compat_command_name, }) } fn tree_domain_event_plan(event_type: &str, stream_delta_hint: Value) -> Value { json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": event_type, "streamDeltaHint": stream_delta_hint, }) } fn tree_domain_event_plan_with_payload( event_type: &str, payload: Value, stream_delta_hint: Value, ) -> Value { json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": event_type, "payload": payload, "streamDeltaHint": stream_delta_hint, }) } fn tree_resync_required_hint(reason: &str, args: Value) -> Value { let mut hint_args = serde_json::Map::new(); hint_args.insert("reason".into(), Value::String(reason.into())); if let Some(args) = args.as_object() { for (key, value) in args { hint_args.insert(key.clone(), value.clone()); } } tree_stream_delta_hint("resync_required", Value::Object(hint_args)) } fn read_trimmed_str_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { value .get(field) .and_then(Value::as_str) .map(str::trim) .filter(|item| !item.is_empty()) } fn read_record_field<'a>(value: &'a Value, field: &str) -> Option<&'a Value> { value.get(field).filter(|item| item.is_object()) } fn materialize_tree_stream_delta( plan: &RuntimeCommandExecutionPlan, result: &Value, ) -> Option { let hint = plan.args_json.get("streamDeltaHint")?; materialize_tree_stream_delta_from_hint(hint, result) } fn materialize_tree_stream_delta_from_hint(hint: &Value, result: &Value) -> Option { if hint.get("family").and_then(Value::as_str) != Some("tree") { return None; } let kind = hint.get("kind").and_then(Value::as_str)?.trim(); let args = hint.get("args").filter(|value| value.is_object())?; match kind { "noop" => Some(json!({ "op": "noop" })), "resync_required" => { let mut delta = serde_json::Map::new(); delta.insert("op".into(), Value::String("resync_required".into())); if let Some(reason) = read_trimmed_str_field(args, "reason") { delta.insert("reason".into(), Value::String(reason.into())); } if let Some(page_id) = read_trimmed_str_field(args, "pageId") { delta.insert("pageId".into(), Value::String(page_id.into())); } if let Some(document_id) = read_trimmed_str_field(args, "documentId") { delta.insert("documentId".into(), Value::String(document_id.into())); } if let Some(block_id) = read_trimmed_str_field(args, "blockId") { delta.insert("blockId".into(), Value::String(block_id.into())); } Some(Value::Object(delta)) } "remove_document" => { let document_id = read_trimmed_str_field(args, "documentId")?; Some(json!({ "op": "remove_document", "documentId": document_id, })) } "upsert_document_patch" => { let document_id = read_trimmed_str_field(args, "documentId")?; let patch = read_record_field(args, "patch")?; let mut document = serde_json::Map::new(); document.insert("id".into(), Value::String(document_id.into())); if let Some(patch_map) = patch.as_object() { for (key, value) in patch_map { document.insert(key.clone(), value.clone()); } } if !document.contains_key("updated_at") { if let Some(updated_at) = read_trimmed_str_field(result, "updated_at") { document.insert("updated_at".into(), Value::String(updated_at.into())); } } Some(json!({ "op": "upsert_document", "document": Value::Object(document), })) } "document_result" => { let document_field = read_trimmed_str_field(args, "documentField").unwrap_or("document"); let document = read_record_field(result, document_field)?; Some(json!({ "op": "upsert_document", "document": document, })) } "result_document" => { if !result.is_object() { return None; } Some(json!({ "op": "upsert_document", "document": result, })) } "copy_result" => { let items_field = read_trimmed_str_field(args, "itemsField").unwrap_or("items"); let document_field = read_trimmed_str_field(args, "documentField").unwrap_or("document"); let items = result.get(items_field)?.as_array()?; let documents = items .iter() .filter_map(|item| read_record_field(item, document_field).cloned()) .collect::>(); if documents.is_empty() { return None; } Some(json!({ "op": "upsert_documents", "upsertDocuments": documents, })) } "asset_result" => { let items_field = read_trimmed_str_field(args, "itemsField").unwrap_or("items"); let items = result.get(items_field)?.as_array()?; if items.is_empty() { return None; } Some(json!({ "op": "upsert_assets", "upsertAssets": items, })) } "move_document" => { let document_id = read_trimmed_str_field(args, "documentId")?; let sort_order = result .get("sort_order") .and_then(Value::as_i64) .or_else(|| args.get("sortOrder").and_then(Value::as_i64))?; let parent_id = if result.get("parent_id").is_some() { result.get("parent_id").cloned().unwrap_or(Value::Null) } else { args.get("parentId").cloned().unwrap_or(Value::Null) }; let mut delta = serde_json::Map::new(); delta.insert("op".into(), Value::String("move_document".into())); delta.insert("documentId".into(), Value::String(document_id.into())); delta.insert("parentId".into(), parent_id); delta.insert( "sortOrder".into(), Value::Number(serde_json::Number::from(sort_order)), ); if let Some(updated_at) = read_trimmed_str_field(result, "updated_at") { delta.insert("updatedAt".into(), Value::String(updated_at.into())); } Some(Value::Object(delta)) } _ => None, } } fn materialize_tree_domain_event_plan( plan: &RuntimeCommandExecutionPlan, result: &Value, ) -> Option<(String, Value)> { let event_plan = plan.args_json.get("domainEventPlan")?; materialize_tree_domain_event_value(event_plan, plan, result) } fn materialize_tree_domain_event_plans( plan: &RuntimeCommandExecutionPlan, result: &Value, ) -> Vec<(String, Value)> { plan.args_json .get("domainEventPlans") .and_then(Value::as_array) .map(|event_plans| { event_plans .iter() .filter_map(|event_plan| { materialize_tree_domain_event_value(event_plan, plan, result) }) .collect() }) .filter(|event_plans: &Vec<(String, Value)>| !event_plans.is_empty()) .or_else(|| { materialize_tree_domain_event_plan(plan, result).map(|event_plan| vec![event_plan]) }) .unwrap_or_default() } fn materialize_tree_domain_event_value( event_plan: &Value, plan: &RuntimeCommandExecutionPlan, result: &Value, ) -> Option<(String, Value)> { if event_plan.get("family").and_then(Value::as_str) != Some("tree") || event_plan.get("schema").and_then(Value::as_str) != Some("mnote.tree.domain_event") || event_plan.get("schemaVersion").and_then(Value::as_i64) != Some(1) { return None; } let event_type = read_trimmed_str_field(event_plan, "eventType")?.to_string(); let mut materialized = event_plan.clone(); if materialized.get("streamDelta").is_none() { let stream_delta = materialize_tree_stream_delta(plan, result).or_else(|| { event_plan .get("streamDeltaHint") .and_then(|hint| materialize_tree_stream_delta_from_hint(hint, result)) }); if let Some(stream_delta) = stream_delta { if let Some(map) = materialized.as_object_mut() { map.insert("streamDelta".into(), stream_delta); } } } Some((event_type, materialized)) } fn tree_artifact_payload( context: &RuntimeBridgeContextWire, command: &RuntimeCommandEnvelopeWire, event_type: &str, aggregate_type: &str, aggregate_id: &str, domain_event_plan: &Value, ) -> Value { let mut payload = json!({ "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": event_type, "aggregate": { "type": aggregate_type, "id": aggregate_id, }, "trace": { "requestId": context.request_id, "traceId": context.trace_id, }, "command": { "id": command.command_id, "name": command.name, "idempotencyKey": command.idempotency_key, }, "error": Value::Null, "request_id": context.request_id, "trace_id": context.trace_id, "command_id": command.command_id, "command_name": command.name, "idempotency_key": command.idempotency_key, }); if let Some(stream_delta) = domain_event_plan.get("streamDelta") { if let Some(map) = payload.as_object_mut() { map.insert("streamDelta".into(), stream_delta.clone()); } } if let Some(formal_payload) = domain_event_plan.get("payload").and_then(Value::as_object) { if let Some(map) = payload.as_object_mut() { for (key, value) in formal_payload { map.insert(key.clone(), value.clone()); } } } payload } pub fn build_runtime_command_artifact_plan( context: &RuntimeBridgeContextWire, command: &RuntimeCommandEnvelopeWire, plan: &RuntimeCommandExecutionPlan, result: &Value, now: &str, ) -> Option { let workspace_id = plan .workspace_id .as_deref() .or(context.workspace_id.as_deref()) .or_else(|| { command .target .as_ref() .and_then(|target| target.workspace_id.as_deref()) })? .trim(); if workspace_id.is_empty() { return None; } let command_log_id = format!("clog_{}", command.command_id); let target_page_id = command .target .as_ref() .and_then(|target| target.page_id.as_ref()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let target_block_id = command .target .as_ref() .and_then(|target| target.block_id.as_ref()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let materialized_event_plans = materialize_tree_domain_event_plans(plan, result); let command_payload = if let Some((_, domain_event_plan)) = materialized_event_plans.first() { if let Some(stream_delta) = domain_event_plan.get("streamDelta") { let mut payload = command.payload.clone(); if let Some(map) = payload.as_object_mut() { map.insert("streamDelta".into(), stream_delta.clone()); payload } else { json!({ "payload": command.payload, "streamDelta": stream_delta, }) } } else { command.payload.clone() } } else { command.payload.clone() }; let aggregate_type = if target_block_id.is_some() { "block" } else if target_page_id.is_some() { "page" } else { "workspace" }; let aggregate_id = target_block_id .as_deref() .or(target_page_id.as_deref()) .unwrap_or(workspace_id); let command_log = RuntimeCommandLogArtifactPlan { workspace_id: workspace_id.into(), id: command_log_id.clone(), request_id: context.request_id.clone(), trace_id: context.trace_id.clone(), command_id: command.command_id.clone(), command_name: command.name.clone(), actor_id: context.actor.actor_id.clone(), actor_type: context.actor.actor_type.clone(), source_channel: context.source.channel.clone(), source_client: context.source.client.clone(), status: "succeeded".into(), target_page_id: target_page_id.clone(), target_block_id: target_block_id.clone(), payload: command_payload, payload_summary: format!( "command={};request_id={};trace_id={}", command.name, context.request_id, context.trace_id ), refs: command.refs.clone(), idempotency_key: command.idempotency_key.clone(), error: None, created_at: now.into(), finished_at: Some(now.into()), }; let domain_events: Vec = materialized_event_plans .iter() .enumerate() .map( |(index, (event_type, domain_event_plan))| RuntimeDomainEventArtifactPlan { workspace_id: workspace_id.into(), id: tree_domain_event_artifact_id( &command.command_id, event_type, index, materialized_event_plans.len(), ), request_id: context.request_id.clone(), trace_id: context.trace_id.clone(), command_id: command.command_id.clone(), command_log_id: command_log_id.clone(), event_type: event_type.clone(), aggregate_type: aggregate_type.into(), aggregate_id: aggregate_id.into(), event_version: 1, status: "committed".into(), actor_type: context.actor.actor_type.clone(), payload: tree_artifact_payload( context, command, &event_type, aggregate_type, aggregate_id, &domain_event_plan, ), created_at: now.into(), }, ) .collect(); let domain_event = domain_events.first().cloned(); Some(RuntimeCommandArtifactPlan { command_log, domain_event, domain_events, }) } fn tree_domain_event_artifact_id( command_id: &str, event_type: &str, index: usize, total: usize, ) -> String { if total <= 1 || index == 0 { return format!("evt_{command_id}"); } let suffix: String = event_type .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '_' } }) .collect(); format!("evt_{command_id}_{:02}_{suffix}", index + 1) } fn workspace_source_value(source: &RuntimeSourceWire) -> Value { let mut value = json!({ "channel": source.channel, "client": source.client, }); if let Some(map) = value.as_object_mut() { if let Some(source_kind) = source .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { map.insert("sourceKind".into(), json!(source_kind)); } if let Some(root_uri) = source .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { map.insert("rootUri".into(), json!(root_uri)); } if let Some(workspace_id) = source .workspace_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { map.insert("workspaceId".into(), json!(workspace_id)); } if !source.capabilities.is_empty() { map.insert("capabilities".into(), json!(source.capabilities)); } } value } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WorkspaceCommandExecutor { LegacyConvex, ConvexWorkspace, LocalFolder, } fn resolve_workspace_command_executor( source: &RuntimeSourceWire, ) -> Result { let Some(source_kind) = source .source_kind .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(WorkspaceCommandExecutor::LegacyConvex); }; let executor = match source_kind { "convex_workspace" => WorkspaceCommandExecutor::ConvexWorkspace, "local_folder" => WorkspaceCommandExecutor::LocalFolder, other => { return Err(BridgeError::validation(format!( "unknown workspace source kind: {other}" ))) } }; if !source .capabilities .iter() .any(|capability| capability.trim() == "execute-command") { return Err(BridgeError::validation(format!( "workspace source {source_kind} missing capability: execute-command" ))); } Ok(executor) } fn read_preflight_field<'a>( command: &'a RuntimeCommandEnvelopeWire, field: &str, ) -> Option<&'a Value> { command .preflight_data .as_ref() .and_then(|preflight| preflight.get(field)) } fn compose_content_with_blocks(content: &Value, blocks: Vec) -> Value { if content.is_array() { return Value::Array(blocks); } if let Some(map) = content.as_object() { let mut next = map.clone(); next.insert("blocks".into(), Value::Array(blocks)); return Value::Object(next); } json!({ "blocks": blocks }) } fn build_page_aggregate_embed_plan( command_wire: &RuntimeCommandEnvelopeWire, payload: &DocumentEmbedCommandPayload, ) -> Result, BridgeError> { let Some(input) = read_preflight_field(command_wire, "pageAggregateEmbed") else { return Ok(None); }; let target_content = input .get("targetContent") .ok_or_else(|| BridgeError::validation("pageAggregateEmbed 缺少 targetContent"))?; let source_title = read_trimmed_str_field(input, "sourceTitle").unwrap_or("无标题"); let anchor_block_id = payload .anchor_block_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| read_trimmed_str_field(input, "anchorBlockId")); let block_id = read_trimmed_str_field(input, "blockId") .map(ToOwned::to_owned) .unwrap_or_else(|| format!("page_ref_{}", payload.source_document_id)); let current_blocks = normalize_blocks_from_value(target_content); let insert_index = anchor_block_id .and_then(|anchor_id| { current_blocks.iter().position(|block| { read_trimmed_str_field(block, "id") .map(|block_id| block_id == anchor_id) .unwrap_or(false) }) }) .map(|index| index + 1) .unwrap_or(current_blocks.len()); let page_reference_block = json!({ "id": block_id, "type": "pageReference", "props": { "pageId": payload.source_document_id, "title": source_title, }, }); let mut next_blocks = Vec::with_capacity(current_blocks.len() + 1); next_blocks.extend(current_blocks.iter().take(insert_index).cloned()); next_blocks.push(page_reference_block.clone()); next_blocks.extend(current_blocks.iter().skip(insert_index).cloned()); let next_content = compose_content_with_blocks(target_content, next_blocks); Ok(Some(json!({ "schema": "mnote.page_aggregate.embed_plan", "schemaVersion": 1, "sourceDocumentId": payload.source_document_id, "targetDocumentId": payload.target_document_id, "anchorBlockId": anchor_block_id, "insertIndex": insert_index, "block": page_reference_block, "content": next_content, "blockCount": normalize_blocks_from_value(&next_content).len(), }))) } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct RuntimeBlockSummary { id: String, #[serde(rename = "type")] block_type: String, text: String, depth: usize, child_count: usize, } #[derive(Debug, Clone)] struct RuntimeInsertSpec { block_type: String, text: String, level: u64, } #[derive(Debug)] struct InsertBlocksResult { inserted: Vec, blocks: Vec, editor_commands: Vec, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct RuntimeMindmapSummary { uid: String, text: String, parent_uid: Option, depth: usize, child_count: usize, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct RuntimeMindmapSubtreeSummary { uid: String, text: String, depth: usize, child_count: usize, } pub fn execute_runtime_input(input: RuntimeInput) -> Result { match input { RuntimeInput::Query { context, query, .. } => execute_query(context, query), RuntimeInput::Command { context, command } => execute_command(context, command), RuntimeInput::CommandArtifact { .. } => Err(BridgeError::validation( "execute_runtime_input 不支持 command artifact 输入", )), RuntimeInput::Tool { context, tool, .. } => execute_tool_plan(context, tool), } } pub fn execute_runtime_query(input: RuntimeInput) -> Result { match input { RuntimeInput::Query { context, query, data, } => execute_query_result(context, query, data.unwrap_or(Value::Null)), RuntimeInput::Tool { context, tool, data, } => execute_tool_result(context, tool, data.unwrap_or(Value::Null)), RuntimeInput::Command { .. } | RuntimeInput::CommandArtifact { .. } => Err( BridgeError::validation("execute_runtime_query 仅支持 query 输入"), ), } } pub fn build_success_response(plan: RuntimeExecutionPlan) -> RuntimeSuccess { RuntimeSuccess { ok: true, plan } } pub fn build_artifact_success_response( artifacts: Option, ) -> RuntimeArtifactSuccess { RuntimeArtifactSuccess { ok: true, artifacts, } } pub fn execute_runtime_command_artifact( input: RuntimeInput, ) -> Result, BridgeError> { match input { RuntimeInput::CommandArtifact { context, command, plan, result, now, } => Ok(build_runtime_command_artifact_plan( &context, &command, &plan, &result, &now, )), _ => Err(BridgeError::validation( "execute_runtime_command_artifact 仅支持 command artifact 输入", )), } } pub fn build_failure_response(error: BridgeError) -> RuntimeFailure { RuntimeFailure { ok: false, error: RuntimeErrorPayload { kind: bridge_error_kind_to_wire(&error.kind).into(), message: error.message, }, } } pub fn runtime_input_requests_result(input: &RuntimeInput) -> bool { match input { RuntimeInput::Query { data: Some(_), .. } => true, RuntimeInput::Tool { tool, .. } => { parse_tool_mode(tool.mode.as_deref()).unwrap_or(ToolExecutionMode::Plan) == ToolExecutionMode::Result } RuntimeInput::CommandArtifact { .. } => false, RuntimeInput::Command { .. } | RuntimeInput::Query { data: None, .. } => false, } } fn execute_query( context_wire: RuntimeBridgeContextWire, query_wire: RuntimeQueryEnvelopeWire, ) -> Result { let context = to_bridge_context(context_wire); match query_wire.name.as_str() { "kernel.node.get" => { let payload: KernelGetNodeQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "kernel.node.get".into(), payload: KernelGetNode { node_id: payload.node_id.clone(), workspace_id: payload.workspace_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "nodeId": payload.node_id, "workspaceId": payload.workspace_id, }), })) } "kernel.subtree.get" => { let payload: KernelGetSubtreeQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "kernel.subtree.get".into(), payload: KernelGetSubtree { subtree: KernelSubtreeRef { root_node_id: payload.root_node_id.clone(), path: vec![payload.root_node_id.clone()], depth: payload.depth, }, workspace_id: payload.workspace_id.clone(), include_edges: payload.include_edges.unwrap_or(true), filters: KernelProjectionFilter { node_types: payload.node_types.clone().unwrap_or_default(), edge_types: payload.edge_types.clone().unwrap_or_default(), query: None, max_results: None, include_deleted: false, }, }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "rootNodeId": payload.root_node_id, "workspaceId": payload.workspace_id, "depth": payload.depth, "includeEdges": payload.include_edges.unwrap_or(true), "nodeTypes": payload.node_types.unwrap_or_default(), "edgeTypes": payload.edge_types.unwrap_or_default(), }), })) } "kernel.children.list" => { let payload: KernelListChildrenQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "kernel.children.list".into(), payload: KernelListChildren { parent_node_id: payload.parent_node_id.clone(), workspace_id: payload.workspace_id.clone(), node_types: payload.node_types.clone().unwrap_or_default(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "parentNodeId": payload.parent_node_id, "workspaceId": payload.workspace_id, "nodeTypes": payload.node_types.unwrap_or_default(), }), })) } "kernel.edges.list" => { let payload: KernelListEdgesQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "kernel.edges.list".into(), payload: KernelListEdges { node_id: payload.node_id.clone(), workspace_id: payload.workspace_id.clone(), edge_types: payload.edge_types.clone().unwrap_or_default(), direction: payload.direction.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "nodeId": payload.node_id, "workspaceId": payload.workspace_id, "edgeTypes": payload.edge_types.unwrap_or_default(), "direction": payload.direction.unwrap_or(KernelGraphDirection::Both), }), })) } "kernel.graph.traverse" => { let payload: KernelTraverseGraphQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "kernel.graph.traverse".into(), payload: KernelTraverseGraph { start_node_id: payload.start_node_id.clone(), workspace_id: payload.workspace_id.clone(), edge_types: payload.edge_types.clone().unwrap_or_default(), max_depth: payload.max_depth.unwrap_or(2), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "startNodeId": payload.start_node_id, "workspaceId": payload.workspace_id, "edgeTypes": payload.edge_types.unwrap_or_default(), "maxDepth": payload.max_depth.unwrap_or(2), }), })) } "kernel.project_view" => { let payload: KernelProjectViewQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "kernel.project_view".into(), payload: KernelProjectionRequest { projection: payload.projection.clone(), workspace_id: payload.workspace_id.clone(), root_node_id: payload.root_node_id.clone(), subtree: payload .root_node_id .as_ref() .map(|root_node_id| KernelSubtreeRef { root_node_id: root_node_id.clone(), path: vec![root_node_id.clone()], depth: payload.depth, }), filters: KernelProjectionFilter { node_types: payload.node_types.clone().unwrap_or_default(), edge_types: payload.edge_types.clone().unwrap_or_default(), query: payload.query.clone(), max_results: payload.max_results, include_deleted: false, }, include_content: payload.include_content.unwrap_or(false), include_edges: payload.include_edges.unwrap_or(true), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "projection": payload.projection, "workspaceId": payload.workspace_id, "rootNodeId": payload.root_node_id, "depth": payload.depth, "includeContent": payload.include_content.unwrap_or(false), "includeEdges": payload.include_edges.unwrap_or(true), "nodeTypes": payload.node_types.unwrap_or_default(), "edgeTypes": payload.edge_types.unwrap_or_default(), }), })) } "documents.content.get" => { let payload: DocumentContentQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "documents.content.get".into(), payload: core_protocol::GetPageContent { page_id: payload.document_id.clone(), workspace_id: payload.workspace_id, }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, }), })) } "page.aggregate.get" => { let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "page.aggregate.get".into(), payload: core_protocol::GetPageContent { page_id: payload.document_id.clone(), workspace_id: payload.workspace_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "documentId": payload.document_id, "workspaceId": payload.workspace_id, }), })) } "mindmaps.get" | "mindmap.projection.get" | "mindmap.kernel_projection.get" | "mindmap.simple_mind_map_scene.get" => { let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "mindmaps.get".into(), payload: GetMindmap { document_id: payload.document_id.clone(), mindmap_id: payload.mindmap_id.clone(), workspace_id: payload.workspace_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id, "mindmapId": payload.mindmap_id, }), })) } "blocks.get" => { let payload: BlockGetQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "blocks.get".into(), payload: GetBlock { block_id: payload.block_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "id": payload.block_id, "workspaceId": payload.workspace_id, }), })) } "sidebar.dataset.list" => { let payload: SidebarDatasetQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "sidebar.dataset.list".into(), payload: core_protocol::ListSidebarDataset { workspace_id: payload.workspace_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, }), })) } "search.documents" | "search.documents.query" => { let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?; let query_name = if query_wire.name == "search.documents.query" { "search.documents.query" } else { "search.documents" }; let time_range = payload.time_range.clone().unwrap_or_else(|| "any".into()); let time_field = payload .time_field .clone() .unwrap_or_else(|| "updated".into()); let query = QueryEnvelope { name: query_name.into(), payload: SearchDocuments { query: payload.query.clone(), workspace_id: payload.workspace_id.clone(), page_id: payload.page_id.clone(), pagination: core_protocol::query::Pagination { limit: payload.limit.unwrap_or(30), cursor: payload.cursor.clone(), }, title_only: payload.title_only.unwrap_or(false), exact: payload.exact.unwrap_or(false), include_ocr: payload.include_ocr.unwrap_or(false), time_range: time_range.clone(), time_field: time_field.clone(), custom_range_from: payload.custom_range_from.clone(), custom_range_to: payload.custom_range_to.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "query": payload.query, "workspaceId": payload.workspace_id, "pageId": payload.page_id, "limit": payload.limit.unwrap_or(30), "cursor": payload.cursor, "titleOnly": payload.title_only.unwrap_or(false), "exact": payload.exact.unwrap_or(false), "includeOcr": payload.include_ocr.unwrap_or(false), "timeRange": time_range, "timeField": time_field, "customRangeFrom": payload.custom_range_from, "customRangeTo": payload.custom_range_to, }), })) } "search.recent" => { let payload: SearchRecentQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "search.recent".into(), payload: SearchRecent { workspace_id: payload.workspace_id.clone(), pagination: core_protocol::query::Pagination { limit: payload.limit.unwrap_or(10), cursor: payload.cursor.clone(), }, }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, "limit": payload.limit.unwrap_or(10), "cursor": payload.cursor, }), })) } "bridge.request.get" => { let payload: BridgeRequestQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "bridge.request.get".into(), payload: GetBridgeRequest { workspace_id: payload.workspace_id.clone(), request_id: payload.request_id.clone(), command_id: payload.command_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, "requestId": payload.request_id, "commandId": payload.command_id, }), })) } "bridge.trace.get" => { let payload: BridgeTraceQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "bridge.trace.get".into(), payload: GetBridgeTrace { workspace_id: payload.workspace_id.clone(), trace_id: payload.trace_id.clone(), command_id: payload.command_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, "traceId": payload.trace_id, "commandId": payload.command_id, }), })) } "bridge.command.get" => { let payload: BridgeCommandQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "bridge.command.get".into(), payload: GetBridgeCommand { workspace_id: payload.workspace_id.clone(), command_id: payload.command_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, "commandId": payload.command_id, }), })) } "bridge.workspace.overview" => { let payload: BridgeWorkspaceOverviewQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { name: "bridge.workspace.overview".into(), payload: ListBridgeWorkspaceOverview { workspace_id: payload.workspace_id.clone(), pagination: core_protocol::query::Pagination { limit: payload.limit.unwrap_or(50), cursor: payload.cursor.clone(), }, command_status: payload.command_status.clone(), event_status: payload.event_status.clone(), target_page_id: payload.target_page_id.clone(), target_block_id: payload.target_block_id.clone(), aggregate_type: payload.aggregate_type.clone(), aggregate_id: payload.aggregate_id.clone(), }, }; let request = build_query_request(&context, &query)?; Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { query_name: query.name, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, "limit": payload.limit.unwrap_or(50), "cursor": payload.cursor, "commandStatus": payload.command_status, "eventStatus": payload.event_status, "targetPageId": payload.target_page_id, "targetBlockId": payload.target_block_id, "aggregateType": payload.aggregate_type, "aggregateId": payload.aggregate_id, }), })) } other => Err(BridgeError::validation(format!( "bridge runtime 暂不支持 query: {other}" ))), } } fn execute_tool_plan( context_wire: RuntimeBridgeContextWire, tool_wire: RuntimeToolInvocationWire, ) -> Result { let context = to_bridge_context(context_wire.clone()); let invocation = to_tool_invocation(&tool_wire)?; let spec = default_tool_registry() .tool(invocation.tool.as_str()) .ok_or_else(|| { BridgeError::validation(format!("bridge runtime 暂不支持 tool: {}", invocation.tool)) })?; let payload_json = build_tool_payload_json(&context_wire, &invocation); let steps = build_tool_plan_steps(&context, &tool_wire, &invocation)?; Ok(RuntimeExecutionPlan::Tool(RuntimeToolExecutionPlan { tool_name: invocation.tool, invocation_kind: invocation_kind_label(&invocation.kind).into(), execution_mode: core_protocol::tool_mode_label(&invocation.mode).into(), effect: tool_effect_label(&spec.effect).into(), toolset_id: spec.toolset_id.into(), requires_confirmation: spec.requires_confirmation, request_id: context.request_id, trace_id: context.trace_id, actor_id: context.actor_id, validate_only: context.validate_only, dry_run: context.dry_run, payload_json, args_json: tool_wire.args_json, target: tool_wire.target.as_ref().map(target_to_json), steps, })) } fn execute_tool_result( _context_wire: RuntimeBridgeContextWire, tool_wire: RuntimeToolInvocationWire, data: Value, ) -> Result { let invocation = to_tool_invocation(&tool_wire)?; let spec = default_tool_registry() .tool(invocation.tool.as_str()) .ok_or_else(|| { BridgeError::validation(format!("bridge runtime 暂不支持 tool: {}", invocation.tool)) })?; let args = tool_wire.args_json; match spec.name { "search_web" => { let query = read_required_string_field(&args, "query")?; let count = read_u64_field(&args, "count").unwrap_or(6).clamp(1, 10); let base = env::var("SEARXNG_BASE_URL") .unwrap_or_else(|_| "http://127.0.0.1:8889".into()) .trim() .trim_end_matches('/') .to_string(); let token = env::var("SEARXNG_API_TOKEN") .unwrap_or_default() .trim() .to_string(); let url = format!( "{base}/search?q={}&format=json&language=zhh-CN&categories=general&safesearch=1", urlencoding::encode(&query) ); let payload = fetch_searxng_json(&url, &token)?; let results = payload .get("results") .and_then(Value::as_array) .cloned() .unwrap_or_default() .into_iter() .filter_map(|row| { let title = read_string_value(&row, "title")?; let url = read_string_value(&row, "url")?; if !(url.starts_with("http://") || url.starts_with("https://")) { return None; } Some(json!({ "title": title, "url": url, "snippet": read_string_value(&row, "content").or_else(|| read_string_value(&row, "snippet")), "engine": read_string_value(&row, "engine"), })) }) .take(count as usize) .collect::>(); return Ok(json!({ "ok": true, "source": infer_tool_source(&data), "query": query, "results": results, })); } "image_read" => { let input = merge_tool_input(&args, &data); return Ok(json!({ "ok": true, "source": infer_tool_source(&data), "found": input.get("asset").is_some(), "asset": input.get("asset").cloned().unwrap_or(Value::Null), "ocrStatus": input.get("ocrStatus").cloned().unwrap_or(Value::Null), "ocrText": input.get("ocrText").cloned().unwrap_or(Value::Null), "hasOcrText": input.get("hasOcrText").cloned().unwrap_or(Value::Null), "note": input.get("note").cloned().unwrap_or(Value::Null), })); } "slash_run" => { let input = merge_tool_input(&args, &data); let parsed = parse_slash_command_payload(&input)?; return Ok(json!({ "ok": true, "source": infer_tool_source(&data), "parsed": parsed, })); } "onlyoffice_session_resolve" => { let input: OnlyOfficeSessionResolveInput = parse_tool_input(&args, &data)?; let result = resolve_session(input); Ok(json!({ "ok": true, "source": infer_tool_source(&data), "locator": result.locator, "session": result.session, })) } "onlyoffice_sign" => { let input = merge_tool_input(&args, &data); let config = input .get("config") .cloned() .ok_or_else(|| BridgeError::validation("onlyoffice_sign 缺少 config"))?; let secret = input.get("secret").and_then(Value::as_str).unwrap_or(""); let tokens = sign_config(&config, secret).map_err(BridgeError::validation)?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "token": tokens.token, "documentToken": tokens.document_token, "editorConfigToken": tokens.editor_config_token, })) } "onlyoffice_prepare_proxy" => { let input: OnlyOfficeProxyPreparationInput = parse_tool_input(&args, &data)?; let result = prepare_proxy_request(input).map_err(BridgeError::validation)?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "targetUrl": result.target_url, "forwardHeaders": result.forward_headers, })) } "onlyoffice_prepare_callback" => { let input: OnlyOfficeCallbackPreparationInput = parse_tool_input(&args, &data)?; let result = prepare_callback(input).map_err(BridgeError::validation)?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "shouldWrite": result.should_write, "downloadUrl": result.download_url, "idempotencyKey": result.idempotency_key, "locator": result.locator, "session": result.session, })) } "onlyoffice_prepare_forcesave" => { let input: OnlyOfficeForcesavePreparationInput = parse_tool_input(&args, &data)?; let requests = prepare_forcesave(input).map_err(BridgeError::validation)?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "requests": requests, })) } "bridge_request_get" => normalize_bridge_observability_result(&data, "bridge.request.get"), "bridge_trace_get" => normalize_bridge_observability_result(&data, "bridge.trace.get"), "bridge_command_get" => normalize_bridge_observability_result(&data, "bridge.command.get"), "event_replay" => execute_event_replay(&args, &data), "index_rebuild" => execute_index_rebuild(&args, &data), "doc_get" => { let blocks = normalize_blocks_from_value(&data); let max_blocks = read_u64_field(&args, "maxBlocks").unwrap_or(80); let summaries = walk_block_summaries(&blocks, max_blocks as usize); Ok(json!({ "ok": true, "source": infer_tool_source(&data), "totalTopLevelBlocks": blocks.len(), "blocks": summaries, })) } "doc_find" => { let query = read_required_string_field(&args, "query")?; let max_results = read_u64_field(&args, "maxResults").unwrap_or(8); let blocks = normalize_blocks_from_value(&data); let summaries = walk_block_summaries(&blocks, 400); let needle = query.to_lowercase(); let results = summaries .into_iter() .filter(|summary| summary.text.to_lowercase().contains(&needle)) .take(max_results as usize) .collect::>(); Ok(json!({ "ok": true, "source": infer_tool_source(&data), "query": query, "results": results, })) } "doc_insert_blocks" => { let blocks = normalize_blocks_from_value(&data); let specs = parse_insert_specs(&args)?; let after_block_id = read_optional_string_field(&args, "afterBlockId"); let before_block_id = read_optional_string_field(&args, "beforeBlockId"); let result = apply_insert_blocks(blocks, after_block_id, before_block_id, specs)?; let editor_command_source = if result.editor_commands.is_empty() { "legacy_snapshot_fallback" } else { "rust_editor_command" }; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "inserted": result.inserted, "editorCommandSource": editor_command_source, "editorCommands": result.editor_commands, "data": result.blocks, })) } "doc_replace_range" => { let blocks = normalize_blocks_from_value(&data); let block_id = read_required_string_field(&args, "blockId")?; let text = read_required_string_field(&args, "text")?; let mode = read_optional_string_field(&args, "mode").unwrap_or_else(|| "replace".into()); let editor_commands = build_replace_editor_commands(&blocks, &block_id, &text, &mode); let result = apply_replace_range(blocks, &block_id, &text, &mode)?; let editor_command_source = if editor_commands.is_empty() { "legacy_snapshot_fallback" } else { "rust_editor_command" }; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "blockId": block_id, "mode": mode, "editorCommandSource": editor_command_source, "editorCommands": editor_commands, "data": result, })) } "mindmap_get" => { let tree = normalize_mindmap_from_value(&data)?; let max_nodes = read_u64_field(&args, "maxNodes").unwrap_or(120); let nodes = walk_mindmap_summaries(&tree, max_nodes as usize); Ok(json!({ "ok": true, "source": infer_tool_source(&data), "documentId": resolve_document_id(&args, tool_wire.target.as_ref())?, "mindmapId": resolve_mindmap_id(&args, tool_wire.target.as_ref())?, "nodes": nodes, })) } "mindmap_get_subtree" => { let tree = normalize_mindmap_from_value(&data)?; let uid = read_required_string_field(&args, "uid")?; let depth = read_u64_field(&args, "depth").unwrap_or(2) as usize; let max_nodes = read_u64_field(&args, "maxNodes").unwrap_or(60) as usize; let node = find_mindmap_node_by_uid(&tree, &uid) .ok_or_else(|| BridgeError::validation(format!("未找到 uid={uid}")))?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "uid": uid, "nodes": summarize_mindmap_subtree(node, depth, max_nodes), })) } "mindmap_put" => { let tree = parse_mindmap_tree_from_args(&args, "data")?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "data": tree, })) } "mindmap_apply_ops" => { let mut tree = normalize_mindmap_from_value(&data)?; let ops = parse_mindmap_ops(&args)?; let result = apply_mindmap_ops(&mut tree, &ops)?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "applied": result.applied, "errors": result.errors, "data": tree, "meta": { "reason": read_optional_string_field(&args, "reason"), }, })) } "mindmap_expand_node" => { let merged = merge_tool_input(&args, &data); let mut tree = normalize_mindmap_from_value(&data)?; let target_uid = read_required_string_field(&merged, "targetUid")?; let instruction = read_optional_string_field(&merged, "instruction").unwrap_or_default(); let mut ops = if let Some(ops_value) = merged.get("ops").cloned() { serde_json::from_value::>(ops_value).map_err(|error| { BridgeError::validation(format!( "mindmap_expand_node ops 反序列化失败: {error}" )) })? } else { Vec::new() }; if ops.is_empty() { let search_results = merged .get("searchResults") .and_then(Value::as_array) .cloned() .unwrap_or_default(); for item in search_results { let title = item .as_object() .and_then(|map| map.get("title")) .and_then(Value::as_str) .unwrap_or("") .trim() .to_string(); let url = item .as_object() .and_then(|map| map.get("url")) .and_then(Value::as_str) .unwrap_or("") .trim() .to_string(); let snippet = item .as_object() .and_then(|map| map.get("snippet")) .and_then(Value::as_str) .unwrap_or("") .trim() .to_string(); if title.is_empty() || url.is_empty() { continue; } ops.push(MindmapOp::AddChild { parent_uid: target_uid.clone(), node: MindmapNodeInput { uid: None, text: title, hyperlink: Some(url.clone()), note: None, refs: Some(vec![MindmapNodeRef { kind: "url".into(), asset_id: None, file_url: Some(url), page: None, slide: None, title: None, snippet: if snippet.is_empty() { None } else { Some(snippet) }, }]), }, }); } } if ops.is_empty() { let base = instruction.chars().take(12).collect::(); let label = if base.trim().is_empty() { "补完".to_string() } else { base.trim().to_string() }; for index in 1..=3 { ops.push(MindmapOp::AddChild { parent_uid: target_uid.clone(), node: MindmapNodeInput { uid: None, text: format!("{label}(待核验){index}"), hyperlink: None, note: None, refs: None, }, }); } } let result = apply_mindmap_ops(&mut tree, &ops)?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "applied": result.applied, "errors": result.errors, "ops": ops, "data": tree, })) } "mindmap_empty_trash" => { let payload: MindmapEmptyTrashToolPayload = parse_tool_input(&args, &data)?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "workspaceId": payload.workspace_id, "removed": 0, })) } "mindmap_outline_to_mindmap" => { let payload: MindmapOutlineToolPayload = parse_tool_input(&args, &data)?; let outline = parse_mindmap_outline_items(&json!({ "outline": payload.outline, }))?; let tree = build_mindmap_outline_tree( &payload.root_title, &outline, &payload.page_link_pattern, )?; Ok(json!({ "ok": true, "source": infer_tool_source(&data), "documentId": payload.document_id, "mindmapId": payload.mindmap_id, "data": tree, "meta": { "title": payload.root_title, "outlineCount": outline.len(), }, })) } other => Err(BridgeError::validation(format!( "bridge runtime 暂不支持 tool 执行: {other}" ))), } } fn fetch_searxng_json(url: &str, token: &str) -> Result { let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(20)) .build() .map_err(|error| BridgeError::transport(format!("SearxNG 客户端创建失败: {error}")))?; let mut request = client.get(url); if !token.is_empty() { request = request.header("Authorization", format!("Bearer {token}")); } let response = request .send() .map_err(|error| BridgeError::transport(format!("SearxNG 请求失败: {error}")))?; let status = response.status(); let text = response .text() .map_err(|error| BridgeError::transport(format!("SearxNG 响应读取失败: {error}")))?; if !status.is_success() { return Err(BridgeError::transport(format!( "SearxNG 请求失败:{} {}", status.as_u16(), status.canonical_reason().unwrap_or("unknown") ))); } serde_json::from_str(&text) .map_err(|error| BridgeError::transport(format!("SearxNG JSON 解析失败: {error}"))) } fn to_tool_invocation( tool_wire: &RuntimeToolInvocationWire, ) -> Result { Ok(ToolInvocation { tool: tool_wire.tool.clone(), kind: parse_invocation_kind(tool_wire.kind.as_str())?, mode: parse_tool_mode(tool_wire.mode.as_deref()).unwrap_or(ToolExecutionMode::Plan), args_json: serde_json::to_string(&tool_wire.args_json).map_err(|error| { BridgeError::validation(format!("tool args_json 序列化失败: {error}")) })?, }) } fn parse_invocation_kind(value: &str) -> Result { match value { "command" => Ok(InvocationKind::Command), "query" => Ok(InvocationKind::Query), "job" => Ok(InvocationKind::Job), other => Err(BridgeError::validation(format!( "未知 tool invocation kind: {other}" ))), } } fn parse_tool_mode(value: Option<&str>) -> Result { match value.unwrap_or("plan") { "plan" => Ok(ToolExecutionMode::Plan), "result" => Ok(ToolExecutionMode::Result), "explain-plan" => Ok(ToolExecutionMode::ExplainPlan), other => Err(BridgeError::validation(format!( "未知 tool execution mode: {other}" ))), } } fn build_tool_payload_json( context_wire: &RuntimeBridgeContextWire, invocation: &ToolInvocation, ) -> String { json!({ "kind": "tool", "tool": invocation.tool, "invocation_kind": invocation_kind_label(&invocation.kind), "mode": core_protocol::tool_mode_label(&invocation.mode), "request_id": context_wire.request_id, "trace_id": context_wire.trace_id, "workspace_id": context_wire.workspace_id, "actor": { "type": context_wire.actor.actor_type, "id": context_wire.actor.actor_id, "session_id": context_wire.actor.session_id, }, "source": { "channel": context_wire.source.channel, "client": context_wire.source.client, }, "validate_only": context_wire.validate_only, "dry_run": context_wire.dry_run, }) .to_string() } fn build_tool_plan_steps( context: &BridgeContext, tool_wire: &RuntimeToolInvocationWire, invocation: &ToolInvocation, ) -> Result, BridgeError> { if invocation.tool == "search_web" { return Ok(vec![RuntimeToolPlanStep { kind: "external".into(), name: "search_web".into(), function_name: None, description: "在 Rust runtime 内直接请求 SearxNG 并归一化搜索结果".into(), args_json: tool_wire.args_json.clone(), }]); } if invocation.tool == "image_read" { return Ok(vec![RuntimeToolPlanStep { kind: "transport".into(), name: "media_assets.resolve".into(), function_name: None, description: "通过 transport 读取附件/媒体行,再由 Rust runtime 统一归一化 OCR 结果" .into(), args_json: tool_wire.args_json.clone(), }]); } if invocation.tool == "slash_run" { return Ok(vec![RuntimeToolPlanStep { kind: "transform".into(), name: "slash_run".into(), function_name: None, description: "在 Rust runtime 内解析斜杠命令并生成统一执行语义,TS 只保留最小 transport 写壳" .into(), args_json: tool_wire.args_json.clone(), }]); } if invocation.tool.starts_with("onlyoffice_") { let description = match invocation.tool.as_str() { "onlyoffice_session_resolve" => { "解析 OnlyOffice 的附件、页面与用户上下文,生成稳定 session/asset 边界" } "onlyoffice_sign" => { "在 Rust adapter 内为 OnlyOffice config/document/editorConfig 生成 JWT 签名" } "onlyoffice_prepare_proxy" => { "在 Rust adapter 内校验 proxy 目标、重写回源地址并生成上游请求头" } "onlyoffice_prepare_callback" => { "在 Rust adapter 内解析 callback 状态、下载地址和 session 边界" } "onlyoffice_prepare_forcesave" => { "在 Rust adapter 内生成 forcesave 请求序列与 token 负载" } _ => "执行 OnlyOffice 对象边界适配", }; let mut steps = vec![RuntimeToolPlanStep { kind: "transform".into(), name: invocation.tool.clone(), function_name: None, description: description.into(), args_json: tool_wire.args_json.clone(), }]; if invocation.tool == "onlyoffice_prepare_callback" { steps.push(RuntimeToolPlanStep { kind: "write".into(), name: "media.assets.replace_storage".into(), function_name: Some("media.assets.replace_storage".into()), description: "callback 下载并上传文件后,会继续通过统一 bridge 命令写回附件 storage 绑定" .into(), args_json: json!({ "assetId": read_string_value(&tool_wire.args_json, "assetId"), "documentId": read_string_value(&tool_wire.args_json, "documentId"), }), }); } return Ok(steps); } if invocation.tool.starts_with("bridge_") { let (query_name, description) = match invocation.tool.as_str() { "bridge_request_get" => ( "bridge.request.get", "按 request_id 回查统一 command log 与 domain event 视图", ), "bridge_trace_get" => ( "bridge.trace.get", "按 trace_id 回查统一 command log 与 domain event 视图", ), "bridge_command_get" => ( "bridge.command.get", "按 command_id 回查统一 command log 与 domain event 视图", ), other => { return Err(BridgeError::validation(format!("未知观测工具: {other}"))); } }; let query = QueryEnvelope { name: query_name.into(), payload: tool_wire.args_json.clone(), }; let request = build_query_request(context, &query)?; return Ok(vec![RuntimeToolPlanStep { kind: "query".into(), name: query_name.into(), function_name: Some(request.function_name), description: description.into(), args_json: tool_wire.args_json.clone(), }]); } if matches!(invocation.tool.as_str(), "event_replay" | "index_rebuild") { let description = if invocation.tool == "event_replay" { "在 Rust runtime 内按事件流回放统一观测结果,并返回回放后的 cursor 摘要" } else { "在 Rust runtime 内基于事件流重建全文索引批次与 cursor 摘要" }; return Ok(vec![RuntimeToolPlanStep { kind: "job".into(), name: invocation.tool.clone(), function_name: None, description: description.into(), args_json: tool_wire.args_json.clone(), }]); } if invocation.tool.starts_with("mindmap_") { if invocation.tool == "mindmap_empty_trash" { return Ok(vec![RuntimeToolPlanStep { kind: "validate".into(), name: "mindmap_empty_trash".into(), function_name: None, description: "校验清空导图回收站所需的工作区对象边界".into(), args_json: tool_wire.args_json.clone(), }]); } if invocation.tool == "mindmap_outline_to_mindmap" { return Ok(vec![RuntimeToolPlanStep { kind: "transform".into(), name: "mindmap_outline_to_mindmap".into(), function_name: None, description: "在 Rust runtime 内把结构化大纲转换成思维导图树".into(), args_json: tool_wire.args_json.clone(), }]); } if invocation.tool == "mindmap_expand_node" { return Ok(vec![RuntimeToolPlanStep { kind: "transform".into(), name: "mindmap_expand_node".into(), function_name: None, description: "在 Rust runtime 内归一化补完候选并应用到当前导图".into(), args_json: tool_wire.args_json.clone(), }]); } let document_id = resolve_document_id(&tool_wire.args_json, tool_wire.target.as_ref())?; let workspace_id = resolve_workspace_id(context, &tool_wire.args_json, tool_wire.target.as_ref()); let mindmap_id = resolve_mindmap_id(&tool_wire.args_json, tool_wire.target.as_ref())?; let mindmap_query = QueryEnvelope { name: "mindmaps.get".into(), payload: GetMindmap { document_id: document_id.clone(), mindmap_id: mindmap_id.clone(), workspace_id, }, }; let mindmap_request = build_query_request(context, &mindmap_query)?; let mut steps = vec![RuntimeToolPlanStep { kind: "query".into(), name: "mindmaps.get".into(), function_name: Some(mindmap_request.function_name), description: "读取当前思维导图树,供工具执行使用".into(), args_json: json!({ "docId": document_id, "mindmapId": mindmap_id, }), }]; if invocation.tool == "mindmap_put" { steps.push(RuntimeToolPlanStep { kind: "write".into(), name: "mindmaps.put".into(), function_name: Some("mindmaps.put".into()), description: "用完整思维导图树覆盖当前导图".into(), args_json: tool_wire.args_json.clone(), }); } else if invocation.tool == "mindmap_apply_ops" { steps.push(RuntimeToolPlanStep { kind: "transform".into(), name: "mindmap_apply_ops".into(), function_name: None, description: "在 Rust runtime 内应用 MindmapOp 列表并返回新树".into(), args_json: tool_wire.args_json.clone(), }); } else if invocation.tool == "mindmap_get_subtree" { steps.push(RuntimeToolPlanStep { kind: "inspect".into(), name: "mindmap_get_subtree".into(), function_name: None, description: "在 Rust runtime 内裁剪指定 uid 的子树摘要".into(), args_json: tool_wire.args_json.clone(), }); } else if invocation.mode == ToolExecutionMode::ExplainPlan { steps.push(RuntimeToolPlanStep { kind: "inspect".into(), name: invocation.tool.clone(), function_name: None, description: "解释该导图工具将如何读取并整理当前树结构".into(), args_json: tool_wire.args_json.clone(), }); } return Ok(steps); } let document_id = resolve_document_id(&tool_wire.args_json, tool_wire.target.as_ref())?; let workspace_id = resolve_workspace_id(context, &tool_wire.args_json, tool_wire.target.as_ref()); let content_query = QueryEnvelope { name: "documents.content.get".into(), payload: core_protocol::GetPageContent { page_id: document_id.clone(), workspace_id: workspace_id.clone(), }, }; let content_request = build_query_request(context, &content_query)?; let mut steps = vec![RuntimeToolPlanStep { kind: "query".into(), name: "documents.content.get".into(), function_name: Some(content_request.function_name), description: "读取当前文档快照,供工具执行使用".into(), args_json: json!({ "id": document_id, }), }]; if invocation.tool == "doc_insert_blocks" { steps.push(RuntimeToolPlanStep { kind: "transform".into(), name: "doc_insert_blocks".into(), function_name: None, description: "在 Rust runtime 内按 before/after block 位置插入块快照".into(), args_json: tool_wire.args_json.clone(), }); } else if invocation.tool == "doc_replace_range" { steps.push(RuntimeToolPlanStep { kind: "transform".into(), name: "doc_replace_range".into(), function_name: None, description: "在 Rust runtime 内替换目标块文本并返回新快照".into(), args_json: tool_wire.args_json.clone(), }); } else if invocation.mode == ToolExecutionMode::ExplainPlan { steps.push(RuntimeToolPlanStep { kind: "inspect".into(), name: invocation.tool.clone(), function_name: None, description: "解释该工具将如何读取并整理当前文档快照".into(), args_json: tool_wire.args_json.clone(), }); } Ok(steps) } fn target_to_json(target: &RuntimeTargetWire) -> Value { json!({ "workspaceId": target.workspace_id, "pageId": target.page_id, "blockId": target.block_id, }) } fn resolve_document_id( args: &Value, target: Option<&RuntimeTargetWire>, ) -> Result { if let Some(value) = read_string_value(args, "documentId") { return Ok(value); } if let Some(value) = read_string_value(args, "pageId") { return Ok(value); } if let Some(target) = target { if let Some(value) = target.page_id.clone() { if !value.trim().is_empty() { return Ok(value); } } } Err(BridgeError::validation( "tool 缺少 documentId/pageId 或 target.pageId", )) } fn resolve_mindmap_id( args: &Value, target: Option<&RuntimeTargetWire>, ) -> Result { if let Some(value) = read_string_value(args, "mindmapId") { return Ok(value); } if let Some(value) = read_string_value(args, "attachmentId") { return Ok(value); } if let Some(target) = target { if let Some(value) = target.block_id.clone() { if !value.trim().is_empty() { return Ok(value); } } } Err(BridgeError::validation( "tool 缺少 mindmapId/attachmentId 或 target.blockId", )) } fn resolve_workspace_id( context: &BridgeContext, args: &Value, target: Option<&RuntimeTargetWire>, ) -> Option { read_string_value(args, "workspaceId") .or_else(|| target.and_then(|value| value.workspace_id.clone())) .or_else(|| context.workspace_id.clone()) } fn infer_tool_source(data: &Value) -> String { data.as_object() .and_then(|map| map.get("source")) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or("runtime") .to_string() } fn merge_tool_input(args: &Value, data: &Value) -> Value { let mut merged = serde_json::Map::new(); if let Some(map) = data.as_object() { for (key, value) in map { merged.insert(key.clone(), value.clone()); } } if let Some(map) = args.as_object() { for (key, value) in map { merged.insert(key.clone(), value.clone()); } } Value::Object(merged) } fn parse_slash_command_payload(args: &Value) -> Result { let raw_text = read_string_value(args, "text").unwrap_or_default(); let command = read_string_value(args, "command").unwrap_or_default(); let params = args .as_object() .and_then(|map| map.get("params")) .cloned() .unwrap_or(Value::Null); if raw_text.starts_with('/') { let segments = raw_text .split_whitespace() .map(str::trim) .filter(|value| !value.is_empty()) .collect::>(); let head = segments.first().copied().unwrap_or_default(); let rest = raw_text[head.len()..].trim(); if matches!(head, "/new" | "/new-doc" | "/newdoc") { if rest.is_empty() { return Err(BridgeError::validation("用法:/new <标题>")); } return Ok(json!({ "ok": true, "command": "new_doc", "params": { "title": rest, "parentId": Value::Null, "workspaceId": Value::Null, } })); } if matches!(head, "/rename" | "/rename-doc" | "/renamedoc") { let document_id = segments.get(1).copied().unwrap_or_default(); let title = segments .iter() .skip(2) .copied() .collect::>() .join(" ") .trim() .to_string(); if document_id.is_empty() || title.is_empty() { return Err(BridgeError::validation( "用法:/rename <新标题>", )); } return Ok(json!({ "ok": true, "command": "rename_doc", "params": { "documentId": document_id, "title": title, } })); } return Err(BridgeError::validation(format!("未知命令:{head}"))); } if command == "new_doc" { let title = params .get("title") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if title.is_empty() { return Err(BridgeError::validation("缺少标题")); } let parent_id = params .get("parentId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()); let workspace_id = params .get("workspaceId") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()); return Ok(json!({ "ok": true, "command": "new_doc", "params": { "title": title, "parentId": parent_id, "workspaceId": workspace_id, } })); } if command == "rename_doc" { let document_id = params .get("documentId") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); let title = params .get("title") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if document_id.is_empty() || title.is_empty() { return Err(BridgeError::validation("缺少 documentId 或 title")); } return Ok(json!({ "ok": true, "command": "rename_doc", "params": { "documentId": document_id, "title": title, } })); } Err(BridgeError::validation("缺少 text(以 / 开头)或 command")) } fn normalize_bridge_observability_result( data: &Value, query_name: &str, ) -> Result { let scope_key = match query_name { "bridge.request.get" => "request_id", "bridge.trace.get" => "trace_id", "bridge.command.get" => "command_id", "bridge.workspace.overview" => "workspace_id", other => { return Err(BridgeError::validation(format!( "未知观测查询类型: {other}" ))); } }; let scope_value = read_string_value(data, scope_key) .or_else(|| { data.as_object() .and_then(|map| { map.get(match scope_key { "request_id" => "requestId", "trace_id" => "traceId", "workspace_id" => "workspaceId", _ => "commandId", }) }) .and_then(Value::as_str) .map(str::to_string) }) .unwrap_or_default(); let command_logs = sort_bridge_entries_by_time( data.as_object() .and_then(|map| map.get("command_logs").or_else(|| map.get("commandLogs"))) .and_then(Value::as_array) .cloned() .unwrap_or_default(), ); let domain_events = sort_bridge_entries_by_time( data.as_object() .and_then(|map| map.get("domain_events").or_else(|| map.get("domainEvents"))) .and_then(Value::as_array) .cloned() .unwrap_or_default(), ); let mut payload = serde_json::Map::new(); payload.insert(scope_key.to_string(), Value::String(scope_value)); payload.insert("command_logs".into(), Value::Array(command_logs.clone())); payload.insert("domain_events".into(), Value::Array(domain_events.clone())); payload.insert( "counts".into(), json!({ "command_logs": command_logs.len(), "domain_events": domain_events.len(), }), ); payload.insert( "generated_at".into(), data.as_object() .and_then(|map| map.get("generated_at").or_else(|| map.get("generatedAt"))) .cloned() .unwrap_or(Value::Null), ); if query_name == "bridge.workspace.overview" { let filters = data .as_object() .and_then(|map| map.get("filters")) .cloned() .unwrap_or(Value::Null); let next_cursor = data .as_object() .and_then(|map| map.get("next_cursor").or_else(|| map.get("nextCursor"))) .cloned() .unwrap_or(Value::Null); let has_more = data .as_object() .and_then(|map| map.get("has_more").or_else(|| map.get("hasMore"))) .cloned() .unwrap_or(Value::Bool(false)); payload.insert("filters".into(), filters); payload.insert("next_cursor".into(), next_cursor); payload.insert("has_more".into(), has_more); } Ok(Value::Object(payload)) } fn sort_bridge_entries_by_time(mut entries: Vec) -> Vec { entries.sort_by(|left, right| bridge_entry_time(right).cmp(&bridge_entry_time(left))); entries } fn bridge_entry_time(entry: &Value) -> String { read_string_value(entry, "created_at") .or_else(|| read_string_value(entry, "createdAt")) .or_else(|| read_string_value(entry, "finished_at")) .or_else(|| read_string_value(entry, "finishedAt")) .unwrap_or_default() } fn execute_event_replay(args: &Value, data: &Value) -> Result { let workspace_id = read_required_string_field(args, "workspaceId")?; let cursor = build_replay_cursor(args, &workspace_id); let events = parse_domain_events_from_value(data)?; let projector = MinimalWorkspaceProjector; let result = rebuild_from_events(&projector, &events, &cursor); Ok(json!({ "ok": true, "workspaceId": workspace_id, "canReplay": can_rebuild_from_events(&cursor), "replayedEventIds": result.batches.iter().map(|batch| batch.source_event_id.clone()).collect::>(), "batchCount": result.batches.len(), "cursor": index_cursor_to_json(&result.cursor), })) } fn execute_index_rebuild(args: &Value, data: &Value) -> Result { let workspace_id = read_required_string_field(args, "workspaceId")?; let cursor = build_replay_cursor(args, &workspace_id); let events = parse_domain_events_from_value(data)?; let projector = MinimalWorkspaceProjector; let result = rebuild_from_events(&projector, &events, &cursor); Ok(json!({ "ok": true, "workspaceId": workspace_id, "indexedObjectKinds": index_fts::supported_index_objects().into_iter().map(|kind| format!("{kind:?}")).collect::>(), "cursor": index_cursor_to_json(&result.cursor), "batchCount": result.batches.len(), "documentCount": result.batches.iter().map(|batch| batch.documents.len()).sum::(), "batches": result.batches.iter().map(|batch| { json!({ "workspaceId": batch.workspace_id, "sourceEventId": batch.source_event_id, "documents": batch.documents.iter().map(|doc| { json!({ "workspaceId": doc.workspace_id, "entityKind": format!("{:?}", doc.entity_kind), "entityId": doc.entity_id, "parentId": doc.parent_id, "title": doc.title, "sourceEventId": doc.source_event_id, "revision": doc.revision, }) }).collect::>(), }) }).collect::>(), })) } fn build_replay_cursor(args: &Value, workspace_id: &str) -> IndexCursor { IndexCursor { workspace_id: workspace_id.to_string(), last_processed_event_id: read_optional_string_field(args, "lastProcessedEventId") .unwrap_or_else(|| "evt_bootstrap".into()), last_processed_at: read_optional_string_field(args, "lastProcessedAt") .unwrap_or_else(|| "1970-01-01T00:00:00Z".into()), } } fn parse_domain_events_from_value(data: &Value) -> Result, BridgeError> { let rows = if let Some(events) = data .as_object() .and_then(|map| map.get("events")) .and_then(Value::as_array) { events.clone() } else if let Some(events) = data.as_array() { events.clone() } else if let Some(events) = data .as_object() .and_then(|map| map.get("domain_events").or_else(|| map.get("domainEvents"))) .and_then(Value::as_array) { events.clone() } else { Vec::new() }; rows.into_iter() .map(|row| parse_domain_event(&row)) .collect() } fn parse_domain_event(row: &Value) -> Result { let event_id = read_required_string_field(row, "event_id") .or_else(|_| read_required_string_field(row, "id"))?; let workspace_id = read_required_string_field(row, "workspace_id") .or_else(|_| read_required_string_field(row, "workspaceId"))?; let aggregate_type = read_required_string_field(row, "aggregate_type") .or_else(|_| read_required_string_field(row, "aggregateType"))?; let aggregate_id = read_required_string_field(row, "aggregate_id") .or_else(|_| read_required_string_field(row, "aggregateId"))?; let event_type = read_required_string_field(row, "event_type") .or_else(|_| read_required_string_field(row, "eventType"))?; let event_version = read_u64_field(row, "event_version") .or_else(|| read_u64_field(row, "eventVersion")) .unwrap_or(1) as u32; let payload = row .as_object() .and_then(|map| map.get("payload")) .cloned() .unwrap_or_else(|| row.get("payload_json").cloned().unwrap_or(Value::Null)); let payload_json = if payload.is_string() { payload.as_str().unwrap_or("").to_string() } else { serde_json::to_string(&payload).map_err(|error| { BridgeError::transport(format!("领域事件 payload 序列化失败: {error}")) })? }; let created_at = read_required_string_field(row, "created_at") .or_else(|_| read_required_string_field(row, "createdAt"))?; let command_log_id = read_required_string_field(row, "command_log_id") .or_else(|_| read_required_string_field(row, "commandLogId"))?; let actor_type = read_required_string_field(row, "actor_type") .or_else(|_| read_required_string_field(row, "actorType"))?; let trace_id = read_required_string_field(row, "trace_id") .or_else(|_| read_required_string_field(row, "traceId"))?; let request_id = read_required_string_field(row, "request_id") .or_else(|_| read_required_string_field(row, "requestId"))?; let command_id = read_required_string_field(row, "command_id") .or_else(|_| read_required_string_field(row, "commandId"))?; Ok(DomainEventRecord { event_id, workspace_id, aggregate_type, aggregate_id, event_type, event_version, payload_json, command_log_id, actor_type, created_at: Timestamp::new(created_at), status: event_log::EventStatus::Committed, trace_id, request_id, command_id, }) } fn index_cursor_to_json(cursor: &IndexCursor) -> Value { json!({ "workspaceId": cursor.workspace_id, "lastProcessedEventId": cursor.last_processed_event_id, "lastProcessedAt": cursor.last_processed_at, }) } fn parse_tool_input(args: &Value, data: &Value) -> Result where T: DeserializeOwned, { serde_json::from_value(merge_tool_input(args, data)) .map_err(|error| BridgeError::validation(format!("tool 输入反序列化失败: {error}"))) } fn normalize_blocks_from_value(data: &Value) -> Vec { if let Some(array) = data.as_array() { return array.clone(); } if let Some(map) = data.as_object() { if let Some(array) = map.get("blocks").and_then(Value::as_array) { return array.clone(); } if let Some(content) = map.get("content") { return normalize_blocks_from_value(content); } } vec![] } fn read_trimmed_string_from_map( map: &serde_json::Map, keys: &[&str], ) -> Option { for key in keys { let value = map .get(*key) .and_then(Value::as_str) .map(str::trim) .unwrap_or(""); if !value.is_empty() { return Some(value.to_string()); } } None } fn read_trimmed_string_field(value: &Value, keys: &[&str]) -> Option { value .as_object() .and_then(|map| read_trimmed_string_from_map(map, keys)) } fn read_u64_field_any(value: &Value, keys: &[&str]) -> Option { let map = value.as_object()?; for key in keys { let Some(raw) = map.get(*key) else { continue; }; if let Some(number) = raw.as_u64() { return Some(number); } if let Some(number) = raw.as_i64().and_then(|number| u64::try_from(number).ok()) { return Some(number); } if let Some(number) = raw .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| value.parse::().ok()) { return Some(number); } } None } fn revision_from_conflict_detection_key(conflict_detection_key: &str) -> Option { conflict_detection_key .trim() .rsplit_once(':') .and_then(|(_, revision)| revision.trim().parse::().ok()) } fn content_revision_value(data: &Value, conflict_detection_key: Option<&str>) -> u64 { let explicit_revision = read_u64_field_any( data, &["revision", "content_revision", "contentRevision", "version"], ) .unwrap_or(0); let key_revision = conflict_detection_key.and_then(revision_from_conflict_detection_key); key_revision .filter(|revision| *revision > explicit_revision) .unwrap_or(explicit_revision) } fn normalize_page_subtree_projection_id(document_id: &str) -> String { format!("kernel_projection:page_tree:{document_id}") } fn pick_first_text(values: &[Option]) -> String { for value in values { if let Some(value) = value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { return value.to_string(); } } String::new() } fn normalize_snippet(value: &str) -> String { value .split_whitespace() .collect::>() .join(" ") .chars() .take(220) .collect() } fn clamp_heading_level(value: Option) -> u32 { let level = value.unwrap_or(1); level.clamp(1, 5) as u32 } fn get_page_block_children(value: Option<&Value>) -> Vec { value.and_then(Value::as_array).cloned().unwrap_or_default() } fn get_inline_text(value: &Value) -> String { if let Some(text) = value.as_str() { return text.to_string(); } let Some(nodes) = value.as_array() else { return String::new(); }; let mut text = String::new(); for node in nodes { if let Some(fragment) = node.as_str() { text.push_str(fragment); continue; } let Some(map) = node.as_object() else { continue; }; if map.get("type").and_then(Value::as_str) == Some("link") { if let Some(content) = map.get("content") { text.push_str(&get_inline_text(content)); } continue; } if let Some(fragment) = map.get("text").and_then(Value::as_str) { text.push_str(fragment); } } text } fn get_block_snippet(block: &Value) -> String { let inline_text = block .as_object() .and_then(|map| map.get("content")) .map(get_inline_text) .map(|value| normalize_snippet(&value)) .unwrap_or_default(); if !inline_text.is_empty() { return inline_text; } let props = block .as_object() .and_then(|map| map.get("props")) .and_then(Value::as_object); let fallback = props.map(|props| { pick_first_text(&[ read_trimmed_string_from_map(props, &["title"]), read_trimmed_string_from_map(props, &["caption"]), read_trimmed_string_from_map(props, &["summary"]), read_trimmed_string_from_map(props, &["fileName", "file_name"]), read_trimmed_string_from_map(props, &["name"]), read_trimmed_string_from_map(props, &["alt"]), read_trimmed_string_from_map(props, &["status"]), ]) }); normalize_snippet(fallback.as_deref().unwrap_or_default()) } fn get_block_display_title(block: &Value, snippet: &str) -> Option { let block_type = read_trimmed_string_field(block, &["type"]); let props = block .as_object() .and_then(|map| map.get("props")) .and_then(Value::as_object); let title = props.and_then(|props| read_trimmed_string_from_map(props, &["title"])); let caption = props.and_then(|props| read_trimmed_string_from_map(props, &["caption"])); let file_name = props.and_then(|props| read_trimmed_string_from_map(props, &["fileName", "file_name"])); match block_type.as_deref() { Some("heading") => Some(if snippet.is_empty() { "未命名标题".into() } else { snippet.into() }), Some("pageReference") => Some(pick_first_text(&[ title, Some(snippet.into()), Some("页面引用".into()), ])), Some("blockReference") => Some(pick_first_text(&[ title, Some(snippet.into()), Some("块引用".into()), ])), Some("onlineTable") => Some(pick_first_text(&[ title, Some(snippet.into()), Some("在线表格".into()), ])), Some("mindmap") => Some(pick_first_text(&[ title, Some(snippet.into()), Some("思维导图".into()), ])), Some("media") => Some(pick_first_text(&[ caption, file_name, Some(snippet.into()), Some("附件".into()), ])), Some("codeBlock") => Some(if snippet.is_empty() { "代码块".into() } else { snippet.into() }), Some("advancedTodo") => Some(if snippet.is_empty() { "任务".into() } else { snippet.into() }), Some("quote") => Some(if snippet.is_empty() { "引用".into() } else { snippet.into() }), _ => { if snippet.is_empty() { None } else { Some(snippet.into()) } } } } fn get_document_read_node_type(block_type: Option<&str>) -> DocumentReadNodeType { match block_type { Some("heading") => DocumentReadNodeType::Section, Some("blockReference") | Some("pageReference") => DocumentReadNodeType::ReferenceAnchor, Some("mindmap") => DocumentReadNodeType::Mindmap, _ => DocumentReadNodeType::ContentNode, } } fn get_document_read_evidence_kind(block_type: Option<&str>) -> DocumentReadEvidenceKind { match block_type { Some("heading") => DocumentReadEvidenceKind::Heading, Some("paragraph") => DocumentReadEvidenceKind::Paragraph, Some("bulletListItem") | Some("numberedListItem") | Some("checkListItem") => { DocumentReadEvidenceKind::List } Some("advancedTodo") => DocumentReadEvidenceKind::Todo, Some("quote") => DocumentReadEvidenceKind::Quote, Some("codeBlock") => DocumentReadEvidenceKind::Code, Some("media") => DocumentReadEvidenceKind::Media, Some("pageReference") | Some("blockReference") => DocumentReadEvidenceKind::Reference, Some("onlineTable") => DocumentReadEvidenceKind::Table, Some("mindmap") => DocumentReadEvidenceKind::Mindmap, _ => DocumentReadEvidenceKind::Text, } } fn build_document_page_subtree( document_id: &str, title: Option<&str>, content: &Value, ) -> DocumentReadPageSubtree { let root_node_id = document_id.trim().to_string(); let blocks = normalize_blocks_from_value(content); let root_title = title .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("无标题") .to_string(); let root_node = DocumentReadNode { id: root_node_id.clone(), parent_node_id: None, node_type: DocumentReadNodeType::Page, block_id: None, anchor_block_id: None, depth: 0, metadata: DocumentReadNodeMeta { title: Some(root_title.clone()), text_snippet: None, block_type: Some("page".into()), heading_level: None, numbering: None, child_count: blocks.len() as u32, order: 0, path: vec![root_node_id.clone()], }, }; let mut nodes = vec![root_node.clone()]; let mut outline = Vec::::new(); let mut evidence = Vec::::new(); let mut heading_counters = [0u32; 5]; let mut heading_stack = Vec::<(u32, String)>::new(); let mut order = 0u32; let mut max_depth = 0u32; fn walk_blocks( items: &[Value], parent_block_node_id: Option<&str>, depth: u32, path: &[usize], root_node_id: &str, nodes: &mut Vec, outline: &mut Vec, evidence: &mut Vec, heading_counters: &mut [u32; 5], heading_stack: &mut Vec<(u32, String)>, order: &mut u32, max_depth: &mut u32, ) { for (index, block) in items.iter().enumerate() { let block_id = read_trimmed_string_field(block, &["id"]); let mut auto_path = path .iter() .map(|value| value.to_string()) .collect::>(); auto_path.push(index.to_string()); let node_id = block_id .as_ref() .map(|value| format!("block:{value}")) .unwrap_or_else(|| format!("block:auto:{}", auto_path.join("."))); let snippet = get_block_snippet(block); let block_type = read_trimmed_string_field(block, &["type"]); let heading_level = if block_type.as_deref() == Some("heading") { let level = block .as_object() .and_then(|map| map.get("props")) .and_then(Value::as_object) .and_then(|props| props.get("level")) .and_then(Value::as_u64); Some(clamp_heading_level(level)) } else { None }; let mut parent_node_id = parent_block_node_id .map(str::to_string) .or_else(|| heading_stack.last().map(|(_, node_id)| node_id.clone())) .unwrap_or_else(|| root_node_id.to_string()); let mut numbering = None::; if let Some(level) = heading_level { while heading_stack .last() .map(|(current_level, _)| *current_level >= level) .unwrap_or(false) { heading_stack.pop(); } parent_node_id = heading_stack .last() .map(|(_, node_id)| node_id.clone()) .or_else(|| parent_block_node_id.map(str::to_string)) .unwrap_or_else(|| root_node_id.to_string()); let index = (level - 1) as usize; heading_counters[index] += 1; for counter in heading_counters.iter_mut().skip(index + 1) { *counter = 0; } numbering = Some( heading_counters .iter() .take(level as usize) .copied() .filter(|value| *value > 0) .map(|value| value.to_string()) .collect::>() .join("."), ); } *order += 1; let children = get_page_block_children(block.as_object().and_then(|map| map.get("children"))); let node = DocumentReadNode { id: node_id.clone(), parent_node_id: Some(parent_node_id), node_type: get_document_read_node_type(block_type.as_deref()), block_id: block_id.clone(), anchor_block_id: block_id.clone(), depth: depth + 1, metadata: DocumentReadNodeMeta { title: get_block_display_title(block, &snippet), text_snippet: if snippet.is_empty() { None } else { Some(snippet.clone()) }, block_type: block_type.clone(), heading_level, numbering: numbering.clone(), child_count: children.len() as u32, order: *order, path: std::iter::once(root_node_id.to_string()) .chain(path.iter().map(|value| value.to_string())) .chain(std::iter::once(index.to_string())) .collect(), }, }; nodes.push(node.clone()); *max_depth = (*max_depth).max(node.depth); if let Some(level) = heading_level { outline.push(DocumentReadOutlineEntry { id: block_id.clone().unwrap_or_else(|| node.id.clone()), node_id: node.id.clone(), anchor_block_id: block_id.clone(), title: node .metadata .title .clone() .unwrap_or_else(|| "未命名标题".into()), level, numbering: numbering.unwrap_or_default(), }); heading_stack.push((level, node.id.clone())); } if !snippet.is_empty() { evidence.push(DocumentReadEvidenceItem { id: format!("evidence:{}", node.id), node_id: node.id.clone(), block_id: block_id.clone(), kind: get_document_read_evidence_kind(block_type.as_deref()), snippet, }); } if !children.is_empty() { let mut next_path = path.to_vec(); next_path.push(index); walk_blocks( &children, Some(node.id.as_str()), depth + 1, &next_path, root_node_id, nodes, outline, evidence, heading_counters, heading_stack, order, max_depth, ); } } } walk_blocks( &blocks, None, 0, &[], &root_node_id, &mut nodes, &mut outline, &mut evidence, &mut heading_counters, &mut heading_stack, &mut order, &mut max_depth, ); if !root_title.is_empty() { evidence.insert( 0, DocumentReadEvidenceItem { id: format!("evidence:{root_node_id}"), node_id: root_node_id.clone(), block_id: None, kind: DocumentReadEvidenceKind::Page, snippet: root_title, }, ); } let block_count = nodes.len().saturating_sub(1) as u32; let heading_count = outline.len() as u32; let evidence_count = evidence.len() as u32; DocumentReadPageSubtree { projection_id: normalize_page_subtree_projection_id(&root_node_id), projection: "page_tree".into(), root_node_id: root_node_id.clone(), root_node, subtree: DocumentReadSubtree { root_node_id: root_node_id.clone(), nodes, }, outline, evidence: evidence.clone(), stats: DocumentReadStats { block_count, heading_count, evidence_count, max_depth, }, } } fn build_document_content_result( data: &Value, document_id: &str, _workspace_id: Option<&str>, ) -> Result { if data.is_null() { return Err(BridgeError::not_found( "documents.content.get 未返回文档内容", )); } let content = data .as_object() .and_then(|map| map.get("content")) .cloned() .unwrap_or(Value::Null); let conflict_detection_key = read_trimmed_string_field(data, &["conflict_detection_key", "conflictDetectionKey"]) .unwrap_or_else(|| { let revision = content_revision_value(data, None); format!("{document_id}:{revision}") }); let revision = content_revision_value(data, Some(&conflict_detection_key)); let title = read_trimmed_string_field(data, &["title"]); let page_subtree = build_document_page_subtree(document_id, title.as_deref(), &content); Ok(DocumentContentResult { content, editor_document: data.get("editorDocument").cloned(), tiptap_document: data.get("tiptapDocument").cloned(), block_document: data.get("blockDocument").cloned(), block_projection_version: data .get("blockProjectionVersion") .and_then(Value::as_u64) .and_then(|value| u32::try_from(value).ok()), revision, conflict_detection_key, title, page_subtree, }) } fn build_page_aggregate_projection_result( data: &Value, document_id: &str, workspace_id: Option<&str>, source: PageAggregateSource, ) -> Result { if data.is_null() { return Err(BridgeError::not_found( "page.aggregate.get 未返回页面聚合数据", )); } let meta = data.get("meta").unwrap_or(data); let content_result = data .get("content") .filter(|value| { value.get("content").is_some() || value.get("editorDocument").is_some() || value.get("blockDocument").is_some() || value.get("revision").is_some() || value.get("pageSubtree").is_some() || value.get("page_subtree").is_some() }) .unwrap_or(data); let resolved_document_id = read_trimmed_string_field(meta, &["id", "documentId"]) .unwrap_or_else(|| document_id.to_string()); let resolved_workspace_id = read_trimmed_string_field(meta, &["workspace_id", "workspaceId"]) .or_else(|| workspace_id.map(ToOwned::to_owned)) .unwrap_or_else(|| "default".into()); let title = read_trimmed_string_field(meta, &["title"]).unwrap_or_else(|| "无标题".into()); let updated_at = read_trimmed_string_field(meta, &["updated_at", "updatedAt"]); let parent_id = read_trimmed_string_field(meta, &["parent_id", "parentId"]); let conflict_detection_key = content_result .get("conflictDetectionKey") .or_else(|| content_result.get("conflict_detection_key")) .cloned() .unwrap_or(Value::Null); let revision = Value::from(content_revision_value( content_result, conflict_detection_key.as_str(), )); let page_subtree = content_result .get("pageSubtree") .or_else(|| content_result.get("page_subtree")) .cloned() .unwrap_or_else(|| json!({ "rootNodeId": resolved_document_id })); let page_options = PageOptions { wide_layout: bool_field(meta, "wide_layout") .or_else(|| bool_field(meta, "wideLayout")) .unwrap_or(false), small_text: bool_field(meta, "use_small_text") .or_else(|| bool_field(meta, "smallText")) .unwrap_or(false), layout_density: read_trimmed_string_field(meta, &["layout_density", "layoutDensity"]) .unwrap_or_else(|| "normal".into()), show_heading_numbers: bool_field(meta, "show_heading_numbers") .or_else(|| bool_field(meta, "showHeadingNumbers")) .unwrap_or(true), show_toc: bool_field(meta, "show_toc") .or_else(|| bool_field(meta, "showToc")) .unwrap_or(false), show_structure: bool_field(meta, "show_structure") .or_else(|| bool_field(meta, "showStructure")) .unwrap_or(false), protect_editing: bool_field(meta, "protect_editing") .or_else(|| bool_field(meta, "protectEditing")) .unwrap_or(false), show_word_count: bool_field(meta, "show_word_count") .or_else(|| bool_field(meta, "showWordCount")) .unwrap_or(true), collapse_backlinks: bool_field(meta, "collapse_backlinks") .or_else(|| bool_field(meta, "collapseBacklinks")) .unwrap_or(false), page_font: read_trimmed_string_field(meta, &["page_font", "pageFont"]) .unwrap_or_else(|| "default".into()), hide_child_pages: bool_field(meta, "hide_child_pages") .or_else(|| bool_field(meta, "hideChildPages")) .unwrap_or(false), show_block_ref_count: bool_field(meta, "show_block_ref_count") .or_else(|| bool_field(meta, "showBlockRefCount")) .unwrap_or(false), hide_title_header: bool_field(meta, "hide_title_header") .or_else(|| bool_field(meta, "hideTitleHeader")) .unwrap_or(false), embed_default_block_id: meta .get("embed_default_block_id") .or_else(|| meta.get("embedDefaultBlockId")) .cloned() .unwrap_or(Value::Null), }; let layout_options = serde_json::to_value(&page_options) .map_err(|error| BridgeError::transport(format!("PageOptions 序列化失败: {error}")))?; let revision_ref = revision .as_u64() .map(|value| format!("{resolved_document_id}:{value}")); let (content, block_document, block_projection_version, projection_source) = page_aggregate_block_document_projection(content_result, &resolved_document_id, &revision)?; Ok(PageAggregateProjection { schema: PageAggregateProjection::SCHEMA.into(), projection_version: PageAggregateProjection::VERSION, source, page_id: resolved_document_id.clone(), parent_id, title: title.clone(), path: vec![resolved_document_id.clone()], sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()], body_ref: revision_ref, layout_options, updated_at: updated_at.clone(), identity: PageIdentity { document_id: resolved_document_id, workspace_id: resolved_workspace_id, }, head: PageHead { title, updated_at: updated_at.map(Value::String).unwrap_or(Value::Null), permissions: PagePermissions { read_only: bool_field(meta, "can_edit") .map(|can_edit| !can_edit) .unwrap_or(false), disable_download: bool_field(meta, "disable_download") .or_else(|| bool_field(meta, "disableDownload")) .unwrap_or(false), disable_copy: bool_field(meta, "disable_copy") .or_else(|| bool_field(meta, "disableCopy")) .unwrap_or(false), }, }, layout: PageLayout { page_options }, body: PageBody { content, revision, conflict_detection_key, file_version: Value::Null, block_document, block_projection_version, projection_source, attachment_refs: Value::Array(Vec::new()), }, tree: PageTree { page_subtree }, stats: PageStats { word_count: meta.get("word_count").and_then(Value::as_u64).unwrap_or(0), character_count: meta .get("character_count") .and_then(Value::as_u64) .unwrap_or(0), block_count: meta.get("block_count").and_then(Value::as_u64).unwrap_or(0), todo_total: meta .get("todo_total") .or_else(|| meta.get("todo_total_count")) .and_then(Value::as_u64) .unwrap_or(0), todo_done: meta .get("todo_done") .or_else(|| meta.get("todo_done_count")) .and_then(Value::as_u64) .unwrap_or(0), }, }) } fn page_aggregate_source_for_data(data: &Value) -> PageAggregateSource { if data.get("meta").is_some() && data.get("content").is_some() { PageAggregateSource::CompatMetaContentJoin } else { PageAggregateSource::KernelProjection } } fn page_aggregate_block_document_projection( content_result: &Value, document_id: &str, revision: &Value, ) -> Result<(Value, Value, u32, String), BridgeError> { if let Some(editor_document_value) = content_result .get("editorDocument") .filter(|value| !value.is_null()) { let mut editor_document = serde_json::from_value::( editor_document_value.clone(), ) .map_err(|error| { BridgeError::validation(format!("page.aggregate.get editorDocument 非法: {error}")) })?; validate_editor_document_structure( editor_document_value, &editor_document, "page.aggregate.get", )?; hydrate_editor_document_props_from_raw(&mut editor_document, Some(editor_document_value)); if editor_document.document_id.trim().is_empty() { editor_document.document_id = document_id.to_string(); } if editor_document.root_block_ids.is_empty() { editor_document.root_block_ids = editor_document .blocks .iter() .map(|block| block.block_id.clone()) .collect(); } let content = legacy_content_from_editor_document(&editor_document); return Ok(( content, project_editor_document_to_block_document(&editor_document, revision)?, 1, "editorDocument".into(), )); } if let Some(block_document) = content_result .get("blockDocument") .filter(|value| !value.is_null()) { if !block_document.is_object() { return Err(BridgeError::validation( "page.aggregate.get blockDocument 非法: 必须是对象", )); } let block_projection_version = content_result .get("blockProjectionVersion") .and_then(Value::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(1); let content = legacy_content_from_block_document_projection(block_document)?; return Ok(( content, block_document.clone(), block_projection_version, "blockDocument".into(), )); } let content = content_result .get("content") .cloned() .unwrap_or_else(|| Value::Array(vec![])); Ok(( content.clone(), project_legacy_content_to_block_document(document_id, &content, revision)?, 1, "documents.content".into(), )) } fn legacy_content_from_block_document_projection( block_document: &Value, ) -> Result { let blocks = block_document .get("blocks") .and_then(Value::as_array) .ok_or_else(|| BridgeError::validation("page.aggregate.get blockDocument 缺少 blocks"))?; let root_ids = block_document .get("rootBlockIds") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let mut rendered = Vec::new(); let mut seen = std::collections::BTreeSet::new(); for root_id in root_ids .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { if let Some(block) = blocks.iter().find(|block| { read_trimmed_string_field(block, &["blockId", "id"]).as_deref() == Some(root_id) }) { if !seen.contains(root_id) { rendered.push(render_block_document_projection_to_legacy( block, blocks, &mut seen, )?); } } } for block in blocks { let block_id = read_trimmed_string_field(block, &["blockId", "id"]).unwrap_or_default(); if !block_id.is_empty() && !seen.contains(&block_id) { rendered.push(render_block_document_projection_to_legacy( block, blocks, &mut seen, )?); } } Ok(Value::Array(rendered)) } fn render_block_document_projection_to_legacy( block: &Value, blocks: &[Value], seen: &mut std::collections::BTreeSet, ) -> Result { let block_id = read_trimmed_string_field(block, &["blockId", "id"]).ok_or_else(|| { BridgeError::validation("page.aggregate.get blockDocument block 缺少 blockId") })?; seen.insert(block_id.clone()); let block_type = read_trimmed_string_field(block, &["type", "blockType"]) .unwrap_or_else(|| "paragraph".into()); let mut value = json!({ "id": block_id, "type": block_type, "content": block .get("text") .cloned() .unwrap_or_else(|| Value::String(String::new())), }); if let Some(attrs) = block.get("attrs").and_then(Value::as_object) { if !attrs.is_empty() { if let Value::Object(map) = &mut value { map.insert("props".into(), Value::Object(attrs.clone())); } } } if let Some(children) = block.get("children").and_then(Value::as_array) { let mut legacy_children = Vec::new(); for child_id in children.iter().filter_map(Value::as_str) { if let Some(child) = blocks.iter().find(|candidate| { read_trimmed_string_field(candidate, &["blockId", "id"]).as_deref() == Some(child_id.trim()) }) { let child_block_id = read_trimmed_string_field(child, &["blockId", "id"]).unwrap_or_default(); if !child_block_id.is_empty() && !seen.contains(&child_block_id) { legacy_children.push(render_block_document_projection_to_legacy( child, blocks, seen, )?); } } } if !legacy_children.is_empty() { if let Value::Object(map) = &mut value { map.insert("children".into(), Value::Array(legacy_children)); } } } Ok(value) } pub fn project_legacy_content_to_block_document( document_id: &str, content: &Value, revision: &Value, ) -> Result { let blocks = normalize_blocks_from_value(content); let mut projected_blocks = Vec::new(); let mut root_block_ids = Vec::new(); for (index, block) in blocks.iter().enumerate() { if let Some(block_id) = project_legacy_block(block, None, vec![index], revision, &mut projected_blocks)? { root_block_ids.push(block_id); } } Ok(json!({ "documentId": document_id, "rootBlockIds": root_block_ids, "blocks": projected_blocks, })) } fn project_editor_document_to_block_document( document: &EditorBlockDocument, revision: &Value, ) -> Result { let mut projected_blocks = Vec::new(); let mut root_block_ids = Vec::new(); let root_ids = if document.root_block_ids.is_empty() { document .blocks .iter() .map(|block| block.block_id.clone()) .collect::>() } else { document.root_block_ids.clone() }; for (index, block_id) in root_ids.iter().enumerate() { if let Some(block) = find_editor_block(document, block_id) { root_block_ids.push(project_editor_block( document, block, None, vec![index], revision, &mut projected_blocks, )?); } } Ok(json!({ "documentId": document.document_id, "rootBlockIds": root_block_ids, "blocks": projected_blocks, })) } fn project_editor_block( document: &EditorBlockDocument, block: &EditorBlock, parent_block_id: Option<&str>, path: Vec, revision: &Value, out: &mut Vec, ) -> Result { let block_id = block.block_id.clone(); let block_type = legacy_type_from_editor_block(block); let text = legacy_text_from_editor_block(block); let attrs = editor_block_projection_attrs(block, block_type); let mut child_block_ids = Vec::new(); for (index, child_id) in block.child_block_ids.iter().enumerate() { if let Some(child) = find_editor_block(document, child_id) { let mut child_path = path.clone(); child_path.push(index); child_block_ids.push(project_editor_block( document, child, Some(&block_id), child_path, revision, out, )?); } } let editable = legacy_block_type_is_editable(block_type); let unsupported_reason = if editable { Value::Null } else { Value::String("复杂块暂不开放 AI 精确写入".into()) }; let revision_label = revision .as_u64() .map(|value| value.to_string()) .or_else(|| revision.as_str().map(ToOwned::to_owned)) .unwrap_or_else(|| "unknown".into()); let block_value = serde_json::to_value(block) .map_err(|error| BridgeError::transport(format!("EditorBlock 序列化失败: {error}")))?; out.push(json!({ "blockId": block_id, "type": block_type, "text": text, "attrs": attrs, "contentNodes": block.content_nodes, "children": child_block_ids, "parentBlockId": parent_block_id, "order": format!("{:08}", path.last().copied().unwrap_or(0)), "path": path, "depth": path.len().saturating_sub(1), "revisionRef": format!( "pageRev:{revision_label}:block:{}:hash:{}", block.block_id, stable_json_content_hash(&block_value)? ), "editable": editable, "unsupportedReason": unsupported_reason, })); Ok(block_id) } fn find_editor_block<'a>( document: &'a EditorBlockDocument, block_id: &str, ) -> Option<&'a EditorBlock> { document .blocks .iter() .find(|block| block.block_id == block_id) } fn editor_block_projection_attrs(block: &EditorBlock, block_type: &str) -> Value { let props = legacy_props_from_editor_block(block).unwrap_or_else(|| json!({})); legacy_block_projection_attrs(&json!({ "props": props }), block_type) } fn project_legacy_block( block: &Value, parent_block_id: Option<&str>, path: Vec, revision: &Value, out: &mut Vec, ) -> Result, BridgeError> { let block_id = read_trimmed_string_field(block, &["blockId", "id"]) .unwrap_or_else(|| legacy_block_id_from_path(&path)); let block_type = read_trimmed_string_field(block, &["blockType", "type"]) .unwrap_or_else(|| "paragraph".into()) .to_lowercase(); let text = get_block_snippet(block); let attrs = legacy_block_projection_attrs(block, &block_type); let children_values = block .as_object() .and_then(|map| map.get("children")) .and_then(Value::as_array) .cloned() .unwrap_or_default(); let mut child_block_ids = Vec::new(); for (index, child) in children_values.iter().enumerate() { let mut child_path = path.clone(); child_path.push(index); if let Some(child_id) = project_legacy_block(child, Some(&block_id), child_path, revision, out)? { child_block_ids.push(child_id); } } let editable = legacy_block_type_is_editable(&block_type); let unsupported_reason = if editable { Value::Null } else { Value::String("复杂块暂不开放 AI 精确写入".into()) }; let revision_label = revision .as_u64() .map(|value| value.to_string()) .or_else(|| revision.as_str().map(ToOwned::to_owned)) .unwrap_or_else(|| "unknown".into()); out.push(json!({ "blockId": block_id, "type": block_type, "text": text, "attrs": attrs, "contentNodes": build_text_content_nodes(&text), "children": child_block_ids, "parentBlockId": parent_block_id, "order": format!("{:08}", path.last().copied().unwrap_or(0)), "path": path, "depth": path.len().saturating_sub(1), "revisionRef": format!( "pageRev:{revision_label}:block:{}:hash:{}", block_id, stable_json_content_hash(block)? ), "editable": editable, "unsupportedReason": unsupported_reason, })); Ok(Some(block_id)) } fn legacy_block_id_from_path(path: &[usize]) -> String { format!( "legacy_block_{}", path.iter() .map(|item| item.to_string()) .collect::>() .join("_") ) } fn legacy_block_projection_attrs(block: &Value, block_type: &str) -> Value { let props = block .as_object() .and_then(|map| map.get("props")) .and_then(Value::as_object); let mut attrs = serde_json::Map::new(); if block_type == "heading" { if let Some(level) = props .and_then(|map| map.get("level").or_else(|| map.get("headingLevel"))) .and_then(Value::as_u64) { attrs.insert("headingLevel".into(), json!(level.clamp(1, 6))); } } if matches!(block_type, "todo" | "task") { if let Some(checked) = props .and_then(|map| map.get("checked")) .and_then(Value::as_bool) { attrs.insert("checked".into(), json!(checked)); } } if matches!(block_type, "code" | "code_block" | "code-block") { if let Some(language) = props .and_then(|map| map.get("language")) .and_then(Value::as_str) { attrs.insert("language".into(), json!(language)); } } if block_type == "mindmap" { for (raw_key, canonical_key) in [ ("mindmapId", "mindmapId"), ("mindmap_id", "mindmapId"), ("sourcePath", "sourcePath"), ("source_path", "sourcePath"), ("rootNodeId", "rootNodeId"), ("root_node_id", "rootNodeId"), ] { if let Some(value) = props .and_then(|map| map.get(raw_key)) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { attrs.insert(canonical_key.into(), json!(value)); } } } if block_type == "image" { for key in ["src", "alt", "title"] { if let Some(value) = props .and_then(|map| map.get(key)) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { attrs.insert(key.into(), json!(value)); } } if let Some(tiptap_image) = props .and_then(|map| map.get("tiptapImage")) .filter(|value| value.get("type").and_then(Value::as_str) == Some("image")) .cloned() { attrs.insert("tiptapImage".into(), tiptap_image); } } if block_type == "media" { for (raw_key, canonical_key) in [ ("name", "name"), ("fileName", "name"), ("file_name", "name"), ("sourcePath", "sourcePath"), ("source_path", "sourcePath"), ("url", "sourcePath"), ("src", "sourcePath"), ] { if let Some(value) = props .and_then(|map| map.get(raw_key)) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { attrs.insert(canonical_key.into(), json!(value)); } } } if matches!(block_type, "page_reference" | "pagereference") { for (raw_key, canonical_key) in [ ("title", "title"), ("name", "title"), ("pageId", "pageId"), ("page_id", "pageId"), ("sourcePath", "sourcePath"), ("source_path", "sourcePath"), ("href", "href"), ("url", "href"), ] { if let Some(value) = props .and_then(|map| map.get(raw_key)) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { attrs.insert(canonical_key.into(), json!(value)); } } } if block_type == "table" { if let Some(tiptap_table) = props .and_then(|map| map.get("tiptapTable")) .filter(|value| value.get("type").and_then(Value::as_str) == Some("table")) .cloned() { attrs.insert("tiptapTable".into(), tiptap_table); } } Value::Object(attrs) } fn legacy_block_type_is_editable(block_type: &str) -> bool { matches!( block_type, "paragraph" | "heading" | "todo" | "task" | "quote" | "blockquote" | "code" | "code_block" | "code-block" | "bullet_list_item" | "numbered_list_item" | "bullet_list" | "ordered_list" ) } fn normalize_mindmap_from_value(data: &Value) -> Result { if data.is_null() { return Ok(default_mindmap_tree()); } let candidate = mindmap_tree_candidate(data); let mut tree: MindmapTreeNode = serde_json::from_value(candidate) .map_err(|error| BridgeError::validation(format!("mindmap 数据反序列化失败: {error}")))?; ensure_mindmap_uids(&mut tree); Ok(tree) } fn mindmap_tree_candidate(data: &Value) -> Value { let mut candidate = data.clone(); for _ in 0..6 { let Some(map) = candidate.as_object() else { return candidate; }; let next = if (map.contains_key("ok") || map.contains_key("meta")) && map.get("data").is_some() { map.get("data").cloned() } else if map .get("data") .map(|value| value.get("data").is_some() || value.get("children").is_some()) .unwrap_or(false) { map.get("data").cloned() } else if let Some(nested) = map.get("mindmap") { Some(nested.clone()) } else if let Some(nested) = map.get("result") { Some(nested.clone()) } else { None }; let Some(next) = next else { return candidate; }; if next == candidate { return candidate; } candidate = next; } candidate } fn build_mindmap_projection_result( data: &Value, mindmap_id: &str, ) -> Result { let revision = data .get("revision") .or_else(|| data.get("meta").and_then(|meta| meta.get("revision"))) .and_then(Value::as_u64) .unwrap_or(1); let tree_input = data .get("data") .filter(|value| value.get("data").is_some() || value.get("children").is_some()) .unwrap_or(data); let tree = normalize_mindmap_from_value(tree_input)?; let root_node = tree.data.uid.clone().unwrap_or_else(|| "root".into()); let mut nodes = Vec::new(); let mut edges = Vec::new(); collect_mindmap_projection_rows(&tree, None, &mut nodes, &mut edges); Ok(MindmapProjection { schema: "mnote.mindmap_projection.v1".into(), map_id: mindmap_id.into(), root_node, nodes, edges, layout_hints: data .get("layoutHints") .or_else(|| data.get("layout_hints")) .cloned() .unwrap_or_else(|| json!({ "layout": "right" })), revision, owner: MindmapProjectionOwner::RustKernel, }) } fn build_mindmap_kernel_projection_result( data: &Value, document_id: &str, mindmap_id: &str, ) -> Result { let revision = mindmap_revision(data); let tree = normalize_mindmap_tree_for_leptos_projection(data)?; let root_node_id = tree.data.uid.clone().unwrap_or_else(|| "root".into()); let source = mindmap_projection_source(data); let mut nodes = Vec::new(); let mut edges = Vec::new(); let mut summaries = Vec::new(); collect_mindmap_kernel_projection_rows(&tree, None, 0, &mut nodes, &mut edges, &mut summaries); Ok(MindmapKernelProjection { schema: "mnote.mindmap.kernel_projection.v1".into(), document_id: document_id.into(), mindmap_id: mindmap_id.into(), root_node_id, revision, nodes, edges, summaries, associative_lines: collect_mindmap_associative_lines(data), layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"]) .unwrap_or_else(|| json!("logicalStructure")), theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")), view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})), capabilities: MindmapKernelCapabilities { can_edit: true, can_edit_text: true, can_insert_child: true, can_insert_sibling_after: true, can_delete_node: true, can_move_node: true, can_patch_view: true, can_set_layout: true, can_set_theme: true, }, source, }) } fn default_mindmap_theme_config() -> Value { json!({ "lineColor": "#7aa2ff", "lineStyle": "curve", "rootLineKeepSameInCurve": true, "rootLineStartPositionKeepSameInCurve": true, "generalizationLineColor": "#ef6a5b", "backgroundColor": "#f6f8fc", "root": { "fillColor": "#e25563", "color": "#ffffff", "fontWeight": "bold", "borderColor": "transparent", "borderWidth": 0, "borderRadius": 8 }, "second": { "fillColor": "#4f7df3", "color": "#ffffff", "borderColor": "transparent", "borderWidth": 0, "borderRadius": 8 }, "node": { "fillColor": "transparent", "color": "#315aa9", "borderColor": "transparent", "borderWidth": 0 }, "generalization": { "fillColor": "#ffffff", "color": "#ef6a5b", "borderColor": "#ef6a5b", "borderWidth": 1, "borderRadius": 8 } }) } fn build_mindmap_adapter_projection_result( data: &Value, mindmap_id: &str, ) -> Result { let tree = normalize_mindmap_tree_for_leptos_projection(data)?; Ok(MindmapAdapterProjection { schema: "mnote.mindmap.simple_mind_map_scene.v1".into(), runtime: "simple-mind-map".into(), root: serde_json::to_value(&tree).map_err(|error| { BridgeError::transport(format!("mindmap adapter root 序列化失败: {error}")) })?, layout: read_mindmap_value_field(data, &["layout", "layoutHints", "layout_hints"]) .unwrap_or_else(|| json!("logicalStructure")), theme: read_mindmap_value_field(data, &["theme"]).unwrap_or_else(|| json!("default")), theme_config: read_mindmap_value_field(data, &["themeConfig", "theme_config"]) .unwrap_or_else(default_mindmap_theme_config), view: read_mindmap_value_field(data, &["view"]).unwrap_or_else(|| json!({})), config: read_mindmap_value_field(data, &["config"]).unwrap_or_else(|| json!({})), compat_payload: read_mindmap_value_field(data, &["compatPayload", "compat_payload"]) .unwrap_or_else(|| json!({ "source": "compat-blob", "mindmapId": mindmap_id })), kernel_revision: mindmap_revision(data), }) } fn mindmap_revision(data: &Value) -> u64 { let storage = mindmap_storage_value(data); data.get("revision") .or_else(|| data.get("meta").and_then(|meta| meta.get("revision"))) .or_else(|| storage.get("revision")) .or_else(|| storage.get("meta").and_then(|meta| meta.get("revision"))) .and_then(Value::as_u64) .unwrap_or(1) } fn mindmap_projection_source(data: &Value) -> MindmapProjectionSource { let raw_source = read_mindmap_value_field(data, &["source"]) .and_then(|value| value.as_str().map(ToOwned::to_owned)) .unwrap_or_else(|| "compat-blob".into()); match raw_source.as_str() { "kernel" | "rust-kernel" | "RustKernel" => MindmapProjectionSource::Kernel, "adapter-cache" | "adapterCache" => MindmapProjectionSource::AdapterCache, _ => MindmapProjectionSource::CompatBlob, } } fn normalize_mindmap_tree_for_leptos_projection( data: &Value, ) -> Result { if data.is_null() { return Ok(default_leptos_mindmap_tree()); } let mut tree = normalize_mindmap_from_value(data)?; let text = tree.data.text.as_deref().unwrap_or("").trim(); if text.is_empty() || text == "中心主题" { tree = default_leptos_mindmap_tree(); } Ok(tree) } fn default_leptos_mindmap_tree() -> MindmapTreeNode { let mut root = MindmapTreeNode { data: MindmapNodeData { uid: Some("root".into()), text: Some("KMIND".into()), hyperlink: None, note: None, refs: None, extra: BTreeMap::from([("generalization".into(), json!({ "text": "概要" }))]), }, children: vec![MindmapTreeNode { data: MindmapNodeData { uid: Some("topic".into()), text: Some("二级节点".into()), hyperlink: None, note: None, refs: None, extra: BTreeMap::new(), }, children: vec![ MindmapTreeNode { data: MindmapNodeData { uid: Some("branch-1".into()), text: Some("分支主题".into()), hyperlink: None, note: None, refs: None, extra: BTreeMap::new(), }, children: vec![], extra: BTreeMap::new(), }, MindmapTreeNode { data: MindmapNodeData { uid: Some("branch-2".into()), text: Some("分支主题".into()), hyperlink: None, note: None, refs: None, extra: BTreeMap::new(), }, children: vec![], extra: BTreeMap::new(), }, ], extra: BTreeMap::new(), }], extra: BTreeMap::new(), }; ensure_mindmap_uids(&mut root); root } fn collect_mindmap_kernel_projection_rows( node: &MindmapTreeNode, parent_id: Option<&str>, order: i64, nodes: &mut Vec, edges: &mut Vec, summaries: &mut Vec, ) { let node_id = node .data .uid .clone() .unwrap_or_else(|| format!("node_{}", nodes.len() + 1)); let text = node .data .text .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "未命名节点".into()); if let Some(parent_id) = parent_id { edges.push(MindmapKernelEdge { edge_id: format!("{parent_id}->{node_id}"), from_node_id: parent_id.into(), to_node_id: node_id.clone(), edge_kind: "tree".into(), }); } nodes.push(MindmapKernelNode { node_id: node_id.clone(), parent_id: parent_id.map(ToOwned::to_owned), text, order, collapsed: node_bool_extra(node, "collapsed"), style: node_object_extra(node, "style"), refs: BTreeMap::new(), }); if let Some(summary) = node_summary(&node_id, node) { summaries.push(summary); } for (index, child) in node.children.iter().enumerate() { collect_mindmap_kernel_projection_rows( child, Some(&node_id), index as i64, nodes, edges, summaries, ); } } fn node_summary(node_id: &str, node: &MindmapTreeNode) -> Option { let value = node.data.extra.get("generalization")?; let text = value .as_object() .and_then(|map| map.get("text")) .and_then(Value::as_str) .or_else(|| value.as_str())? .trim(); if text.is_empty() { return None; } Some(MindmapSummary { summary_id: format!("summary:{node_id}"), parent_node_id: node_id.into(), node_ids: node .children .iter() .filter_map(|child| child.data.uid.clone()) .collect(), text: text.into(), style: BTreeMap::new(), }) } fn collect_mindmap_associative_lines(data: &Value) -> Vec { data.get("associativeLines") .or_else(|| data.get("associative_lines")) .and_then(Value::as_array) .map(|lines| { lines .iter() .enumerate() .filter_map(|(index, line)| { let from_node_id = read_string_value(line, "fromNodeId") .or_else(|| read_string_value(line, "from_node_id"))?; let to_node_id = read_string_value(line, "toNodeId") .or_else(|| read_string_value(line, "to_node_id"))?; Some(MindmapAssociativeLine { line_id: read_string_value(line, "lineId") .or_else(|| read_string_value(line, "line_id")) .unwrap_or_else(|| format!("associative-line-{index}")), from_node_id, to_node_id, text: read_string_value(line, "text"), style: object_field_as_btreemap(line, "style"), }) }) .collect() }) .unwrap_or_default() } fn node_bool_extra(node: &MindmapTreeNode, key: &str) -> bool { node.data .extra .get(key) .and_then(Value::as_bool) .or_else(|| node.extra.get(key).and_then(Value::as_bool)) .unwrap_or(false) } fn node_object_extra(node: &MindmapTreeNode, key: &str) -> BTreeMap { node.data .extra .get(key) .or_else(|| node.extra.get(key)) .and_then(Value::as_object) .map(|map| { map.iter() .map(|(key, value)| (key.clone(), value.clone())) .collect() }) .unwrap_or_default() } fn object_field_as_btreemap(value: &Value, field: &str) -> BTreeMap { value .as_object() .and_then(|map| map.get(field)) .and_then(Value::as_object) .map(|map| { map.iter() .map(|(key, value)| (key.clone(), value.clone())) .collect() }) .unwrap_or_default() } fn read_mindmap_value_field(data: &Value, fields: &[&str]) -> Option { let storage = mindmap_storage_value(data); fields.iter().find_map(|field| { data.get(*field) .or_else(|| data.get("meta").and_then(|meta| meta.get(*field))) .or_else(|| storage.get(*field)) .or_else(|| storage.get("meta").and_then(|meta| meta.get(*field))) .cloned() }) } fn mindmap_storage_value(data: &Value) -> &Value { let Some(map) = data.as_object() else { return data; }; if (map.contains_key("ok") || map.contains_key("meta")) && map.get("data").is_some() { return map.get("data").unwrap_or(data); } data } fn collect_mindmap_projection_rows( node: &MindmapTreeNode, parent_id: Option<&str>, nodes: &mut Vec, edges: &mut Vec, ) { let node_id = node .data .uid .clone() .unwrap_or_else(|| format!("node_{}", nodes.len() + 1)); let title = node .data .text .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "未命名节点".into()); if let Some(parent_id) = parent_id { edges.push(MindmapProjectionEdge { edge_id: format!("{parent_id}->{node_id}"), from_node_id: parent_id.into(), to_node_id: node_id.clone(), edge_kind: "mindmap_child".into(), }); } nodes.push(MindmapProjectionNode { node_id: node_id.clone(), title, parent_id: parent_id.map(ToOwned::to_owned), }); for child in &node.children { collect_mindmap_projection_rows(child, Some(&node_id), nodes, edges); } } fn search_documents_canonical_projection(evaluation: SearchDocumentsEvaluation) -> Value { let mut payload = serde_json::to_value(evaluation).unwrap_or_else(|_| { json!({ "enqueueAssetIds": [], "results": [] }) }); if let Some(map) = payload.as_object_mut() { map.insert("projectionOwner".into(), json!("rust-kernel")); if let Some(results) = map.get_mut("results").and_then(Value::as_array_mut) { for result in results { if let Some(result_map) = result.as_object_mut() { result_map.insert("projectionOwner".into(), json!("rust-kernel")); } } } } payload } fn walk_block_summaries(root_blocks: &[Value], max_nodes: usize) -> Vec { let mut queue = VecDeque::new(); for block in root_blocks { queue.push_back((block.clone(), 0usize)); } let mut summaries = Vec::new(); while let Some((block, depth)) = queue.pop_front() { if summaries.len() >= max_nodes { break; } let id = block .as_object() .and_then(|map| map.get("id")) .and_then(Value::as_str) .unwrap_or("") .trim() .to_string(); let block_type = block .as_object() .and_then(|map| map.get("type")) .and_then(Value::as_str) .unwrap_or("unknown") .trim() .to_string(); let children = block .as_object() .and_then(|map| map.get("children")) .and_then(Value::as_array) .cloned() .unwrap_or_default(); if !id.is_empty() { summaries.push(RuntimeBlockSummary { id, block_type, text: extract_inline_text(&block), depth, child_count: children.len(), }); } for child in children { queue.push_back((child, depth + 1)); } } summaries } fn extract_inline_text(block: &Value) -> String { block .as_object() .and_then(|map| map.get("content")) .and_then(Value::as_array) .map(|nodes| { nodes .iter() .filter_map(|node| { node.as_object() .and_then(|map| map.get("text")) .and_then(Value::as_str) }) .collect::() .trim() .to_string() }) .unwrap_or_default() } fn parse_insert_specs(args: &Value) -> Result, BridgeError> { let specs_raw = args .as_object() .and_then(|map| map.get("blocks")) .and_then(Value::as_array) .ok_or_else(|| BridgeError::validation("doc_insert_blocks 缺少 blocks"))?; if specs_raw.is_empty() { return Err(BridgeError::validation("doc_insert_blocks 缺少 blocks")); } if specs_raw.len() > 20 { return Err(BridgeError::validation( "doc_insert_blocks blocks 过多(最多 20)", )); } Ok(specs_raw .iter() .map(|spec| RuntimeInsertSpec { block_type: read_string_value(spec, "type") .filter(|value| value == "heading") .unwrap_or_else(|| "paragraph".into()), text: read_string_value(spec, "text").unwrap_or_default(), level: read_u64_field(spec, "level").unwrap_or(2), }) .collect()) } fn apply_insert_blocks( mut blocks: Vec, after_block_id: Option, before_block_id: Option, specs: Vec, ) -> Result { let created = specs .iter() .map(build_block_from_spec) .collect::>(); let editor_commands = build_insert_editor_commands( &blocks, after_block_id.as_deref(), before_block_id.as_deref(), &created, &specs, ); let inserted = created .iter() .filter_map(|block| read_string_value(block, "id")) .collect::>(); let target_id = before_block_id.clone().or(after_block_id.clone()); match target_id { Some(target_id) => { let inserted_ok = insert_blocks_at_target( &mut blocks, &target_id, before_block_id.is_some(), &created, ); if !inserted_ok { return Err(BridgeError::validation(format!( "未找到 blockId:{target_id}" ))); } } None => blocks.extend(created), } Ok(InsertBlocksResult { inserted, blocks, editor_commands, }) } fn insert_blocks_at_target( blocks: &mut Vec, target_id: &str, before: bool, created: &[Value], ) -> bool { for index in 0..blocks.len() { if read_string_value(&blocks[index], "id").as_deref() == Some(target_id) { let insert_at = if before { index } else { index + 1 }; blocks.splice(insert_at..insert_at, created.iter().cloned()); return true; } if let Some(children) = blocks[index] .as_object_mut() .and_then(|map| map.get_mut("children")) .and_then(Value::as_array_mut) { if insert_blocks_at_target(children, target_id, before, created) { return true; } } } false } fn build_block_from_spec(spec: &RuntimeInsertSpec) -> Value { let id = format!( "rust_tool_block_{}", TOOL_BLOCK_COUNTER.fetch_add(1, Ordering::Relaxed) ); let level = spec.level.clamp(1, 5); let props = if spec.block_type == "heading" { json!({ "level": level }) } else { json!({}) }; json!({ "id": id, "type": spec.block_type, "props": props, "content": [{"type":"text","text": spec.text}], "children": [], }) } fn build_text_content_nodes(text: &str) -> Vec { if text.trim().is_empty() { return vec![]; } vec![ContentNode { payload: ContentNodePayload::Text { text: text.to_string(), marks: vec![], }, attrs: BTreeMap::new(), }] } fn editor_block_type_from_spec(spec: &RuntimeInsertSpec) -> EditorBlockType { match spec.block_type.as_str() { "heading" => EditorBlockType::Heading, _ => EditorBlockType::Paragraph, } } fn build_editor_block_from_spec(spec: &RuntimeInsertSpec, block_id: &str) -> EditorBlock { let mut props = BlockProps::default(); if matches!(editor_block_type_from_spec(spec), EditorBlockType::Heading) { props.heading_level = Some(spec.level.clamp(1, 5) as u8); } EditorBlock { block_id: block_id.to_string(), block_type: editor_block_type_from_spec(spec), props, content_nodes: build_text_content_nodes(&spec.text), child_block_ids: vec![], } } fn collect_block_order(blocks: &[Value], order: &mut Vec) { for block in blocks { if let Some(block_id) = read_string_value(block, "id") { order.push(block_id); } if let Some(children) = block .as_object() .and_then(|map| map.get("children")) .and_then(Value::as_array) { collect_block_order(children, order); } } } fn resolve_insert_anchor( blocks: &[Value], after_block_id: Option<&str>, before_block_id: Option<&str>, ) -> Option { let mut order = Vec::new(); collect_block_order(blocks, &mut order); if let Some(after_block_id) = after_block_id { if order.iter().any(|item| item == after_block_id) { return Some(after_block_id.to_string()); } return None; } if let Some(before_block_id) = before_block_id { let index = order.iter().position(|item| item == before_block_id)?; if index == 0 { return None; } return order.get(index - 1).cloned(); } order.last().cloned() } fn build_insert_editor_commands( blocks: &[Value], after_block_id: Option<&str>, before_block_id: Option<&str>, created: &[Value], specs: &[RuntimeInsertSpec], ) -> Vec { let mut anchor = resolve_insert_anchor(blocks, after_block_id, before_block_id); if anchor.is_none() { return vec![]; } let mut commands = Vec::new(); for (created_block, spec) in created.iter().zip(specs.iter()) { let Some(block_id) = read_string_value(created_block, "id") else { continue; }; let Some(after_block_id) = anchor.clone() else { break; }; commands.push(EditorCommand::InsertBlockAfter(EditorInsertBlockAfter { after_block_id, block: build_editor_block_from_spec(spec, &block_id), })); anchor = Some(block_id); } commands } fn find_block_text(blocks: &[Value], block_id: &str) -> Option { for block in blocks { if read_string_value(block, "id").as_deref() == Some(block_id) { return Some(extract_inline_text(block)); } if let Some(children) = block .as_object() .and_then(|map| map.get("children")) .and_then(Value::as_array) { if let Some(found) = find_block_text(children, block_id) { return Some(found); } } } None } fn build_replace_editor_commands( blocks: &[Value], block_id: &str, text: &str, mode: &str, ) -> Vec { let Some(previous) = find_block_text(blocks, block_id) else { return vec![]; }; let next_text = match mode { "append" => format!("{previous}{text}"), "prepend" => format!("{text}{previous}"), _ => text.to_string(), }; vec![EditorCommand::ReplaceBlock(EditorReplaceBlock { block_id: block_id.to_string(), block_type: None, props: None, content_nodes: Some(build_text_content_nodes(&next_text)), })] } fn apply_replace_range( mut blocks: Vec, block_id: &str, text: &str, mode: &str, ) -> Result, BridgeError> { if !replace_block_text(&mut blocks, block_id, text, mode)? { return Err(BridgeError::validation(format!( "未找到 blockId:{block_id}" ))); } Ok(blocks) } fn replace_block_text( blocks: &mut Vec, block_id: &str, text: &str, mode: &str, ) -> Result { for block in blocks.iter_mut() { if read_string_value(block, "id").as_deref() == Some(block_id) { let previous = extract_inline_text(block); let next_text = match mode { "append" => format!("{previous}{text}"), "prepend" => format!("{text}{previous}"), _ => text.to_string(), }; let object = block .as_object_mut() .ok_or_else(|| BridgeError::validation(format!("block 数据异常:{block_id}")))?; object.insert("content".into(), json!([{"type":"text","text": next_text}])); return Ok(true); } if let Some(children) = block .as_object_mut() .and_then(|map| map.get_mut("children")) .and_then(Value::as_array_mut) { if replace_block_text(children, block_id, text, mode)? { return Ok(true); } } } Ok(false) } fn read_required_string_field(value: &Value, field: &str) -> Result { read_string_value(value, field) .ok_or_else(|| BridgeError::validation(format!("tool 缺少 {field}"))) } fn read_optional_string_field(value: &Value, field: &str) -> Option { read_string_value(value, field) } fn read_string_value(value: &Value, field: &str) -> Option { value .as_object() .and_then(|map| map.get(field)) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn read_u64_field(value: &Value, field: &str) -> Option { value .as_object() .and_then(|map| map.get(field)) .and_then(Value::as_u64) } fn next_mindmap_uid() -> String { format!( "rust_mindmap_uid_{}", MINDMAP_UID_COUNTER.fetch_add(1, Ordering::Relaxed) ) } fn default_mindmap_tree() -> MindmapTreeNode { let mut root = MindmapTreeNode { data: MindmapNodeData { uid: None, text: Some("中心主题".into()), hyperlink: None, note: None, refs: None, extra: BTreeMap::new(), }, children: vec![], extra: BTreeMap::new(), }; ensure_mindmap_uids(&mut root); root } fn ensure_mindmap_uids(node: &mut MindmapTreeNode) { if node .data .uid .as_ref() .map(|value| value.trim().is_empty()) .unwrap_or(true) { node.data.uid = Some(next_mindmap_uid()); } let text = node.data.text.clone().unwrap_or_else(|| "新节点".into()); node.data.text = Some(text); for child in &mut node.children { ensure_mindmap_uids(child); } } fn strip_html_tags(value: &str) -> String { let mut result = String::with_capacity(value.len()); let mut inside_tag = false; for ch in value.chars() { match ch { '<' => inside_tag = true, '>' => inside_tag = false, _ if !inside_tag => result.push(ch), _ => {} } } result.trim().to_string() } fn walk_mindmap_summaries(root: &MindmapTreeNode, max_nodes: usize) -> Vec { let mut list = Vec::new(); let mut queue = VecDeque::from([(root, None::, 0usize)]); while let Some((node, parent_uid, depth)) = queue.pop_front() { if list.len() >= max_nodes { break; } let uid = node.data.uid.clone().unwrap_or_default(); let text = strip_html_tags(node.data.text.as_deref().unwrap_or("")); list.push(RuntimeMindmapSummary { uid: uid.clone(), text, parent_uid, depth, child_count: node.children.len(), }); for child in &node.children { queue.push_back((child, Some(uid.clone()), depth + 1)); } } list } fn summarize_mindmap_subtree( root: &MindmapTreeNode, depth_limit: usize, max_nodes: usize, ) -> Vec { let mut list = Vec::new(); let mut queue = VecDeque::from([(root, 0usize)]); while let Some((node, depth)) = queue.pop_front() { if list.len() >= max_nodes { break; } list.push(RuntimeMindmapSubtreeSummary { uid: node.data.uid.clone().unwrap_or_default(), text: strip_html_tags(node.data.text.as_deref().unwrap_or("")), depth, child_count: node.children.len(), }); if depth < depth_limit { for child in &node.children { queue.push_back((child, depth + 1)); } } } list } fn find_mindmap_node_by_uid<'a>( node: &'a MindmapTreeNode, uid: &str, ) -> Option<&'a MindmapTreeNode> { if node.data.uid.as_deref() == Some(uid) { return Some(node); } for child in &node.children { if let Some(found) = find_mindmap_node_by_uid(child, uid) { return Some(found); } } None } fn parse_mindmap_tree_from_args(args: &Value, field: &str) -> Result { let value = args .as_object() .and_then(|map| map.get(field)) .cloned() .ok_or_else(|| BridgeError::validation(format!("tool 缺少 {field}")))?; let mut tree: MindmapTreeNode = serde_json::from_value(value).map_err(|error| { BridgeError::validation(format!("{field} 不是合法的思维导图树: {error}")) })?; ensure_mindmap_uids(&mut tree); Ok(tree) } fn parse_mindmap_ops(args: &Value) -> Result, BridgeError> { let ops_value = args .as_object() .and_then(|map| map.get("ops")) .cloned() .ok_or_else(|| BridgeError::validation("mindmap_apply_ops 缺少 ops"))?; let ops: Vec = serde_json::from_value(ops_value) .map_err(|error| BridgeError::validation(format!("ops 反序列化失败: {error}")))?; if ops.is_empty() { return Err(BridgeError::validation("mindmap_apply_ops 缺少 ops")); } if ops.len() > 80 { return Err(BridgeError::validation( "mindmap_apply_ops ops 过多(最多 80)", )); } Ok(ops) } fn validate_mindmap_kernel_command_values(commands: &[Value]) -> Result<(), BridgeError> { if commands.is_empty() { return Err(BridgeError::validation( "mindmap.command.apply 缺少 commands", )); } if commands.len() > 80 { return Err(BridgeError::validation( "mindmap.command.apply commands 过多(最多 80)", )); } for command in commands { if command .get("type") .and_then(Value::as_str) .is_some_and(|command_type| command_type == "compatPayloadPatch") { continue; } if serde_json::from_value::(command.clone()).is_ok() || serde_json::from_value::(command.clone()).is_ok() { continue; } return Err(BridgeError::validation(format!( "mindmap.command.apply 包含不支持的 command: {command}" ))); } Ok(()) } fn parse_mindmap_outline_items( args: &Value, ) -> Result, BridgeError> { let outline_value = args .as_object() .and_then(|map| map.get("outline")) .cloned() .ok_or_else(|| BridgeError::validation("mindmap_outline_to_mindmap 缺少 outline"))?; let outline: Vec = serde_json::from_value(outline_value) .map_err(|error| BridgeError::validation(format!("outline 反序列化失败: {error}")))?; if outline.is_empty() { return Err(BridgeError::validation( "mindmap_outline_to_mindmap 缺少 outline", )); } if outline.len() > 600 { return Err(BridgeError::validation( "mindmap_outline_to_mindmap outline 过多(最多 600)", )); } Ok(outline) } #[derive(Debug)] struct MindmapOpsResult { applied: usize, errors: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MindmapKernelCommandApplyResult { pub applied: usize, pub errors: Vec, pub data: Value, } fn mindmap_metadata_fields(data: &Value) -> BTreeMap { let mut fields = BTreeMap::new(); for value in [mindmap_storage_value(data), data] { let Some(map) = value.as_object() else { continue; }; for key in [ "layout", "layoutHints", "layout_hints", "theme", "themeConfig", "theme_config", "view", "config", "compatPayload", "compat_payload", "revision", ] { if let Some(value) = map.get(key) { fields.insert(key.to_string(), value.clone()); } } } fields } fn merge_json_object(base: &mut Value, patch: &Value) { if let (Some(base_map), Some(patch_map)) = (base.as_object_mut(), patch.as_object()) { for (key, value) in patch_map { if value.is_null() { base_map.remove(key); continue; } match base_map.get_mut(key) { Some(existing) if existing.is_object() && value.is_object() => { merge_json_object(existing, value); } _ => { base_map.insert(key.clone(), value.clone()); } } } return; } *base = patch.clone(); } fn set_json_path(root: &mut Value, path: &str, value: Value) -> bool { let segments: Vec<&str> = path .split('.') .filter(|segment| !segment.is_empty()) .collect(); if segments.is_empty() { return false; } let mut current = root; for segment in &segments[..segments.len() - 1] { if !current.is_object() { *current = json!({}); } let Some(map) = current.as_object_mut() else { return false; }; current = map .entry((*segment).to_string()) .or_insert_with(|| json!({})); } if !current.is_object() { *current = json!({}); } let Some(map) = current.as_object_mut() else { return false; }; map.insert(segments[segments.len() - 1].to_string(), value); true } fn apply_mindmap_compat_payload_patch( tree: &mut MindmapTreeNode, metadata: &mut BTreeMap, command: &Value, ) -> bool { let path = command .get("path") .and_then(Value::as_str) .unwrap_or_default(); if path.trim().is_empty() { return false; } let value = command.get("value").cloned().unwrap_or(Value::Null); if let Some(rest) = path.strip_prefix("root.data.") { tree.data.extra.insert(rest.to_string(), value); return true; } if let Some(rest) = path.strip_prefix("nodes.") { let mut parts = rest.splitn(3, '.'); let Some(uid) = parts.next() else { return false; }; let Some(scope) = parts.next() else { return false; }; let Some(key) = parts.next() else { return false; }; if scope == "data" { if let Some(node) = find_mindmap_node_mut(tree, uid) { node.data.extra.insert(key.to_string(), value); return true; } } } let compat_payload = metadata .entry("compatPayload".into()) .or_insert_with(|| json!({})); set_json_path(compat_payload, path, value) } fn build_mindmap_command_output( tree: MindmapTreeNode, metadata: BTreeMap, ) -> Result { let tree_value = serde_json::to_value(tree).map_err(|error| { BridgeError::transport(format!("mindmap command result 序列化失败: {error}")) })?; if metadata.is_empty() { return Ok(tree_value); } let mut map = serde_json::Map::new(); map.insert("data".into(), tree_value); for (key, value) in metadata { map.insert(key, value); } Ok(Value::Object(map)) } pub fn apply_mindmap_kernel_commands_to_value( data: &Value, commands: &[Value], ) -> Result { validate_mindmap_kernel_command_values(commands)?; let mut tree = normalize_mindmap_tree_for_leptos_projection(data)?; let mut ops = Vec::new(); let mut errors = Vec::new(); let mut metadata = mindmap_metadata_fields(data); let mut metadata_applied = 0usize; for command in commands { if command .get("type") .and_then(Value::as_str) .is_some_and(|command_type| command_type == "compatPayloadPatch") { if apply_mindmap_compat_payload_patch(&mut tree, &mut metadata, command) { metadata_applied += 1; } else { errors.push(format!("无法应用 compatPayloadPatch: {command}")); } continue; } if let Ok(kernel_command) = serde_json::from_value::(command.clone()) { match kernel_command { MindmapKernelCommand::UpdateText { node_id, text, .. } => { ops.push(MindmapOp::UpdateText { uid: node_id, text }); } MindmapKernelCommand::InsertChild { parent_node_id, node, .. } => { ops.push(MindmapOp::AddChild { parent_uid: parent_node_id, node, }); } MindmapKernelCommand::InsertSiblingAfter { target_node_id, node, .. } => { ops.push(MindmapOp::AddSiblingAfter { target_uid: target_node_id, node, }); } MindmapKernelCommand::DeleteNode { node_id, .. } => { ops.push(MindmapOp::DeleteNode { uid: node_id }); } MindmapKernelCommand::PatchView { patch, .. } => { let entry = metadata.entry("view".into()).or_insert_with(|| json!({})); merge_json_object(entry, &patch); metadata_applied += 1; } MindmapKernelCommand::SetLayout { layout, .. } => { metadata.insert("layout".into(), layout); metadata_applied += 1; } MindmapKernelCommand::SetTheme { theme, theme_config, .. } => { metadata.insert("theme".into(), theme); if !theme_config.is_null() { metadata.insert("themeConfig".into(), theme_config); } metadata_applied += 1; } MindmapKernelCommand::MoveNode { .. } => { errors.push(format!("暂未持久化的 mindmap command: {command}")); } } continue; } if let Ok(legacy_command) = serde_json::from_value::(command.clone()) { match legacy_command { MindmapCommand::CreateNode { parent_id, node, .. } => { ops.push(MindmapOp::AddChild { parent_uid: parent_id, node, }); } MindmapCommand::RenameNode { node_id, title, .. } => { ops.push(MindmapOp::UpdateText { uid: node_id, text: title, }); } MindmapCommand::DeleteNode { node_id, .. } => { ops.push(MindmapOp::DeleteNode { uid: node_id }); } MindmapCommand::MoveNode { .. } | MindmapCommand::SetLayout { .. } | MindmapCommand::AttachPageRef { .. } => { errors.push(format!("暂未持久化的 legacy mindmap command: {command}")); } } continue; } errors.push(format!("无法识别的 mindmap command: {command}")); } let result = apply_mindmap_ops(&mut tree, &ops)?; errors.extend(result.errors); let data = build_mindmap_command_output(tree, metadata)?; Ok(MindmapKernelCommandApplyResult { applied: result.applied + metadata_applied, errors, data, }) } fn apply_mindmap_ops( root: &mut MindmapTreeNode, ops: &[MindmapOp], ) -> Result { ensure_mindmap_uids(root); let mut applied = 0usize; let mut errors = Vec::new(); for op in ops { let result = match op { MindmapOp::AddChild { parent_uid, node } => add_mindmap_child(root, parent_uid, node), MindmapOp::AddSiblingAfter { target_uid, node } => { add_mindmap_sibling_after(root, target_uid, node) } MindmapOp::UpdateText { uid, text } => update_mindmap_text(root, uid, text), MindmapOp::SetHyperlink { uid, hyperlink } => { set_mindmap_hyperlink(root, uid, hyperlink.clone()) } MindmapOp::SetRefs { uid, refs } => set_mindmap_refs(root, uid, refs.clone()), MindmapOp::AppendNote { uid, markdown } => append_mindmap_note(root, uid, markdown), MindmapOp::DeleteNode { uid } => delete_mindmap_node(root, uid), }; match result { Ok(true) => applied += 1, Ok(false) => errors.push(format!( "未找到可操作节点: {}", describe_mindmap_op_target(op) )), Err(error) => errors.push(error), } } ensure_mindmap_uids(root); Ok(MindmapOpsResult { applied, errors }) } fn describe_mindmap_op_target(op: &MindmapOp) -> String { match op { MindmapOp::AddChild { parent_uid, .. } => format!("parentUid={parent_uid}"), MindmapOp::AddSiblingAfter { target_uid, .. } => format!("targetUid={target_uid}"), MindmapOp::UpdateText { uid, .. } | MindmapOp::SetHyperlink { uid, .. } | MindmapOp::SetRefs { uid, .. } | MindmapOp::AppendNote { uid, .. } | MindmapOp::DeleteNode { uid } => format!("uid={uid}"), } } fn mindmap_node_mut_by_path<'a>( node: &'a mut MindmapTreeNode, path: &[usize], ) -> Option<&'a mut MindmapTreeNode> { if path.is_empty() { return Some(node); } let (first, rest) = path.split_first()?; let child = node.children.get_mut(*first)?; mindmap_node_mut_by_path(child, rest) } fn build_mindmap_outline_tree( root_title: &str, outline: &[MindmapOutlineItemPayload], page_link_pattern: &str, ) -> Result { if !page_link_pattern.contains("{page}") { return Err(BridgeError::validation( "mindmap_outline_to_mindmap 的 pageLinkPattern 必须包含 {page}", )); } let mut root = default_mindmap_tree(); root.data.text = Some(root_title.trim().to_string()); let mut latest_paths: Vec> = vec![vec![]]; for item in outline { let level = item.level.clamp(1, 6) as usize; let title = item.title.trim(); if title.is_empty() { continue; } let page = item.page.max(1); let hyperlink = page_link_pattern.replace("{page}", &page.to_string()); let node = MindmapTreeNode { data: MindmapNodeData { uid: Some(next_mindmap_uid()), text: Some(title.to_string()), hyperlink: Some(hyperlink.clone()), note: None, refs: Some(vec![MindmapNodeRef { kind: "pdf".into(), asset_id: None, file_url: Some(hyperlink), page: Some(page), slide: None, title: Some(title.to_string()), snippet: None, }]), extra: BTreeMap::new(), }, children: vec![], extra: BTreeMap::new(), }; if latest_paths.len() > level + 1 { latest_paths.truncate(level + 1); } while latest_paths.len() <= level { latest_paths.push(vec![]); } let parent_path = if level == 1 { vec![] } else { latest_paths.get(level - 1).cloned().unwrap_or_default() }; let parent = mindmap_node_mut_by_path(&mut root, &parent_path) .ok_or_else(|| BridgeError::validation("mindmap_outline_to_mindmap 树路径异常"))?; parent.children.push(node); let next_index = parent.children.len().saturating_sub(1); let mut node_path = parent_path; node_path.push(next_index); latest_paths[level] = node_path; } ensure_mindmap_uids(&mut root); Ok(root) } fn build_mindmap_child(node: &MindmapNodeInput) -> MindmapTreeNode { let mut child = MindmapTreeNode { data: MindmapNodeData { uid: node.uid.clone(), text: Some(node.text.clone()), hyperlink: node.hyperlink.clone(), note: node.note.clone(), refs: node.refs.clone(), extra: BTreeMap::new(), }, children: vec![], extra: BTreeMap::new(), }; ensure_mindmap_uids(&mut child); child } fn add_mindmap_child( root: &mut MindmapTreeNode, parent_uid: &str, node: &MindmapNodeInput, ) -> Result { if let Some(parent) = find_mindmap_node_mut(root, parent_uid) { parent.children.push(build_mindmap_child(node)); return Ok(true); } Ok(false) } fn add_mindmap_sibling_after( root: &mut MindmapTreeNode, target_uid: &str, node: &MindmapNodeInput, ) -> Result { insert_mindmap_sibling_after(&mut root.children, target_uid, node) } fn insert_mindmap_sibling_after( nodes: &mut Vec, target_uid: &str, node: &MindmapNodeInput, ) -> Result { for index in 0..nodes.len() { if nodes[index].data.uid.as_deref() == Some(target_uid) { nodes.insert(index + 1, build_mindmap_child(node)); return Ok(true); } if insert_mindmap_sibling_after(&mut nodes[index].children, target_uid, node)? { return Ok(true); } } Ok(false) } fn update_mindmap_text(root: &mut MindmapTreeNode, uid: &str, text: &str) -> Result { if let Some(node) = find_mindmap_node_mut(root, uid) { node.data.text = Some(text.to_string()); return Ok(true); } Ok(false) } fn set_mindmap_hyperlink( root: &mut MindmapTreeNode, uid: &str, hyperlink: Option, ) -> Result { if let Some(node) = find_mindmap_node_mut(root, uid) { node.data.hyperlink = hyperlink; return Ok(true); } Ok(false) } fn set_mindmap_refs( root: &mut MindmapTreeNode, uid: &str, refs: Vec, ) -> Result { if let Some(node) = find_mindmap_node_mut(root, uid) { node.data.refs = Some(refs); return Ok(true); } Ok(false) } fn append_mindmap_note( root: &mut MindmapTreeNode, uid: &str, markdown: &str, ) -> Result { if let Some(node) = find_mindmap_node_mut(root, uid) { let next = if let Some(current) = node.data.note.as_ref() { if current.trim().is_empty() { markdown.to_string() } else { format!("{current}\n\n{markdown}") } } else { markdown.to_string() }; node.data.note = Some(next); return Ok(true); } Ok(false) } fn delete_mindmap_node(root: &mut MindmapTreeNode, uid: &str) -> Result { if root.data.uid.as_deref() == Some(uid) { return Err("deleteNode: 不能删除根节点".into()); } Ok(delete_mindmap_node_in_children(&mut root.children, uid)) } fn delete_mindmap_node_in_children(nodes: &mut Vec, uid: &str) -> bool { if let Some(index) = nodes .iter() .position(|node| node.data.uid.as_deref() == Some(uid)) { nodes.remove(index); return true; } for child in nodes.iter_mut() { if delete_mindmap_node_in_children(&mut child.children, uid) { return true; } } false } fn record_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { value.as_object().and_then(|map| map.get(key)) } fn string_field(value: &Value, key: &str) -> Option { record_field(value, key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn bool_field(value: &Value, key: &str) -> Option { record_field(value, key).and_then(Value::as_bool) } fn integer_field(value: &Value, key: &str) -> Option { record_field(value, key).and_then(|field| { field .as_i64() .or_else(|| field.as_f64().map(|number| number as i64)) }) } fn is_pdf_asset(file_name: &str, mime_type: &str) -> bool { let lowered_mime = mime_type.trim().to_ascii_lowercase(); let lowered_name = file_name.trim().to_ascii_lowercase(); lowered_mime.contains("pdf") || lowered_name.ends_with(".pdf") } fn is_book_asset(file_name: &str, mime_type: &str) -> bool { let lowered_mime = mime_type.trim().to_ascii_lowercase(); let lowered_name = file_name.trim().to_ascii_lowercase(); lowered_mime.contains("epub") || lowered_mime.contains("ebook") || [".epub", ".mobi", ".azw3", ".azw", ".fb2"] .iter() .any(|suffix| lowered_name.ends_with(suffix)) } fn is_onlyoffice_asset(file_name: &str, mime_type: &str) -> bool { let lowered_mime = mime_type.trim().to_ascii_lowercase(); let lowered_name = file_name.trim().to_ascii_lowercase(); lowered_mime.contains("officedocument") || lowered_mime.contains("msword") || lowered_mime.contains("ms-excel") || lowered_mime.contains("ms-powerpoint") || [ ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ] .iter() .any(|suffix| lowered_name.ends_with(suffix)) } fn is_code_asset(file_name: &str, mime_type: &str) -> bool { let lowered_mime = mime_type.trim().to_ascii_lowercase(); let lowered_name = file_name.trim().to_ascii_lowercase(); lowered_mime.starts_with("text/") && [ ".c", ".cc", ".cpp", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx", ".json", ".kt", ".lua", ".md", ".py", ".rs", ".sh", ".sql", ".toml", ".ts", ".tsx", ".xml", ".yaml", ".yml", ] .iter() .any(|suffix| lowered_name.ends_with(suffix)) } fn classify_asset_kind( asset_type: &str, file_name: &str, mime_type: &str, ) -> KernelProjectionAssetKind { let lowered_type = asset_type.trim().to_ascii_lowercase(); let lowered_mime = mime_type.trim().to_ascii_lowercase(); if lowered_type == "mindmap" { return KernelProjectionAssetKind::Mindmap; } if lowered_type == "table" { return KernelProjectionAssetKind::Table; } if is_pdf_asset(file_name, mime_type) { return KernelProjectionAssetKind::Pdf; } if is_book_asset(file_name, mime_type) { return KernelProjectionAssetKind::Book; } if lowered_mime.starts_with("image/") { return KernelProjectionAssetKind::Image; } if lowered_mime.starts_with("video/") { return KernelProjectionAssetKind::Video; } if lowered_mime.starts_with("audio/") { return KernelProjectionAssetKind::Audio; } if lowered_type == "file" || lowered_type.is_empty() { return KernelProjectionAssetKind::File; } KernelProjectionAssetKind::Unknown } fn classify_asset_resource_kind( asset_kind: &KernelProjectionAssetKind, file_name: &str, mime_type: &str, ) -> KernelProjectionResourceKind { if matches!(asset_kind, KernelProjectionAssetKind::File) { if is_onlyoffice_asset(file_name, mime_type) { return KernelProjectionResourceKind::OnlyOffice; } if is_code_asset(file_name, mime_type) { return KernelProjectionResourceKind::Code; } } match asset_kind { KernelProjectionAssetKind::Mindmap => KernelProjectionResourceKind::Mindmap, KernelProjectionAssetKind::Table => KernelProjectionResourceKind::Table, KernelProjectionAssetKind::Book => KernelProjectionResourceKind::Book, KernelProjectionAssetKind::Pdf => KernelProjectionResourceKind::Pdf, _ => KernelProjectionResourceKind::Asset, } } fn classify_asset_node_type(asset_kind: &KernelProjectionAssetKind) -> KernelNodeType { match asset_kind { KernelProjectionAssetKind::Mindmap => KernelNodeType::Mindmap, KernelProjectionAssetKind::Table => KernelNodeType::Table, KernelProjectionAssetKind::Book => KernelNodeType::Book, KernelProjectionAssetKind::Pdf => KernelNodeType::Pdf, _ => KernelNodeType::Asset, } } fn classify_asset_icon_hint(asset_kind: &KernelProjectionAssetKind) -> &'static str { match asset_kind { KernelProjectionAssetKind::Mindmap => "mindmap", KernelProjectionAssetKind::Table => "table", KernelProjectionAssetKind::Book => "book", KernelProjectionAssetKind::Pdf => "pdf", KernelProjectionAssetKind::Image => "image", KernelProjectionAssetKind::Video => "video", KernelProjectionAssetKind::Audio => "audio", _ => "file", } } fn build_projection_row_id( projection: &KernelProjectionKind, row_kind: &KernelProjectionRowKind, node_id: &str, ) -> String { match projection { KernelProjectionKind::FileTree => match row_kind { KernelProjectionRowKind::Document => format!("doc:{node_id}"), KernelProjectionRowKind::Index => format!("index:{node_id}"), KernelProjectionRowKind::Asset => format!("asset:{node_id}"), KernelProjectionRowKind::AssetFolder => format!("asset-folder:{node_id}"), }, _ => format!("page:{node_id}"), } } fn build_document_projection_capabilities(child_count: u32) -> Vec { let mut capabilities = vec![ KernelProjectionCapability::Open, KernelProjectionCapability::Drag, KernelProjectionCapability::Drop, KernelProjectionCapability::Select, KernelProjectionCapability::CreateChild, KernelProjectionCapability::Rename, KernelProjectionCapability::Archive, KernelProjectionCapability::Restore, KernelProjectionCapability::ContextMenu, KernelProjectionCapability::Reorder, ]; if child_count > 0 { capabilities.insert(0, KernelProjectionCapability::Expand); } capabilities } fn build_asset_folder_projection_capabilities(child_count: u32) -> Vec { let mut capabilities = vec![ KernelProjectionCapability::OpenAsset, KernelProjectionCapability::Select, KernelProjectionCapability::ContextMenu, ]; if child_count > 0 { capabilities.insert(0, KernelProjectionCapability::Expand); } capabilities } fn normalize_kernel_node_from_record( value: &Value, workspace_id: Option<&str>, ) -> Result { let id = string_field(value, "id") .or_else(|| string_field(value, "documentId")) .ok_or_else(|| BridgeError::validation("kernel node 缺少 id"))?; let title = string_field(value, "title").or_else(|| string_field(value, "text")); let parent_id = string_field(value, "parent_id") .or_else(|| string_field(value, "parentId")) .or_else(|| string_field(value, "document_id")); let updated_at = string_field(value, "updated_at").or_else(|| string_field(value, "updatedAt")); let created_at = string_field(value, "created_at").or_else(|| string_field(value, "createdAt")); let access_scope = string_field(value, "access_scope").or_else(|| string_field(value, "accessScope")); let is_mindmap = string_field(value, "asset_type") .map(|asset_type| asset_type == "mindmap") .unwrap_or(false); let node_type = if is_mindmap { KernelNodeType::Mindmap } else if bool_field(value, "is_template").unwrap_or(false) { KernelNodeType::Page } else if value.get("content").is_some() || value.get("rawText").is_some() { KernelNodeType::ContentNode } else { KernelNodeType::Page }; let mut extra = BTreeMap::new(); if let Some(access_scope) = access_scope { extra.insert("accessScope".into(), json!(access_scope)); } if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) { extra.insert("isStarred".into(), json!(is_starred)); } if let Some(sort_order) = integer_field(value, "sort_order").or_else(|| integer_field(value, "sortOrder")) { extra.insert("sortOrder".into(), json!(sort_order)); } let content = value .get("content") .cloned() .or_else(|| value.get("rawText").cloned()) .map(|body| KernelContentPayload { format: if body.is_string() { "text".into() } else { "json".into() }, body, }); Ok(KernelNode { id: id.clone(), node_type, workspace_id: workspace_id .map(ToOwned::to_owned) .or_else(|| string_field(value, "workspace_id")) .or_else(|| string_field(value, "workspaceId")), parent_id, subtree: Some(KernelSubtreeRef { root_node_id: id.clone(), path: vec![id.clone()], depth: Some(0), }), metadata: KernelNodeMetadata { title, icon: None, tags: Vec::new(), created_at, updated_at, extra, }, content, refs: None, audit: KernelAuditStamp::default(), }) } fn build_sidebar_kernel_nodes( data: &Value, workspace_id: Option<&str>, ) -> Result, BridgeError> { let documents = data .get("documents") .and_then(Value::as_array) .cloned() .unwrap_or_default(); documents .iter() .map(|item| normalize_kernel_node_from_record(item, workspace_id)) .collect::, _>>() } fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec { let mut edges = Vec::new(); for node in nodes { if let Some(parent_id) = node.parent_id.as_ref() { edges.push(KernelEdge { id: format!("edge_parent_of_{}_{}", parent_id, node.id), edge_type: KernelEdgeType::ParentOf, workspace_id: node.workspace_id.clone(), from_node_id: parent_id.clone(), to_node_id: node.id.clone(), metadata: BTreeMap::new(), audit: KernelAuditStamp::default(), }); if let Some(artifact_type) = ai_artifact_type_for_node(node) { edges.push(KernelEdge { id: format!("edge_ai_artifact_reference_{}_{}", parent_id, node.id), edge_type: KernelEdgeType::SourceOf, workspace_id: node.workspace_id.clone(), from_node_id: parent_id.clone(), to_node_id: node.id.clone(), metadata: BTreeMap::from([ ("kind".into(), json!("ai_artifact_reference")), ("artifactType".into(), json!(artifact_type)), ("projectionOnlyGroup".into(), json!("AI Artifacts")), ]), audit: KernelAuditStamp::default(), }); } } } edges } fn ai_artifact_type_for_node(node: &KernelNode) -> Option<&'static str> { let parent_id = node.parent_id.as_ref()?; if node.id == format!("summary_{parent_id}") { return Some("summary"); } if node.id.starts_with(&format!("ai_note_{parent_id}_")) { return Some("ai_note"); } None } fn ai_artifacts_projection_group_meta(edges: &[KernelEdge]) -> Value { let artifact_document_ids = edges .iter() .filter(|edge| { edge.metadata .get("kind") .and_then(Value::as_str) .map(|kind| kind == "ai_artifact_reference") .unwrap_or(false) }) .map(|edge| edge.to_node_id.clone()) .collect::>(); json!({ "title": "AI Artifacts", "projectionOnly": true, "source": "kernel.project_view.synthetic_group", "kernelNodeId": Value::Null, "edgeKind": "ai_artifact_reference", "artifactDocumentIds": artifact_document_ids, }) } fn kernel_node_sort_order(node: &KernelNode) -> i64 { node.metadata .extra .get("sortOrder") .and_then(Value::as_i64) .unwrap_or(0) } fn file_tree_leaf_capabilities(open_asset: bool) -> Vec { let mut capabilities = vec![ KernelProjectionCapability::Select, KernelProjectionCapability::ContextMenu, ]; if open_asset { capabilities.insert(0, KernelProjectionCapability::OpenAsset); } else { capabilities.insert(0, KernelProjectionCapability::Open); } capabilities } fn make_projection_edge( parent_node_id: &str, child_node_id: &str, workspace_id: Option<&str>, ) -> KernelEdge { KernelEdge { id: format!("edge_parent_of_{}_{}", parent_node_id, child_node_id), edge_type: KernelEdgeType::ParentOf, workspace_id: workspace_id.map(ToOwned::to_owned), from_node_id: parent_node_id.to_string(), to_node_id: child_node_id.to_string(), metadata: BTreeMap::new(), audit: KernelAuditStamp::default(), } } fn convex_projection_source_metadata( document_id: Option<&str>, asset_id: Option<&str>, workspace_id: Option<&str>, row_kind: &str, ) -> Option { let workspace_id = workspace_id?.trim(); if workspace_id.is_empty() { return None; } match row_kind { "document" => { let document_id = document_id?.trim(); if document_id.is_empty() { return None; } Some(json!({ "sourceKind": "convex_workspace", "sourceUri": format!("convex://workspace/{workspace_id}/documents/{document_id}"), "relativePath": format!("documents/{document_id}"), "storageIdentity": document_id, "operationProfile": "convex_workspace", })) } "index" => { let document_id = document_id?.trim(); if document_id.is_empty() { return None; } Some(json!({ "sourceKind": "convex_workspace", "sourceUri": format!("convex://workspace/{workspace_id}/documents/{document_id}/index"), "relativePath": format!("documents/{document_id}/index"), "storageIdentity": format!("{document_id}:index"), "operationProfile": "convex_workspace", })) } "asset" | "asset_folder" => { let asset_id = asset_id?.trim(); if asset_id.is_empty() { return None; } Some(json!({ "sourceKind": "convex_workspace", "sourceUri": format!("convex://workspace/{workspace_id}/assets/{asset_id}"), "relativePath": format!("assets/{asset_id}"), "storageIdentity": asset_id, "operationProfile": "convex_workspace", })) } _ => None, } } fn make_projection_resource_meta( resource_kind: KernelProjectionResourceKind, document_id: Option, asset_id: Option, block_id: Option, workspace_id: Option, asset_kind: Option, icon_hint: &str, row_kind: &str, ) -> KernelProjectionResourceMeta { let mut extra = BTreeMap::new(); extra.insert("rowKind".into(), json!(row_kind)); if let Some(source) = convex_projection_source_metadata( document_id.as_deref(), asset_id.as_deref(), workspace_id.as_deref(), row_kind, ) { extra.insert("source".into(), source); } let object_kind = match (&resource_kind, &asset_kind, asset_id.as_ref()) { (KernelProjectionResourceKind::Document, _, _) => KernelObjectKind::Page, (KernelProjectionResourceKind::Index, _, _) => KernelObjectKind::Index, (KernelProjectionResourceKind::Mindmap, _, _) => KernelObjectKind::Mindmap, (KernelProjectionResourceKind::OnlyOffice, _, _) => KernelObjectKind::OnlyOffice, (KernelProjectionResourceKind::Code, _, _) => KernelObjectKind::Code, (_, Some(KernelProjectionAssetKind::Mindmap), _) => KernelObjectKind::Mindmap, (_, _, Some(_)) => KernelObjectKind::Attachment, _ => KernelObjectKind::Page, }; let object_identity = Some(KernelObjectIdentity { object_kind, document_id: document_id.clone(), block_id: block_id.clone(), asset_id: asset_id.clone(), }); let block_asset_relation = match ( document_id.clone(), block_id, asset_id.clone(), asset_kind.clone(), ) { (Some(document_id), Some(block_id), Some(asset_id), Some(asset_kind)) => { Some(KernelBlockAssetRelation { document_id, block_id, asset_id, asset_kind, }) } _ => None, }; KernelProjectionResourceMeta { resource_kind: Some(resource_kind), document_id, asset_id, workspace_id, asset_kind, icon_hint: Some(icon_hint.into()), object_identity, block_asset_relation, extra, } } #[derive(Debug, Clone)] struct NormalizedFileTreeAsset { id: String, document_id: String, block_id: Option, workspace_id: Option, title: String, resource_kind: KernelProjectionResourceKind, asset_kind: KernelProjectionAssetKind, icon_hint: &'static str, } fn infer_file_tree_asset_shape( asset_type: &str, file_name: &str, mime_type: &str, ) -> ( KernelProjectionResourceKind, KernelProjectionAssetKind, &'static str, ) { let asset_kind = classify_asset_kind(asset_type, file_name, mime_type); ( classify_asset_resource_kind(&asset_kind, file_name, mime_type), asset_kind.clone(), classify_asset_icon_hint(&asset_kind), ) } fn normalize_file_tree_asset(value: &Value) -> Option { let id = string_field(value, "id")?; let document_id = string_field(value, "document_id").or_else(|| string_field(value, "documentId"))?; let file_name = string_field(value, "file_name") .or_else(|| string_field(value, "title")) .unwrap_or_else(|| "附件".into()); let mime_type = string_field(value, "mime_type").unwrap_or_default(); let asset_type = string_field(value, "asset_type").unwrap_or_else(|| "file".into()); let (resource_kind, asset_kind, icon_hint) = infer_file_tree_asset_shape(&asset_type, &file_name, &mime_type); Some(NormalizedFileTreeAsset { id, document_id, block_id: string_field(value, "block_id").or_else(|| string_field(value, "blockId")), workspace_id: string_field(value, "workspace_id") .or_else(|| string_field(value, "workspaceId")), title: file_name, resource_kind, asset_kind, icon_hint, }) } fn build_file_tree_assets( data: &Value, ) -> ( BTreeMap>, BTreeMap, BTreeMap>, ) { let mut assets_by_doc = BTreeMap::>::new(); let mut asset_by_id = BTreeMap::::new(); let mut seen = BTreeMap::::new(); for key in ["media_assets", "mindmap_assets", "table_assets"] { for value in data .get(key) .and_then(Value::as_array) .cloned() .unwrap_or_default() { let Some(asset) = normalize_file_tree_asset(&value) else { continue; }; if seen.insert(asset.id.clone(), true).is_some() { continue; } assets_by_doc .entry(asset.document_id.clone()) .or_default() .push(asset.clone()); asset_by_id.insert(asset.id.clone(), asset); } } let child_map = data .get("mindmap_asset_children") .and_then(Value::as_object) .map(|object| { object .iter() .map(|(asset_id, children)| { let child_ids = children .as_array() .cloned() .unwrap_or_default() .into_iter() .filter_map(|value| value.as_str().map(ToOwned::to_owned)) .collect::>(); (asset_id.clone(), child_ids) }) .collect::>() }) .unwrap_or_default(); (assets_by_doc, asset_by_id, child_map) } fn build_file_tree_projection_result( subtree: &KernelSubtreeResult, _workspace_id: Option<&str>, root_node_id: Option<&str>, data: &Value, filters: &KernelProjectionFilter, ) -> KernelProjectionResult { let (assets_by_doc, asset_by_id, child_assets_by_parent) = build_file_tree_assets(data); let requested_query = normalize_projection_query(filters.query.as_deref()); let requested_max_results = filters.max_results; let page_ids = subtree .nodes .iter() .map(|node| (node.id.clone(), node.clone())) .collect::>(); let mut child_pages = BTreeMap::>::new(); for node in &subtree.nodes { if let Some(parent_id) = node.parent_id.as_ref() { child_pages .entry(parent_id.clone()) .or_default() .push(node.id.clone()); } } for ids in child_pages.values_mut() { ids.sort_by_key(|node_id| { page_ids .get(node_id) .map(kernel_node_sort_order) .unwrap_or_default() }); } let mut roots = if let Some(root_node_id) = root_node_id { vec![root_node_id.to_string()] } else { subtree .nodes .iter() .filter(|node| { node.parent_id .as_ref() .map(|parent_id| !page_ids.contains_key(parent_id)) .unwrap_or(true) }) .map(|node| node.id.clone()) .collect::>() }; roots.sort_by_key(|node_id| { page_ids .get(node_id) .map(kernel_node_sort_order) .unwrap_or_default() }); let mut items = Vec::::new(); let mut edges = subtree.edges.clone(); fn walk_page( page_id: &str, depth: u32, page_ids: &BTreeMap, child_pages: &BTreeMap>, assets_by_doc: &BTreeMap>, asset_by_id: &BTreeMap, child_assets_by_parent: &BTreeMap>, items: &mut Vec, edges: &mut Vec, ) { let Some(node) = page_ids.get(page_id) else { return; }; let direct_child_pages = child_pages.get(page_id).cloned().unwrap_or_default(); let doc_assets = assets_by_doc.get(page_id).cloned().unwrap_or_default(); let mut nested_child_asset_ids = BTreeMap::::new(); for asset in &doc_assets { if let Some(child_ids) = child_assets_by_parent.get(&asset.id) { for child_id in child_ids { nested_child_asset_ids.insert(child_id.clone(), true); } } } let direct_asset_count = doc_assets .iter() .filter(|asset| !nested_child_asset_ids.contains_key(&asset.id)) .count() as u32; let doc_child_count = direct_asset_count + direct_child_pages.len() as u32; let parent_node_id = if depth == 0 { None } else { node.parent_id.clone() }; let workspace_id = node.workspace_id.clone(); let page_markdown_title = node .metadata .title .as_deref() .map(|title| { let trimmed = title.trim(); if trimmed.ends_with(".md") { trimmed.to_string() } else { format!("{trimmed}.md") } }) .unwrap_or_else(|| "无标题.md".into()); items.push(KernelProjectionItem { row_id: build_projection_row_id( &KernelProjectionKind::FileTree, &KernelProjectionRowKind::Document, &node.id, ), row_kind: KernelProjectionRowKind::Document, node_id: node.id.clone(), parent_node_id, node_type: KernelNodeType::Page, projection_kind: KernelProjectionKind::FileTree, title: Some(page_markdown_title), depth, position: Some(kernel_node_sort_order(node)), child_count: doc_child_count, expandable: doc_child_count > 0, expanded_by_default: true, capabilities: build_document_projection_capabilities(doc_child_count), resource_meta: Some(make_projection_resource_meta( KernelProjectionResourceKind::Document, Some(node.id.clone()), None, None, workspace_id.clone(), None, "page", "document", )), icon_hint: Some("page".into()), }); let mut asset_position = 0i64; for asset in doc_assets { if nested_child_asset_ids.contains_key(&asset.id) { continue; } if let Some(child_ids) = child_assets_by_parent .get(&asset.id) .filter(|ids| !ids.is_empty()) { let folder_node_id = format!("asset-folder:{}", asset.id); items.push(KernelProjectionItem { row_id: build_projection_row_id( &KernelProjectionKind::FileTree, &KernelProjectionRowKind::AssetFolder, &asset.id, ), row_kind: KernelProjectionRowKind::AssetFolder, node_id: folder_node_id.clone(), parent_node_id: Some(node.id.clone()), node_type: KernelNodeType::Mindmap, projection_kind: KernelProjectionKind::FileTree, title: Some(asset.title.trim_end_matches(".json").to_string()), depth: depth + 1, position: Some(asset_position), child_count: child_ids.len() as u32, expandable: true, expanded_by_default: false, capabilities: build_asset_folder_projection_capabilities(child_ids.len() as u32), resource_meta: Some(make_projection_resource_meta( asset.resource_kind.clone(), Some(asset.document_id.clone()), Some(asset.id.clone()), asset.block_id.clone(), asset.workspace_id.clone(), Some(KernelProjectionAssetKind::Mindmap), "mindmap", "asset_folder", )), icon_hint: Some("mindmap".into()), }); edges.push(make_projection_edge( &node.id, &folder_node_id, workspace_id.as_deref(), )); for (child_index, child_id) in child_ids.iter().enumerate() { let Some(child_asset) = asset_by_id.get(child_id) else { continue; }; let child_node_id = format!("asset:{}", child_asset.id); items.push(KernelProjectionItem { row_id: build_projection_row_id( &KernelProjectionKind::FileTree, &KernelProjectionRowKind::Asset, &child_asset.id, ), row_kind: KernelProjectionRowKind::Asset, node_id: child_node_id.clone(), parent_node_id: Some(folder_node_id.clone()), node_type: KernelNodeType::Asset, projection_kind: KernelProjectionKind::FileTree, title: Some(child_asset.title.clone()), depth: depth + 2, position: Some(child_index as i64), child_count: 0, expandable: false, expanded_by_default: false, capabilities: file_tree_leaf_capabilities(true), resource_meta: Some(make_projection_resource_meta( child_asset.resource_kind.clone(), Some(child_asset.document_id.clone()), Some(child_asset.id.clone()), child_asset.block_id.clone(), child_asset.workspace_id.clone(), Some(child_asset.asset_kind.clone()), child_asset.icon_hint, "asset", )), icon_hint: Some(child_asset.icon_hint.into()), }); edges.push(make_projection_edge( &folder_node_id, &child_node_id, workspace_id.as_deref(), )); } asset_position += 1; continue; } let asset_node_id = format!("asset:{}", asset.id); items.push(KernelProjectionItem { row_id: build_projection_row_id( &KernelProjectionKind::FileTree, &KernelProjectionRowKind::Asset, &asset.id, ), row_kind: KernelProjectionRowKind::Asset, node_id: asset_node_id.clone(), parent_node_id: Some(node.id.clone()), node_type: classify_asset_node_type(&asset.asset_kind), projection_kind: KernelProjectionKind::FileTree, title: Some(asset.title.clone()), depth: depth + 1, position: Some(asset_position), child_count: 0, expandable: false, expanded_by_default: false, capabilities: file_tree_leaf_capabilities(true), resource_meta: Some(make_projection_resource_meta( asset.resource_kind.clone(), Some(asset.document_id.clone()), Some(asset.id.clone()), asset.block_id.clone(), asset.workspace_id.clone(), Some(asset.asset_kind.clone()), asset.icon_hint, "asset", )), icon_hint: Some(asset.icon_hint.into()), }); edges.push(make_projection_edge( &node.id, &asset_node_id, workspace_id.as_deref(), )); asset_position += 1; } for child_page_id in direct_child_pages { walk_page( &child_page_id, depth + 1, page_ids, child_pages, assets_by_doc, asset_by_id, child_assets_by_parent, items, edges, ); } } for root_id in roots { walk_page( &root_id, 0, &page_ids, &child_pages, &assets_by_doc, &asset_by_id, &child_assets_by_parent, &mut items, &mut edges, ); } apply_file_tree_projection_search_filter(&mut items, &mut edges, filters); let visible_rows = items.len(); let visible_edges = edges.len(); let mut meta = BTreeMap::from([( "search".into(), json!({ "query": requested_query.clone(), "maxResults": requested_max_results, "maxResultsRule": "matches_only_before_ancestor_completion", "ancestorCompletion": "include_all_ancestors_after_match_truncation", "ordering": "kernel_file_tree_preorder", "indexingVisibility": { "schema": "mnote.file_tree.indexing_visibility", "schemaVersion": 1, "source": "kernel.project_view", "status": "visible", "requestKey": requested_query .as_ref() .map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))), "indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"], "visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"], "metrics": { "visibleRows": visible_rows, "visibleEdges": visible_edges, }, }, }), )]); meta.insert( "aiArtifacts".into(), ai_artifacts_projection_group_meta(&edges), ); KernelProjectionResult { projection_id: format!( "kernel_projection:file_tree:{}", root_node_id.unwrap_or("root") ), projection: KernelProjectionKind::FileTree, root_node_id: root_node_id.map(ToOwned::to_owned), items, edges, meta, } } fn normalize_projection_query(raw: Option<&str>) -> Option { let query = raw?.trim().to_lowercase(); if query.is_empty() { None } else { Some(query) } } fn file_tree_item_matches_query(item: &KernelProjectionItem, query: &str) -> bool { let title = item.title.as_deref().unwrap_or("").to_lowercase(); if title.contains(query) { return true; } item.resource_meta.as_ref().is_some_and(|meta| { meta.document_id .as_deref() .map(|value| value.to_lowercase().contains(query)) .unwrap_or(false) || meta .asset_id .as_deref() .map(|value| value.to_lowercase().contains(query)) .unwrap_or(false) }) } fn apply_file_tree_projection_search_filter( items: &mut Vec, edges: &mut Vec, filters: &KernelProjectionFilter, ) { let Some(query) = normalize_projection_query(filters.query.as_deref()) else { return; }; let parent_by_node_id = items .iter() .filter_map(|item| { item.parent_node_id .as_ref() .map(|parent_id| (item.node_id.clone(), parent_id.clone())) }) .collect::>(); let mut include = BTreeMap::::new(); let mut matched_count = 0usize; for item in items.iter() { if !file_tree_item_matches_query(item, &query) { continue; } if let Some(max_results) = filters.max_results { if matched_count >= max_results { continue; } } matched_count += 1; include.insert(item.node_id.clone(), true); let mut current = item.parent_node_id.clone(); while let Some(parent_id) = current { include.insert(parent_id.clone(), true); current = parent_by_node_id.get(&parent_id).cloned(); } } items.retain(|item| include.contains_key(&item.node_id)); for item in items.iter_mut() { if item.expandable { item.expanded_by_default = true; } } edges.retain(|edge| { include.contains_key(&edge.from_node_id) && include.contains_key(&edge.to_node_id) }); } fn build_kernel_subtree_result( data: &Value, root_node_id: &str, workspace_id: Option<&str>, depth: Option, ) -> Result { let mut all_nodes = build_sidebar_kernel_nodes(data, workspace_id)?; let max_depth = depth.unwrap_or(u32::MAX); let mut by_parent = BTreeMap::, Vec>::new(); for node in &all_nodes { by_parent .entry(node.parent_id.clone()) .or_default() .push(node.id.clone()); } let mut queue = VecDeque::from([(root_node_id.to_string(), 0u32)]); let mut visited = BTreeMap::::new(); while let Some((node_id, current_depth)) = queue.pop_front() { if visited.contains_key(&node_id) || current_depth > max_depth { continue; } visited.insert(node_id.clone(), current_depth); if let Some(children) = by_parent.get(&Some(node_id.clone())) { for child in children { queue.push_back((child.clone(), current_depth + 1)); } } } let nodes = all_nodes .iter_mut() .filter_map(|node| { visited.get(&node.id).copied().map(|node_depth| { node.subtree = Some(KernelSubtreeRef { root_node_id: root_node_id.to_string(), path: vec![root_node_id.to_string(), node.id.clone()], depth: Some(node_depth), }); node.clone() }) }) .collect::>(); let edges = build_sidebar_kernel_edges(&nodes); Ok(KernelSubtreeResult { root_node_id: root_node_id.to_string(), nodes, edges, }) } fn build_kernel_edge_list_result( data: &Value, node_id: &str, workspace_id: Option<&str>, ) -> Result { let nodes = build_sidebar_kernel_nodes(data, workspace_id)?; let edges = build_sidebar_kernel_edges(&nodes) .into_iter() .filter(|edge| edge.from_node_id == node_id || edge.to_node_id == node_id) .collect::>(); Ok(KernelEdgeListResult { node_id: node_id.to_string(), edges, }) } fn build_kernel_graph_traversal_result( data: &Value, start_node_id: &str, workspace_id: Option<&str>, max_depth: u32, ) -> Result { let subtree = build_kernel_subtree_result(data, start_node_id, workspace_id, Some(max_depth))?; let mut visits = Vec::new(); let mut queue = VecDeque::from([(start_node_id.to_string(), 0u32)]); let parent_map = subtree .nodes .iter() .map(|node| (node.id.clone(), node.parent_id.clone())) .collect::>(); let mut seen = BTreeMap::::new(); while let Some((node_id, depth)) = queue.pop_front() { if seen.insert(node_id.clone(), true).is_some() || depth > max_depth { continue; } visits.push(KernelGraphVisit { node_id: node_id.clone(), depth, }); for (candidate_id, parent_id) in &parent_map { if parent_id.as_deref() == Some(node_id.as_str()) { queue.push_back((candidate_id.clone(), depth + 1)); } } } Ok(KernelGraphTraversalResult { start_node_id: start_node_id.to_string(), visited: visits, edges: subtree.edges, }) } fn build_kernel_projection_result( data: &Value, projection: KernelProjectionKind, root_node_id: Option<&str>, workspace_id: Option<&str>, depth: Option, filters: KernelProjectionFilter, ) -> Result { let subtree = if let Some(root_node_id) = root_node_id { build_kernel_subtree_result(data, root_node_id, workspace_id, depth)? } else { let mut nodes = build_sidebar_kernel_nodes(data, workspace_id)?; for node in &mut nodes { node.subtree = Some(KernelSubtreeRef { root_node_id: node.id.clone(), path: vec![node.id.clone()], depth: Some(0), }); } let edges = build_sidebar_kernel_edges(&nodes); KernelSubtreeResult { root_node_id: "workspace_root".into(), nodes, edges, } }; if projection == KernelProjectionKind::FileTree { return Ok(build_file_tree_projection_result( &subtree, workspace_id, root_node_id, data, &filters, )); } let items = subtree .nodes .iter() .map(|node| { let child_count = subtree .edges .iter() .filter(|edge| { edge.from_node_id == node.id && edge.edge_type == KernelEdgeType::ParentOf }) .count() as u32; KernelProjectionItem { row_id: build_projection_row_id( &projection, &KernelProjectionRowKind::Document, &node.id, ), row_kind: KernelProjectionRowKind::Document, node_id: node.id.clone(), parent_node_id: node.parent_id.clone(), node_type: node.node_type.clone(), projection_kind: projection.clone(), title: node.metadata.title.clone(), depth: node .subtree .as_ref() .and_then(|subtree| subtree.depth) .unwrap_or(0), position: node.metadata.extra.get("sortOrder").and_then(Value::as_i64), child_count, expandable: child_count > 0, expanded_by_default: true, capabilities: build_document_projection_capabilities(child_count), resource_meta: Some(make_projection_resource_meta( KernelProjectionResourceKind::Document, Some(node.id.clone()), None, None, node.workspace_id.clone(), None, "page", "document", )), icon_hint: Some("page".into()), } }) .collect::>(); Ok(KernelProjectionResult { projection_id: format!( "kernel_projection:{}:{}", match projection { KernelProjectionKind::SidebarTree => "sidebar_tree", KernelProjectionKind::PageTree => "page_tree", KernelProjectionKind::FileTree => "file_tree", KernelProjectionKind::Mindmap => "mindmap", KernelProjectionKind::ReadView => "read_view", KernelProjectionKind::SearchResults => "search_results", KernelProjectionKind::RagIndex => "rag_index", }, root_node_id.unwrap_or("root") ), projection, root_node_id: root_node_id.map(ToOwned::to_owned), items, edges: subtree.edges, meta: BTreeMap::new(), }) } fn find_mindmap_node_mut<'a>( node: &'a mut MindmapTreeNode, uid: &str, ) -> Option<&'a mut MindmapTreeNode> { if node.data.uid.as_deref() == Some(uid) { return Some(node); } for child in &mut node.children { if let Some(found) = find_mindmap_node_mut(child, uid) { return Some(found); } } None } fn execute_query_result( _context_wire: RuntimeBridgeContextWire, query_wire: RuntimeQueryEnvelopeWire, data: Value, ) -> Result { match query_wire.name.as_str() { "documents.content.get" => { let payload: DocumentContentQueryPayload = parse_payload(query_wire.payload)?; let result = build_document_content_result( &data, &payload.document_id, payload.workspace_id.as_deref(), )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("documents.content.get result 序列化失败: {error}")) }) } "page.aggregate.get" => { let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?; let source = page_aggregate_source_for_data(&data); let result = build_page_aggregate_projection_result( &data, &payload.document_id, payload.workspace_id.as_deref(), source, )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}")) }) } "kernel.node.get" => { let payload: KernelGetNodeQueryPayload = parse_payload(query_wire.payload)?; let node = normalize_kernel_node_from_record(&data, payload.workspace_id.as_deref())?; serde_json::to_value(node).map_err(|error| { BridgeError::transport(format!("kernel.node.get result 序列化失败: {error}")) }) } "kernel.subtree.get" => { let payload: KernelGetSubtreeQueryPayload = parse_payload(query_wire.payload)?; let result = build_kernel_subtree_result( &data, &payload.root_node_id, payload.workspace_id.as_deref(), payload.depth, )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("kernel.subtree.get result 序列化失败: {error}")) }) } "kernel.children.list" => { let payload: KernelListChildrenQueryPayload = parse_payload(query_wire.payload)?; let subtree = build_kernel_subtree_result( &data, &payload.parent_node_id, payload.workspace_id.as_deref(), Some(1), )?; let nodes = subtree .nodes .into_iter() .filter(|node| node.parent_id.as_deref() == Some(payload.parent_node_id.as_str())) .collect::>(); serde_json::to_value(nodes).map_err(|error| { BridgeError::transport(format!("kernel.children.list result 序列化失败: {error}")) }) } "kernel.edges.list" => { let payload: KernelListEdgesQueryPayload = parse_payload(query_wire.payload)?; let result = build_kernel_edge_list_result( &data, &payload.node_id, payload.workspace_id.as_deref(), )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("kernel.edges.list result 序列化失败: {error}")) }) } "kernel.graph.traverse" => { let payload: KernelTraverseGraphQueryPayload = parse_payload(query_wire.payload)?; let result = build_kernel_graph_traversal_result( &data, &payload.start_node_id, payload.workspace_id.as_deref(), payload.max_depth.unwrap_or(2), )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("kernel.graph.traverse result 序列化失败: {error}")) }) } "kernel.project_view" => { let payload: KernelProjectViewQueryPayload = parse_payload(query_wire.payload)?; let result = build_kernel_projection_result( &data, payload.projection, payload.root_node_id.as_deref(), payload.workspace_id.as_deref(), payload.depth, KernelProjectionFilter { node_types: payload.node_types.unwrap_or_default(), edge_types: payload.edge_types.unwrap_or_default(), query: payload.query, max_results: payload.max_results, include_deleted: false, }, )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("kernel.project_view result 序列化失败: {error}")) }) } "mindmaps.get" => { let tree = normalize_mindmap_from_value(&data)?; serde_json::to_value(tree).map_err(|error| { BridgeError::transport(format!("mindmaps.get result 序列化失败: {error}")) }) } "mindmap.projection.get" => { let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?; let result = build_mindmap_projection_result(&data, &payload.mindmap_id)?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!("mindmap.projection.get result 序列化失败: {error}")) }) } "mindmap.kernel_projection.get" => { let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?; let result = build_mindmap_kernel_projection_result( &data, &payload.document_id, &payload.mindmap_id, )?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!( "mindmap.kernel_projection.get result 序列化失败: {error}" )) }) } "mindmap.simple_mind_map_scene.get" => { let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?; let result = build_mindmap_adapter_projection_result(&data, &payload.mindmap_id)?; serde_json::to_value(result).map_err(|error| { BridgeError::transport(format!( "mindmap.simple_mind_map_scene.get result 序列化失败: {error}" )) }) } "search.documents" | "search.documents.query" => { let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?; let dataset: SearchDocumentsDataset = serde_json::from_value(data).map_err(|error| { BridgeError::validation(format!( "search.documents dataset 反序列化失败: {error}" )) })?; let result = evaluate_search_documents( &SearchDocumentsRequest { query: payload.query, workspace_id: payload.workspace_id, page_id: payload.page_id, limit: payload.limit.unwrap_or(30) as usize, title_only: payload.title_only.unwrap_or(false), exact: payload.exact.unwrap_or(false), include_ocr: payload.include_ocr.unwrap_or(false), time_range: payload.time_range.unwrap_or_else(|| "any".into()), time_field: payload.time_field.unwrap_or_else(|| "updated".into()), custom_range_from: payload.custom_range_from, custom_range_to: payload.custom_range_to, }, &dataset, ); let legacy_result = SearchDocumentsEvaluation { enqueue_asset_ids: result.enqueue_asset_ids, results: result.results, }; if query_wire.name == "search.documents.query" { serde_json::to_value(search_documents_canonical_projection(legacy_result)) } else { serde_json::to_value(legacy_result) } .map_err(|error| { BridgeError::transport(format!("search.documents result 序列化失败: {error}")) }) } "search.recent" => { let rows = data .as_object() .and_then(|map| map.get("recents")) .and_then(Value::as_array) .cloned() .unwrap_or_default(); let items = rows .into_iter() .filter_map(|row| { let document_id = row.get("documentId")?.as_str()?.trim(); if document_id.is_empty() { return None; } Some(json!({ "documentId": document_id })) }) .collect::>(); Ok(json!({ "items": items })) } "bridge.request.get" | "bridge.trace.get" | "bridge.command.get" | "bridge.workspace.overview" => { normalize_bridge_observability_result(&data, query_wire.name.as_str()) } other => Err(BridgeError::validation(format!( "bridge runtime 暂不支持 query 执行: {other}" ))), } } fn execute_command( context_wire: RuntimeBridgeContextWire, command_wire: RuntimeCommandEnvelopeWire, ) -> Result { let context = to_bridge_context(context_wire); let source_executor = resolve_workspace_command_executor(&command_wire.source)?; if source_executor == WorkspaceCommandExecutor::LocalFolder { return Err(BridgeError::validation( "local_folder command executor 尚未接入 runtime command router", )); } match command_wire.name.as_str() { "kernel.node.create" => { let payload: KernelCreateNodeCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "kernel.node.create".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: KernelCreateNode { node: payload.node.clone(), position: payload.position, }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.node.create 参数序列化失败: {error}")) })?, })) } "kernel.node.update" => { let payload: KernelUpdateNodeCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "kernel.node.update".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: KernelUpdateNode { node_id: payload.node_id.clone(), metadata: payload.metadata.clone(), content: payload.content.clone(), refs: payload.refs.clone(), audit: payload.audit.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.node.update 参数序列化失败: {error}")) })?, })) } "kernel.subtree.move" => { let payload: KernelMoveSubtreeCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "kernel.subtree.move".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: KernelMoveSubtree { subtree: payload.subtree.clone(), new_parent_node_id: payload.new_parent_node_id.clone(), sort_order: payload.sort_order, }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.subtree.move 参数序列化失败: {error}")) })?, })) } "kernel.edge.attach" => { let payload: KernelAttachEdgeCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "kernel.edge.attach".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: KernelAttachEdge { edge: payload.edge.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.edge.attach 参数序列化失败: {error}")) })?, })) } "kernel.edge.detach" => { let payload: KernelDetachEdgeCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "kernel.edge.detach".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: KernelDetachEdge { edge_id: payload.edge_id.clone(), from_node_id: payload.from_node_id.clone(), to_node_id: payload.to_node_id.clone(), edge_type: payload.edge_type.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.edge.detach 参数序列化失败: {error}")) })?, })) } "blocks.patch" => { let payload: BlockPatchCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "blocks.patch".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: PatchBlock { page_id: payload.document_id.clone(), block_id: payload.block_id.clone(), workspace_id: payload.workspace_id.clone(), revision: None, block_snapshot_json: serde_json::to_string(&payload.next_block).map_err( |error| { BridgeError::validation(format!( "blocks.patch block snapshot 序列化失败: {error}" )) }, )?, conflict_detection_key: None, }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: { let stream_delta_hint = tree_resync_required_hint( "blocks.patch", json!({ "documentId": payload.document_id, "blockId": payload.block_id, }), ); let domain_event_payload = block_patch_domain_event_payload(&payload); json!({ "id": payload.document_id, "blockId": payload.block_id, "nextBlock": payload.next_block, "streamDeltaHint": stream_delta_hint, "domainEventHint": tree_domain_event_hint("block.patched"), "domainEventPlan": tree_domain_event_plan_with_payload( "block.patched", domain_event_payload, stream_delta_hint, ), }) }, })) } "blocks.move" => { let payload: BlockMoveCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "blocks.move".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: MoveBlock { block_id: payload.block_id.clone(), new_parent_block_id: Some(payload.target_document_id.clone()), new_page_id: Some(payload.target_document_id.clone()), prev_block_id: None, }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; let stream_delta_hint = tree_resync_required_hint( "blocks.move", json!({ "documentId": payload.target_document_id, "blockId": payload.block_id, }), ); let domain_event_payload = block_move_domain_event_payload(&payload); Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.block_id, "sourceDocumentId": payload.source_document_id, "targetDocumentId": payload.target_document_id, "streamDeltaHint": stream_delta_hint, "domainEventHint": tree_domain_event_hint("block.moved"), "domainEventPlan": tree_domain_event_plan_with_payload( "block.moved", domain_event_payload, stream_delta_hint, ), }), })) } "blocks.embed" => { let payload: BlockEmbedCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "blocks.embed".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: EmbedBlock { source_document_id: payload.source_document_id.clone(), source_block_id: payload.block_id.clone(), target_document_id: payload.target_document_id.clone(), target_block_id: payload.target_block_id.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; let stream_delta_hint = tree_resync_required_hint( "blocks.embed", json!({ "documentId": payload.target_document_id, "blockId": payload.block_id, }), ); let domain_event_payload = block_embed_domain_event_payload(&payload); Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "sourceDocumentId": payload.source_document_id, "blockId": payload.block_id, "targetDocumentId": payload.target_document_id, "targetBlockId": payload.target_block_id, "streamDeltaHint": stream_delta_hint, "domainEventHint": tree_domain_event_hint("block.embedded"), "domainEventPlan": tree_domain_event_plan_with_payload( "block.embedded", domain_event_payload, stream_delta_hint, ), }), })) } "documents.title.update" | "tree.node.rename" | "page.head.updateTitle" => { let payload: DocumentTitleCommandPayload = parse_payload(command_wire.payload.clone())?; let command_name = match command_wire.name.as_str() { "tree.node.rename" => "tree.node.rename", "page.head.updateTitle" => "page.head.updateTitle", _ => "documents.title.update", }; let command_protocol = if command_name == "page.head.updateTitle" { None } else { Some(tree_command_protocol_hint( command_name, "tree.node.rename", "documents.title.update", )) }; let command = CommandEnvelope { 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), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: core_protocol::UpdatePageTitle { page_id: payload.document_id.clone(), title: payload.title.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: { let mut args = json!({ "id": payload.document_id, "title": payload.title, "streamDeltaHint": tree_stream_delta_hint("upsert_document_patch", json!({ "documentId": payload.document_id, "patch": { "title": payload.title, }, })), "domainEventHint": tree_domain_event_hint("tree.node.renamed"), "domainEventPlan": tree_domain_event_plan( "tree.node.renamed", tree_stream_delta_hint("upsert_document_patch", json!({ "documentId": payload.document_id, "patch": { "title": payload.title, }, })), ), }); if let (Value::Object(map), Some(command_protocol)) = (&mut args, command_protocol) { map.insert("commandProtocol".into(), command_protocol); } args }, })) } "documents.options.update" | "page.layout.updateOptions" => { let payload: DocumentOptionsCommandPayload = parse_payload(command_wire.payload.clone())?; let command_name = match command_wire.name.as_str() { "page.layout.updateOptions" => "page.layout.updateOptions", _ => "documents.options.update", }; let options = payload.options; let mut options_json = serde_json::Map::new(); if let Some(value) = options.wide_layout { options_json.insert("wideLayout".into(), json!(value)); } if let Some(value) = options.small_text { options_json.insert("smallText".into(), json!(value)); } if let Some(value) = options.show_heading_numbers { options_json.insert("showHeadingNumbers".into(), json!(value)); } if let Some(value) = options.show_toc { options_json.insert("showToc".into(), json!(value)); } if let Some(value) = options.show_structure { options_json.insert("showStructure".into(), json!(value)); } if let Some(value) = options.protect_editing { options_json.insert("protectEditing".into(), json!(value)); } if let Some(value) = options.show_word_count { options_json.insert("showWordCount".into(), json!(value)); } if let Some(value) = options.collapse_backlinks { options_json.insert("collapseBacklinks".into(), json!(value)); } if let Some(value) = options.page_font.clone() { options_json.insert("pageFont".into(), json!(value)); } if let Some(value) = options.layout_density.clone() { options_json.insert("layoutDensity".into(), json!(value)); } if let Some(value) = options.hide_child_pages { options_json.insert("hideChildPages".into(), json!(value)); } if let Some(value) = options.show_block_ref_count { options_json.insert("showBlockRefCount".into(), json!(value)); } if let Some(value) = options.embed_default_block_id.clone() { options_json.insert("embedDefaultBlockId".into(), json!(value)); } let command = CommandEnvelope { 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), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: UpdatePageOptions { page_id: payload.document_id.clone(), wide_layout: options.wide_layout, small_text: options.small_text, show_heading_numbers: options.show_heading_numbers, show_toc: options.show_toc, show_structure: options.show_structure, protect_editing: options.protect_editing, show_word_count: options.show_word_count, collapse_backlinks: options.collapse_backlinks, page_font: options.page_font.clone(), layout_density: options.layout_density.clone(), hide_child_pages: options.hide_child_pages, show_block_ref_count: options.show_block_ref_count, embed_default_block_id: options.embed_default_block_id.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "options": Value::Object(options_json), "streamDeltaHint": tree_resync_required_hint( "page.layout.updateOptions", json!({ "documentId": payload.document_id, }), ), "domainEventHint": tree_domain_event_hint("page.layout.options_updated"), "domainEventPlan": tree_domain_event_plan( "page.layout.options_updated", tree_resync_required_hint( "page.layout.updateOptions", json!({ "documentId": payload.document_id, }), ), ), }), })) } "documents.stats.update" => { let payload: DocumentStatsCommandPayload = parse_payload(command_wire.payload.clone())?; let stats = payload.stats; let command = CommandEnvelope { name: "documents.stats.update".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: UpdatePageStats { page_id: payload.document_id.clone(), word_count: stats.word_count, character_count: stats.character_count, block_count: stats.block_count, todo_total: stats.todo_total, todo_done: stats.todo_done, }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "workspaceId": payload.workspace_id, "wordCount": stats.word_count, "characterCount": stats.character_count, "blockCount": stats.block_count, "todoTotal": stats.todo_total, "todoDone": stats.todo_done, }), })) } "media.assets.replace_storage" => { let payload: MediaAssetReplaceStorageCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "media.assets.replace_storage".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: core_protocol::ReplaceMediaAssetStorage { asset_id: payload.asset_id.clone(), storage_id: payload.storage_id.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "userId": payload.user_id, "id": payload.asset_id, "documentId": payload.document_id, "workspaceId": payload.workspace_id, "storageId": payload.storage_id, }), })) } "documents.save" | "page.body.save" => { let payload: DocumentSaveCommandPayload = parse_payload(command_wire.payload.clone())?; let editor_document = normalize_save_editor_document(&payload)?; let canonical_content = legacy_content_from_editor_document(&editor_document); let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: core_protocol::SavePageContent { page_id: payload.document_id.clone(), workspace_id: payload.workspace_id.clone(), revision: payload.revision, content_json: serde_json::to_string(&canonical_content).map_err(|error| { BridgeError::validation(format!( "{} content 序列化失败: {error}", command_wire.name )) })?, conflict_detection_key: payload.conflict_detection_key.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: { let stream_delta_hint = tree_resync_required_hint( "page_body_saved", json!({ "pageId": payload.document_id, "documentId": payload.document_id, }), ); let page_body_event_payload = document_save_page_body_domain_event_payload(&payload, &editor_document); let snapshot_event_payload = document_save_snapshot_domain_event_payload(&payload, &canonical_content)?; let page_body_event_plan = tree_domain_event_plan_with_payload( "page.body.saved", page_body_event_payload, stream_delta_hint.clone(), ); let snapshot_event_plan = tree_domain_event_plan_with_payload( "document.snapshot.saved", snapshot_event_payload, stream_delta_hint.clone(), ); json!({ "id": payload.document_id, "content": canonical_content, "editorDocument": editor_document, "tiptapDocument": payload.tiptap_document, "expectedRevision": payload.revision, "conflictDetectionKey": payload.conflict_detection_key, "streamDeltaHint": stream_delta_hint, "domainEventHint": tree_domain_event_hint("page.body.saved"), "domainEventPlan": page_body_event_plan.clone(), "domainEventPlans": [page_body_event_plan, snapshot_event_plan], }) }, })) } "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 page_aggregate_embed_plan = build_page_aggregate_embed_plan(&command_wire, &payload)?; let embed_content = page_aggregate_embed_plan .as_ref() .and_then(|plan| plan.get("content")) .cloned() .or_else(|| payload.content.clone()) .ok_or_else(|| { BridgeError::validation(format!( "{command_name} 缺少 content 或 pageAggregateEmbed preflight" )) })?; let command = CommandEnvelope { 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), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: core_protocol::SavePageContent { page_id: payload.document_id.clone(), workspace_id: payload.workspace_id.clone(), revision: payload.revision, content_json: serde_json::to_string(&embed_content).map_err(|error| { BridgeError::validation(format!( "{command_name} content 序列化失败: {error}" )) })?, conflict_detection_key: payload.conflict_detection_key.clone(), }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "content": embed_content, "expectedRevision": payload.revision, "conflictDetectionKey": payload.conflict_detection_key, "sourceDocumentId": payload.source_document_id, "targetDocumentId": payload.target_document_id, "anchorBlockId": payload.anchor_block_id, "pageAggregateEmbedPlan": page_aggregate_embed_plan, "streamDeltaHint": tree_stream_delta_hint("noop", json!({})), "domainEventHint": tree_domain_event_hint("tree.node.embedded"), "domainEventPlan": tree_domain_event_plan( "tree.node.embedded", tree_stream_delta_hint("noop", json!({})), ), }), })) } "mindmaps.put" => { let payload: MindmapPutCommandPayload = parse_payload(command_wire.payload.clone())?; let create_only = payload.create_only.unwrap_or(false); let (event_type, stream_delta_hint) = if create_only { ( "tree.resource.mindmap.put", tree_stream_delta_hint( "resync_required", json!({ "reason": "mindmap.put", "documentId": payload.document_id.clone(), "blockId": payload.mindmap_id.clone(), }), ), ) } else { ( "mindmap.content.updated", tree_stream_delta_hint("noop", json!({})), ) }; let command = CommandEnvelope { name: "mindmaps.put".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: PutMindmap { document_id: payload.document_id.clone(), mindmap_id: payload.mindmap_id.clone(), data_json: serde_json::to_string(&payload.data).map_err(|error| { BridgeError::validation(format!("mindmaps.put data 序列化失败: {error}")) })?, create_only, }, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id.clone(), "mindmapId": payload.mindmap_id.clone(), "data": payload.data, "createOnly": create_only, "streamDeltaHint": stream_delta_hint.clone(), "domainEventHint": tree_domain_event_hint(event_type), "domainEventPlan": tree_domain_event_plan( event_type, stream_delta_hint, ), }), })) } "mindmap.command.apply" => { let payload: MindmapCommandApplyPayload = parse_payload(command_wire.payload.clone())?; validate_mindmap_kernel_command_values(&payload.commands)?; let command = CommandEnvelope { name: "mindmap.command.apply".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()).or(Some(TargetRef { workspace_id: payload.workspace_id.clone(), page_id: Some(payload.document_id.clone()), block_id: None, })), payload: payload.commands.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "documentId": payload.document_id.clone(), "mindmapId": payload.mindmap_id.clone(), "commands": payload.commands, "projectionRevision": payload.projection_revision, "canonicalCommand": "mindmap.command.apply", "streamDeltaHint": tree_stream_delta_hint("noop", json!({})), "domainEventHint": tree_domain_event_hint("mindmap.content.updated"), "domainEventPlan": tree_domain_event_plan( "mindmap.content.updated", tree_stream_delta_hint("noop", json!({})), ), }), })) } "mindmaps.delete" => { let payload: MindmapDeleteCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "mindmaps.delete".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.mindmap_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id.clone(), "mindmapId": payload.mindmap_id.clone(), "streamDeltaHint": tree_stream_delta_hint("resync_required", json!({ "reason": "mindmap.delete", "documentId": payload.document_id.clone(), "blockId": payload.mindmap_id.clone(), })), "domainEventHint": tree_domain_event_hint("tree.resource.mindmap.deleted"), "domainEventPlan": tree_domain_event_plan( "tree.resource.mindmap.deleted", tree_stream_delta_hint("resync_required", json!({ "reason": "mindmap.delete", "documentId": payload.document_id.clone(), "blockId": payload.mindmap_id.clone(), })), ), }), })) } "mindmaps.restore" => { let payload: MindmapRestoreCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "mindmaps.restore".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.mindmap_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id.clone(), "mindmapId": payload.mindmap_id.clone(), "streamDeltaHint": tree_stream_delta_hint("resync_required", json!({ "reason": "mindmap.restore", "documentId": payload.document_id.clone(), "blockId": payload.mindmap_id.clone(), })), "domainEventHint": tree_domain_event_hint("tree.resource.mindmap.restored"), "domainEventPlan": tree_domain_event_plan( "tree.resource.mindmap.restored", tree_stream_delta_hint("resync_required", json!({ "reason": "mindmap.restore", "documentId": payload.document_id.clone(), "blockId": payload.mindmap_id.clone(), })), ), }), })) } "mindmaps.purge" => { let payload: MindmapPurgeCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "mindmaps.purge".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.mindmap_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id, "mindmapId": payload.mindmap_id, }), })) } "mindmaps.emptyTrashByWorkspace" => { let payload: MindmapEmptyTrashCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "mindmaps.emptyTrashByWorkspace".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.workspace_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, }), })) } "documents.create" | "tree.node.create" => { let payload: DocumentCreateCommandPayload = parse_payload(command_wire.payload.clone())?; let command_name = if command_wire.name == "tree.node.create" { "tree.node.create" } else { "documents.create" }; let command = CommandEnvelope { 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), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.content.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "workspaceId": payload.workspace_id, "parentId": payload.parent_id, "title": payload.title, "accessScope": payload.access_scope, "content": payload.content, "commandProtocol": tree_command_protocol_hint( command_name, "tree.node.create", "documents.create", ), "streamDeltaHint": tree_document_result_hint("document"), "domainEventHint": tree_domain_event_hint("tree.node.created"), "domainEventPlan": tree_domain_event_plan( "tree.node.created", tree_document_result_hint("document"), ), }), })) } "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 move_order_plan = resolve_document_move_order_plan(&payload, command_wire.preflight_data.as_ref())?; let tree_write_operation = document_move_write_operation( context.workspace_id.as_deref(), move_order_plan.as_ref(), ); let command_name = if command_wire.name == "tree.subtree.move" { "tree.subtree.move" } else { "documents.move" }; let command = CommandEnvelope { 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), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.sort_order, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "parentId": payload.parent_id, "sortOrder": payload.sort_order, "treeWriteOperation": tree_write_operation, "commandProtocol": tree_command_protocol_hint( command_name, "tree.subtree.move", "documents.move", ), "streamDeltaHint": tree_stream_delta_hint("move_document", json!({ "documentId": payload.document_id, "parentId": payload.parent_id, "sortOrder": payload.sort_order, })), "domainEventHint": tree_domain_event_hint("tree.subtree.moved"), "domainEventPlan": tree_domain_event_plan( "tree.subtree.moved", tree_stream_delta_hint("move_document", json!({ "documentId": payload.document_id, "parentId": payload.parent_id, "sortOrder": payload.sort_order, })), ), }), })) } "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: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.document_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "commandProtocol": tree_command_protocol_hint( command_name, "tree.node.archive", "documents.delete", ), "streamDeltaHint": tree_stream_delta_hint("remove_document", json!({ "documentId": payload.document_id, })), "domainEventHint": tree_domain_event_hint("tree.node.archived"), "domainEventPlan": tree_domain_event_plan( "tree.node.archived", tree_stream_delta_hint("remove_document", json!({ "documentId": payload.document_id, })), ), }), })) } "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: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.document_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "commandProtocol": tree_command_protocol_hint( command_name, "tree.node.restore", "documents.restore", ), "streamDeltaHint": tree_document_result_hint("document"), "domainEventHint": tree_domain_event_hint("tree.node.restored"), "domainEventPlan": tree_domain_event_plan( "tree.node.restored", tree_document_result_hint("document"), ), }), })) } "documents.duplicate" => { let payload: DocumentDuplicateCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "documents.duplicate".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.source_document_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "sourceId": payload.source_document_id, "newId": payload.new_document_id, "title": payload.title, "streamDeltaHint": tree_result_document_hint(), "domainEventHint": tree_domain_event_hint("tree.node.duplicated"), "domainEventPlan": tree_domain_event_plan( "tree.node.duplicated", tree_result_document_hint(), ), }), })) } "documents.template" => { let payload: DocumentTemplateCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { name: "documents.template".into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.is_template, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "isTemplate": payload.is_template, }), })) } "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!({ "workspaceId": payload.workspace_id, }), ); let command = CommandEnvelope { 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), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.workspace_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id.clone(), "streamDeltaHint": stream_delta_hint.clone(), "domainEventHint": tree_domain_event_hint("tree.trash.documents.emptied"), "domainEventPlan": tree_domain_event_plan( "tree.trash.documents.emptied", stream_delta_hint, ), }), })) } "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: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.document_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, "commandProtocol": tree_command_protocol_hint( command_name, "tree.node.purge", "documents.purge", ), }), })) } "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: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: payload.items.len(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "items": payload.items.iter().map(|item| json!({ "documentId": item.document_id, "recursive": item.recursive, })).collect::>(), "targetParentId": payload.target_parent_id, "commandProtocol": tree_command_protocol_hint( command_name, "tree.subtree.copy", "documents.copy_tree", ), "streamDeltaHint": tree_stream_delta_hint("copy_result", json!({ "itemsField": "items", "documentField": "document", })), "domainEventHint": tree_domain_event_hint("tree.subtree.copied"), "domainEventPlan": tree_domain_event_plan( "tree.subtree.copied", tree_stream_delta_hint("copy_result", json!({ "itemsField": "items", "documentField": "document", })), ), }), })) } "tree.filetree.drop.preflight" => { let payload: FileTreeDropPreflightCommandPayload = parse_payload(command_wire.payload.clone())?; let drop_plan = build_filetree_drop_plan(&payload)?; let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: drop_plan.row_ids.len(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreeDropPlan": drop_plan, }), })) } "tree.filetree.delete.preflight" => { let payload: FileTreeDeletePreflightCommandPayload = parse_payload(command_wire.payload.clone())?; let delete_plan = build_filetree_delete_plan(&payload)?; let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: delete_plan.row_ids.len(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreeDeletePlan": delete_plan, }), })) } "tree.filetree.paste.preflight" => { let payload: FileTreePastePreflightCommandPayload = parse_payload(command_wire.payload.clone())?; let paste_plan = build_filetree_paste_plan(&payload)?; let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: paste_plan.row_ids.len(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreePastePlan": paste_plan, }), })) } "tree.filetree.upload-target.preflight" => { let payload: FileTreeUploadTargetPreflightCommandPayload = parse_payload(command_wire.payload.clone())?; let upload_target_plan = build_filetree_upload_target_plan(&payload)?; let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: 1, reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreeUploadTargetPlan": upload_target_plan, }), })) } "tree.resource.copy" | "tree.resource.move" => { let payload: ResourceTransferCommandPayload = parse_payload(command_wire.payload.clone())?; let action = if command_wire.name == "tree.resource.copy" { "copy" } else { "move" }; let transfer_plan = build_resource_transfer_plan(action, &payload)?; let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: transfer_plan.asset_ids.len(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "assetIds": transfer_plan.asset_ids, "targetDocumentId": transfer_plan.target_document_id, "targetSubPath": transfer_plan.target_sub_path, "resourceTransferPlan": transfer_plan, "streamDeltaHint": tree_stream_delta_hint("asset_result", json!({ "itemsField": "items", })), "domainEventHint": tree_domain_event_hint(if action == "copy" { "tree.resource.copied" } else { "tree.resource.moved" }), "domainEventPlan": tree_domain_event_plan( if action == "copy" { "tree.resource.copied" } else { "tree.resource.moved" }, tree_stream_delta_hint("asset_result", json!({ "itemsField": "items", })), ), }), })) } "tree.resource.upload" => { let payload: ResourceUploadCommandPayload = parse_payload(command_wire.payload.clone())?; let upload_plan = build_resource_upload_plan(&payload)?; let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: upload_plan.asset_id.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "assetId": upload_plan.asset_id, "workspaceId": upload_plan.workspace_id, "targetDocumentId": upload_plan.target_document_id, "targetSubPath": upload_plan.target_sub_path, "fileName": upload_plan.file_name, "fileSize": upload_plan.file_size, "mimeType": upload_plan.mime_type, "assetType": upload_plan.asset_type, "resourceUploadPlan": upload_plan, "streamDeltaHint": tree_stream_delta_hint("asset_result", json!({ "itemsField": "items", })), "domainEventHint": tree_domain_event_hint("tree.resource.uploaded"), "domainEventPlan": tree_domain_event_plan( "tree.resource.uploaded", tree_stream_delta_hint("asset_result", json!({ "itemsField": "items", })), ), }), })) } "tree.resource.archive" | "tree.resource.restore" | "tree.resource.purge" | "tree.resource.rename" => { let action = match command_wire.name.as_str() { "tree.resource.archive" => "archive", "tree.resource.restore" => "restore", "tree.resource.purge" => "purge", "tree.resource.rename" => "rename", _ => unreachable!("resource lifecycle command 已匹配"), }; let payload: ResourceLifecycleCommandPayload = parse_payload(command_wire.payload.clone())?; let lifecycle_plan = build_resource_lifecycle_plan(action, &payload)?; let command = CommandEnvelope { name: command_wire.name.clone(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), source: to_source_payload(&command_wire.source), target: to_target_ref(command_wire.target.as_ref()), payload: lifecycle_plan.action.clone(), reason: command_wire.reason, refs: command_wire.refs, dry_run: command_wire.dry_run, validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; retired_resource_lifecycle_transport_function(action, &lifecycle_plan.resource_kind)?; let stream_delta_hint = resource_lifecycle_stream_delta_hint(&lifecycle_plan); let event_type = resource_lifecycle_event_type(action); let mut args_json = json!({ "resourceKind": lifecycle_plan.resource_kind, "resourceLifecyclePlan": lifecycle_plan, "streamDeltaHint": stream_delta_hint, "domainEventHint": tree_domain_event_hint(event_type), "domainEventPlan": tree_domain_event_plan(event_type, stream_delta_hint), }); if let Some(asset_id) = args_json .get("resourceLifecyclePlan") .and_then(|value| value.get("assetId")) .and_then(Value::as_str) .map(ToOwned::to_owned) { args_json["id"] = json!(asset_id); args_json["userId"] = json!(command_wire.actor.actor_id); } if let Some(document_id) = args_json .get("resourceLifecyclePlan") .and_then(|value| value.get("documentId")) .and_then(Value::as_str) .map(ToOwned::to_owned) { args_json["docId"] = json!(document_id); } if let Some(mindmap_id) = args_json .get("resourceLifecyclePlan") .and_then(|value| value.get("mindmapId")) .and_then(Value::as_str) .map(ToOwned::to_owned) { args_json["mindmapId"] = json!(mindmap_id); } if let Some(table_id) = args_json .get("resourceLifecyclePlan") .and_then(|value| value.get("tableId")) .and_then(Value::as_str) .map(ToOwned::to_owned) { args_json["tableId"] = json!(table_id); args_json["userId"] = json!(command_wire.actor.actor_id); } if let Some(new_name) = args_json .get("resourceLifecyclePlan") .and_then(|value| value.get("newName")) .and_then(Value::as_str) .map(ToOwned::to_owned) { args_json["newName"] = json!(new_name); if action == "rename" && lifecycle_plan.resource_kind == "file" { args_json["patch"] = json!({ "file_name": new_name, }); } } Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, function_name: request.function_name, workspace_id: request.workspace_id, request_id: request.request_id, trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json, })) } other => Err(BridgeError::validation(format!( "bridge runtime 暂不支持 command: {other}" ))), } } fn parse_payload(value: Value) -> Result where T: DeserializeOwned, { serde_json::from_value(value).map_err(|error| { BridgeError::validation(format!("bridge runtime payload 反序列化失败: {error}")) }) } fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType { match raw_type { "mindmap" => EditorBlockType::Mindmap, "heading" => EditorBlockType::Heading, "bullet_list_item" | "bullet_list" | "bullet-list" => EditorBlockType::BulletListItem, "numbered_list_item" | "ordered_list" | "ordered-list" => EditorBlockType::NumberedListItem, "todo" | "task" => EditorBlockType::Todo, "quote" | "blockquote" => EditorBlockType::Quote, "code" | "code_block" | "code-block" => EditorBlockType::CodeBlock, "divider" | "horizontal_rule" | "horizontalrule" => EditorBlockType::Divider, "table" => EditorBlockType::Table, "image" | "picture" => EditorBlockType::Image, "toc" | "toc_node" | "tocnode" => EditorBlockType::Toc, "page_reference" => EditorBlockType::PageReference, "block_reference" => EditorBlockType::BlockReference, "resource" | "resource_block" => EditorBlockType::Resource, _ => EditorBlockType::Paragraph, } } fn normalize_editor_block_from_legacy_path(block: &Value, path: &[usize]) -> EditorBlock { let block_id = read_trimmed_string_field(block, &["blockId", "id"]) .unwrap_or_else(|| legacy_block_id_from_path(path)); let raw_type = read_trimmed_string_field(block, &["blockType", "type"]) .unwrap_or_else(|| "paragraph".into()) .to_lowercase(); let normalized_block_type = normalize_editor_block_type_for_save(&raw_type); let mut props = BlockProps::default(); if matches!(normalized_block_type, EditorBlockType::Heading) { props.heading_level = block .get("props") .and_then(Value::as_object) .and_then(|map| map.get("level").or_else(|| map.get("headingLevel"))) .and_then(Value::as_u64) .and_then(|value| u8::try_from(value).ok()) .map(|value| value.clamp(1, 6)); props.collapsed = block .get("props") .and_then(Value::as_object) .and_then(|map| map.get("collapsed")) .and_then(Value::as_bool); } if matches!(normalized_block_type, EditorBlockType::Todo) { props.checked = block .get("props") .and_then(Value::as_object) .and_then(|map| map.get("checked")) .and_then(Value::as_bool); } if matches!(normalized_block_type, EditorBlockType::CodeBlock) { props.language = block .get("props") .and_then(Value::as_object) .and_then(|map| map.get("language")) .and_then(Value::as_str) .map(ToOwned::to_owned); } if let Some(tiptap_table) = block .get("props") .and_then(Value::as_object) .and_then(|map| map.get("tiptapTable")) .cloned() { props.extra.insert("tiptapTable".into(), tiptap_table); } if let Some(tiptap_image) = block .get("props") .and_then(Value::as_object) .and_then(|map| map.get("tiptapImage")) .cloned() { props.extra.insert("tiptapImage".into(), tiptap_image); } if let Some(tiptap_toc) = block .get("props") .and_then(Value::as_object) .and_then(|map| map.get("tiptapTocNode").or_else(|| map.get("tiptapToc"))) .cloned() { props.extra.insert("tiptapTocNode".into(), tiptap_toc); } if matches!(normalized_block_type, EditorBlockType::Mindmap) { let props_map = block.get("props").and_then(Value::as_object); let legacy_data = props_map.and_then(|map| map.get("data")); let data_object = legacy_data.and_then(Value::as_object); if let Some(mindmap_id) = props_map .and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id"))) .and_then(Value::as_str) .or_else(|| { data_object.and_then(|map| { map.get("mindmapId") .or_else(|| map.get("mindmap_id")) .or_else(|| map.get("id")) .and_then(Value::as_str) }) }) .map(str::trim) .filter(|value| !value.is_empty()) { props .extra .insert("mindmapId".into(), Value::String(mindmap_id.to_string())); } else { props .extra .insert("mindmapId".into(), Value::String(block_id.clone())); } if let Some(root_node_id) = props_map .and_then(|map| map.get("rootNodeId").or_else(|| map.get("root_node_id"))) .and_then(Value::as_str) .or_else(|| { data_object.and_then(|map| { map.get("rootNodeId") .or_else(|| map.get("root_node_id")) .and_then(Value::as_str) }) }) .map(str::trim) .filter(|value| !value.is_empty()) { props .extra .insert("rootNodeId".into(), Value::String(root_node_id.to_string())); } if let Some(projection_version) = props_map .and_then(|map| map.get("projectionVersion")) .and_then(Value::as_u64) { props.extra.insert( "projectionVersion".into(), Value::Number(projection_version.into()), ); } } if matches!(normalized_block_type, EditorBlockType::Resource) { if let Some(props_map) = block.get("props").and_then(Value::as_object) { props .extra .insert("resourceProps".into(), Value::Object(props_map.clone())); } } let text = read_trimmed_string_field(block, &["content"]) .filter(|value| !value.is_empty()) .unwrap_or_else(|| extract_inline_text(block)); EditorBlock { block_id, block_type: normalized_block_type.clone(), props, content_nodes: if matches!(normalized_block_type, EditorBlockType::Mindmap) { vec![] } else { build_text_content_nodes(&text) }, child_block_ids: vec![], } } pub fn editor_document_from_legacy_content( document_id: &str, content: &Value, ) -> EditorBlockDocument { let mut blocks = Vec::::new(); let mut root_block_ids = Vec::::new(); for (index, block) in normalize_blocks_from_value(content).iter().enumerate() { root_block_ids.push(collect_editor_blocks_from_legacy( block, &[index], &mut blocks, )); } EditorBlockDocument { document_id: document_id.to_string(), root_block_ids, blocks, } } fn collect_editor_blocks_from_legacy( block: &Value, path: &[usize], out: &mut Vec, ) -> String { let mut editor_block = normalize_editor_block_from_legacy_path(block, path); let children = block .as_object() .and_then(|map| map.get("children")) .and_then(Value::as_array) .cloned() .unwrap_or_default(); let mut child_block_ids = Vec::::new(); for (index, child) in children.iter().enumerate() { let mut child_path = path.to_vec(); child_path.push(index); child_block_ids.push(collect_editor_blocks_from_legacy(child, &child_path, out)); } editor_block.child_block_ids = child_block_ids; let block_id = editor_block.block_id.clone(); out.push(editor_block); block_id } fn normalize_save_editor_document( payload: &DocumentSaveCommandPayload, ) -> Result { if let Some(editor_document) = payload.editor_document.clone() { match serde_json::from_value::(editor_document) { Ok(mut parsed) => { let raw = payload.editor_document.as_ref().ok_or_else(|| { BridgeError::validation("documents.save editorDocument 非法: 缺少原始值") })?; validate_editor_document_structure(raw, &parsed, "documents.save")?; hydrate_editor_document_props_from_raw( &mut parsed, payload.editor_document.as_ref(), ); if parsed.document_id.trim().is_empty() { parsed.document_id = payload.document_id.clone(); } if parsed.root_block_ids.is_empty() { parsed.root_block_ids = parsed .blocks .iter() .map(|block| block.block_id.clone()) .collect(); } return Ok(parsed); } Err(error) => { return Err(BridgeError::validation(format!( "documents.save editorDocument 非法: {error}" ))); } } } if let Some(tiptap_document) = payload.tiptap_document.clone() { let parsed = serde_json::from_value::(tiptap_document).map_err(|error| { BridgeError::validation(format!("documents.save tiptapDocument 非法: {error}")) })?; return EditorBlockDocumentTiptapBridge::from_tiptap_doc( payload.document_id.clone(), &parsed, ) .map_err(|error| { BridgeError::validation(format!( "documents.save tiptap -> editorDocument 失败: {error:?}" )) }); } Ok(editor_document_from_legacy_content( &payload.document_id, &payload.content, )) } fn validate_editor_document_structure( raw: &Value, parsed: &EditorBlockDocument, phase: &str, ) -> Result<(), BridgeError> { raw.get("blocks").and_then(Value::as_array).ok_or_else(|| { BridgeError::validation(format!("{phase} editorDocument 非法: 缺少 blocks")) })?; let block_ids = parsed .blocks .iter() .map(|block| block.block_id.as_str()) .collect::>(); let missing_roots = parsed .root_block_ids .iter() .map(String::as_str) .map(str::trim) .filter(|block_id| !block_id.is_empty()) .filter(|block_id| !block_ids.contains(block_id)) .collect::>(); if !missing_roots.is_empty() { return Err(BridgeError::validation(format!( "{phase} editorDocument 非法: rootBlockIds 引用缺失 blockId: {}", missing_roots.join(", ") ))); } Ok(()) } fn hydrate_editor_document_props_from_raw(parsed: &mut EditorBlockDocument, raw: Option<&Value>) { let Some(raw_blocks) = raw .and_then(|value| value.get("blocks")) .and_then(Value::as_array) else { return; }; for (index, block) in parsed.blocks.iter_mut().enumerate() { let Some(raw_block) = raw_blocks .iter() .find(|candidate| { read_trimmed_string_field(candidate, &["blockId", "block_id"]).as_deref() == Some(block.block_id.as_str()) }) .or_else(|| raw_blocks.get(index)) else { continue; }; match block.block_type { EditorBlockType::Mindmap => hydrate_mindmap_block_props_from_raw(block, raw_block), EditorBlockType::Image => hydrate_image_block_props_from_raw(block, raw_block), _ => {} } } } fn hydrate_image_block_props_from_raw(block: &mut EditorBlock, raw_block: &Value) { let props = raw_block.get("props").and_then(Value::as_object); if let Some(tiptap_image) = props .and_then(|map| map.get("tiptapImage")) .filter(|value| value.get("type").and_then(Value::as_str) == Some("image")) .cloned() { block.props.extra.insert("tiptapImage".into(), tiptap_image); } for key in ["src", "alt", "title"] { if let Some(value) = props.and_then(|map| map.get(key)).cloned() { block.props.extra.insert(key.into(), value); } } } fn hydrate_mindmap_block_props_from_raw(block: &mut EditorBlock, raw_block: &Value) { let props = raw_block.get("props").and_then(Value::as_object); let legacy_data = props .and_then(|map| map.get("data")) .and_then(Value::as_object); if let Some(mindmap_id) = props .and_then(|map| map.get("mindmapId").or_else(|| map.get("mindmap_id"))) .and_then(Value::as_str) .or_else(|| { legacy_data.and_then(|map| { map.get("mindmapId") .or_else(|| map.get("mindmap_id")) .or_else(|| map.get("id")) .and_then(Value::as_str) }) }) .map(str::trim) .filter(|value| !value.is_empty()) { block .props .extra .insert("mindmapId".into(), Value::String(mindmap_id.to_string())); } for (raw_key, canonical_key) in [("rootNodeId", "rootNodeId"), ("root_node_id", "rootNodeId")] { if let Some(value) = props .and_then(|map| map.get(raw_key)) .and_then(Value::as_str) .or_else(|| legacy_data.and_then(|map| map.get(raw_key).and_then(Value::as_str))) .map(str::trim) .filter(|value| !value.is_empty()) { block .props .extra .insert(canonical_key.into(), Value::String(value.to_string())); } } if let Some(projection_version) = props .and_then(|map| map.get("projectionVersion")) .and_then(Value::as_u64) { block.props.extra.insert( "projectionVersion".into(), Value::Number(projection_version.into()), ); } } fn legacy_props_from_editor_block(block: &EditorBlock) -> Option { let mut props = serde_json::Map::new(); match block.block_type { EditorBlockType::Heading => { props.insert( "level".into(), json!(block.props.heading_level.unwrap_or(1)), ); if let Some(collapsed) = block.props.collapsed { props.insert("collapsed".into(), json!(collapsed)); } } EditorBlockType::Todo => { props.insert( "checked".into(), json!(block.props.checked.unwrap_or(false)), ); } EditorBlockType::CodeBlock => { props.insert("language".into(), json!(block.props.language)); } EditorBlockType::Table => { if let Some(tiptap_table) = block.props.extra.get("tiptapTable") { props.insert("tiptapTable".into(), tiptap_table.clone()); } } EditorBlockType::Image => { for key in ["src", "alt", "title"] { if let Some(value) = block.props.extra.get(key) { props.insert(key.into(), value.clone()); } } if let Some(tiptap_image) = block.props.extra.get("tiptapImage") { props.insert("tiptapImage".into(), tiptap_image.clone()); if let Some(attrs) = tiptap_image.get("attrs").and_then(Value::as_object) { for key in ["src", "alt", "title"] { if let Some(value) = attrs.get(key) { props.insert(key.into(), value.clone()); } } } } } EditorBlockType::Toc => { if let Some(tiptap_toc) = block.props.extra.get("tiptapTocNode") { props.insert("tiptapTocNode".into(), tiptap_toc.clone()); } } EditorBlockType::Mindmap => { if let Some(mindmap_id) = block.props.extra.get("mindmapId").and_then(Value::as_str) { props.insert("mindmapId".into(), json!(mindmap_id)); } if let Some(root_node_id) = block.props.extra.get("rootNodeId").and_then(Value::as_str) { props.insert("rootNodeId".into(), json!(root_node_id)); } if let Some(projection_version) = block .props .extra .get("projectionVersion") .and_then(Value::as_u64) { props.insert("projectionVersion".into(), json!(projection_version)); } } EditorBlockType::Resource => { if let Some(resource_props) = block .props .extra .get("resourceProps") .and_then(Value::as_object) { for (key, value) in resource_props { props.insert(key.clone(), value.clone()); } } } _ => {} } if let Some(text_align) = block .props .extra .get("textAlign") .or_else(|| block.props.extra.get("text_align")) .and_then(Value::as_str) .map(str::trim) .filter(|value| matches!(*value, "left" | "center" | "right" | "justify")) { props.insert("textAlign".into(), json!(text_align)); } if props.is_empty() { None } else { Some(Value::Object(props)) } } fn legacy_type_from_editor_block(block: &EditorBlock) -> &'static str { match block.block_type { EditorBlockType::Paragraph => "paragraph", EditorBlockType::Mindmap => "mindmap", EditorBlockType::Heading => "heading", EditorBlockType::BulletListItem => "bullet_list_item", EditorBlockType::NumberedListItem => "numbered_list_item", EditorBlockType::Quote => "blockquote", EditorBlockType::Todo => "todo", EditorBlockType::CodeBlock => "code_block", EditorBlockType::Divider => "divider", EditorBlockType::Table => "table", EditorBlockType::Image => "image", EditorBlockType::Toc => "toc", EditorBlockType::PageReference => "page_reference", EditorBlockType::BlockReference => "block_reference", EditorBlockType::Resource => "resource", } } fn legacy_text_from_editor_block(block: &EditorBlock) -> String { block .content_nodes .iter() .filter_map(|node| match &node.payload { ContentNodePayload::Text { text, .. } => Some(text.as_str()), ContentNodePayload::HardBreak => Some("\n"), ContentNodePayload::ReferenceToken { token } => token.label.as_deref(), }) .collect::() } pub fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value { fn legacy_styles_from_content_node(node: &ContentNode) -> serde_json::Map { let mut styles = node .attrs .get("styles") .and_then(Value::as_object) .cloned() .unwrap_or_default(); if let ContentNodePayload::Text { marks, .. } = &node.payload { for mark in marks { match mark { core_protocol::TextMark::Bold => { styles.insert("bold".into(), Value::Bool(true)); } core_protocol::TextMark::Italic => { styles.insert("italic".into(), Value::Bool(true)); } core_protocol::TextMark::Underline => { styles.insert("underline".into(), Value::Bool(true)); } core_protocol::TextMark::Strike => { styles.insert("strike".into(), Value::Bool(true)); } core_protocol::TextMark::Code => { styles.insert("code".into(), Value::Bool(true)); } }; } } styles } fn content_node_requires_structured_legacy(node: &ContentNode) -> bool { !legacy_styles_from_content_node(node).is_empty() || matches!(&node.payload, ContentNodePayload::ReferenceToken { .. }) } fn legacy_inline_content_from_editor_block(block: &EditorBlock) -> Value { if !block .content_nodes .iter() .any(content_node_requires_structured_legacy) { return Value::String(legacy_text_from_editor_block(block)); } let nodes = block .content_nodes .iter() .filter_map(|node| { let text = match &node.payload { ContentNodePayload::Text { text, .. } => text.as_str(), ContentNodePayload::HardBreak => "\n", ContentNodePayload::ReferenceToken { token } => { token.label.as_deref().unwrap_or(token.target_id.as_str()) } }; if text.is_empty() { return None; } let styles = legacy_styles_from_content_node(node); let mut value = json!({ "type": "text", "text": text, }); if !styles.is_empty() { if let Value::Object(map) = &mut value { map.insert("styles".into(), Value::Object(styles)); } } Some(value) }) .collect::>(); Value::Array(nodes) } fn block_to_legacy_value(block: &EditorBlock, document: &EditorBlockDocument) -> Value { let children = block .child_block_ids .iter() .filter_map(|child_id| { document .blocks .iter() .find(|candidate| &candidate.block_id == child_id) }) .map(|child| block_to_legacy_value(child, document)) .collect::>(); let mut value = json!({ "id": block.block_id, "type": legacy_type_from_editor_block(block), "props": legacy_props_from_editor_block(block), "content": if matches!(block.block_type, EditorBlockType::Divider) { Value::Array(Vec::new()) } else if matches!(block.block_type, EditorBlockType::Mindmap) { Value::String(String::new()) } else { legacy_inline_content_from_editor_block(block) }, }); if !children.is_empty() { if let Value::Object(map) = &mut value { map.insert("children".into(), Value::Array(children)); } } value } fn mark_block_tree_seen( block: &EditorBlock, document: &EditorBlockDocument, seen: &mut std::collections::BTreeSet, ) { seen.insert(block.block_id.clone()); for child_id in &block.child_block_ids { if let Some(child) = document .blocks .iter() .find(|candidate| &candidate.block_id == child_id) { mark_block_tree_seen(child, document, seen); } } } let mut ordered = Vec::<&EditorBlock>::new(); let mut seen = std::collections::BTreeSet::::new(); for root_block_id in &document.root_block_ids { if let Some(block) = document .blocks .iter() .find(|block| &block.block_id == root_block_id) { mark_block_tree_seen(block, document, &mut seen); ordered.push(block); } } for block in &document.blocks { if seen.insert(block.block_id.clone()) { ordered.push(block); } } Value::Array( ordered .into_iter() .map(|block| block_to_legacy_value(block, document)) .collect(), ) } pub fn apply_editor_command_to_legacy_content( document_id: &str, content: &Value, command: EditorCommand, ) -> Result { let mut document = editor_document_from_legacy_content(document_id, content); apply_editor_command_to_document(&mut document, command)?; Ok(legacy_content_from_editor_document(&document)) } pub fn apply_editor_command_to_document( document: &mut EditorBlockDocument, command: EditorCommand, ) -> Result<(), BridgeError> { match command { EditorCommand::ReplaceBlock(command) => apply_replace_block_command(document, command), EditorCommand::InsertBlockAfter(command) => { apply_insert_block_after_command(document, command) } EditorCommand::DeleteBlock(command) => apply_delete_block_command(document, command), EditorCommand::MoveBlock(command) => apply_move_block_command(document, command), other => Err(BridgeError::validation(format!( "页面块 AI 工具暂不支持执行 editor command: {other:?}" ))), } } fn apply_replace_block_command( document: &mut EditorBlockDocument, command: EditorReplaceBlock, ) -> Result<(), BridgeError> { let block = document .blocks .iter_mut() .find(|block| block.block_id == command.block_id) .ok_or_else(|| BridgeError::validation(format!("未找到 blockId:{}", command.block_id)))?; if let Some(block_type) = command.block_type { block.block_type = block_type; } if let Some(props) = command.props { block.props = props; } if let Some(content_nodes) = command.content_nodes { block.content_nodes = content_nodes; } Ok(()) } fn apply_insert_block_after_command( document: &mut EditorBlockDocument, command: EditorInsertBlockAfter, ) -> Result<(), BridgeError> { if document .blocks .iter() .any(|block| block.block_id == command.block.block_id) { return Err(BridgeError::validation(format!( "blockId 已存在:{}", command.block.block_id ))); } let parent_id = find_parent_block_id(document, &command.after_block_id).ok_or_else(|| { BridgeError::validation(format!("未找到 blockId:{}", command.after_block_id)) })?; insert_block_id_after( document, parent_id.as_deref(), &command.after_block_id, command.block.block_id.clone(), )?; document.blocks.push(command.block); Ok(()) } fn apply_delete_block_command( document: &mut EditorBlockDocument, command: EditorDeleteBlock, ) -> Result<(), BridgeError> { let block = document .blocks .iter() .find(|block| block.block_id == command.block_id) .ok_or_else(|| BridgeError::validation(format!("未找到 blockId:{}", command.block_id)))?; if !block.child_block_ids.is_empty() || command.preserve_children { return Err(BridgeError::validation( "页面块 AI 工具第一阶段仅支持删除无子块的普通块", )); } remove_block_id_from_order(document, &command.block_id)?; document .blocks .retain(|block| block.block_id != command.block_id); Ok(()) } fn apply_move_block_command( document: &mut EditorBlockDocument, command: EditorMoveBlock, ) -> Result<(), BridgeError> { if !document .blocks .iter() .any(|block| block.block_id == command.block_id) { return Err(BridgeError::validation(format!( "未找到 blockId:{}", command.block_id ))); } if command.after_block_id.as_deref() == Some(command.block_id.as_str()) { return Err(BridgeError::validation("不能把块移动到自身之后")); } remove_block_id_from_order(document, &command.block_id)?; match command.after_block_id { Some(after_block_id) => { insert_block_id_after( document, command.parent_block_id.as_deref(), &after_block_id, command.block_id, )?; } None => { let siblings = sibling_ids_mut(document, command.parent_block_id.as_deref()) .ok_or_else(|| BridgeError::validation("目标父块不存在"))?; siblings.insert(0, command.block_id); } } Ok(()) } fn find_parent_block_id(document: &EditorBlockDocument, block_id: &str) -> Option> { if document.root_block_ids.iter().any(|id| id == block_id) { return Some(None); } document .blocks .iter() .find(|block| block.child_block_ids.iter().any(|id| id == block_id)) .map(|block| Some(block.block_id.clone())) } fn sibling_ids_mut<'a>( document: &'a mut EditorBlockDocument, parent_block_id: Option<&str>, ) -> Option<&'a mut Vec> { match parent_block_id { Some(parent_block_id) => document .blocks .iter_mut() .find(|block| block.block_id == parent_block_id) .map(|block| &mut block.child_block_ids), None => Some(&mut document.root_block_ids), } } fn insert_block_id_after( document: &mut EditorBlockDocument, parent_block_id: Option<&str>, after_block_id: &str, block_id: String, ) -> Result<(), BridgeError> { let siblings = sibling_ids_mut(document, parent_block_id) .ok_or_else(|| BridgeError::validation("目标父块不存在"))?; let index = siblings .iter() .position(|candidate| candidate == after_block_id) .ok_or_else(|| { BridgeError::validation(format!("未找到 anchor blockId:{after_block_id}")) })?; siblings.insert(index + 1, block_id); Ok(()) } fn remove_block_id_from_order( document: &mut EditorBlockDocument, block_id: &str, ) -> Result<(), BridgeError> { if let Some(index) = document .root_block_ids .iter() .position(|candidate| candidate == block_id) { document.root_block_ids.remove(index); return Ok(()); } for block in &mut document.blocks { if let Some(index) = block .child_block_ids .iter() .position(|candidate| candidate == block_id) { block.child_block_ids.remove(index); return Ok(()); } } Err(BridgeError::validation(format!( "未找到 blockId:{block_id}" ))) } fn stable_json_content_hash(value: &Value) -> Result { let serialized = serde_json::to_string(value).map_err(|error| { BridgeError::validation(format!("复合命令 payload content hash 序列化失败: {error}")) })?; let mut hash = 0xcbf29ce484222325u64; for byte in serialized.as_bytes() { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x100000001b3); } Ok(format!("fnv1a64:{hash:016x}")) } fn editor_document_block_ids(document: &EditorBlockDocument) -> Vec { let mut ids = document.root_block_ids.clone(); for block in &document.blocks { if !ids.iter().any(|id| id == &block.block_id) { ids.push(block.block_id.clone()); } } ids } fn document_save_page_body_domain_event_payload( payload: &DocumentSaveCommandPayload, editor_document: &EditorBlockDocument, ) -> Value { json!({ "page": { "id": payload.document_id.clone(), "workspaceId": payload.workspace_id.clone(), }, "blocks": { "ids": editor_document_block_ids(editor_document), "count": editor_document.blocks.len(), }, }) } fn document_save_snapshot_domain_event_payload( payload: &DocumentSaveCommandPayload, canonical_content: &Value, ) -> Result { Ok(json!({ "page": { "id": payload.document_id.clone(), "workspaceId": payload.workspace_id.clone(), }, "snapshot": { "version": payload.revision, "contentHash": stable_json_content_hash(canonical_content)?, "updatedAt": Value::Null, }, })) } fn block_patch_domain_event_payload(payload: &BlockPatchCommandPayload) -> Value { let next_type = read_trimmed_str_field(&payload.next_block, "type") .or_else(|| read_trimmed_str_field(&payload.next_block, "blockType")); json!({ "document": { "id": payload.document_id.clone(), "workspaceId": payload.workspace_id.clone(), }, "block": { "id": payload.block_id.clone(), }, "patch": { "summary": "replace_block", "nextType": next_type, }, }) } fn block_move_domain_event_payload(payload: &BlockMoveCommandPayload) -> Value { json!({ "block": { "id": payload.block_id.clone(), }, "move": { "sourceDocumentId": payload.source_document_id.clone(), "targetDocumentId": payload.target_document_id.clone(), }, }) } fn block_embed_domain_event_payload(payload: &BlockEmbedCommandPayload) -> Value { json!({ "block": { "id": payload.block_id.clone(), }, "embed": { "sourceDocumentId": payload.source_document_id.clone(), "targetDocumentId": payload.target_document_id.clone(), "targetBlockId": payload.target_block_id.clone(), }, }) } fn to_bridge_context(context: RuntimeBridgeContextWire) -> BridgeContext { BridgeContext { deployment_id: context.deployment_id, project_id: context.project_id, workspace_id: context.workspace_id, request_id: context.request_id, trace_id: context.trace_id, actor_type: context.actor.actor_type, actor_id: context.actor.actor_id, session_id: context.actor.session_id, tenant_id: context.tenant_id, auth_token: context.auth_token, source_channel: context.source.channel, source_client: context.source.client, idempotency_key: context.idempotency_key, validate_only: context.validate_only, dry_run: context.dry_run, } } fn to_actor_payload(actor: &RuntimeActorWire) -> ActorPayload { ActorPayload { actor_type: actor.actor_type.clone(), actor_id: actor.actor_id.clone(), session_id: actor.session_id.clone(), } } fn to_source_payload(source: &RuntimeSourceWire) -> SourcePayload { SourcePayload { channel: source.channel.clone(), client: source.client.clone(), } } fn to_target_ref(target: Option<&RuntimeTargetWire>) -> Option { target.map(|target| TargetRef { workspace_id: target.workspace_id.clone(), page_id: target.page_id.clone(), block_id: target.block_id.clone(), }) } fn bridge_error_kind_to_wire(kind: &BridgeErrorKind) -> &'static str { match kind { BridgeErrorKind::Validation => "validation", BridgeErrorKind::Unauthorized => "unauthorized", BridgeErrorKind::Conflict => "conflict", BridgeErrorKind::NotFound => "not_found", BridgeErrorKind::Transport => "transport", BridgeErrorKind::Rejected => "rejected", } } #[cfg(test)] mod tests { use super::*; fn demo_context() -> RuntimeBridgeContextWire { RuntimeBridgeContextWire { deployment_id: Some("dep_1".into()), project_id: Some("proj_1".into()), workspace_id: Some("ws_1".into()), request_id: "req_1".into(), trace_id: "trace_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, tenant_id: None, auth_token: None, idempotency_key: Some("idem_1".into()), validate_only: false, dry_run: false, } } fn demo_tree_rename_command(command_id: &str) -> RuntimeCommandEnvelopeWire { RuntimeCommandEnvelopeWire { name: "tree.node.rename".into(), command_id: command_id.into(), idempotency_key: Some(format!("idem_{command_id}")), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "mnote-tree-shell-v1".into(), client: "mnote-web".into(), source_kind: Some("local_folder".into()), root_uri: Some("file:///tmp/mnote-vault".into()), workspace_id: Some("local_ws".into()), capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("local_ws".into()), page_id: Some("local_page".into()), block_id: None, }), payload: json!({ "documentId": "local_page", "title": "本地页面", }), preflight_data: None, reason: Some("mock workspace source".into()), refs: vec!["workspace-source-test".into()], dry_run: false, validate_only: false, } } #[test] fn blocks_get_query_plan_maps_to_blocks_get_by_id() { let plan = execute_runtime_input(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "blocks.get".into(), payload: json!({ "blockId": "block_1", "workspaceId": "ws_1", }), }, data: None, }) .expect("query plan should build"); match plan { RuntimeExecutionPlan::Query(plan) => { assert_eq!(plan.function_name, "blocks.get"); assert_eq!( plan.args_json, json!({ "id": "block_1", "workspaceId": "ws_1", }) ); } RuntimeExecutionPlan::Command(_) => panic!("expected query plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected query plan"), } } #[test] fn blocks_patch_command_plan_maps_to_documents_update_content() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "blocks.patch".into(), command_id: "cmd_patch_1".into(), idempotency_key: Some("idem_patch".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("block_1".into()), }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "blockId": "block_1", "nextBlock": { "id": "block_1", "type": "paragraph", }, }), 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, "blocks.patch"); assert_eq!(plan.command_name, "blocks.patch"); assert_eq!( plan.args_json, json!({ "id": "doc_1", "blockId": "block_1", "nextBlock": { "id": "block_1", "type": "paragraph", }, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "blocks.patch", "documentId": "doc_1", "blockId": "block_1" } }, "domainEventHint": { "family": "tree", "eventType": "block.patched" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "block.patched", "payload": { "document": { "id": "doc_1", "workspaceId": "ws_1" }, "block": { "id": "block_1" }, "patch": { "summary": "replace_block", "nextType": "paragraph" } }, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "blocks.patch", "documentId": "doc_1", "blockId": "block_1" } } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn tree_command_plan_keeps_workspace_source_contract_in_envelope() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.node.rename".into(), command_id: "cmd_tree_source_1".into(), idempotency_key: Some("idem_tree_source".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "mnote-tree-shell-v1".into(), client: "mnote-web".into(), source_kind: Some("convex_workspace".into()), root_uri: Some("convex://workspace/ws_1".into()), workspace_id: Some("ws_1".into()), capabilities: vec![ "load-snapshot".into(), "preflight-command".into(), "execute-command".into(), ], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "title": "新标题", }), preflight_data: None, reason: Some("tree-shell rename".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }, }) .expect("command plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "tree.node.rename"); assert_eq!(plan.source["sourceKind"], json!("convex_workspace")); assert_eq!(plan.source["rootUri"], json!("convex://workspace/ws_1")); assert_eq!(plan.source["workspaceId"], json!("ws_1")); assert_eq!( plan.source["capabilities"], json!(["load-snapshot", "preflight-command", "execute-command"]) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn tree_command_rejects_unknown_workspace_source_kind() { let error = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.node.rename".into(), command_id: "cmd_unknown_source".into(), idempotency_key: Some("idem_unknown_source".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "mnote-tree-shell-v1".into(), client: "mnote-web".into(), source_kind: Some("unknown_source".into()), root_uri: Some("unknown://workspace/ws_1".into()), workspace_id: Some("ws_1".into()), capabilities: vec!["execute-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "title": "新标题", }), preflight_data: None, reason: Some("tree-shell rename".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }, }) .expect_err("unknown source kind should be rejected"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("unknown_source")); } #[test] fn tree_command_rejects_workspace_source_without_execute_capability() { let error = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.node.rename".into(), command_id: "cmd_missing_execute".into(), idempotency_key: Some("idem_missing_execute".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "mnote-tree-shell-v1".into(), client: "mnote-web".into(), source_kind: Some("convex_workspace".into()), root_uri: Some("convex://workspace/ws_1".into()), workspace_id: Some("ws_1".into()), capabilities: vec!["load-snapshot".into(), "preflight-command".into()], }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "title": "新标题", }), preflight_data: None, reason: Some("tree-shell rename".into()), refs: vec!["mnote-web-tree".into()], dry_run: false, validate_only: false, }, }) .expect_err("source without execute-command should be rejected"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("execute-command")); } struct TestWorkspaceSourceAdapter { source: core_protocol::WorkspaceSource, } impl WorkspaceSourceAdapter for TestWorkspaceSourceAdapter { fn source(&self) -> &core_protocol::WorkspaceSource { &self.source } fn load_snapshot( &self, projection: KernelProjectionKind, ) -> Result { Ok(KernelProjectionResult { projection_id: format!("mock:{projection:?}"), projection, root_node_id: Some("mock_root".into()), items: Vec::new(), edges: Vec::new(), meta: BTreeMap::new(), }) } fn watch(&self) -> Result { Ok(WorkspaceSourceWatch { source_kind: "local_folder".into(), active: true, }) } fn preflight_command( &self, _command: &RuntimeCommandEnvelopeWire, ) -> Result { Ok(json!({ "ok": true, "executor": "mock" })) } fn execute_command( &self, _command: &RuntimeCommandEnvelopeWire, ) -> Result { Ok(json!({ "ok": true, "executor": "mock" })) } fn resolve_page_aggregate( &self, page_id: &str, ) -> Result { build_page_aggregate_projection_result( &json!({ "id": page_id, "workspaceId": self.source.workspace_id, "title": "Mock Page", "content": [], }), page_id, Some(self.source.workspace_id.as_str()), PageAggregateSource::Fixture, ) } } #[test] fn workspace_source_adapter_mock_outputs_file_and_page_tree_snapshots() { let adapter = TestWorkspaceSourceAdapter { source: core_protocol::WorkspaceSource { source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, root_uri: "file:///tmp/mnote-vault".into(), workspace_id: "local_ws".into(), capabilities: vec![ core_protocol::WorkspaceSourceCapability::LoadSnapshot, core_protocol::WorkspaceSourceCapability::Watch, core_protocol::WorkspaceSourceCapability::PreflightCommand, core_protocol::WorkspaceSourceCapability::ExecuteCommand, core_protocol::WorkspaceSourceCapability::ResolvePageAggregate, ], }, }; assert_eq!(adapter.source().root_uri, "file:///tmp/mnote-vault"); assert_eq!( adapter .load_snapshot(KernelProjectionKind::FileTree) .expect("file_tree snapshot") .projection, KernelProjectionKind::FileTree ); assert_eq!( adapter .load_snapshot(KernelProjectionKind::PageTree) .expect("page_tree snapshot") .projection, KernelProjectionKind::PageTree ); assert!(adapter.watch().expect("watch").active); assert_eq!( adapter .preflight_command(&demo_tree_rename_command("cmd_preflight")) .expect("preflight")["executor"], json!("mock") ); assert_eq!( adapter .execute_command(&demo_tree_rename_command("cmd_execute")) .expect("execute")["executor"], json!("mock") ); assert_eq!( adapter .resolve_page_aggregate("local_page") .expect("page aggregate") .page_id, "local_page" ); } #[test] fn sidebar_dataset_query_plan_maps_to_sidebar_dataset_list() { let plan = execute_runtime_input(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "sidebar.dataset.list".into(), payload: json!({ "workspaceId": "ws_1", }), }, data: None, }) .expect("query plan should build"); match plan { RuntimeExecutionPlan::Query(plan) => { assert_eq!(plan.function_name, "sidebar.dataset.list"); assert_eq!(plan.args_json, json!({ "workspaceId": "ws_1" })); } RuntimeExecutionPlan::Command(_) => panic!("expected query plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected query plan"), } } #[test] fn search_documents_query_plan_maps_to_search_documents() { let plan = execute_runtime_input(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "search.documents".into(), payload: json!({ "query": "rust", "workspaceId": "ws_1", "pageId": "page_1", "limit": 20, "titleOnly": false, "exact": false, "includeOcr": true, "timeRange": "any", "timeField": "updated", "customRangeFrom": null, "customRangeTo": null, }), }, data: None, }) .expect("query plan should build"); match plan { RuntimeExecutionPlan::Query(plan) => { assert_eq!(plan.function_name, "search.documents"); assert_eq!( plan.args_json, json!({ "query": "rust", "workspaceId": "ws_1", "pageId": "page_1", "limit": 20, "cursor": null, "titleOnly": false, "exact": false, "includeOcr": true, "timeRange": "any", "timeField": "updated", "customRangeFrom": null, "customRangeTo": null, }) ); } RuntimeExecutionPlan::Command(_) => panic!("expected query plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected query plan"), } } #[test] fn page_aggregate_get_query_executes_into_core_projection() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_1", "workspace_id": "ws_1", "title": "聚合页面", "parent_id": "root", "updated_at": "2026-04-29T00:00:00Z" }, "content": { "content": [], "revision": 7, "conflict_detection_key": "doc_1:7", "pageSubtree": {"rootNodeId": "doc_1"} } })), }) .expect("page aggregate query should build"); assert_eq!(result["schema"], json!("mnote.page_aggregate.v1")); assert_eq!(result["projectionVersion"], json!(1)); assert_eq!(result["source"], json!("CompatMetaContentJoin")); assert_eq!(result["pageId"], json!("doc_1")); assert_eq!(result["identity"]["documentId"], json!("doc_1")); assert_eq!(result["body"]["revision"], json!(7)); } #[test] fn page_aggregate_get_projects_legacy_content_to_block_document() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_1", "workspace_id": "ws_1", "title": "块投影页面" }, "content": { "content": [ { "id": "p_1", "type": "paragraph", "content": [{"type": "text", "text": "第一段"}] }, { "id": "h_1", "type": "heading", "props": {"level": 2}, "content": "标题" } ], "revision": 8, "conflict_detection_key": "doc_1:8" } })), }) .expect("page aggregate query should build"); assert_eq!(result["body"]["blockProjectionVersion"], json!(1)); assert_eq!( result["body"]["blockDocument"]["documentId"], json!("doc_1") ); assert_eq!( result["body"]["blockDocument"]["rootBlockIds"], json!(["p_1", "h_1"]) ); assert_eq!( result["body"]["blockDocument"]["blocks"][0]["blockId"], json!("p_1") ); assert_eq!( result["body"]["blockDocument"]["blocks"][0]["type"], json!("paragraph") ); assert_eq!( result["body"]["blockDocument"]["blocks"][0]["text"], json!("第一段") ); assert_eq!( result["body"]["blockDocument"]["blocks"][0]["path"], json!([0]) ); let revision_ref = result["body"]["blockDocument"]["blocks"][0]["revisionRef"] .as_str() .expect("revisionRef"); assert!(revision_ref.starts_with("pageRev:8:block:p_1:hash:fnv1a64:")); assert_eq!( result["body"]["blockDocument"]["blocks"][1]["attrs"]["headingLevel"], json!(2) ); assert_eq!( result["body"]["projectionSource"], json!("documents.content") ); } #[test] fn page_aggregate_block_document_preserves_mindmap_projection_attrs() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_mindmap", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_mindmap", "workspace_id": "ws_1", "title": "Mindmap Page" }, "content": { "content": [ { "id": "local-block-1", "type": "mindmap", "props": { "mindmapId": "mindmap-123456.json", "sourcePath": "mindmap-123456.json", "rootNodeId": "root" }, "content": [] } ], "revision": 3, "conflict_detection_key": "doc_mindmap:3" } })), }) .expect("page aggregate query should build"); let block = &result["body"]["blockDocument"]["blocks"][0]; assert_eq!(block["type"], json!("mindmap")); assert_eq!(block["attrs"]["mindmapId"], json!("mindmap-123456.json")); assert_eq!(block["attrs"]["sourcePath"], json!("mindmap-123456.json")); assert_eq!(block["attrs"]["rootNodeId"], json!("root")); } #[test] fn page_aggregate_get_prefers_editor_document_over_legacy_content() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_1", "workspace_id": "ws_1", "title": "原生块文档页面" }, "content": { "content": [ { "id": "legacy_1", "type": "paragraph", "content": "旧 content 不应成为块投影真相" } ], "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["editor_1"], "blocks": [{ "blockId": "editor_1", "blockType": "paragraph", "contentNodes": [{ "payload": { "type": "text", "text": "来自 editorDocument 的正文" }, "attrs": {} }], "childBlockIds": [] }] }, "revision": 9, "conflict_detection_key": "doc_1:9" } })), }) .expect("page aggregate query should build"); assert_eq!( result["body"]["blockDocument"]["rootBlockIds"], json!(["editor_1"]) ); assert_eq!( result["body"]["blockDocument"]["blocks"][0]["blockId"], json!("editor_1") ); assert_eq!( result["body"]["blockDocument"]["blocks"][0]["text"], json!("来自 editorDocument 的正文") ); assert_eq!(result["body"]["content"][0]["id"], json!("editor_1")); assert_eq!( result["body"]["content"][0]["content"], json!("来自 editorDocument 的正文") ); assert_eq!(result["body"]["projectionSource"], json!("editorDocument")); } #[test] fn page_aggregate_get_rejects_invalid_editor_document_instead_of_falling_back() { let error = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_1", "workspace_id": "ws_1", "title": "非法块文档页面" }, "content": { "content": [ { "id": "legacy_1", "type": "paragraph", "content": "不能静默回退" } ], "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["editor_1"], "blocks": "非法 blocks" }, "revision": 10, "conflict_detection_key": "doc_1:10" } })), }) .expect_err("invalid editorDocument should be rejected"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("editorDocument")); } #[test] fn page_aggregate_get_rejects_editor_document_root_ids_missing_blocks() { let error = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_1", "workspace_id": "ws_1", "title": "损坏 root 引用页面" }, "content": { "content": [ { "id": "legacy_1", "type": "paragraph", "content": "不能静默回退" } ], "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["missing_root"], "blocks": [{ "blockId": "editor_1", "blockType": "paragraph", "contentNodes": [{ "payload": { "type": "text", "text": "来自 editorDocument" }, "attrs": {} }], "childBlockIds": [] }] }, "revision": 10, "conflict_detection_key": "doc_1:10" } })), }) .expect_err("editorDocument rootBlockIds should reference existing blocks"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("rootBlockIds")); } #[test] fn page_aggregate_get_rejects_invalid_block_document_instead_of_falling_back() { let error = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_1", "workspace_id": "ws_1", "title": "非法块投影页面" }, "content": { "content": [ { "id": "legacy_1", "type": "paragraph", "content": "不能静默回退" } ], "blockDocument": "非法 blockDocument", "revision": 11, "conflict_detection_key": "doc_1:11" } })), }) .expect_err("invalid blockDocument should be rejected"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("blockDocument")); } #[test] fn page_aggregate_get_projects_body_content_from_block_document_source() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "page.aggregate.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "meta": { "id": "doc_1", "workspace_id": "ws_1", "title": "块投影来源页面" }, "content": { "content": [ { "id": "legacy_1", "type": "paragraph", "content": "旧 content 不应成为 body 真相" } ], "blockDocument": { "documentId": "doc_1", "rootBlockIds": ["block_doc_1"], "blocks": [{ "blockId": "block_doc_1", "type": "paragraph", "text": "来自 blockDocument 的正文", "attrs": {}, "contentNodes": [{ "payload": { "type": "text", "text": "来自 blockDocument 的正文" }, "attrs": {} }], "children": [], "parentBlockId": null }] }, "blockProjectionVersion": 2, "revision": 12, "conflict_detection_key": "doc_1:12" } })), }) .expect("page aggregate query should build"); assert_eq!(result["body"]["projectionSource"], json!("blockDocument")); assert_eq!(result["body"]["blockProjectionVersion"], json!(2)); assert_eq!(result["body"]["content"][0]["id"], json!("block_doc_1")); assert_eq!( result["body"]["content"][0]["content"], json!("来自 blockDocument 的正文") ); } #[test] fn editor_command_apply_replaces_and_moves_legacy_content_through_canonical_document() { let content = json!([ {"id": "p_1", "type": "paragraph", "content": "第一段"}, {"id": "p_2", "type": "paragraph", "content": "第二段"}, {"id": "p_3", "type": "paragraph", "content": "第三段"} ]); let replaced = apply_editor_command_to_legacy_content( "doc_1", &content, EditorCommand::ReplaceBlock(EditorReplaceBlock { block_id: "p_2".into(), block_type: None, props: None, content_nodes: Some(build_text_content_nodes("替换第二段")), }), ) .expect("replace block should apply"); assert_eq!(replaced[1]["id"], json!("p_2")); assert_eq!(replaced[1]["content"], json!("替换第二段")); let moved = apply_editor_command_to_legacy_content( "doc_1", &replaced, EditorCommand::MoveBlock(EditorMoveBlock { block_id: "p_3".into(), parent_block_id: None, after_block_id: Some("p_1".into()), }), ) .expect("move block should apply"); assert_eq!( moved .as_array() .expect("array") .iter() .map(|block| block["id"].as_str().expect("id")) .collect::>(), vec!["p_1", "p_3", "p_2"] ); } #[test] fn editor_document_roundtrip_preserves_resource_block_type() { let content = json!([ { "id": "resource_1", "type": "resource", "props": { "resourceKind": "asset", "assetId": "asset_1" }, "content": "资源块" } ]); let document = editor_document_from_legacy_content("doc_1", &content); assert_eq!(document.blocks[0].block_type, EditorBlockType::Resource); let legacy = legacy_content_from_editor_document(&document); assert_eq!(legacy[0]["id"], json!("resource_1")); assert_eq!(legacy[0]["type"], json!("resource")); assert_eq!(legacy[0]["props"]["resourceKind"], json!("asset")); assert_eq!(legacy[0]["props"]["assetId"], json!("asset_1")); let projection = project_legacy_content_to_block_document("doc_1", &legacy, &json!(1)) .expect("resource block should project"); assert_eq!(projection["blocks"][0]["type"], json!("resource")); assert_eq!(projection["blocks"][0]["editable"], json!(false)); assert_eq!( projection["blocks"][0]["unsupportedReason"], json!("复杂块暂不开放 AI 精确写入") ); } #[test] fn local_markdown_image_legacy_projection_exposes_image_attrs() { let legacy = json!([{ "id": "local-block-2", "type": "image", "props": { "src": "./Page.assets/photo.png", "alt": "photo", "title": "photo" }, "content": [], "children": [] }]); let projection = project_legacy_content_to_block_document("local-md:docs~2FPage.md", &legacy, &json!(0)) .expect("image block should project"); let block = &projection["blocks"][0]; assert_eq!(block["type"], json!("image")); assert_eq!(block["attrs"]["src"], json!("./Page.assets/photo.png")); assert_eq!(block["attrs"]["alt"], json!("photo")); assert_eq!(block["attrs"]["title"], json!("photo")); } #[test] fn local_markdown_media_legacy_projection_exposes_attachment_attrs() { let legacy = json!([{ "id": "local-block-3", "type": "media", "props": { "name": "Spec.pdf", "sourcePath": ".assets/file/Spec.pdf" }, "content": [], "children": [] }]); let projection = project_legacy_content_to_block_document("local-md:docs~2FPage.md", &legacy, &json!(0)) .expect("media block should project"); let block = &projection["blocks"][0]; assert_eq!(block["type"], json!("media")); assert_eq!(block["attrs"]["name"], json!("Spec.pdf")); assert_eq!(block["attrs"]["sourcePath"], json!(".assets/file/Spec.pdf")); } #[test] fn local_markdown_table_legacy_projection_exposes_tiptap_table_attrs() { let legacy = json!([{ "id": "local-block-4", "type": "table", "props": { "tiptapTable": { "type": "table", "content": [{ "type": "tableRow", "content": [{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Provider 类别" }] }] }] }] } }, "content": [], "children": [] }]); let projection = project_legacy_content_to_block_document("local-md:docs~2FPage.md", &legacy, &json!(0)) .expect("table block should project"); let block = &projection["blocks"][0]; assert_eq!(block["type"], json!("table")); assert_eq!(block["attrs"]["tiptapTable"]["type"], json!("table")); assert_eq!( block["attrs"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0] ["text"], json!("Provider 类别") ); } #[test] fn legacy_content_from_editor_document_preserves_inline_link_styles() { let mut attrs = BTreeMap::new(); attrs.insert( "styles".into(), json!({ "link": "/onlyoffice?assetId=asset_1", }), ); let document = EditorBlockDocument { document_id: "doc_1".into(), root_block_ids: vec!["attachment_block".into()], blocks: vec![EditorBlock { block_id: "attachment_block".into(), block_type: EditorBlockType::Paragraph, props: BlockProps::default(), content_nodes: vec![ContentNode { payload: ContentNodePayload::Text { text: "附件.docx".into(), marks: vec![], }, attrs, }], child_block_ids: vec![], }], }; let legacy = legacy_content_from_editor_document(&document); assert_eq!(legacy[0]["content"][0]["text"], json!("附件.docx")); assert_eq!( legacy[0]["content"][0]["styles"]["link"], json!("/onlyoffice?assetId=asset_1") ); } #[test] fn documents_save_hydrates_image_props_from_raw_editor_document() { let payload = DocumentSaveCommandPayload { document_id: "doc_1".into(), workspace_id: Some("ws_1".into()), revision: None, editor_document: Some(json!({ "documentId": "doc_1", "rootBlockIds": ["image_block"], "blocks": [{ "blockId": "image_block", "blockType": "image", "props": { "src": "http://127.0.0.1:3210/api/storage/image_1", "alt": "图片.png", "title": "图片.png", "tiptapImage": { "type": "image", "attrs": { "src": "http://127.0.0.1:3210/api/storage/image_1", "alt": "图片.png", "title": "图片.png" } } }, "contentNodes": [], "childBlockIds": [] }] })), content: json!([]), tiptap_document: None, conflict_detection_key: None, }; let document = normalize_save_editor_document(&payload).expect("valid editor document"); let legacy = legacy_content_from_editor_document(&document); assert_eq!( legacy[0]["props"]["src"], json!("http://127.0.0.1:3210/api/storage/image_1") ); assert_eq!(legacy[0]["props"]["alt"], json!("图片.png")); assert_eq!(legacy[0]["props"]["title"], json!("图片.png")); assert_eq!(legacy[0]["props"]["tiptapImage"]["type"], json!("image")); } #[test] fn editor_command_apply_insert_preserves_nested_children() { let content = json!([{ "id": "parent", "type": "heading", "content": "父级", "children": [ {"id": "child_1", "type": "paragraph", "content": "子级一"} ] }]); let inserted = apply_editor_command_to_legacy_content( "doc_1", &content, EditorCommand::InsertBlockAfter(EditorInsertBlockAfter { after_block_id: "child_1".into(), block: EditorBlock { block_id: "child_2".into(), block_type: EditorBlockType::Paragraph, props: BlockProps::default(), content_nodes: build_text_content_nodes("子级二"), child_block_ids: vec![], }, }), ) .expect("insert nested block should apply"); assert_eq!( inserted[0]["children"] .as_array() .expect("children") .iter() .map(|block| block["id"].as_str().expect("id")) .collect::>(), vec!["child_1", "child_2"] ); } #[test] fn search_documents_query_canonical_facade_keeps_projection_owner() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "search.documents.query".into(), payload: json!({ "query": "rust", "workspaceId": "ws_1", "pageId": null, "limit": 10, "titleOnly": false, "exact": false, "includeOcr": true, "timeRange": "any", "timeField": "updated", "customRangeFrom": null, "customRangeTo": null, }), }, data: Some(json!({ "documents": [ { "id": "page_1", "workspaceId": "ws_1", "title": "Rust Notes", "rawText": "这里有 rust 搜索内容", "createdAt": "2026-04-15T00:00:00Z", "updatedAt": "2026-04-15T01:00:00Z" } ], "mindmaps": [], "tables": [], "tableRows": [], "assets": [] })), }) .expect("canonical search query result should build"); assert_eq!(result["projectionOwner"], json!("rust-kernel")); assert_eq!( result["results"][0]["projectionOwner"], json!("rust-kernel") ); assert_eq!(result["results"][0]["id"], json!("page_1")); } #[test] fn mindmap_projection_get_query_returns_kernel_projection() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "mindmap.projection.get".into(), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "data": { "data": {"uid": "root", "text": "中心主题"}, "children": [ {"data": {"uid": "child_1", "text": "子节点"}, "children": []} ] }, "revision": 3 })), }) .expect("mindmap projection should build"); assert_eq!(result["schema"], json!("mnote.mindmap_projection.v1")); assert_eq!(result["owner"], json!("rust-kernel")); assert_eq!(result["mapId"], json!("mind_1")); assert_eq!(result["revision"], json!(3)); } #[test] fn mindmap_kernel_projection_get_query_returns_leptos_contract_projection() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "mindmap.kernel_projection.get".into(), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "source": "kernel", "data": { "data": {"uid": "root", "text": "KMIND", "generalization": {"text": "概要"}}, "children": [ { "data": {"uid": "topic", "text": "二级节点"}, "children": [ {"data": {"uid": "branch-1", "text": "分支主题"}, "children": []}, {"data": {"uid": "branch-2", "text": "分支主题"}, "children": []} ] } ] }, "revision": 9, "layout": "logicalStructure", "theme": "classic", "view": {"scale": 1.0} })), }) .expect("mindmap kernel projection should build"); assert_eq!( result["schema"], json!("mnote.mindmap.kernel_projection.v1") ); assert_eq!(result["documentId"], json!("doc_1")); assert_eq!(result["mindmapId"], json!("mind_1")); assert_eq!(result["rootNodeId"], json!("root")); assert_eq!(result["revision"], json!(9)); assert_eq!(result["source"], json!("kernel")); assert_eq!(result["nodes"].as_array().map(Vec::len), Some(4)); assert_eq!(result["edges"].as_array().map(Vec::len), Some(3)); assert_eq!(result["summaries"][0]["text"], json!("概要")); assert_eq!(result["capabilities"]["canEditText"], json!(true)); } #[test] fn mindmap_adapter_scene_get_query_returns_simple_mind_map_projection() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "mindmap.simple_mind_map_scene.get".into(), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "data": { "data": {"uid": "root", "text": "KMIND", "generalization": {"text": "概要"}}, "children": [ { "data": {"uid": "topic", "text": "二级节点"}, "children": [ {"data": {"uid": "branch-1", "text": "分支主题"}, "children": []}, {"data": {"uid": "branch-2", "text": "分支主题"}, "children": []} ] } ] }, "revision": 9, "layout": "logicalStructure", "theme": "classic", "themeConfig": {"lineColor": "#f59e0b"}, "view": {"scale": 1.0}, "config": {"mousewheelAction": "zoom"} })), }) .expect("mindmap adapter projection should build"); assert_eq!( result["schema"], json!("mnote.mindmap.simple_mind_map_scene.v1") ); assert_eq!(result["runtime"], json!("simple-mind-map")); assert_eq!(result["root"]["data"]["text"], json!("KMIND")); assert_eq!( result["root"]["children"][0]["data"]["text"], json!("二级节点") ); assert_eq!( result["root"]["children"][0]["children"][1]["data"]["text"], json!("分支主题") ); assert_eq!(result["layout"], json!("logicalStructure")); assert_eq!(result["theme"], json!("classic")); assert_eq!(result["themeConfig"]["lineColor"], json!("#f59e0b")); assert_eq!(result["kernelRevision"], json!(9)); } #[test] fn mindmap_adapter_scene_reads_metadata_from_convex_data_wrapper() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "mindmap.simple_mind_map_scene.get".into(), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "ok": true, "data": { "data": { "data": {"uid": "root", "text": "KMIND"}, "children": [] }, "view": { "state": {"scale": 1.25, "x": 10, "y": 20}, "transform": {"scaleX": 1.25, "scaleY": 1.25} }, "theme": "classic", "revision": 7 }, "meta": {"mindmap_id": "mind_1"} })), }) .expect("mindmap adapter projection should read Convex data wrapper"); assert_eq!(result["root"]["data"]["text"], json!("KMIND")); assert_eq!(result["view"]["state"]["scale"], json!(1.25)); assert_eq!(result["view"]["transform"]["scaleX"], json!(1.25)); assert_eq!(result["theme"], json!("classic")); assert_eq!(result["kernelRevision"], json!(7)); } #[test] fn mindmap_adapter_scene_get_query_uses_kmind_default_skeleton_for_empty_data() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "mindmap.simple_mind_map_scene.get".into(), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "workspaceId": "ws_1", }), }, data: Some(Value::Null), }) .expect("empty mindmap adapter projection should use default skeleton"); assert_eq!(result["root"]["data"]["text"], json!("KMIND")); assert_eq!( result["root"]["data"]["generalization"]["text"], json!("概要") ); assert_eq!( result["root"]["children"][0]["data"]["text"], json!("二级节点") ); assert_eq!( result["root"]["children"][0]["children"][0]["data"]["text"], json!("分支主题") ); assert_eq!( result["root"]["children"][0]["children"][1]["data"]["text"], json!("分支主题") ); } #[test] fn mindmap_new_projection_queries_plan_through_existing_substrate_transport() { for query_name in [ "mindmap.kernel_projection.get", "mindmap.simple_mind_map_scene.get", ] { let plan = execute_runtime_input(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: query_name.into(), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "workspaceId": "ws_1", }), }, data: None, }) .expect("new mindmap projection query should plan"); match plan { RuntimeExecutionPlan::Query(plan) => { assert_eq!(plan.query_name, "mindmaps.get"); assert_eq!(plan.function_name, "mindmaps.get"); assert_eq!(plan.args_json["docId"], json!("doc_1")); assert_eq!(plan.args_json["mindmapId"], json!("mind_1")); } RuntimeExecutionPlan::Command(_) | RuntimeExecutionPlan::Tool(_) => { panic!("expected query plan") } } } } #[test] fn mindmap_command_apply_plan_uses_kernel_command_facade() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "mindmap.command.apply".into(), command_id: "cmd_mindmap_apply".into(), idempotency_key: None, actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: None, }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: None, payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "commands": [ {"type": "updateText", "mindmapId": "mind_1", "nodeId": "root", "text": "新标题"} ], "projectionRevision": 3 }), preflight_data: None, reason: Some("mindmap command facade".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("mindmap command facade should plan"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "mindmap.command.apply"); assert_eq!(plan.function_name, "mindmap.command.apply"); assert_eq!( plan.args_json["canonicalCommand"], json!("mindmap.command.apply") ); assert_eq!( plan.args_json["commands"][0], json!({ "type": "updateText", "mindmapId": "mind_1", "nodeId": "root", "text": "新标题" }) ); assert_eq!( plan.args_json["domainEventPlan"]["eventType"], json!("mindmap.content.updated") ); assert_eq!(plan.args_json["streamDeltaHint"]["kind"], json!("noop")); } RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => { panic!("expected command plan") } } } #[test] fn mindmap_kernel_command_apply_updates_default_projection_tree() { let result = apply_mindmap_kernel_commands_to_value( &json!({ "ok": true, "data": { "data": {"text": "中心主题"}, "children": [], }, "meta": {"mindmap_id": "mind_1"} }), &[json!({ "type": "updateText", "mindmapId": "mind_1", "nodeId": "root", "text": "新标题" })], ) .expect("kernel command should apply to leptos default tree"); assert_eq!(result.applied, 1); assert!(result.errors.is_empty()); assert_eq!(result.data["data"]["uid"], json!("root")); assert_eq!(result.data["data"]["text"], json!("新标题")); assert_eq!( result.data["children"][0]["data"]["text"], json!("二级节点") ); } #[test] fn mindmap_kernel_command_apply_persists_view_and_compat_payload_patch() { let result = apply_mindmap_kernel_commands_to_value( &json!({ "data": {"data": {"uid": "root", "text": "KMIND"}, "children": []}, "view": {"state": {"scale": 1.0}, "transform": {"scaleX": 1.0, "scaleY": 1.0}}, "theme": "classic" }), &[ json!({ "type": "patchView", "mindmapId": "mind_1", "patch": { "state": {"scale": 1.2, "x": 10, "y": 20}, "transform": {"scaleX": 1.2, "scaleY": 1.2} } }), json!({ "type": "compatPayloadPatch", "mindmapId": "mind_1", "path": "root.data.generalization", "value": {"text": "概要"}, "source": "toolbar" }), ], ) .expect("view patch should persist"); assert_eq!(result.applied, 2); assert!(result.errors.is_empty()); assert_eq!(result.data["data"]["data"]["text"], json!("KMIND")); assert_eq!( result.data["data"]["data"]["generalization"]["text"], json!("概要") ); assert_eq!(result.data["view"]["state"]["scale"], json!(1.2)); assert_eq!(result.data["view"]["state"]["x"], json!(10)); assert_eq!(result.data["view"]["transform"]["scaleX"], json!(1.2)); assert_eq!(result.data["theme"], json!("classic")); } #[test] fn search_documents_query_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "search.documents".into(), payload: json!({ "query": "rust", "workspaceId": "ws_1", "pageId": null, "limit": 10, "titleOnly": false, "exact": false, "includeOcr": true, "timeRange": "any", "timeField": "updated", "customRangeFrom": null, "customRangeTo": null, }), }, data: Some(json!({ "documents": [ { "id": "page_1", "workspaceId": "ws_1", "title": "Rust Notes", "rawText": "这里有 rust 搜索内容", "createdAt": "2026-04-15T00:00:00Z", "updatedAt": "2026-04-15T01:00:00Z" } ], "mindmaps": [], "tables": [], "tableRows": [], "assets": [] })), }) .expect("query result should build"); assert_eq!( result, json!({ "enqueueAssetIds": [], "results": [ { "id": "page_1", "title": "Rust Notes", "snippet": "Rust Notes", "updatedAt": "2026-04-15T01:00:00Z", "createdAt": "2026-04-15T00:00:00Z", "matchField": "title", "hasOcr": false, "publicPath": "/documents/page_1", "nodeId": "page_1", "subtreeRootId": "page_1", "evidence": [ { "kind": "title", "nodeId": "page_1", "snippet": "Rust Notes" } ], "score": 3.0 } ] }) ); } #[test] fn documents_content_query_executes_into_page_subtree_projection() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "documents.content.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "title": "服务端标题", "content": [ { "id": "heading_1", "type": "heading", "props": { "level": 1 }, "content": [{ "type": "text", "text": "章节一" }] } ], "revision": 5, "conflict_detection_key": "doc_1:5" })), }) .expect("query result should build"); assert_eq!(result["revision"], json!(5)); assert_eq!(result["pageSubtree"]["rootNodeId"], json!("doc_1")); assert_eq!( result["pageSubtree"]["outline"][0]["title"], json!("章节一") ); assert_eq!(result["pageSubtree"]["stats"]["headingCount"], json!(1)); } #[test] fn documents_content_query_uses_storage_revision_aliases() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "documents.content.get".into(), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), }, data: Some(json!({ "content": [], "content_revision": 6, "conflict_detection_key": "doc_1:6" })), }) .expect("query result should build"); assert_eq!(result["revision"], json!(6)); assert_eq!(result["conflictDetectionKey"], json!("doc_1:6")); } #[test] fn search_recent_query_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "search.recent".into(), payload: json!({ "workspaceId": "ws_1", "limit": 10, "cursor": null, }), }, data: Some(json!({ "recents": [ { "documentId": "page_1", "workspaceId": "ws_1", "lastAccessedAt": "2026-04-15T02:00:00Z" } ] })), }) .expect("query result should build"); assert_eq!( result, json!({ "items": [ { "documentId": "page_1" } ] }) ); } #[test] fn mindmaps_get_query_plan_maps_to_mindmaps_get() { let plan = execute_runtime_input(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "mindmaps.get".into(), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "workspaceId": "ws_1", }), }, data: None, }) .expect("mindmap query plan should build"); match plan { RuntimeExecutionPlan::Query(plan) => { assert_eq!(plan.function_name, "mindmaps.get"); assert_eq!( plan.args_json, json!({ "docId": "doc_1", "mindmapId": "mind_1", }) ); } _ => panic!("expected query plan"), } } #[test] fn mindmaps_put_command_plan_maps_to_mindmaps_put() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "mindmaps.put".into(), command_id: "cmd_mindmap_put_1".into(), idempotency_key: Some("idem_mindmap_put".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("mind_1".into()), }), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "data": { "data": {"text": "中心主题"}, "children": [], }, "createOnly": true, }), preflight_data: None, reason: Some("保存导图".into()), refs: vec!["task-032".into()], dry_run: false, validate_only: false, }, }) .expect("mindmap command plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.function_name, "mindmaps.put"); assert_eq!(plan.args_json["docId"], json!("doc_1")); assert_eq!(plan.args_json["mindmapId"], json!("mind_1")); assert_eq!( plan.args_json["data"], json!({ "data": {"text": "中心主题"}, "children": [], }) ); assert_eq!(plan.args_json["createOnly"], json!(true)); assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "resync_required", "args": { "reason": "mindmap.put", "documentId": "doc_1", "blockId": "mind_1", } }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.resource.mindmap.put", "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "mindmap.put", "documentId": "doc_1", "blockId": "mind_1", } } }) ); } _ => panic!("expected command plan"), } } #[test] fn mindmaps_put_existing_content_update_uses_object_event_without_tree_resync() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "mindmaps.put".into(), command_id: "cmd_mindmap_put_update".into(), idempotency_key: None, actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: None, }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("mind_1".into()), }), payload: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "data": { "data": {"text": "中心主题已更新"}, "children": [], }, "createOnly": false, }), preflight_data: None, reason: Some("更新导图内容".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("mindmap update command plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "noop", "args": {} }) ); assert_eq!( plan.args_json["domainEventPlan"]["eventType"], json!("mindmap.content.updated") ); assert_eq!( plan.args_json["domainEventPlan"]["streamDeltaHint"], json!({ "family": "tree", "kind": "noop", "args": {} }) ); } _ => panic!("expected command plan"), } } #[test] fn doc_get_tool_plan_uses_documents_content_query() { let plan = execute_runtime_input(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "doc_get".into(), kind: "query".into(), mode: Some("plan".into()), args_json: json!({ "maxBlocks": 20, }), target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), reason: Some("查看页面结构".into()), refs: vec!["task-031".into()], }, data: None, }) .expect("tool plan should build"); match plan { RuntimeExecutionPlan::Tool(plan) => { assert_eq!(plan.tool_name, "doc_get"); assert_eq!(plan.invocation_kind, "query"); assert_eq!(plan.execution_mode, "plan"); assert_eq!(plan.toolset_id, "toolset.doc_read"); assert_eq!(plan.steps[0].name, "documents.content.get"); } _ => panic!("expected tool plan"), } } #[test] fn search_web_tool_plan_executes_inside_rust_runtime() { let plan = execute_runtime_input(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "search_web".into(), kind: "query".into(), mode: Some("plan".into()), args_json: json!({ "query": "onlyoffice rust", "count": 6, }), target: None, reason: Some("查询资料".into()), refs: vec!["task-040".into()], }, data: None, }) .expect("search_web plan should build"); match plan { RuntimeExecutionPlan::Tool(plan) => { assert_eq!(plan.tool_name, "search_web"); assert_eq!(plan.toolset_id, "toolset.readonly"); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].kind, "external"); assert_eq!(plan.steps[0].name, "search_web"); } _ => panic!("expected tool plan"), } } #[test] fn image_read_tool_plan_uses_transport_step() { let plan = execute_runtime_input(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "image_read".into(), kind: "query".into(), mode: Some("plan".into()), args_json: json!({ "assetId": "asset_1", }), target: None, reason: Some("读取 OCR".into()), refs: vec!["task-040".into()], }, data: None, }) .expect("image_read plan should build"); match plan { RuntimeExecutionPlan::Tool(plan) => { assert_eq!(plan.tool_name, "image_read"); assert_eq!(plan.toolset_id, "toolset.media_read"); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].kind, "transport"); assert_eq!(plan.steps[0].name, "media_assets.resolve"); } _ => panic!("expected tool plan"), } } #[test] fn slash_run_tool_plan_builds_without_ts_fallback() { let plan = execute_runtime_input(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "slash_run".into(), kind: "command".into(), mode: Some("plan".into()), args_json: json!({ "text": "/new 新页面", }), target: None, reason: Some("执行斜杠命令".into()), refs: vec!["task-040".into()], }, data: None, }) .expect("slash_run plan should build"); match plan { RuntimeExecutionPlan::Tool(plan) => { assert_eq!(plan.tool_name, "slash_run"); assert_eq!(plan.toolset_id, "toolset.slash_write"); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].kind, "transform"); assert_eq!(plan.steps[0].name, "slash_run"); } _ => panic!("expected tool plan"), } } #[test] fn slash_run_tool_result_parses_new_command_inside_rust() { let result = execute_runtime_query(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "slash_run".into(), kind: "command".into(), mode: Some("result".into()), args_json: json!({ "text": "/new 新页面", }), target: None, reason: Some("执行斜杠命令".into()), refs: vec!["task-040".into()], }, data: Some(json!({ "source": "ai-agent-route" })), }) .expect("slash_run result should build"); assert_eq!(result.get("ok"), Some(&json!(true))); assert_eq!(result.get("source"), Some(&json!("ai-agent-route"))); assert_eq!(result.pointer("/parsed/command"), Some(&json!("new_doc"))); assert_eq!( result.pointer("/parsed/params/title"), Some(&json!("新页面")) ); } #[test] fn doc_replace_range_tool_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "doc_replace_range".into(), kind: "command".into(), mode: Some("result".into()), args_json: json!({ "blockId": "block_1", "text": "新的正文", "mode": "replace", }), target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("block_1".into()), }), reason: Some("改写段落".into()), refs: vec!["task-031".into()], }, data: Some(json!({ "source": "client", "blocks": [ { "id": "block_1", "type": "paragraph", "content": [{"type":"text","text":"旧内容"}], "children": [] } ] })), }) .expect("tool result should build"); assert_eq!( result, json!({ "ok": true, "source": "client", "blockId": "block_1", "mode": "replace", "editorCommandSource": "rust_editor_command", "editorCommands": [ { "kind": "replace_block", "blockId": "block_1", "blockType": null, "props": null, "contentNodes": [ { "payload": { "type": "text", "text": "新的正文", "marks": [] }, "attrs": {} } ] } ], "data": [ { "id": "block_1", "type": "paragraph", "content": [{"type":"text","text":"新的正文"}], "children": [] } ] }) ); } #[test] fn doc_insert_blocks_tool_emits_editor_commands() { let result = execute_runtime_query(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "doc_insert_blocks".into(), kind: "command".into(), mode: Some("result".into()), args_json: json!({ "afterBlockId": "block_1", "blocks": [ { "type": "heading", "level": 2, "text": "Rust 命令插入标题" } ] }), target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("block_1".into()), }), reason: Some("在块后插入标题".into()), refs: vec!["task-054".into()], }, data: Some(json!({ "source": "client", "blocks": [ { "id": "block_1", "type": "paragraph", "content": [{"type":"text","text":"旧内容"}], "children": [] } ] })), }) .expect("tool result should build"); assert_eq!(result.get("ok"), Some(&json!(true))); assert_eq!( result.get("editorCommandSource"), Some(&json!("rust_editor_command")) ); assert_eq!( result.pointer("/editorCommands/0/kind"), Some(&json!("insert_block_after")) ); assert_eq!( result.pointer("/editorCommands/0/afterBlockId"), Some(&json!("block_1")) ); assert_eq!( result.pointer("/editorCommands/0/block/blockType"), Some(&json!("heading")) ); assert_eq!( result.pointer("/editorCommands/0/block/props/headingLevel"), Some(&json!(2)) ); assert_eq!( result.pointer("/editorCommands/0/block/contentNodes/0/payload/text"), Some(&json!("Rust 命令插入标题")) ); } #[test] fn documents_save_command_plan_normalizes_tiptap_into_rust_truth() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_1".into(), idempotency_key: Some("idem_save".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 4, "content": [], "tiptapDocument": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "blockId": "p_1" }, "content": [ { "type": "text", "text": "来自 Rust tiptap 归一化" } ] } ] }, "conflictDetectionKey": "doc_1:4" }), preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-055".into()], dry_run: false, validate_only: false, }, }) .expect("documents.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.function_name, "documents.save"); assert_eq!(plan.args_json.get("id"), Some(&json!("doc_1"))); assert_eq!( plan.args_json.pointer("/editorDocument/rootBlockIds/0"), Some(&json!("p_1")) ); assert_eq!( plan.args_json.pointer("/editorDocument/blocks/0/blockType"), Some(&json!("paragraph")) ); assert_eq!(plan.args_json.pointer("/content/0/id"), Some(&json!("p_1"))); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("来自 Rust tiptap 归一化")) ); } #[test] fn documents_save_command_plan_preserves_horizontal_rule_as_divider() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_divider".into(), idempotency_key: Some("idem_save_divider".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 5, "content": [], "tiptapDocument": { "type": "doc", "content": [ { "type": "horizontalRule", "attrs": { "blockId": "divider_1" } } ] }, "conflictDetectionKey": "doc_1:5" }), preflight_data: None, reason: Some("保存分割线".into()), refs: vec!["phase-e-divider".into()], dry_run: false, validate_only: false, }, }) .expect("documents.save horizontalRule plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json.pointer("/editorDocument/blocks/0/blockType"), Some(&json!("divider")) ); assert_eq!( plan.args_json.pointer("/content/0/type"), Some(&json!("divider")) ); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!([])) ); } #[test] fn documents_save_command_plan_preserves_mindmap_placeholder() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_mindmap".into(), idempotency_key: Some("idem_save_mindmap".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 6, "content": [], "tiptapDocument": { "type": "doc", "content": [{ "type": "paragraph", "attrs": { "blockId": "mind_1", "mnoteBlockType": "mindmap", "mindmapId": "mind_1", "rootNodeId": "root", "projectionVersion": 1 } }] }, "conflictDetectionKey": "doc_1:6" }), preflight_data: None, reason: Some("保存导图占位".into()), refs: vec!["phase6-mindmap".into()], dry_run: false, validate_only: false, }, }) .expect("documents.save mindmap plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json.pointer("/editorDocument/blocks/0/blockType"), Some(&json!("mindmap")) ); assert_eq!( plan.args_json.pointer("/content/0/type"), Some(&json!("mindmap")) ); assert_eq!( plan.args_json.pointer("/content/0/id"), Some(&json!("mind_1")) ); assert_eq!( plan.args_json.pointer("/content/0/props/mindmapId"), Some(&json!("mind_1")) ); assert_eq!(plan.args_json.pointer("/content/0/props/data"), None); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("")) ); } #[test] fn documents_save_command_plan_preserves_mindmap_props_from_editor_document() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: "cmd_save_mindmap_editor_document".into(), idempotency_key: Some("idem_save_mindmap_editor_document".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 7, "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["block-1"], "blocks": [{ "blockId": "block-1", "blockType": "mindmap", "props": { "data": null, "mindmapId": "mind_1", "rootNodeId": "root", "projectionVersion": 1 }, "contentNodes": [], "childBlockIds": [] }] }, "content": [], "tiptapDocument": { "type": "doc", "content": [{ "type": "paragraph", "attrs": { "blockId": "block-1", "mnoteBlockType": "mindmap", "mindmapId": "mind_1", "rootNodeId": "root", "projectionVersion": 1 } }] }, "conflictDetectionKey": "doc_1:7" }), preflight_data: None, reason: Some("保存 editorDocument 导图占位".into()), refs: vec!["task169-mindmap-realtime-smoke".into()], dry_run: false, validate_only: false, }, }) .expect("page.body.save mindmap editorDocument plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json.pointer("/content/0/props/mindmapId"), Some(&json!("mind_1")) ); assert_eq!( plan.args_json.pointer("/content/0/props/rootNodeId"), Some(&json!("root")) ); assert_eq!( plan.args_json.pointer("/content/0/props/projectionVersion"), Some(&json!(1)) ); assert_eq!(plan.args_json.pointer("/content/0/props/data"), None); } #[test] fn documents_save_command_plan_preserves_tiptap_image() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_image".into(), idempotency_key: Some("idem_save_image".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 5, "content": [], "tiptapDocument": { "type": "doc", "content": [{ "type": "image", "attrs": { "src": "/api/editor/image-placeholder.svg", "alt": "E24 图片占位", "title": "E24 图片" } }] }, "conflictDetectionKey": "doc_1:5" }), preflight_data: None, reason: Some("保存图片".into()), refs: vec!["phase-e-image".into()], dry_run: false, validate_only: false, }, }) .expect("documents.save image plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json.pointer("/editorDocument/blocks/0/blockType"), Some(&json!("image")) ); assert_eq!( plan.args_json.pointer("/content/0/type"), Some(&json!("image")) ); assert_eq!( plan.args_json.pointer("/content/0/props/tiptapImage/type"), Some(&json!("image")) ); assert_eq!( plan.args_json.pointer("/content/0/props/src"), Some(&json!("/api/editor/image-placeholder.svg")) ); } #[test] fn documents_save_command_plan_preserves_tiptap_table() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_table".into(), idempotency_key: Some("idem_save_table".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 5, "content": [], "tiptapDocument": { "type": "doc", "content": [{ "type": "table", "attrs": { "blockId": "table_1" }, "content": [{ "type": "tableRow", "content": [{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "A1" }] }] }] }] }] }, "conflictDetectionKey": "doc_1:5" }), preflight_data: None, reason: Some("保存简单表格".into()), refs: vec!["phase-e-table".into()], dry_run: false, validate_only: false, }, }) .expect("documents.save table plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json.pointer("/editorDocument/blocks/0/blockType"), Some(&json!("table")) ); assert_eq!( plan.args_json.pointer("/content/0/type"), Some(&json!("table")) ); assert_eq!( plan.args_json.pointer("/content/0/props/tiptapTable/type"), Some(&json!("table")) ); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("A1")) ); } #[test] fn documents_save_command_plan_preserves_nested_list_children() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_nested_list".into(), idempotency_key: Some("idem_save_nested_list".into()), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 5, "content": [], "tiptapDocument": { "type": "doc", "content": [{ "type": "bulletList", "content": [{ "type": "listItem", "attrs": { "blockId": "parent" }, "content": [ { "type": "paragraph", "content": [{ "type": "text", "text": "E19 parent" }] }, { "type": "bulletList", "content": [{ "type": "listItem", "attrs": { "blockId": "child" }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "E19 child" }] }] }]} ] }] }] }, "conflictDetectionKey": "doc_1:5" }), preflight_data: None, reason: Some("保存嵌套列表".into()), refs: vec!["phase-e-indent".into()], dry_run: false, validate_only: false, }, }) .expect("documents.save nested list plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json.pointer("/editorDocument/rootBlockIds"), Some(&json!(["parent"])) ); assert_eq!( plan.args_json .pointer("/editorDocument/blocks/0/childBlockIds"), Some(&json!(["child"])) ); assert_eq!( plan.args_json.pointer("/content/0/children/0/type"), Some(&json!("bullet_list_item")) ); assert_eq!( plan.args_json.pointer("/content/0/children/0/content"), Some(&json!("E19 child")) ); assert_eq!(plan.args_json.pointer("/content/1"), None); } #[test] fn page_body_save_command_plan_uses_canonical_page_command() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: "cmd_page_body_save".into(), idempotency_key: None, actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: None, }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 1, "content": [], "conflictDetectionKey": "doc_1:1" }), preflight_data: None, reason: Some("canonical body save".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("page.body.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "page.body.save"); assert_eq!(plan.function_name, "page.body.save"); assert_eq!(plan.args_json["expectedRevision"], json!(1)); assert_eq!(plan.args_json["conflictDetectionKey"], json!("doc_1:1")); } #[test] fn page_head_update_title_command_plan_uses_canonical_page_command() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.head.updateTitle".into(), command_id: "cmd_page_head_title".into(), idempotency_key: None, actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: None, }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: None, payload: json!({ "documentId": "doc_1", "title": "新标题" }), preflight_data: None, reason: Some("canonical title update".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("page.head.updateTitle plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "page.head.updateTitle"); assert_eq!(plan.function_name, "page.head.updateTitle"); } #[test] fn page_layout_update_options_command_plan_uses_canonical_page_command() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.layout.updateOptions".into(), command_id: "cmd_page_layout_options".into(), idempotency_key: None, actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: None, }, source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: None, payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "options": {"showToc": true} }), preflight_data: None, reason: Some("canonical layout update".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("page.layout.updateOptions plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "page.layout.updateOptions"); assert_eq!(plan.function_name, "page.layout.updateOptions"); } #[test] fn documents_save_command_plan_rejects_wrong_shape_editor_document_instead_of_falling_back() { let error = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_2".into(), idempotency_key: Some("idem_save_2".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 6, "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["legacy_block_1"], "blocks": [ { "blockId": "legacy_block_1", "blockType": "paragraph", "contentNodes": [ { "attrs": {} } ], "childBlockIds": [] } ] }, "tiptapDocument": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "blockId": "p_1" }, "content": [ { "type": "text", "text": "来自 tiptap 回退" } ] } ] }, "content": [ { "id": "content_block_1", "type": "paragraph", "content": "来自 content 回退" } ], "conflictDetectionKey": "doc_1:6" }), preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-save-fallback".into()], dry_run: false, validate_only: false, }, }) .expect_err("documents.save should reject invalid editorDocument"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("editorDocument")); } #[test] fn documents_save_command_plan_rejects_editor_document_missing_blocks() { let error = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: "cmd_save_missing_editor_blocks".into(), idempotency_key: Some("idem_save_missing_editor_blocks".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 7, "editorDocument": { "documentId": "doc_1" }, "content": [ { "id": "legacy_1", "type": "paragraph", "content": "不能静默回退" } ], "conflictDetectionKey": "doc_1:7" }), preflight_data: None, reason: Some("保存缺少 blocks 的 editorDocument".into()), refs: vec!["task-save-missing-editor-blocks".into()], dry_run: false, validate_only: false, }, }) .expect_err("editorDocument without blocks should be rejected"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("blocks")); } #[test] fn documents_save_command_plan_rejects_unparseable_editor_document_instead_of_falling_back() { let error = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: "cmd_save_invalid_editor_document".into(), idempotency_key: Some("idem_save_invalid_editor_document".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 7, "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["editor_block_1"], "blocks": "非法 blocks" }, "tiptapDocument": { "type": "doc", "content": [{ "type": "paragraph", "attrs": { "blockId": "p_1" }, "content": [{ "type": "text", "text": "不能静默回退" }] }] }, "content": [ { "id": "content_block_1", "type": "paragraph", "content": "不能静默回退" } ], "conflictDetectionKey": "doc_1:7" }), preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-save-invalid-editor-document".into()], dry_run: false, validate_only: false, }, }) .expect_err("unparseable editorDocument should be rejected"); assert_eq!(error.kind, BridgeErrorKind::Validation); assert!(error.message.contains("editorDocument")); } #[test] fn documents_save_command_plan_prefers_valid_editor_document_over_other_sources() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.save".into(), command_id: "cmd_save_3".into(), idempotency_key: Some("idem_save_3".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 7, "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["editor_block_1"], "blocks": [ { "blockId": "editor_block_1", "blockType": "paragraph", "contentNodes": [ { "payload": { "type": "text", "text": "来自 editorDocument", "marks": [] }, "attrs": {} } ], "childBlockIds": [] } ] }, "tiptapDocument": { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "blockId": "p_1" }, "content": [ { "type": "text", "text": "来自 tiptap" } ] } ] }, "content": [ { "id": "content_block_1", "type": "paragraph", "content": "来自 content" } ], "conflictDetectionKey": "doc_1:7" }), preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-save-prefer-editor".into()], dry_run: false, validate_only: false, }, }) .expect("documents.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.function_name, "documents.save"); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("来自 editorDocument")) ); assert_eq!( plan.args_json .pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"), Some(&json!("来自 editorDocument")) ); assert_eq!( plan.args_json.pointer("/content/0/id"), Some(&json!("editor_block_1")) ); } #[test] fn documents_save_command_plan_supports_legacy_content_only_payloads() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: "cmd_save_4".into(), idempotency_key: Some("idem_save_4".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 8, "content": [ { "id": "legacy_content_1", "type": "paragraph", "content": "来自旧式 content-only 保存" } ], "conflictDetectionKey": "doc_1:8" }), preflight_data: None, reason: Some("保存正文".into()), refs: vec!["task-save-content-only".into()], dry_run: false, validate_only: false, }, }) .expect("page.body.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.function_name, "page.body.save"); assert_eq!( plan.args_json.pointer("/content/0/id"), Some(&json!("legacy_content_1")) ); assert_eq!( plan.args_json.pointer("/content/0/content"), Some(&json!("来自旧式 content-only 保存")) ); assert_eq!( plan.args_json.pointer("/editorDocument/rootBlockIds/0"), Some(&json!("legacy_content_1")) ); } #[test] fn documents_save_command_plans_include_formal_domain_event_contract() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: "cmd_save_contract_1".into(), idempotency_key: Some("idem_save_contract_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 8, "content": [ { "id": "legacy_content_1", "type": "paragraph", "content": "来自旧式 content-only 保存" } ], "conflictDetectionKey": "doc_1:8" }), preflight_data: None, reason: Some("保存正文".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("page.body.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "resync_required", "args": { "reason": "page_body_saved", "pageId": "doc_1", "documentId": "doc_1" } }) ); assert_eq!( plan.args_json["domainEventHint"], json!({ "family": "tree", "eventType": "page.body.saved" }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "page.body.saved", "payload": { "page": { "id": "doc_1", "workspaceId": "ws_1" }, "blocks": { "ids": ["legacy_content_1"], "count": 1 } }, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "page_body_saved", "pageId": "doc_1", "documentId": "doc_1" } } }) ); assert_eq!( plan.args_json["domainEventPlans"], json!([ { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "page.body.saved", "payload": { "page": { "id": "doc_1", "workspaceId": "ws_1" }, "blocks": { "ids": ["legacy_content_1"], "count": 1 } }, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "page_body_saved", "pageId": "doc_1", "documentId": "doc_1" } } }, { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "document.snapshot.saved", "payload": { "page": { "id": "doc_1", "workspaceId": "ws_1" }, "snapshot": { "version": 8, "contentHash": "fnv1a64:d039f8f3496411e8", "updatedAt": null } }, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "page_body_saved", "pageId": "doc_1", "documentId": "doc_1" } } } ]) ); } #[test] fn page_body_save_artifact_preserves_editor_document_payload() { let context = demo_context(); let command = RuntimeCommandEnvelopeWire { name: "page.body.save".into(), command_id: "cmd_save_artifact_editor_document".into(), idempotency_key: Some("idem_save_artifact_editor_document".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "revision": 9, "editorDocument": { "documentId": "doc_1", "rootBlockIds": ["editor_block_1"], "blocks": [{ "blockId": "editor_block_1", "blockType": "paragraph", "contentNodes": [{ "payload": { "type": "text", "text": "来自 editorDocument artifact" }, "attrs": {} }], "childBlockIds": [] }] }, "content": [], "tiptapDocument": { "type": "doc", "content": [{ "type": "paragraph", "attrs": { "blockId": "editor_block_1" }, "content": [{ "type": "text", "text": "来自 tiptapDocument artifact" }] }] }, "conflictDetectionKey": "doc_1:9" }), preflight_data: None, reason: Some("保存 artifact provenance".into()), refs: vec!["page-body-save-artifact-provenance".into()], dry_run: false, validate_only: false, }; let plan = execute_runtime_input(RuntimeInput::Command { context: context.clone(), command: command.clone(), }) .expect("page.body.save plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; let artifacts = build_runtime_command_artifact_plan( &context, &command, &plan, &json!({"ok": true, "revision": 10}), "2026-05-18T00:00:00Z", ) .expect("artifact plan should build"); assert_eq!( artifacts .command_log .payload .pointer("/editorDocument/rootBlockIds/0"), Some(&json!("editor_block_1")) ); assert_eq!( artifacts .command_log .payload .pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"), Some(&json!("来自 editorDocument artifact")) ); assert_eq!( artifacts .command_log .payload .pointer("/tiptapDocument/content/0/type"), Some(&json!("paragraph")) ); } #[test] fn blocks_move_command_plan_maps_to_blocks_move() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "blocks.move".into(), command_id: "cmd_move_1".into(), idempotency_key: Some("idem_move".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("block_1".into()), }), payload: json!({ "sourceDocumentId": "doc_1", "blockId": "block_1", "targetDocumentId": "doc_2", }), 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, "blocks.move"); assert_eq!(plan.command_name, "blocks.move"); assert_eq!( plan.args_json, json!({ "id": "block_1", "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "blocks.move", "documentId": "doc_2", "blockId": "block_1" } }, "domainEventHint": { "family": "tree", "eventType": "block.moved" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "block.moved", "payload": { "block": { "id": "block_1" }, "move": { "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2" } }, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "blocks.move", "documentId": "doc_2", "blockId": "block_1" } } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn blocks_embed_command_plan_maps_to_blocks_embed() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "blocks.embed".into(), command_id: "cmd_embed_1".into(), idempotency_key: Some("idem_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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_2".into()), block_id: Some("block_2".into()), }), payload: json!({ "sourceDocumentId": "doc_1", "blockId": "block_1", "targetDocumentId": "doc_2", "targetBlockId": "anchor_1", }), 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, "blocks.embed"); assert_eq!(plan.command_name, "blocks.embed"); assert_eq!( plan.args_json, json!({ "sourceDocumentId": "doc_1", "blockId": "block_1", "targetDocumentId": "doc_2", "targetBlockId": "anchor_1", "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "blocks.embed", "documentId": "doc_2", "blockId": "block_1" } }, "domainEventHint": { "family": "tree", "eventType": "block.embedded" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "block.embedded", "payload": { "block": { "id": "block_1" }, "embed": { "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "targetBlockId": "anchor_1" } }, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "blocks.embed", "documentId": "doc_2", "blockId": "block_1" } } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn block_commands_include_formal_domain_event_contract() { let cases = [ ( "blocks.patch", json!({ "documentId": "doc_1", "workspaceId": "ws_1", "blockId": "block_1", "nextBlock": { "id": "block_1", "type": "paragraph" } }), "block.patched", json!({ "document": { "id": "doc_1", "workspaceId": "ws_1" }, "block": { "id": "block_1" }, "patch": { "summary": "replace_block", "nextType": "paragraph" } }), ), ( "blocks.move", json!({ "sourceDocumentId": "doc_1", "blockId": "block_1", "targetDocumentId": "doc_2" }), "block.moved", json!({ "block": { "id": "block_1" }, "move": { "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2" } }), ), ( "blocks.embed", json!({ "sourceDocumentId": "doc_1", "blockId": "block_1", "targetDocumentId": "doc_2", "targetBlockId": "anchor_1" }), "block.embedded", json!({ "block": { "id": "block_1" }, "embed": { "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "targetBlockId": "anchor_1" } }), ), ]; for (command_name, payload, event_type, expected_payload) in cases { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: command_name.into(), command_id: format!("cmd_{command_name}_contract"), idempotency_key: Some(format!("idem_{command_name}_contract")), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "next-route".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_2".into()), block_id: Some("block_1".into()), }), payload, preflight_data: None, reason: Some("块命令 formal contract".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("block command plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "resync_required", "args": { "reason": command_name, "documentId": if command_name == "blocks.patch" { "doc_1" } else { "doc_2" }, "blockId": "block_1" } }) ); assert_eq!( plan.args_json["domainEventHint"], json!({ "family": "tree", "eventType": event_type }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": event_type, "payload": expected_payload, "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": command_name, "documentId": if command_name == "blocks.patch" { "doc_1" } else { "doc_2" }, "blockId": "block_1" } } }) ); } } #[test] fn documents_embed_command_plan_maps_to_documents_update_content() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.embed".into(), command_id: "cmd_doc_embed_1".into(), idempotency_key: Some("idem_doc_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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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("command plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.function_name, "documents.embed"); assert_eq!(plan.command_name, "documents.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", "pageAggregateEmbedPlan": null, "streamDeltaHint": { "family": "tree", "kind": "noop", "args": {} }, "domainEventHint": { "family": "tree", "eventType": "tree.node.embedded" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.node.embedded", "streamDeltaHint": { "family": "tree", "kind": "noop", "args": {} } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn tree_lifecycle_command_aliases_keep_tree_command_names() { let cases = [ ( "tree.node.archive", "tree.node.archive", json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), json!({ "id": "doc_1", "commandProtocol": { "family": "tree", "owner": "rust-runtime-kernel", "preferredCommandName": "tree.node.archive", "compatCommandName": "documents.delete", "deprecatedAlias": false }, "streamDeltaHint": { "family": "tree", "kind": "remove_document", "args": { "documentId": "doc_1" } }, "domainEventHint": { "family": "tree", "eventType": "tree.node.archived" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.node.archived", "streamDeltaHint": { "family": "tree", "kind": "remove_document", "args": { "documentId": "doc_1" } } } }), ), ( "tree.node.restore", "tree.node.restore", json!({ "documentId": "doc_1", "workspaceId": "ws_1", }), json!({ "id": "doc_1", "commandProtocol": { "family": "tree", "owner": "rust-runtime-kernel", "preferredCommandName": "tree.node.restore", "compatCommandName": "documents.restore", "deprecatedAlias": false }, "streamDeltaHint": { "family": "tree", "kind": "document_result", "args": { "documentField": "document" } }, "domainEventHint": { "family": "tree", "eventType": "tree.node.restored" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.node.restored", "streamDeltaHint": { "family": "tree", "kind": "document_result", "args": { "documentField": "document" } } } }), ), ( "tree.node.purge", "tree.node.purge", json!({ "documentId": "doc_1", }), json!({ "id": "doc_1", "commandProtocol": { "family": "tree", "owner": "rust-runtime-kernel", "preferredCommandName": "tree.node.purge", "compatCommandName": "documents.purge", "deprecatedAlias": false } }), ), ]; 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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 document_move_order_plan_normalizes_same_parent_order() { let payload = DocumentMoveCommandPayload { document_id: "doc_b".into(), parent_id: Some("root".into()), sort_order: 2, }; let snapshot = serde_json::from_value::(json!({ "documents": [ { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "root", "sort_order": 0, "created_at": "2026-04-25T00:00:02Z" }, { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "root", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "root", "sort_order": null, "created_at": "2026-04-25T00:00:03Z" } ] })) .expect("snapshot"); let plan = build_document_move_order_plan_from_snapshot(&payload, &snapshot) .expect("move order plan"); assert_eq!(plan.normalized_sort_order, 2); assert_eq!( plan.patches, vec![ DocumentMoveOrderPatch { document_id: "doc_c".into(), parent_id: Some("root".into()), sort_order: 1, moved: false, }, DocumentMoveOrderPatch { document_id: "doc_b".into(), parent_id: Some("root".into()), sort_order: 2, moved: true, }, ] ); } #[test] fn document_move_order_plan_accepts_convex_float_sort_order_snapshot() { let payload = DocumentMoveCommandPayload { document_id: "doc_b".into(), parent_id: None, sort_order: 0, }; let snapshot = serde_json::from_value::(json!({ "documents": [ { "id": "doc_a", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0.0, "created_at": "2026-04-25T00:00:01Z" }, { "id": "doc_b", "workspace_id": "ws_1", "parent_id": null, "sort_order": 1.0, "created_at": "2026-04-25T00:00:02Z" } ] })) .expect("snapshot with float sort_order"); let plan = build_document_move_order_plan_from_snapshot(&payload, &snapshot) .expect("move order plan"); assert_eq!(plan.normalized_sort_order, 0); assert!(plan .patches .iter() .any(|patch| patch.document_id == "doc_b" && patch.sort_order == 0 && patch.moved)); } #[test] fn document_move_order_plan_normalizes_cross_parent_and_clamps_index() { let payload = DocumentMoveCommandPayload { document_id: "doc_b".into(), parent_id: Some("target".into()), sort_order: 99, }; let snapshot = serde_json::from_value::(json!({ "documents": [ { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 1, "created_at": "2026-04-25T00:00:02Z" }, { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 2, "created_at": "2026-04-25T00:00:03Z" }, { "id": "doc_d", "workspace_id": "ws_1", "parent_id": "target", "sort_order": 7, "created_at": "2026-04-25T00:00:04Z" } ] })) .expect("snapshot"); let plan = build_document_move_order_plan_from_snapshot(&payload, &snapshot) .expect("move order plan"); assert_eq!(plan.from_parent_id, Some("source".into())); assert_eq!(plan.to_parent_id, Some("target".into())); assert_eq!(plan.normalized_sort_order, 1); assert_eq!( plan.patches, vec![ DocumentMoveOrderPatch { document_id: "doc_c".into(), parent_id: Some("source".into()), sort_order: 1, moved: false, }, DocumentMoveOrderPatch { document_id: "doc_d".into(), parent_id: Some("target".into()), sort_order: 0, moved: false, }, DocumentMoveOrderPatch { document_id: "doc_b".into(), parent_id: Some("target".into()), sort_order: 1, moved: true, }, ] ); } #[test] fn tree_subtree_move_command_does_not_emit_legacy_normalized_move_fallback() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.subtree.move".into(), command_id: "cmd_tree_move_plan".into(), idempotency_key: Some("idem_tree_move_plan".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_b".into()), block_id: None, }), payload: json!({ "documentId": "doc_b", "parentId": "target", "sortOrder": -2 }), preflight_data: Some(json!({ "documents": [ { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 1, "created_at": "2026-04-25T00:00:02Z" }, { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "target", "sort_order": 0, "created_at": "2026-04-25T00:00:03Z" } ] })), reason: Some("树命令切流".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("command plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "tree.subtree.move"); assert_eq!(plan.function_name, "tree.subtree.move"); assert_eq!(plan.args_json["sortOrder"], json!(-2)); assert_eq!( plan.args_json["commandProtocol"], json!({ "family": "tree", "owner": "rust-runtime-kernel", "preferredCommandName": "tree.subtree.move", "compatCommandName": "documents.move", "deprecatedAlias": false }) ); assert!(plan.args_json.get("normalizedMove").is_none()); assert_eq!( plan.args_json["treeWriteOperation"], json!({ "family": "tree", "schema": "mnote.tree.write_operation", "schemaVersion": 1, "operation": "tree.subtree.move.write", "workspaceId": "ws_1", "documentId": "doc_b", "fromParentId": "source", "toParentId": "target", "requestedSortOrder": -2, "normalizedSortOrder": 0, "patches": [ { "documentId": "doc_b", "parentId": "target", "sortOrder": 0, "moved": true }, { "documentId": "doc_c", "parentId": "target", "sortOrder": 1, "moved": false } ] }) ); assert_eq!( plan.args_json["domainEventHint"], json!({ "family": "tree", "eventType": "tree.subtree.moved" }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.subtree.moved", "streamDeltaHint": { "family": "tree", "kind": "move_document", "args": { "documentId": "doc_b", "parentId": "target", "sortOrder": -2 } } }) ); } #[test] fn documents_lifecycle_aliases_are_marked_as_deprecated_tree_protocol_aliases() { let cases = [ ("documents.create", "tree.node.create", "documents.create"), ( "documents.title.update", "tree.node.rename", "documents.title.update", ), ("documents.move", "tree.subtree.move", "documents.move"), ("documents.delete", "tree.node.archive", "documents.delete"), ( "documents.restore", "tree.node.restore", "documents.restore", ), ("documents.purge", "tree.node.purge", "documents.purge"), ( "documents.copy_tree", "tree.subtree.copy", "documents.copy_tree", ), ]; for (command_name, preferred_command_name, compat_command_name) in cases { let 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: match command_name { "documents.create" => json!({ "documentId": "doc_1", "workspaceId": "ws_1", "parentId": null, "title": "新页面", "accessScope": "private", "content": [] }), "documents.title.update" => json!({ "documentId": "doc_1", "title": "改名" }), "documents.move" => json!({ "documentId": "doc_1", "parentId": null, "sortOrder": 0 }), "documents.delete" | "documents.restore" | "documents.purge" => json!({ "documentId": "doc_1", "workspaceId": "ws_1" }), "documents.copy_tree" => json!({ "items": [{"documentId": "doc_1", "recursive": true}], "targetParentId": null }), _ => unreachable!("unexpected command"), }, preflight_data: if command_name == "documents.move" { Some(json!({ "documents": [ { "id": "doc_1", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" } ] })) } else { None }, reason: Some("兼容 alias 标识验证".into()), refs: vec![], dry_run: false, validate_only: false, }; let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command, }) .expect("compat command plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json["commandProtocol"], json!({ "family": "tree", "owner": "rust-runtime-kernel", "preferredCommandName": preferred_command_name, "compatCommandName": compat_command_name, "deprecatedAlias": true }), "{command_name} 应标记为 tree compat deprecated alias" ); } } #[test] fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.subtree.move".into(), command_id: "cmd_tree_move_write".into(), idempotency_key: Some("idem_tree_move_write".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_b".into()), block_id: None, }), payload: json!({ "documentId": "doc_b", "parentId": "target", "sortOrder": 99 }), preflight_data: Some(json!({ "documents": [ { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 1, "created_at": "2026-04-25T00:00:02Z" }, { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "target", "sort_order": 0, "created_at": "2026-04-25T00:00:03Z" } ] })), reason: Some("树命令切流".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("command plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json["treeWriteOperation"], json!({ "family": "tree", "schema": "mnote.tree.write_operation", "schemaVersion": 1, "operation": "tree.subtree.move.write", "workspaceId": "ws_1", "documentId": "doc_b", "fromParentId": "source", "toParentId": "target", "requestedSortOrder": 99, "normalizedSortOrder": 1, "patches": [ { "documentId": "doc_b", "parentId": "target", "sortOrder": 1, "moved": true } ] }) ); } #[test] fn tree_command_artifact_plan_materializes_domain_event_payload_from_rust_plan() { let context = demo_context(); let command = RuntimeCommandEnvelopeWire { name: "tree.subtree.move".into(), command_id: "cmd_tree_move_artifact".into(), idempotency_key: Some("idem_tree_move_artifact".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_b".into()), block_id: None, }), payload: json!({ "documentId": "doc_b", "parentId": "target", "sortOrder": 3 }), preflight_data: Some(json!({ "documents": [ { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, { "id": "doc_b", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" } ] })), reason: Some("树命令移动".into()), refs: vec!["next-tree-route".into()], dry_run: false, validate_only: false, }; let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(RuntimeInput::Command { context: context.clone(), command: command.clone(), }) .expect("command plan should build") else { panic!("expected command plan"); }; let artifacts = build_runtime_command_artifact_plan( &context, &command, &plan, &json!({ "ok": true, "parent_id": "target", "sort_order": 1, "workspace_id": "ws_1", "updated_at": "2026-04-26T10:00:00Z" }), "2026-04-26T10:00:01Z", ) .expect("artifact plan should build"); assert_eq!(artifacts.command_log.id, "clog_cmd_tree_move_artifact"); assert_eq!(artifacts.command_log.workspace_id, "ws_1"); assert_eq!(artifacts.command_log.command_name, "tree.subtree.move"); assert_eq!( artifacts.command_log.payload["streamDelta"], json!({ "op": "move_document", "documentId": "doc_b", "parentId": "target", "sortOrder": 1, "updatedAt": "2026-04-26T10:00:00Z" }) ); let event = artifacts .domain_event .expect("tree command should have domain event"); assert_eq!(event.id, "evt_cmd_tree_move_artifact"); assert_eq!(event.event_type, "tree.subtree.moved"); assert_eq!(event.aggregate_type, "page"); assert_eq!(event.aggregate_id, "doc_b"); assert_eq!(event.status, "committed"); assert_eq!( event.payload["streamDelta"], json!({ "op": "move_document", "documentId": "doc_b", "parentId": "target", "sortOrder": 1, "updatedAt": "2026-04-26T10:00:00Z" }) ); assert_eq!(event.payload["schema"], json!("mnote.tree.domain_event")); assert_eq!(event.payload["schemaVersion"], json!(1)); } #[test] fn runtime_command_artifact_input_returns_rust_artifact_plan() { let context = demo_context(); let command = RuntimeCommandEnvelopeWire { name: "tree.subtree.move".into(), command_id: "cmd_tree_move_artifact_input".into(), idempotency_key: Some("idem_tree_move_artifact_input".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_b".into()), block_id: None, }), payload: json!({ "documentId": "doc_b", "parentId": "target", "sortOrder": 3 }), preflight_data: Some(json!({ "documents": [ { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, { "id": "doc_b", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" } ] })), reason: Some("树命令移动".into()), refs: vec!["next-tree-route".into()], dry_run: false, validate_only: false, }; let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(RuntimeInput::Command { context: context.clone(), command: command.clone(), }) .expect("command plan should build") else { panic!("expected command plan"); }; let artifacts = execute_runtime_command_artifact(RuntimeInput::CommandArtifact { context, command, plan, result: json!({ "ok": true, "parent_id": "target", "sort_order": 1, "workspace_id": "ws_1", "updated_at": "2026-04-26T10:00:00Z" }), now: "2026-04-26T10:00:01Z".into(), }) .expect("artifact input should execute") .expect("artifact plan should build"); assert_eq!( artifacts.command_log.payload["streamDelta"], json!({ "op": "move_document", "documentId": "doc_b", "parentId": "target", "sortOrder": 1, "updatedAt": "2026-04-26T10:00:00Z" }) ); assert_eq!( artifacts .domain_event .as_ref() .expect("domain event") .event_type, "tree.subtree.moved" ); } #[test] fn tree_create_and_rename_command_plans_include_stream_delta_hints() { let create_plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.node.create".into(), command_id: "cmd_tree_create_1".into(), idempotency_key: Some("idem_tree_create_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_new".into()), block_id: None, }), payload: json!({ "documentId": "doc_new", "workspaceId": "ws_1", "parentId": null, "title": "新页面", "accessScope": "private", "content": [] }), preflight_data: None, reason: Some("树命令创建页面".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("create plan should build"); match create_plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "tree.node.create"); assert_eq!(plan.function_name, "tree.node.create"); assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "document_result", "args": { "documentField": "document" } }) ); assert_eq!( plan.args_json["domainEventHint"], json!({ "family": "tree", "eventType": "tree.node.created" }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.node.created", "streamDeltaHint": { "family": "tree", "kind": "document_result", "args": { "documentField": "document" } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } let rename_plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.node.rename".into(), command_id: "cmd_tree_rename_1".into(), idempotency_key: Some("idem_tree_rename_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "title": "改名" }), preflight_data: None, reason: Some("树命令重命名".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("rename plan should build"); match rename_plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "tree.node.rename"); assert_eq!(plan.function_name, "tree.node.rename"); assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "upsert_document_patch", "args": { "documentId": "doc_1", "patch": { "title": "改名" } } }) ); assert_eq!( plan.args_json["domainEventHint"], json!({ "family": "tree", "eventType": "tree.node.renamed" }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.node.renamed", "streamDeltaHint": { "family": "tree", "kind": "upsert_document_patch", "args": { "documentId": "doc_1", "patch": { "title": "改名" } } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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, "conflictDetectionKey": "conflict_5", "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "anchorBlockId": "anchor_1", }), preflight_data: Some(json!({ "pageAggregateEmbed": { "sourceDocumentId": "doc_1", "sourceTitle": "来源页面", "targetDocumentId": "doc_2", "targetContent": { "blocks": [ { "id": "anchor_1", "type": "paragraph" } ], "format": "editor" }, "anchorBlockId": "anchor_1", "blockId": "page_ref_doc_1" } })), 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, "tree.node.embed"); assert_eq!(plan.command_name, "tree.node.embed"); assert_eq!( plan.args_json, json!({ "id": "doc_2", "content": { "blocks": [ { "id": "anchor_1", "type": "paragraph" }, { "id": "page_ref_doc_1", "type": "pageReference", "props": { "pageId": "doc_1", "title": "来源页面" } } ], "format": "editor" }, "expectedRevision": 5, "conflictDetectionKey": "conflict_5", "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "anchorBlockId": "anchor_1", "pageAggregateEmbedPlan": { "schema": "mnote.page_aggregate.embed_plan", "schemaVersion": 1, "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", "anchorBlockId": "anchor_1", "insertIndex": 1, "block": { "id": "page_ref_doc_1", "type": "pageReference", "props": { "pageId": "doc_1", "title": "来源页面" } }, "content": { "blocks": [ { "id": "anchor_1", "type": "paragraph" }, { "id": "page_ref_doc_1", "type": "pageReference", "props": { "pageId": "doc_1", "title": "来源页面" } } ], "format": "editor" }, "blockCount": 2 }, "streamDeltaHint": { "family": "tree", "kind": "noop", "args": {} }, "domainEventHint": { "family": "tree", "eventType": "tree.node.embedded" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.node.embedded", "streamDeltaHint": { "family": "tree", "kind": "noop", "args": {} } } }) ); } 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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, 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, "tree.subtree.copy"); assert_eq!(plan.command_name, "tree.subtree.copy"); assert_eq!( plan.args_json, json!({ "items": [ { "documentId": "doc_1", "recursive": true, } ], "targetParentId": "parent_1", "commandProtocol": { "family": "tree", "owner": "rust-runtime-kernel", "preferredCommandName": "tree.subtree.copy", "compatCommandName": "documents.copy_tree", "deprecatedAlias": false }, "streamDeltaHint": { "family": "tree", "kind": "copy_result", "args": { "itemsField": "items", "documentField": "document" } }, "domainEventHint": { "family": "tree", "eventType": "tree.subtree.copied" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.subtree.copied", "streamDeltaHint": { "family": "tree", "kind": "copy_result", "args": { "itemsField": "items", "documentField": "document" } } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn documents_empty_trash_plan_emits_tree_resync_event() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.emptyTrashByWorkspace".into(), command_id: "cmd_empty_trash_1".into(), idempotency_key: Some("idem_empty_trash_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: Some("ws_1".into()), capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: None, block_id: None, }), payload: json!({ "workspaceId": "ws_1", }), preflight_data: None, reason: Some("清空页面垃圾箱".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("empty trash plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "documents.emptyTrashByWorkspace"); assert_eq!(plan.function_name, "documents.emptyTrashByWorkspace"); assert_eq!(plan.args_json["workspaceId"], json!("ws_1")); assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "resync_required", "args": { "reason": "documents_empty_trash", "workspaceId": "ws_1" } }) ); assert_eq!( plan.args_json["domainEventHint"], json!({ "family": "tree", "eventType": "tree.trash.documents.emptied" }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.trash.documents.emptied", "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "documents_empty_trash", "workspaceId": "ws_1" } } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn documents_duplicate_plan_includes_result_document_artifact_contract() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.duplicate".into(), command_id: "cmd_duplicate_1".into(), idempotency_key: Some("idem_duplicate_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_copy".into()), block_id: None, }), payload: json!({ "sourceDocumentId": "doc_1", "newDocumentId": "doc_copy", "title": "页面副本" }), preflight_data: None, reason: Some("复制页面".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("duplicate plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.function_name, "documents.duplicate"); assert_eq!(plan.command_name, "documents.duplicate"); assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "result_document", "args": {} }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.node.duplicated", "streamDeltaHint": { "family": "tree", "kind": "result_document", "args": {} } }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn document_options_command_plan_maps_to_documents_update_options() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.options.update".into(), command_id: "cmd_options_1".into(), idempotency_key: Some("idem_options_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "options": { "showToc": true, "layoutDensity": "compact", "embedDefaultBlockId": null } }), preflight_data: None, reason: Some("更新页面选项".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("options plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "documents.options.update"); assert_eq!(plan.function_name, "documents.options.update"); assert_eq!(plan.args_json["id"], json!("doc_1")); assert_eq!( plan.args_json["options"], json!({ "showToc": true, "layoutDensity": "compact", }) ); assert_eq!( plan.args_json["streamDeltaHint"], json!({ "family": "tree", "kind": "resync_required", "args": { "reason": "page.layout.updateOptions", "documentId": "doc_1", }, }) ); assert_eq!( plan.args_json["domainEventPlan"], json!({ "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "page.layout.options_updated", "streamDeltaHint": { "family": "tree", "kind": "resync_required", "args": { "reason": "page.layout.updateOptions", "documentId": "doc_1", }, }, }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn document_stats_command_plan_maps_to_documents_update_stats() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "documents.stats.update".into(), command_id: "cmd_stats_1".into(), idempotency_key: Some("idem_stats_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "documentId": "doc_1", "workspaceId": "ws_1", "stats": { "wordCount": 12, "characterCount": 34, "blockCount": 5, "todoTotal": 6, "todoDone": 2 } }), preflight_data: None, reason: Some("更新页面统计".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("stats plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "documents.stats.update"); assert_eq!(plan.function_name, "documents.stats.update"); assert_eq!( plan.args_json, json!({ "id": "doc_1", "workspaceId": "ws_1", "wordCount": 12, "characterCount": 34, "blockCount": 5, "todoTotal": 6, "todoDone": 2, }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn media_asset_replace_storage_command_plan_maps_to_transport_write() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "media.assets.replace_storage".into(), command_id: "cmd_asset_writeback_1".into(), idempotency_key: Some("idem_asset_writeback_1".into()), actor: RuntimeActorWire { actor_type: "service".into(), actor_id: "onlyoffice-callback".into(), session_id: Some("onlyoffice".into()), }, source: RuntimeSourceWire { channel: "onlyoffice-callback".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload: json!({ "assetId": "asset_1", "documentId": "doc_1", "workspaceId": "ws_1", "storageId": "storage_1", "userId": "user_1" }), preflight_data: None, reason: Some("OnlyOffice callback 写回".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect("media asset writeback plan should build"); match plan { RuntimeExecutionPlan::Command(plan) => { assert_eq!(plan.command_name, "media.assets.replace_storage"); assert_eq!(plan.function_name, "media.assets.replace_storage"); assert_eq!( plan.args_json, json!({ "userId": "user_1", "id": "asset_1", "documentId": "doc_1", "workspaceId": "ws_1", "storageId": "storage_1", }) ); } RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), } } #[test] fn tree_resource_copy_and_move_plans_include_transfer_contract() { let cases = [ ( "tree.resource.copy", "tree.resource.copy", "copy", "tree.resource.copied", ), ( "tree.resource.move", "tree.resource.move", "move", "tree.resource.moved", ), ]; for (command_name, function_name, action, event_type) in cases { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: command_name.into(), command_id: format!("cmd_{action}_asset"), idempotency_key: Some(format!("idem_{action}_asset")), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "next-route".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("target_doc".into()), block_id: None, }), payload: json!({ "assetIds": [" asset_1 ", "asset_1", "asset_2"], "targetDocumentId": "target_doc", "targetSubPath": "mindmaps/mind_1/../assets", }), preflight_data: None, reason: Some("文件树资源投放".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: false, }, }) .expect("resource transfer plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, command_name); assert_eq!(plan.function_name, function_name); assert_eq!( plan.args_json, json!({ "assetIds": ["asset_1", "asset_2"], "targetDocumentId": "target_doc", "targetSubPath": "mindmaps/mind_1/assets", "resourceTransferPlan": { "action": action, "assetIds": ["asset_1", "asset_2"], "targetDocumentId": "target_doc", "targetSubPath": "mindmaps/mind_1/assets", }, "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "itemsField": "items" } }, "domainEventHint": { "family": "tree", "eventType": event_type }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": event_type, "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "itemsField": "items" } } } }) ); } } #[test] fn tree_resource_lifecycle_plans_cover_file_asset_commands() { let cases = [ ( "tree.resource.archive", "tree.resource.archive", "archive", "tree.resource.archived", json!({ "resourceKind": "file", "assetId": "asset_1", }), json!({ "userId": "user_1", "id": "asset_1", "resourceKind": "file", "resourceLifecyclePlan": { "action": "archive", "resourceKind": "file", "assetId": "asset_1", "documentId": null, "mindmapId": null, "tableId": null, "newName": null, }, "streamDeltaHint": { "family": "tree", "kind": "remove_asset", "args": { "assetId": "asset_1" } }, "domainEventHint": { "family": "tree", "eventType": "tree.resource.archived" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.resource.archived", "streamDeltaHint": { "family": "tree", "kind": "remove_asset", "args": { "assetId": "asset_1" } } } }), ), ( "tree.resource.restore", "tree.resource.restore", "restore", "tree.resource.restored", json!({ "resourceKind": "file", "assetId": "asset_1", }), json!({ "userId": "user_1", "id": "asset_1", "resourceKind": "file", "resourceLifecyclePlan": { "action": "restore", "resourceKind": "file", "assetId": "asset_1", "documentId": null, "mindmapId": null, "tableId": null, "newName": null, }, "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "assetField": "asset" } }, "domainEventHint": { "family": "tree", "eventType": "tree.resource.restored" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.resource.restored", "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "assetField": "asset" } } } }), ), ( "tree.resource.purge", "tree.resource.purge", "purge", "tree.resource.purged", json!({ "resourceKind": "file", "assetId": "asset_1", }), json!({ "userId": "user_1", "id": "asset_1", "resourceKind": "file", "resourceLifecyclePlan": { "action": "purge", "resourceKind": "file", "assetId": "asset_1", "documentId": null, "mindmapId": null, "tableId": null, "newName": null, }, "streamDeltaHint": { "family": "tree", "kind": "remove_asset", "args": { "assetId": "asset_1" } }, "domainEventHint": { "family": "tree", "eventType": "tree.resource.purged" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.resource.purged", "streamDeltaHint": { "family": "tree", "kind": "remove_asset", "args": { "assetId": "asset_1" } } } }), ), ( "tree.resource.rename", "tree.resource.rename", "rename", "tree.resource.renamed", json!({ "resourceKind": "file", "assetId": "asset_1", "newName": "new.pdf", }), json!({ "userId": "user_1", "id": "asset_1", "patch": { "file_name": "new.pdf", }, "resourceKind": "file", "newName": "new.pdf", "resourceLifecyclePlan": { "action": "rename", "resourceKind": "file", "assetId": "asset_1", "documentId": null, "mindmapId": null, "tableId": null, "newName": "new.pdf", }, "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "assetField": "asset" } }, "domainEventHint": { "family": "tree", "eventType": "tree.resource.renamed" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.resource.renamed", "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "assetField": "asset" } } } }), ), ]; for (command_name, function_name, action, event_type, 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_{action}_asset"), idempotency_key: Some(format!("idem_{action}_asset")), actor: RuntimeActorWire { actor_type: "user".into(), actor_id: "user_1".into(), session_id: Some("sess_1".into()), }, source: RuntimeSourceWire { channel: "next-route".into(), client: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload, preflight_data: None, reason: Some(format!("文件树资源 {action}")), refs: vec!["file-tree-resource-command".into()], dry_run: false, validate_only: false, }, }) .expect("resource lifecycle plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, command_name); assert_eq!(plan.function_name, function_name); assert_eq!(plan.args_json, args_json); assert_eq!(plan.args_json["domainEventHint"]["eventType"], event_type); } } #[test] fn tree_resource_lifecycle_plans_cover_mindmap_and_table_commands() { let cases = [ ( "tree.resource.archive", "mindmap", "tree.resource.archive", json!({ "resourceKind": "mindmap", "documentId": "doc_1", "mindmapId": "mind_1", }), json!({ "docId": "doc_1", "mindmapId": "mind_1", "resourceKind": "mindmap", "resourceLifecyclePlan": { "action": "archive", "resourceKind": "mindmap", "assetId": null, "documentId": "doc_1", "mindmapId": "mind_1", "tableId": null, "newName": null, }, }), ), ( "tree.resource.restore", "mindmap", "tree.resource.restore", json!({ "resourceKind": "mindmap", "documentId": "doc_1", "mindmapId": "mind_1", }), json!({ "docId": "doc_1", "mindmapId": "mind_1", "resourceKind": "mindmap", "resourceLifecyclePlan": { "action": "restore", "resourceKind": "mindmap", "assetId": null, "documentId": "doc_1", "mindmapId": "mind_1", "tableId": null, "newName": null, }, }), ), ( "tree.resource.purge", "mindmap", "tree.resource.purge", json!({ "resourceKind": "mindmap", "documentId": "doc_1", "mindmapId": "mind_1", }), json!({ "docId": "doc_1", "mindmapId": "mind_1", "resourceKind": "mindmap", "resourceLifecyclePlan": { "action": "purge", "resourceKind": "mindmap", "assetId": null, "documentId": "doc_1", "mindmapId": "mind_1", "tableId": null, "newName": null, }, }), ), ( "tree.resource.archive", "table", "tree.resource.archive", json!({ "resourceKind": "table", "tableId": "table_1", }), json!({ "tableId": "table_1", "userId": "user_1", "resourceKind": "table", "resourceLifecyclePlan": { "action": "archive", "resourceKind": "table", "assetId": null, "documentId": null, "mindmapId": null, "tableId": "table_1", "newName": null, }, }), ), ( "tree.resource.restore", "table", "tree.resource.restore", json!({ "resourceKind": "table", "tableId": "table_1", }), json!({ "tableId": "table_1", "userId": "user_1", "resourceKind": "table", "resourceLifecyclePlan": { "action": "restore", "resourceKind": "table", "assetId": null, "documentId": null, "mindmapId": null, "tableId": "table_1", "newName": null, }, }), ), ( "tree.resource.purge", "table", "tree.resource.purge", json!({ "resourceKind": "table", "tableId": "table_1", }), json!({ "tableId": "table_1", "userId": "user_1", "resourceKind": "table", "resourceLifecyclePlan": { "action": "purge", "resourceKind": "table", "assetId": null, "documentId": null, "mindmapId": null, "tableId": "table_1", "newName": null, }, }), ), ]; for (command_name, resource_kind, function_name, payload, expected_subset) in cases { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: command_name.into(), command_id: format!("cmd_{resource_kind}_{command_name}"), idempotency_key: Some(format!("idem_{resource_kind}_{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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: None, }), payload, preflight_data: None, reason: Some(format!("{resource_kind} 资源生命周期")), refs: vec!["file-tree-resource-command".into()], dry_run: false, validate_only: false, }, }) .expect("mindmap/table resource lifecycle plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, command_name); assert_eq!(plan.function_name, function_name); for (key, expected) in expected_subset.as_object().expect("subset object") { assert_eq!(&plan.args_json[key], expected, "args_json.{key}"); } assert_eq!( plan.args_json["domainEventHint"]["eventType"], match command_name { "tree.resource.archive" => "tree.resource.archived", "tree.resource.restore" => "tree.resource.restored", "tree.resource.purge" => "tree.resource.purged", _ => unreachable!("covered command"), } ); } } #[test] fn tree_resource_transfer_rejects_empty_assets_or_target() { let empty_assets = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.resource.move".into(), command_id: "cmd_move_empty_assets".into(), idempotency_key: Some("idem_move_empty_assets".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("target_doc".into()), block_id: None, }), payload: json!({ "assetIds": [], "targetDocumentId": "target_doc", }), preflight_data: None, reason: Some("文件树资源投放".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect_err("empty assets should be rejected"); assert_eq!(empty_assets.kind, BridgeErrorKind::Validation); assert_eq!(empty_assets.message, "resource command 缺少 assetIds"); let missing_target = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.resource.copy".into(), command_id: "cmd_copy_missing_target".into(), idempotency_key: Some("idem_copy_missing_target".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: None, block_id: None, }), payload: json!({ "assetIds": ["asset_1"], "targetDocumentId": " ", }), preflight_data: None, reason: Some("文件树资源投放".into()), refs: vec![], dry_run: false, validate_only: false, }, }) .expect_err("missing target should be rejected"); assert_eq!(missing_target.kind, BridgeErrorKind::Validation); assert_eq!( missing_target.message, "resource command 缺少 targetDocumentId" ); } #[test] fn tree_filetree_drop_preflight_plans_mixed_doc_and_asset_drop() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.drop.preflight".into(), command_id: "cmd_filetree_drop_preflight_1".into(), idempotency_key: Some("idem_filetree_drop_preflight_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_target".into()), block_id: None, }), payload: json!({ "copy": false, "targetDocumentId": null, "targetRowId": "asset-folder:mind_1", "focusedRowId": null, "activeDocumentId": null, "rowIds": ["asset:asset_child_1", "doc:doc_child", "asset:pdf_1", "asset:pdf_1"], "rows": [ { "rowId": "doc:doc_target", "rowKind": "doc", "documentId": "doc_target" }, { "rowId": "doc:doc_child", "rowKind": "doc", "documentId": "doc_child" }, { "rowId": "asset-folder:mind_1", "rowKind": "asset-folder", "documentId": "doc_target", "assetId": "mind_1", "assetType": "mindmap", "storagePath": "mindmaps/mind_1/mindmap.json" }, { "rowId": "asset:asset_child_1", "rowKind": "asset", "documentId": "doc_source", "assetId": "asset_child_1", "assetDocumentId": "doc_source", "assetType": "file", "storagePath": "mindmaps/mind_1/assets/node.png" }, { "rowId": "asset:pdf_1", "rowKind": "asset", "documentId": "doc_source", "assetId": "pdf_1", "assetDocumentId": "doc_source", "assetType": "file", "storagePath": "uploads/guide.pdf" } ], "documentParents": [ { "documentId": "doc_target", "parentId": null }, { "documentId": "doc_child", "parentId": null } ] }), preflight_data: None, reason: Some("文件树内部拖放预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect("filetree drop preflight plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "tree.filetree.drop.preflight"); assert_eq!(plan.function_name, "tree.filetree.drop.preflight"); assert_eq!( plan.args_json["fileTreeDropPlan"], json!({ "copy": false, "targetDocumentId": "doc_target", "targetMindmapId": "mind_1", "targetSubPath": "mindmaps/mind_1", "rowIds": ["asset:asset_child_1", "doc:doc_child", "asset:pdf_1"], "docIds": ["doc_child"], "topLevelDocIds": ["doc_child"], "copyableAssetIds": ["asset_child_1", "pdf_1"], "sourceAssetDocumentIds": ["doc_source"], "documentTransferPlan": { "action": "move", "targetParentId": "doc_target", "documentIds": ["doc_child"], "topLevelDocumentIds": ["doc_child"], "copyItems": [ { "documentId": "doc_child", "recursive": true } ] }, "resourceTransferPlan": { "action": "move", "assetIds": ["asset_child_1", "pdf_1"], "targetDocumentId": "doc_target", "targetSubPath": "mindmaps/mind_1" } }) ); } #[test] fn tree_filetree_drop_preflight_rejects_illegal_doc_move() { let err = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.drop.preflight".into(), command_id: "cmd_filetree_drop_preflight_illegal".into(), idempotency_key: Some("idem_filetree_drop_preflight_illegal".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_child".into()), block_id: None, }), payload: json!({ "copy": false, "targetDocumentId": "doc_child", "targetRowId": null, "focusedRowId": null, "activeDocumentId": null, "rowIds": ["doc:doc_parent"], "rows": [ { "rowId": "doc:doc_parent", "rowKind": "doc", "documentId": "doc_parent" }, { "rowId": "doc:doc_child", "rowKind": "doc", "documentId": "doc_child" } ], "documentParents": [ { "documentId": "doc_parent", "parentId": null }, { "documentId": "doc_child", "parentId": "doc_parent" } ] }), preflight_data: None, reason: Some("文件树内部拖放预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect_err("illegal descendant move should be rejected"); assert_eq!(err.kind, BridgeErrorKind::Validation); assert_eq!(err.message, "不能把页面移动到自身或后代下面"); } #[test] fn tree_filetree_drop_preflight_rejects_readonly_source_or_target() { let readonly_target = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.drop.preflight".into(), command_id: "cmd_filetree_drop_preflight_readonly_target".into(), idempotency_key: Some("idem_filetree_drop_preflight_readonly_target".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_target".into()), block_id: None, }), payload: json!({ "copy": false, "sourceCapabilities": ["read", "write", "move"], "targetCapabilities": ["read"], "targetDocumentId": "doc_target", "targetRowId": "doc:doc_target", "focusedRowId": null, "activeDocumentId": null, "rowIds": ["doc:doc_source"], "rows": [ { "rowId": "doc:doc_target", "rowKind": "doc", "documentId": "doc_target", "title": "目标" }, { "rowId": "doc:doc_source", "rowKind": "doc", "documentId": "doc_source", "title": "来源" } ], "documentParents": [ { "documentId": "doc_target", "parentId": null }, { "documentId": "doc_source", "parentId": null } ] }), preflight_data: None, reason: Some("文件树内部拖放预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect_err("readonly target should reject drop before execution"); assert_eq!(readonly_target.kind, BridgeErrorKind::Validation); assert_eq!(readonly_target.message, "目标位置是只读,不能拖放到这里"); let readonly_source = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.drop.preflight".into(), command_id: "cmd_filetree_drop_preflight_readonly_source".into(), idempotency_key: Some("idem_filetree_drop_preflight_readonly_source".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_target".into()), block_id: None, }), payload: json!({ "copy": false, "sourceCapabilities": ["read"], "targetCapabilities": ["read", "write", "drop"], "targetDocumentId": "doc_target", "targetRowId": "doc:doc_target", "focusedRowId": null, "activeDocumentId": null, "rowIds": ["doc:doc_source"], "rows": [ { "rowId": "doc:doc_target", "rowKind": "doc", "documentId": "doc_target", "title": "目标" }, { "rowId": "doc:doc_source", "rowKind": "doc", "documentId": "doc_source", "title": "来源" } ], "documentParents": [ { "documentId": "doc_target", "parentId": null }, { "documentId": "doc_source", "parentId": null } ] }), preflight_data: None, reason: Some("文件树内部拖放预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect_err("readonly source should reject move before execution"); assert_eq!(readonly_source.kind, BridgeErrorKind::Validation); assert_eq!(readonly_source.message, "来源是只读,不能移动这些对象"); } #[test] fn tree_filetree_drop_preflight_marks_same_name_conflicts_for_confirmation() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.drop.preflight".into(), command_id: "cmd_filetree_drop_preflight_conflict".into(), idempotency_key: Some("idem_filetree_drop_preflight_conflict".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_target".into()), block_id: None, }), payload: json!({ "copy": false, "sourceCapabilities": ["read", "write", "move"], "targetCapabilities": ["read", "write", "drop"], "targetDocumentId": "doc_target", "targetRowId": "doc:doc_target", "focusedRowId": null, "activeDocumentId": null, "rowIds": ["doc:doc_source"], "rows": [ { "rowId": "doc:doc_target", "rowKind": "doc", "documentId": "doc_target", "title": "目标" }, { "rowId": "doc:doc_source", "rowKind": "doc", "documentId": "doc_source", "title": "同名页面" } ], "targetChildren": [ { "rowKind": "doc", "documentId": "doc_existing", "title": "同名页面" } ], "documentParents": [ { "documentId": "doc_target", "parentId": null }, { "documentId": "doc_source", "parentId": null }, { "documentId": "doc_existing", "parentId": "doc_target" } ], "conflictPolicy": "prompt" }), preflight_data: None, reason: Some("文件树内部拖放预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect("same-name conflict should return a confirmable plan"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!( plan.args_json["fileTreeDropPlan"]["requiresConfirmation"], true ); assert_eq!( plan.args_json["fileTreeDropPlan"]["conflicts"], json!([ { "rowKind": "doc", "sourceRowId": "doc:doc_source", "sourceDocumentId": "doc_source", "sourceAssetId": null, "existingDocumentId": "doc_existing", "existingAssetId": null, "targetDocumentId": "doc_target", "title": "同名页面", "policy": "prompt" } ]) ); } #[test] fn tree_filetree_delete_preflight_plans_doc_and_asset_targets() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.delete.preflight".into(), command_id: "cmd_filetree_delete_preflight_1".into(), idempotency_key: Some("idem_filetree_delete_preflight_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: None, block_id: None, }), payload: json!({ "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1", "asset:asset_other_1", "missing"], "rows": [ { "rowId": "doc:doc_parent", "rowKind": "doc", "documentId": "doc_parent" }, { "rowId": "index:doc_child", "rowKind": "index", "documentId": "doc_child" }, { "rowId": "asset:pdf_1", "rowKind": "asset", "documentId": "doc_parent", "assetId": "pdf_1", "assetDocumentId": "doc_parent", "assetType": "file", "storagePath": "uploads/guide.pdf" }, { "rowId": "asset:asset_child_1", "rowKind": "asset", "documentId": "doc_child", "assetId": "asset_child_1", "assetDocumentId": "doc_child", "assetType": "file", "storagePath": "uploads/child.png" }, { "rowId": "asset:asset_other_1", "rowKind": "asset", "documentId": "doc_other", "assetId": "asset_other_1", "assetDocumentId": "doc_other", "assetType": "file", "storagePath": "uploads/other.png" } ], "documentParents": [ { "documentId": "doc_parent", "parentId": null }, { "documentId": "doc_child", "parentId": "doc_parent" }, { "documentId": "doc_other", "parentId": null } ] }), preflight_data: None, reason: Some("文件树删除预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect("filetree delete preflight plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "tree.filetree.delete.preflight"); assert_eq!(plan.function_name, "tree.filetree.delete.preflight"); assert_eq!( plan.args_json["fileTreeDeletePlan"], json!({ "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1", "asset:asset_other_1"], "docIds": ["doc_parent"], "assetIds": ["asset_other_1"], "assetDocumentIds": ["doc_other"] }) ); } #[test] fn tree_filetree_paste_preflight_plans_doc_index_and_asset_copy() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.paste.preflight".into(), command_id: "cmd_filetree_paste_preflight_1".into(), idempotency_key: Some("idem_filetree_paste_preflight_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_target".into()), block_id: None, }), payload: json!({ "targetDocumentId": null, "focusedRowId": "asset-folder:mind_1", "activeDocumentId": null, "rowIds": ["index:doc_index", "doc:doc_full", "asset:pdf_1", "asset-folder:mind_1", "asset:virtual_1", "asset:pdf_1"], "rows": [ { "rowId": "asset-folder:mind_1", "rowKind": "asset-folder", "documentId": "doc_target", "assetId": "mind_1", "assetType": "mindmap", "storagePath": "mindmaps/mind_1/mindmap.json" }, { "rowId": "doc:doc_full", "rowKind": "doc", "documentId": "doc_full" }, { "rowId": "index:doc_index", "rowKind": "index", "documentId": "doc_index" }, { "rowId": "asset:pdf_1", "rowKind": "asset", "documentId": "doc_source", "assetId": "pdf_1", "assetDocumentId": "doc_source", "assetType": "file", "storagePath": "uploads/guide.pdf" }, { "rowId": "asset:virtual_1", "rowKind": "asset", "documentId": "doc_source", "assetId": "virtual_1", "assetDocumentId": "doc_source", "assetType": "file", "storagePath": null } ] }), preflight_data: None, reason: Some("文件树粘贴预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect("filetree paste preflight plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "tree.filetree.paste.preflight"); assert_eq!(plan.function_name, "tree.filetree.paste.preflight"); assert_eq!( plan.args_json["fileTreePastePlan"], json!({ "targetDocumentId": "doc_target", "targetMindmapId": "mind_1", "targetSubPath": "mindmaps/mind_1", "rowIds": ["index:doc_index", "doc:doc_full", "asset:pdf_1", "asset-folder:mind_1", "asset:virtual_1"], "docItems": [ { "documentId": "doc_index", "recursive": false }, { "documentId": "doc_full", "recursive": true } ], "copyableAssetIds": ["pdf_1"], "resourceTransferPlan": { "action": "copy", "assetIds": ["pdf_1"], "targetDocumentId": "doc_target", "targetSubPath": "mindmaps/mind_1" } }) ); } #[test] fn tree_filetree_upload_target_preflight_resolves_workspace_doc_and_mindmap_target() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.filetree.upload-target.preflight".into(), command_id: "cmd_filetree_upload_target_preflight_1".into(), idempotency_key: Some("idem_filetree_upload_target_preflight_1".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_target".into()), block_id: None, }), payload: json!({ "workspaceId": "ws_fallback", "targetDocumentId": null, "targetRowId": "asset:asset_child_1", "focusedRowId": null, "activeDocumentId": "doc_active", "rows": [ { "rowId": "asset:asset_child_1", "rowKind": "asset", "documentId": "doc_target", "assetId": "asset_child_1", "assetDocumentId": "doc_target", "assetType": "file", "storagePath": "mindmaps/mind_1/assets/node.png" } ], "documentWorkspaces": [ { "documentId": "doc_target", "workspaceId": "ws_1" }, { "documentId": "doc_active", "workspaceId": "ws_active" } ] }), preflight_data: None, reason: Some("文件树上传目标预检".into()), refs: vec!["file-tree-shell".into()], dry_run: false, validate_only: true, }, }) .expect("filetree upload target preflight plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "tree.filetree.upload-target.preflight"); assert_eq!(plan.function_name, "tree.filetree.upload-target.preflight"); assert_eq!( plan.args_json["fileTreeUploadTargetPlan"], json!({ "workspaceId": "ws_1", "targetDocumentId": "doc_target", "targetMindmapId": "mind_1", "targetSubPath": "mindmaps/mind_1" }) ); } #[test] fn tree_resource_upload_plan_includes_upload_contract_and_delta_hint() { let plan = execute_runtime_input(RuntimeInput::Command { context: demo_context(), command: RuntimeCommandEnvelopeWire { name: "tree.resource.upload".into(), command_id: "cmd_upload_asset".into(), idempotency_key: Some("idem_upload_asset".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: "mnote-web".into(), source_kind: None, root_uri: None, workspace_id: None, capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("target_doc".into()), block_id: None, }), payload: json!({ "assetId": " asset_upload_1 ", "workspaceId": " ws_1 ", "targetDocumentId": " target_doc ", "targetSubPath": "mindmaps/mind_1/../assets", "fileName": "demo/file.pdf", "fileSize": 1024, "mimeType": "application/pdf", "assetType": "file", }), preflight_data: None, reason: Some("文件树资源上传".into()), refs: vec!["file-tree-upload".into()], dry_run: false, validate_only: false, }, }) .expect("resource upload plan should build"); let RuntimeExecutionPlan::Command(plan) = plan else { panic!("expected command plan"); }; assert_eq!(plan.command_name, "tree.resource.upload"); assert_eq!(plan.function_name, "tree.resource.upload"); assert_eq!( plan.args_json, json!({ "assetId": "asset_upload_1", "workspaceId": "ws_1", "targetDocumentId": "target_doc", "targetSubPath": "mindmaps/mind_1/assets", "fileName": "demo_file.pdf", "fileSize": 1024, "mimeType": "application/pdf", "assetType": "file", "resourceUploadPlan": { "action": "upload", "assetId": "asset_upload_1", "workspaceId": "ws_1", "targetDocumentId": "target_doc", "targetSubPath": "mindmaps/mind_1/assets", "fileName": "demo_file.pdf", "fileSize": 1024, "mimeType": "application/pdf", "assetType": "file", }, "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "itemsField": "items" } }, "domainEventHint": { "family": "tree", "eventType": "tree.resource.uploaded" }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.resource.uploaded", "streamDeltaHint": { "family": "tree", "kind": "asset_result", "args": { "itemsField": "items" } } } }) ); } #[test] fn mindmap_get_tool_plan_uses_mindmaps_get_query() { let plan = execute_runtime_input(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "mindmap_get".into(), kind: "query".into(), mode: Some("plan".into()), args_json: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "maxNodes": 20, }), target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("mind_1".into()), }), reason: Some("查看导图摘要".into()), refs: vec!["task-032".into()], }, data: None, }) .expect("tool plan should build"); match plan { RuntimeExecutionPlan::Tool(plan) => { assert_eq!(plan.tool_name, "mindmap_get"); assert_eq!(plan.toolset_id, "toolset.mindmap_read"); assert_eq!(plan.steps[0].name, "mindmaps.get"); } _ => panic!("expected tool plan"), } } #[test] fn mindmap_apply_ops_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "mindmap_apply_ops".into(), kind: "command".into(), mode: Some("result".into()), args_json: json!({ "documentId": "doc_1", "mindmapId": "mind_1", "ops": [ { "op": "addChild", "parentUid": "root_1", "node": { "text": "新分支" } } ], }), target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), page_id: Some("doc_1".into()), block_id: Some("mind_1".into()), }), reason: Some("补充分支".into()), refs: vec!["task-032".into()], }, data: Some(json!({ "source": "convex", "data": { "data": { "uid": "root_1", "text": "中心主题" }, "children": [] } })), }) .expect("mindmap tool result should build"); assert_eq!(result.get("ok"), Some(&json!(true))); assert_eq!(result.get("source"), Some(&json!("convex"))); assert_eq!(result.get("applied"), Some(&json!(1))); assert_eq!(result.get("errors"), Some(&json!([]))); assert_eq!(result.pointer("/data/data/uid"), Some(&json!("root_1"))); assert_eq!(result.pointer("/data/data/text"), Some(&json!("中心主题"))); assert_eq!( result.pointer("/data/children/0/data/text"), Some(&json!("新分支")) ); let generated_uid = result .pointer("/data/children/0/data/uid") .and_then(Value::as_str) .expect("mindmap child uid should exist"); assert!( generated_uid.starts_with("rust_mindmap_uid_"), "unexpected generated uid: {generated_uid}" ); assert_eq!(result.pointer("/meta/reason"), Some(&Value::Null)); } #[test] fn bridge_request_query_plan_maps_to_bridge_logs() { let plan = execute_runtime_input(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "bridge.request.get".into(), payload: json!({ "workspaceId": "ws_1", "requestId": "req_lookup_1", "commandId": "cmd_lookup_1", }), }, data: None, }) .expect("bridge request query plan should build"); match plan { RuntimeExecutionPlan::Query(plan) => { assert_eq!(plan.function_name, "bridge.request.get"); assert_eq!(plan.args_json["workspaceId"], json!("ws_1")); assert_eq!(plan.args_json["requestId"], json!("req_lookup_1")); assert_eq!(plan.args_json["commandId"], json!("cmd_lookup_1")); } _ => panic!("expected query plan"), } } #[test] fn bridge_trace_tool_plan_uses_observe_toolset() { let plan = execute_runtime_input(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "bridge_trace_get".into(), kind: "query".into(), mode: Some("plan".into()), args_json: json!({ "workspaceId": "ws_1", "traceId": "trace_lookup_1", }), target: None, reason: Some("排查链路".into()), refs: vec!["task-034".into()], }, data: None, }) .expect("bridge trace tool plan should build"); match plan { RuntimeExecutionPlan::Tool(plan) => { assert_eq!(plan.tool_name, "bridge_trace_get"); assert_eq!(plan.toolset_id, "toolset.observe_read"); assert_eq!(plan.steps[0].name, "bridge.trace.get"); assert_eq!( plan.steps[0].function_name.as_deref(), Some("bridge.trace.get") ); } _ => panic!("expected tool plan"), } } #[test] fn kernel_project_view_query_executes_into_sidebar_projection() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "sidebar_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 2, "includeEdges": true, "nodeTypes": ["page"], }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "page_child", "workspace_id": "ws_1", "title": "子页面", "parent_id": "page_root", "sort_order": 1, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ] })), }) .expect("kernel projection result should build"); assert_eq!(result["projection"], json!("sidebar_tree")); assert_eq!(result["rootNodeId"], json!("page_root")); assert_eq!(result["items"][0]["rowId"], json!("page:page_root")); assert_eq!(result["items"][0]["rowKind"], json!("document")); assert_eq!(result["items"][0]["nodeId"], json!("page_root")); assert_eq!(result["items"][0]["position"], json!(0)); assert_eq!(result["items"][0]["projectionKind"], json!("sidebar_tree")); assert_eq!( result["items"][0]["resourceMeta"]["resourceKind"], json!("document") ); assert!(result["items"][0]["capabilities"] .as_array() .map(|caps| caps.contains(&json!("create-child"))) .unwrap_or(false)); assert_eq!(result["items"][1]["parentNodeId"], json!("page_root")); assert_eq!(result["items"][1]["position"], json!(1)); } #[test] fn kernel_project_view_query_executes_into_file_tree_projection_contract() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "file_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 2, "includeEdges": true, "nodeTypes": ["page"], }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "page_child", "workspace_id": "ws_1", "title": "子页面", "parent_id": "page_root", "sort_order": 2, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ], "media_assets": [ { "id": "asset_img_1", "workspace_id": "ws_1", "document_id": "page_root", "asset_type": "file", "file_name": "cover.png", "mime_type": "image/png", "storage_path": "cover.png" }, { "id": "asset_pdf_1", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "file", "file_name": "notes.pdf", "mime_type": "application/pdf", "storage_path": "notes.pdf" }, { "id": "asset_book_1", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "file", "file_name": "guide.epub", "mime_type": "application/epub+zip", "storage_path": "guide.epub" }, { "id": "asset_ref_1", "workspace_id": "ws_1", "document_id": "page_root", "asset_type": "file", "file_name": "diagram-source.png", "mime_type": "image/png", "storage_path": "diagram-source.png" } ], "mindmap_assets": [ { "id": "mind_1", "workspace_id": "ws_1", "document_id": "page_root", "block_id": "block_mind_1", "asset_type": "mindmap", "file_name": "roadmap.json", "mime_type": "application/json", "storage_path": "roadmap.json" } ], "table_assets": [ { "id": "table_1", "workspace_id": "ws_1", "document_id": "page_root", "asset_type": "table", "file_name": "budget.table", "mime_type": "application/json", "storage_path": "budget.table" } ], "mindmap_asset_children": { "mind_1": ["asset_ref_1"] } })), }) .expect("file_tree projection result should build"); let items = result["items"].as_array().expect("items should be array"); let item_by_row_id = items .iter() .filter_map(|item| { item.get("rowId") .and_then(Value::as_str) .map(|row_id| (row_id.to_string(), item)) }) .collect::>(); assert_eq!(result["projection"], json!("file_tree")); assert_eq!( result["projectionId"], json!("kernel_projection:file_tree:page_root") ); assert_eq!( result["meta"]["search"]["indexingVisibility"]["schema"], json!("mnote.file_tree.indexing_visibility") ); assert_eq!( result["meta"]["search"]["indexingVisibility"]["status"], json!("visible") ); assert_eq!( result["meta"]["search"]["indexingVisibility"]["metrics"]["visibleRows"], json!(items.len()) ); assert_eq!( item_by_row_id["doc:page_root"]["rowKind"], json!("document") ); assert_eq!( item_by_row_id["doc:page_root"]["resourceMeta"]["resourceKind"], json!("document") ); assert_eq!(item_by_row_id["doc:page_root"]["title"], json!("根页面.md")); assert!(!item_by_row_id.contains_key("index:page_root")); assert_eq!( item_by_row_id["doc:page_root"]["resourceMeta"]["extra"]["source"], json!({ "sourceKind": "convex_workspace", "sourceUri": "convex://workspace/ws_1/documents/page_root", "relativePath": "documents/page_root", "storageIdentity": "page_root", "operationProfile": "convex_workspace" }) ); assert_eq!( item_by_row_id["doc:page_root"]["resourceMeta"]["objectIdentity"], json!({ "objectKind": "page", "documentId": "page_root", "blockId": null, "assetId": null }) ); assert_eq!( item_by_row_id["asset:asset_img_1"]["resourceMeta"]["assetKind"], json!("image") ); assert_eq!( item_by_row_id["asset:asset_img_1"]["resourceMeta"]["resourceKind"], json!("asset") ); assert_eq!( item_by_row_id["asset:asset_img_1"]["resourceMeta"]["extra"]["source"], json!({ "sourceKind": "convex_workspace", "sourceUri": "convex://workspace/ws_1/assets/asset_img_1", "relativePath": "assets/asset_img_1", "storageIdentity": "asset_img_1", "operationProfile": "convex_workspace" }) ); assert_eq!( item_by_row_id["asset-folder:mind_1"]["rowKind"], json!("asset_folder") ); assert_eq!( item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], json!("mindmap") ); assert_eq!( item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["assetKind"], json!("mindmap") ); assert_eq!( item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["objectIdentity"], json!({ "objectKind": "mindmap", "documentId": "page_root", "blockId": "block_mind_1", "assetId": "mind_1" }) ); assert_eq!( item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["blockAssetRelation"], json!({ "documentId": "page_root", "blockId": "block_mind_1", "assetId": "mind_1", "assetKind": "mindmap" }) ); assert_eq!( item_by_row_id["asset:asset_ref_1"]["parentNodeId"], json!("asset-folder:mind_1") ); assert_eq!( item_by_row_id["asset:asset_ref_1"]["capabilities"][0], json!("open-asset") ); assert_eq!( item_by_row_id["asset:table_1"]["resourceMeta"]["resourceKind"], json!("table") ); assert_eq!( item_by_row_id["asset:asset_pdf_1"]["resourceMeta"]["resourceKind"], json!("pdf") ); assert_eq!( item_by_row_id["asset:asset_book_1"]["resourceMeta"]["resourceKind"], json!("book") ); } #[test] fn kernel_file_tree_projection_query_keeps_matches_and_ancestors() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "file_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 3, "includeEdges": true, "nodeTypes": ["page"], "query": "roadmap" }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "page_child", "workspace_id": "ws_1", "title": "子页面", "parent_id": "page_root", "sort_order": 1, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ], "mindmap_assets": [ { "id": "mind_roadmap", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "mindmap", "file_name": "roadmap.json", "mime_type": "application/json" } ], "media_assets": [ { "id": "asset_other", "workspace_id": "ws_1", "document_id": "page_root", "asset_type": "file", "file_name": "cover.png", "mime_type": "image/png" } ], "table_assets": [], "mindmap_asset_children": {} })), }) .expect("file_tree search projection should build"); let row_ids = result["items"] .as_array() .expect("items should be array") .iter() .filter_map(|item| item.get("rowId").and_then(Value::as_str)) .collect::>(); assert_eq!( row_ids, vec!["doc:page_root", "doc:page_child", "asset:mind_roadmap"] ); assert_eq!(result["items"][0]["expandedByDefault"], json!(true)); assert_eq!(result["items"][1]["expandedByDefault"], json!(true)); assert_eq!( result["edges"] .as_array() .expect("edges should be array") .iter() .filter_map(|edge| edge.get("toNodeId").and_then(Value::as_str)) .collect::>(), vec!["page_child", "asset:mind_roadmap"] ); } #[test] fn kernel_file_tree_projection_query_supports_extended_resources_and_max_results() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "file_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 3, "includeEdges": true, "nodeTypes": ["page"], "query": "rust", "maxResults": 4 }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "page_child", "workspace_id": "ws_1", "title": "子页面", "parent_id": "page_root", "sort_order": 1, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ], "media_assets": [ { "id": "asset_rust_pdf", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "file", "file_name": "rust-guide.pdf", "mime_type": "application/pdf" }, { "id": "asset_rust_book", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "file", "file_name": "rust-book.epub", "mime_type": "application/epub+zip" }, { "id": "asset_rust_child", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "file", "file_name": "rust-node.png", "mime_type": "image/png" }, { "id": "asset_extra", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "file", "file_name": "rust-extra.png", "mime_type": "image/png" } ], "mindmap_assets": [ { "id": "mind_rust", "workspace_id": "ws_1", "document_id": "page_child", "asset_type": "mindmap", "file_name": "rust-map.json", "mime_type": "application/json" } ], "table_assets": [], "mindmap_asset_children": { "mind_rust": ["asset_rust_child"] } })), }) .expect("file_tree search projection should build"); let items = result["items"].as_array().expect("items should be array"); let row_ids = items .iter() .filter_map(|item| item.get("rowId").and_then(Value::as_str)) .collect::>(); assert_eq!( row_ids, vec![ "doc:page_root", "doc:page_child", "asset:asset_rust_pdf", "asset:asset_rust_book", "asset:asset_extra", "asset-folder:mind_rust" ] ); assert_eq!(items[0]["expandedByDefault"], json!(true)); assert_eq!(items[1]["expandedByDefault"], json!(true)); assert_eq!(items[2]["resourceMeta"]["resourceKind"], json!("pdf")); assert_eq!(items[3]["resourceMeta"]["resourceKind"], json!("book")); assert_eq!(items[4]["resourceMeta"]["resourceKind"], json!("asset")); assert_eq!(items[5]["resourceMeta"]["resourceKind"], json!("mindmap")); assert!(!row_ids.contains(&"asset:asset_rust_child")); } #[test] fn kernel_file_tree_projection_separates_attachment_object_identities() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "file_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 2, "includeEdges": true, "nodeTypes": ["page"], }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ], "media_assets": [ { "id": "office_1", "workspace_id": "ws_1", "document_id": "page_root", "block_id": "block_office_1", "asset_type": "file", "file_name": "contract.docx", "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, { "id": "code_1", "workspace_id": "ws_1", "document_id": "page_root", "block_id": "block_code_1", "asset_type": "file", "file_name": "main.rs", "mime_type": "text/rust" }, { "id": "image_1", "workspace_id": "ws_1", "document_id": "page_root", "block_id": "block_image_1", "asset_type": "file", "file_name": "cover.png", "mime_type": "image/png" } ], "mindmap_assets": [], "table_assets": [], "mindmap_asset_children": {} })), }) .expect("file_tree object identity projection should build"); let items = result["items"].as_array().expect("items should be array"); let item_by_row_id = items .iter() .filter_map(|item| { item.get("rowId") .and_then(Value::as_str) .map(|row_id| (row_id.to_string(), item)) }) .collect::>(); assert_eq!( item_by_row_id["asset:office_1"]["resourceMeta"]["resourceKind"], json!("only_office") ); assert_eq!( item_by_row_id["asset:office_1"]["resourceMeta"]["objectIdentity"]["objectKind"], json!("only_office") ); assert_eq!( item_by_row_id["asset:code_1"]["resourceMeta"]["resourceKind"], json!("code") ); assert_eq!( item_by_row_id["asset:code_1"]["resourceMeta"]["objectIdentity"]["objectKind"], json!("code") ); assert_eq!( item_by_row_id["asset:image_1"]["resourceMeta"]["objectIdentity"], json!({ "objectKind": "attachment", "documentId": "page_root", "blockId": "block_image_1", "assetId": "image_1" }) ); } #[test] fn kernel_file_tree_projection_query_matches_page_markdown_resource() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "file_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 2, "includeEdges": true, "nodeTypes": ["page"], "query": "根页面.md" }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ], "media_assets": [], "mindmap_assets": [], "table_assets": [], "mindmap_asset_children": {} })), }) .expect("file_tree page markdown search projection should build"); let row_ids = result["items"] .as_array() .expect("items should be array") .iter() .filter_map(|item| item.get("rowId").and_then(Value::as_str)) .collect::>(); assert_eq!(row_ids, vec!["doc:page_root"]); assert_eq!( result["items"][0]["resourceMeta"]["resourceKind"], json!("document") ); } #[test] fn convex_file_tree_projects_page_body_as_title_markdown_row_without_visible_index() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "file_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 2, "includeEdges": true, "nodeTypes": ["page"] }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ], "media_assets": [], "mindmap_assets": [], "table_assets": [], "mindmap_asset_children": {} })), }) .expect("file_tree projection result should build"); let items = result["items"].as_array().expect("items should be array"); let row_ids = items .iter() .filter_map(|item| item.get("rowId").and_then(Value::as_str)) .collect::>(); assert_eq!(row_ids, vec!["doc:page_root"]); assert_eq!(items[0]["title"], json!("根页面.md")); assert_eq!(items[0]["rowKind"], json!("document")); assert_eq!( items[0]["resourceMeta"]["objectIdentity"], json!({ "objectKind": "page", "documentId": "page_root", "blockId": null, "assetId": null }) ); } #[test] fn kernel_subtree_query_executes_into_unified_subtree() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.subtree.get".into(), payload: json!({ "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 2, "includeEdges": true, "nodeTypes": ["page"], }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "page_child", "workspace_id": "ws_1", "title": "子页面", "parent_id": "page_root", "sort_order": 1, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ] })), }) .expect("kernel subtree result should build"); assert_eq!(result["rootNodeId"], json!("page_root")); assert_eq!(result["nodes"].as_array().map(Vec::len), Some(2)); assert_eq!(result["edges"].as_array().map(Vec::len), Some(1)); } #[test] fn kernel_edges_list_exposes_ai_artifact_reference_edges() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.edges.list".into(), payload: json!({ "workspaceId": "ws_1", "nodeId": "page_root", "direction": "both", }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "summary_page_root", "workspace_id": "ws_1", "title": "AI Summary", "parent_id": "page_root", "sort_order": 1, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "ai_note_page_root_req_1", "workspace_id": "ws_1", "title": "AI Note", "parent_id": "page_root", "sort_order": 2, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ] })), }) .expect("kernel edge list should build"); let edges = result["edges"].as_array().expect("edges should be array"); let artifact_edges = edges .iter() .filter(|edge| edge["metadata"]["kind"] == json!("ai_artifact_reference")) .collect::>(); assert_eq!(artifact_edges.len(), 2); assert_eq!(artifact_edges[0]["fromNodeId"], json!("page_root")); assert_eq!(artifact_edges[0]["toNodeId"], json!("summary_page_root")); assert_eq!(artifact_edges[0]["edgeType"], json!("source_of")); assert_eq!( artifact_edges[0]["metadata"]["artifactType"], json!("summary") ); assert_eq!( artifact_edges[1]["toNodeId"], json!("ai_note_page_root_req_1") ); assert_eq!( artifact_edges[1]["metadata"]["artifactType"], json!("ai_note") ); } #[test] fn file_tree_projection_marks_ai_artifacts_group_as_projection_only() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": "file_tree", "workspaceId": "ws_1", "rootNodeId": "page_root", "depth": 2, "includeEdges": true, "nodeTypes": ["page"], }), }, data: Some(json!({ "documents": [ { "id": "page_root", "workspace_id": "ws_1", "title": "根页面", "parent_id": null, "sort_order": 0, "is_starred": true, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" }, { "id": "summary_page_root", "workspace_id": "ws_1", "title": "AI Summary", "parent_id": "page_root", "sort_order": 1, "is_starred": false, "is_template": false, "created_at": "2026-04-16T00:00:00Z", "updated_at": "2026-04-16T00:00:00Z" } ], "media_assets": [], "mindmap_assets": [], "table_assets": [], "mindmap_asset_children": {} })), }) .expect("file tree projection should build"); let items = result["items"].as_array().expect("items should be array"); assert!(!items .iter() .any(|item| item["nodeId"] == json!("AI Artifacts"))); assert_eq!( result["meta"]["aiArtifacts"]["title"], json!("AI Artifacts") ); assert_eq!(result["meta"]["aiArtifacts"]["projectionOnly"], json!(true)); assert_eq!( result["meta"]["aiArtifacts"]["source"], json!("kernel.project_view.synthetic_group") ); assert_eq!(result["meta"]["aiArtifacts"]["kernelNodeId"], Value::Null); assert_eq!( result["meta"]["aiArtifacts"]["artifactDocumentIds"], json!(["summary_page_root"]) ); } #[test] fn index_rebuild_tool_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Tool { context: demo_context(), tool: RuntimeToolInvocationWire { tool: "index_rebuild".into(), kind: "job".into(), mode: Some("result".into()), args_json: json!({ "workspaceId": "ws_1", "lastProcessedEventId": "evt_0", "lastProcessedAt": "2026-04-15T00:00:00Z", }), target: None, reason: Some("重建搜索索引".into()), refs: vec!["task-034".into()], }, data: Some(json!({ "events": [ { "id": "evt_1", "workspace_id": "ws_1", "command_id": "cmd_1", "command_log_id": "clog_1", "aggregate_type": "page", "aggregate_id": "page_1", "event_type": "page.created", "event_version": 1, "actor_type": "user", "payload": {"title": "第一篇"}, "request_id": "req_1", "trace_id": "trace_1", "created_at": "2026-04-15T01:00:00Z" } ] })), }) .expect("index rebuild tool result should build"); assert_eq!(result.get("ok"), Some(&json!(true))); assert_eq!(result.get("workspaceId"), Some(&json!("ws_1"))); assert_eq!(result.get("batchCount"), Some(&json!(1))); assert_eq!(result.get("documentCount"), Some(&json!(1))); assert_eq!( result.pointer("/cursor/lastProcessedEventId"), Some(&json!("evt_1")) ); } }