1173 lines
36 KiB
Rust
1173 lines
36 KiB
Rust
use core_protocol::{
|
|||
|
|
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
|
||
|
|
GetMindmap, GetPageContent, InsertBlock, InvocationKind, ListSidebarDataset, MindmapOp,
|
||
|
|
MindmapTreeNode, PatchPageBlock, PutMindmap, QueryEnvelope, SavePageContent, SearchBlocks,
|
||
|
|
SearchPages, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
|
||
|
|
};
|
||
|
|
use serde::Serialize;
|
||
|
|
use serde_json::{json, Value};
|
||
|
|
use storage_convex_bridge::{build_query_request, build_write_request, BridgeContext};
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
|
|
pub struct CliContext {
|
||
|
|
pub actor_id: String,
|
||
|
|
pub actor_type: String,
|
||
|
|
pub session_id: Option<String>,
|
||
|
|
pub reason: Option<String>,
|
||
|
|
pub idempotency_key: Option<String>,
|
||
|
|
pub validate_only: bool,
|
||
|
|
pub dry_run: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for CliContext {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self {
|
||
|
|
actor_id: "cli_user".into(),
|
||
|
|
actor_type: "human".into(),
|
||
|
|
session_id: None,
|
||
|
|
reason: None,
|
||
|
|
idempotency_key: None,
|
||
|
|
validate_only: false,
|
||
|
|
dry_run: false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
|
|
pub struct CliError {
|
||
|
|
pub code: &'static str,
|
||
|
|
pub message: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl CliError {
|
||
|
|
pub fn validation(message: impl Into<String>) -> Self {
|
||
|
|
Self {
|
||
|
|
code: "VALIDATION_ERROR",
|
||
|
|
message: message.into(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub type CliResult<T> = Result<T, CliError>;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct CliJsonOutput {
|
||
|
|
pub ok: bool,
|
||
|
|
pub entrypoint: &'static str,
|
||
|
|
pub domain: String,
|
||
|
|
pub action: String,
|
||
|
|
pub context: CliOutputContext,
|
||
|
|
pub operation: CliOperationOutput,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct CliOutputContext {
|
||
|
|
pub request_id: String,
|
||
|
|
pub trace_id: String,
|
||
|
|
pub actor_id: String,
|
||
|
|
pub actor_type: String,
|
||
|
|
pub session_id: Option<String>,
|
||
|
|
pub workspace_id: Option<String>,
|
||
|
|
pub idempotency_key: Option<String>,
|
||
|
|
pub validate_only: bool,
|
||
|
|
pub dry_run: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||
|
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
||
|
|
pub enum CliOperationOutput {
|
||
|
|
Command {
|
||
|
|
name: String,
|
||
|
|
command_id: String,
|
||
|
|
normalized_input: Value,
|
||
|
|
transport: CliTransportPlan,
|
||
|
|
},
|
||
|
|
Query {
|
||
|
|
name: String,
|
||
|
|
normalized_input: Value,
|
||
|
|
transport: CliTransportPlan,
|
||
|
|
},
|
||
|
|
Tool {
|
||
|
|
tool_name: String,
|
||
|
|
invocation_kind: String,
|
||
|
|
execution_mode: String,
|
||
|
|
toolset_id: String,
|
||
|
|
effect: String,
|
||
|
|
requires_confirmation: bool,
|
||
|
|
normalized_input: Value,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||
|
|
#[serde(rename_all = "camelCase")]
|
||
|
|
pub struct CliTransportPlan {
|
||
|
|
pub kind: String,
|
||
|
|
pub function_name: String,
|
||
|
|
pub payload_json: String,
|
||
|
|
pub args_json: Value,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn supported_command_surface() -> Vec<&'static str> {
|
||
|
|
let mut surface = vec![
|
||
|
|
"page get",
|
||
|
|
"page title",
|
||
|
|
"page save",
|
||
|
|
"block insert",
|
||
|
|
"block patch",
|
||
|
|
"mindmap get",
|
||
|
|
"mindmap put",
|
||
|
|
"mindmap op",
|
||
|
|
"search documents",
|
||
|
|
"search blocks",
|
||
|
|
"sidebar dataset",
|
||
|
|
"tool run",
|
||
|
|
];
|
||
|
|
for tool_name in default_tool_registry().tool_names() {
|
||
|
|
surface.push(Box::leak(format!("tool run --tool-name {tool_name}").into_boxed_str()));
|
||
|
|
}
|
||
|
|
surface
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn supported_json_contracts() -> Vec<&'static str> {
|
||
|
|
let mut contracts = vec![
|
||
|
|
"page.get",
|
||
|
|
"page.title",
|
||
|
|
"page.save",
|
||
|
|
"block.insert",
|
||
|
|
"block.patch",
|
||
|
|
"mindmap.get",
|
||
|
|
"mindmap.put",
|
||
|
|
"mindmap.op",
|
||
|
|
"search.documents",
|
||
|
|
"search.blocks",
|
||
|
|
"sidebar.dataset",
|
||
|
|
"tool.run",
|
||
|
|
];
|
||
|
|
for tool_name in default_tool_registry().tool_names() {
|
||
|
|
contracts.push(Box::leak(format!("tool.{tool_name}").into_boxed_str()));
|
||
|
|
}
|
||
|
|
contracts
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn render_plain_output(output: &CliJsonOutput) -> String {
|
||
|
|
match &output.operation {
|
||
|
|
CliOperationOutput::Command {
|
||
|
|
name,
|
||
|
|
command_id,
|
||
|
|
transport,
|
||
|
|
..
|
||
|
|
} => format!(
|
||
|
|
"domain={} action={} kind=command name={} command_id={} function={} request_id={} trace_id={}",
|
||
|
|
output.domain,
|
||
|
|
output.action,
|
||
|
|
name,
|
||
|
|
command_id,
|
||
|
|
transport.function_name,
|
||
|
|
output.context.request_id,
|
||
|
|
output.context.trace_id,
|
||
|
|
),
|
||
|
|
CliOperationOutput::Query {
|
||
|
|
name, transport, ..
|
||
|
|
} => format!(
|
||
|
|
"domain={} action={} kind=query name={} function={} request_id={} trace_id={}",
|
||
|
|
output.domain,
|
||
|
|
output.action,
|
||
|
|
name,
|
||
|
|
transport.function_name,
|
||
|
|
output.context.request_id,
|
||
|
|
output.context.trace_id,
|
||
|
|
),
|
||
|
|
CliOperationOutput::Tool {
|
||
|
|
tool_name,
|
||
|
|
invocation_kind,
|
||
|
|
execution_mode,
|
||
|
|
toolset_id,
|
||
|
|
effect,
|
||
|
|
requires_confirmation,
|
||
|
|
..
|
||
|
|
} => format!(
|
||
|
|
"domain={} action={} kind=tool tool={} invocation_kind={} execution_mode={} toolset={} effect={} requires_confirmation={} request_id={} trace_id={}",
|
||
|
|
output.domain,
|
||
|
|
output.action,
|
||
|
|
tool_name,
|
||
|
|
invocation_kind,
|
||
|
|
execution_mode,
|
||
|
|
toolset_id,
|
||
|
|
effect,
|
||
|
|
requires_confirmation,
|
||
|
|
output.context.request_id,
|
||
|
|
output.context.trace_id,
|
||
|
|
),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_page_get(
|
||
|
|
ctx: &CliContext,
|
||
|
|
page_id: &str,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let bridge = build_bridge_context(ctx, workspace_id, "page", "get", page_id);
|
||
|
|
let query = QueryEnvelope {
|
||
|
|
name: "documents.content.get".into(),
|
||
|
|
payload: GetPageContent {
|
||
|
|
page_id: page_id.into(),
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
let request = build_query_request(&bridge, &query).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_query_output(
|
||
|
|
"page",
|
||
|
|
"get",
|
||
|
|
&bridge,
|
||
|
|
query.name,
|
||
|
|
json!({
|
||
|
|
"pageId": page_id,
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_query".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"id": page_id,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_page_title(
|
||
|
|
ctx: &CliContext,
|
||
|
|
page_id: &str,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
title: &str,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let bridge = build_bridge_context(ctx, workspace_id, "page", "title", page_id);
|
||
|
|
let command = CommandEnvelope {
|
||
|
|
name: "documents.title.update".into(),
|
||
|
|
command_id: build_command_id("page", "title", page_id),
|
||
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
||
|
|
actor: build_actor_payload(ctx),
|
||
|
|
source: build_source_payload(),
|
||
|
|
target: Some(TargetRef {
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
page_id: Some(page_id.into()),
|
||
|
|
block_id: None,
|
||
|
|
}),
|
||
|
|
payload: UpdatePageTitle {
|
||
|
|
page_id: page_id.into(),
|
||
|
|
title: title.into(),
|
||
|
|
},
|
||
|
|
reason: ctx.reason.clone(),
|
||
|
|
refs: vec!["phase2-cli".into()],
|
||
|
|
dry_run: ctx.dry_run,
|
||
|
|
validate_only: ctx.validate_only,
|
||
|
|
};
|
||
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_command_output(
|
||
|
|
"page",
|
||
|
|
"title",
|
||
|
|
&bridge,
|
||
|
|
command.name,
|
||
|
|
command.command_id,
|
||
|
|
json!({
|
||
|
|
"pageId": page_id,
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"title": title,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_mutation".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"id": page_id,
|
||
|
|
"title": title,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_page_save(
|
||
|
|
ctx: &CliContext,
|
||
|
|
page_id: &str,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
revision: Option<u64>,
|
||
|
|
content_json: &str,
|
||
|
|
conflict_detection_key: Option<&str>,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let content_value = parse_json_value(content_json, "content_json")?;
|
||
|
|
let bridge = build_bridge_context(ctx, workspace_id, "page", "save", page_id);
|
||
|
|
let command = CommandEnvelope {
|
||
|
|
name: "documents.save".into(),
|
||
|
|
command_id: build_command_id("page", "save", page_id),
|
||
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
||
|
|
actor: build_actor_payload(ctx),
|
||
|
|
source: build_source_payload(),
|
||
|
|
target: Some(TargetRef {
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
page_id: Some(page_id.into()),
|
||
|
|
block_id: None,
|
||
|
|
}),
|
||
|
|
payload: SavePageContent {
|
||
|
|
page_id: page_id.into(),
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
revision,
|
||
|
|
content_json: content_json.into(),
|
||
|
|
conflict_detection_key: conflict_detection_key.map(|value| value.into()),
|
||
|
|
},
|
||
|
|
reason: ctx.reason.clone(),
|
||
|
|
refs: vec!["phase2-cli".into()],
|
||
|
|
dry_run: ctx.dry_run,
|
||
|
|
validate_only: ctx.validate_only,
|
||
|
|
};
|
||
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_command_output(
|
||
|
|
"page",
|
||
|
|
"save",
|
||
|
|
&bridge,
|
||
|
|
command.name,
|
||
|
|
command.command_id,
|
||
|
|
json!({
|
||
|
|
"pageId": page_id,
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"revision": revision,
|
||
|
|
"content": content_value,
|
||
|
|
"conflictDetectionKey": conflict_detection_key,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_mutation".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"id": page_id,
|
||
|
|
"content": content_value,
|
||
|
|
"expectedRevision": revision,
|
||
|
|
"conflictDetectionKey": conflict_detection_key,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_block_insert(
|
||
|
|
ctx: &CliContext,
|
||
|
|
workspace_id: &str,
|
||
|
|
page_id: &str,
|
||
|
|
content: &str,
|
||
|
|
block_type: &str,
|
||
|
|
parent_block_id: Option<&str>,
|
||
|
|
prev_block_id: Option<&str>,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let bridge = build_bridge_context(ctx, Some(workspace_id), "block", "insert", page_id);
|
||
|
|
let command = CommandEnvelope {
|
||
|
|
name: "insert_block".into(),
|
||
|
|
command_id: build_command_id("block", "insert", page_id),
|
||
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
||
|
|
actor: build_actor_payload(ctx),
|
||
|
|
source: build_source_payload(),
|
||
|
|
target: Some(TargetRef {
|
||
|
|
workspace_id: Some(workspace_id.into()),
|
||
|
|
page_id: Some(page_id.into()),
|
||
|
|
block_id: None,
|
||
|
|
}),
|
||
|
|
payload: InsertBlock {
|
||
|
|
page_id: page_id.into(),
|
||
|
|
block_type: block_type.into(),
|
||
|
|
content: content.into(),
|
||
|
|
parent_block_id: parent_block_id.map(|value| value.into()),
|
||
|
|
prev_block_id: prev_block_id.map(|value| value.into()),
|
||
|
|
},
|
||
|
|
reason: ctx.reason.clone(),
|
||
|
|
refs: vec!["phase2-cli".into()],
|
||
|
|
dry_run: ctx.dry_run,
|
||
|
|
validate_only: ctx.validate_only,
|
||
|
|
};
|
||
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_command_output(
|
||
|
|
"block",
|
||
|
|
"insert",
|
||
|
|
&bridge,
|
||
|
|
command.name,
|
||
|
|
command.command_id,
|
||
|
|
json!({
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"pageId": page_id,
|
||
|
|
"content": content,
|
||
|
|
"blockType": block_type,
|
||
|
|
"parentBlockId": parent_block_id,
|
||
|
|
"prevBlockId": prev_block_id,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_mutation".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"pageId": page_id,
|
||
|
|
"content": content,
|
||
|
|
"blockType": block_type,
|
||
|
|
"parentBlockId": parent_block_id,
|
||
|
|
"prevBlockId": prev_block_id,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_block_patch(
|
||
|
|
ctx: &CliContext,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
page_id: &str,
|
||
|
|
block_id: &str,
|
||
|
|
revision: Option<u64>,
|
||
|
|
snapshot_json: &str,
|
||
|
|
conflict_detection_key: Option<&str>,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let snapshot_value = parse_json_value(snapshot_json, "snapshot_json")?;
|
||
|
|
let bridge = build_bridge_context(ctx, workspace_id, "block", "patch", block_id);
|
||
|
|
let command = CommandEnvelope {
|
||
|
|
name: "blocks.patch".into(),
|
||
|
|
command_id: build_command_id("block", "patch", block_id),
|
||
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
||
|
|
actor: build_actor_payload(ctx),
|
||
|
|
source: build_source_payload(),
|
||
|
|
target: Some(TargetRef {
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
page_id: Some(page_id.into()),
|
||
|
|
block_id: Some(block_id.into()),
|
||
|
|
}),
|
||
|
|
payload: PatchPageBlock {
|
||
|
|
page_id: page_id.into(),
|
||
|
|
block_id: block_id.into(),
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
revision,
|
||
|
|
block_snapshot_json: snapshot_json.into(),
|
||
|
|
conflict_detection_key: conflict_detection_key.map(|value| value.into()),
|
||
|
|
},
|
||
|
|
reason: ctx.reason.clone(),
|
||
|
|
refs: vec!["phase2-cli".into()],
|
||
|
|
dry_run: ctx.dry_run,
|
||
|
|
validate_only: ctx.validate_only,
|
||
|
|
};
|
||
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_command_output(
|
||
|
|
"block",
|
||
|
|
"patch",
|
||
|
|
&bridge,
|
||
|
|
command.name,
|
||
|
|
command.command_id,
|
||
|
|
json!({
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"pageId": page_id,
|
||
|
|
"blockId": block_id,
|
||
|
|
"revision": revision,
|
||
|
|
"snapshot": snapshot_value,
|
||
|
|
"conflictDetectionKey": conflict_detection_key,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_mutation".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"pageId": page_id,
|
||
|
|
"blockId": block_id,
|
||
|
|
"snapshot": snapshot_value,
|
||
|
|
"expectedRevision": revision,
|
||
|
|
"conflictDetectionKey": conflict_detection_key,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_search_documents(
|
||
|
|
ctx: &CliContext,
|
||
|
|
query: &str,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
limit: u32,
|
||
|
|
cursor: Option<&str>,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let bridge = build_bridge_context(ctx, workspace_id, "search", "documents", query);
|
||
|
|
let envelope = QueryEnvelope {
|
||
|
|
name: "search_pages".into(),
|
||
|
|
payload: SearchPages {
|
||
|
|
query: query.into(),
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
pagination: Pagination {
|
||
|
|
limit,
|
||
|
|
cursor: cursor.map(|value| value.into()),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_query_output(
|
||
|
|
"search",
|
||
|
|
"documents",
|
||
|
|
&bridge,
|
||
|
|
envelope.name,
|
||
|
|
json!({
|
||
|
|
"query": query,
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"pagination": {
|
||
|
|
"limit": limit,
|
||
|
|
"cursor": cursor,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_query".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"query": query,
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"limit": limit,
|
||
|
|
"cursor": cursor,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_search_blocks(
|
||
|
|
ctx: &CliContext,
|
||
|
|
query: &str,
|
||
|
|
page_id: Option<&str>,
|
||
|
|
limit: u32,
|
||
|
|
cursor: Option<&str>,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let bridge = build_bridge_context(ctx, None, "search", "blocks", query);
|
||
|
|
let envelope = QueryEnvelope {
|
||
|
|
name: "search_blocks".into(),
|
||
|
|
payload: SearchBlocks {
|
||
|
|
query: query.into(),
|
||
|
|
page_id: page_id.map(|value| value.into()),
|
||
|
|
pagination: Pagination {
|
||
|
|
limit,
|
||
|
|
cursor: cursor.map(|value| value.into()),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_query_output(
|
||
|
|
"search",
|
||
|
|
"blocks",
|
||
|
|
&bridge,
|
||
|
|
envelope.name,
|
||
|
|
json!({
|
||
|
|
"query": query,
|
||
|
|
"pageId": page_id,
|
||
|
|
"pagination": {
|
||
|
|
"limit": limit,
|
||
|
|
"cursor": cursor,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_query".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"query": query,
|
||
|
|
"pageId": page_id,
|
||
|
|
"limit": limit,
|
||
|
|
"cursor": cursor,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult<CliJsonOutput> {
|
||
|
|
let bridge = build_bridge_context(ctx, Some(workspace_id), "sidebar", "dataset", workspace_id);
|
||
|
|
let envelope = QueryEnvelope {
|
||
|
|
name: "sidebar.dataset.list".into(),
|
||
|
|
payload: ListSidebarDataset {
|
||
|
|
workspace_id: workspace_id.into(),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_query_output(
|
||
|
|
"sidebar",
|
||
|
|
"dataset",
|
||
|
|
&bridge,
|
||
|
|
envelope.name,
|
||
|
|
json!({
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_query".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_mindmap_get(
|
||
|
|
ctx: &CliContext,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
document_id: &str,
|
||
|
|
mindmap_id: &str,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let bridge = build_bridge_context(
|
||
|
|
ctx,
|
||
|
|
workspace_id,
|
||
|
|
"mindmap",
|
||
|
|
"get",
|
||
|
|
&format!("{document_id}_{mindmap_id}"),
|
||
|
|
);
|
||
|
|
let envelope = QueryEnvelope {
|
||
|
|
name: "mindmaps.get".into(),
|
||
|
|
payload: GetMindmap {
|
||
|
|
document_id: document_id.into(),
|
||
|
|
mindmap_id: mindmap_id.into(),
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_query_output(
|
||
|
|
"mindmap",
|
||
|
|
"get",
|
||
|
|
&bridge,
|
||
|
|
envelope.name,
|
||
|
|
json!({
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"documentId": document_id,
|
||
|
|
"mindmapId": mindmap_id,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_query".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"docId": document_id,
|
||
|
|
"mindmapId": mindmap_id,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_mindmap_put(
|
||
|
|
ctx: &CliContext,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
document_id: &str,
|
||
|
|
mindmap_id: &str,
|
||
|
|
data_json: &str,
|
||
|
|
create_only: bool,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let data = parse_json_value(data_json, "data_json")?;
|
||
|
|
serde_json::from_value::<MindmapTreeNode>(data.clone())
|
||
|
|
.map_err(|error| CliError::validation(format!("data_json 不是合法导图树: {error}")))?;
|
||
|
|
let bridge = build_bridge_context(
|
||
|
|
ctx,
|
||
|
|
workspace_id,
|
||
|
|
"mindmap",
|
||
|
|
"put",
|
||
|
|
&format!("{document_id}_{mindmap_id}"),
|
||
|
|
);
|
||
|
|
let command = CommandEnvelope {
|
||
|
|
name: "mindmaps.put".into(),
|
||
|
|
command_id: build_command_id("mindmap", "put", &format!("{document_id}_{mindmap_id}")),
|
||
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
||
|
|
actor: build_actor_payload(ctx),
|
||
|
|
source: build_source_payload(),
|
||
|
|
target: Some(TargetRef {
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
page_id: Some(document_id.into()),
|
||
|
|
block_id: Some(mindmap_id.into()),
|
||
|
|
}),
|
||
|
|
payload: PutMindmap {
|
||
|
|
document_id: document_id.into(),
|
||
|
|
mindmap_id: mindmap_id.into(),
|
||
|
|
data_json: serde_json::to_string(&data).map_err(|error| {
|
||
|
|
CliError::validation(format!("mindmap data_json 序列化失败: {error}"))
|
||
|
|
})?,
|
||
|
|
create_only,
|
||
|
|
},
|
||
|
|
reason: ctx.reason.clone(),
|
||
|
|
refs: vec!["phase6-cli".into(), "task-032".into()],
|
||
|
|
dry_run: ctx.dry_run,
|
||
|
|
validate_only: ctx.validate_only,
|
||
|
|
};
|
||
|
|
let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?;
|
||
|
|
Ok(build_command_output(
|
||
|
|
"mindmap",
|
||
|
|
"put",
|
||
|
|
&bridge,
|
||
|
|
command.name,
|
||
|
|
command.command_id,
|
||
|
|
json!({
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"documentId": document_id,
|
||
|
|
"mindmapId": mindmap_id,
|
||
|
|
"createOnly": create_only,
|
||
|
|
"data": data,
|
||
|
|
}),
|
||
|
|
CliTransportPlan {
|
||
|
|
kind: "convex_mutation".into(),
|
||
|
|
function_name: request.function_name,
|
||
|
|
payload_json: request.payload_json,
|
||
|
|
args_json: json!({
|
||
|
|
"docId": document_id,
|
||
|
|
"mindmapId": mindmap_id,
|
||
|
|
"data": data,
|
||
|
|
"createOnly": create_only,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_mindmap_op(
|
||
|
|
ctx: &CliContext,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
document_id: &str,
|
||
|
|
mindmap_id: &str,
|
||
|
|
ops_json: &str,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let ops_value = parse_json_value(ops_json, "ops_json")?;
|
||
|
|
let ops: Vec<MindmapOp> = serde_json::from_value(ops_value.clone()).map_err(|error| {
|
||
|
|
CliError::validation(format!("ops_json 不是合法 MindmapOp[]: {error}"))
|
||
|
|
})?;
|
||
|
|
if ops.is_empty() {
|
||
|
|
return Err(CliError::validation("ops_json 不能为空数组"));
|
||
|
|
}
|
||
|
|
let bridge = build_bridge_context(
|
||
|
|
ctx,
|
||
|
|
workspace_id,
|
||
|
|
"mindmap",
|
||
|
|
"op",
|
||
|
|
&format!("{document_id}_{mindmap_id}"),
|
||
|
|
);
|
||
|
|
let spec = default_tool_registry()
|
||
|
|
.tool("mindmap_apply_ops")
|
||
|
|
.ok_or_else(|| CliError::validation("未注册 tool: mindmap_apply_ops"))?;
|
||
|
|
Ok(CliJsonOutput {
|
||
|
|
ok: true,
|
||
|
|
entrypoint: "mnote-cli",
|
||
|
|
domain: "mindmap".into(),
|
||
|
|
action: "op".into(),
|
||
|
|
context: build_output_context(&bridge, ctx),
|
||
|
|
operation: CliOperationOutput::Tool {
|
||
|
|
tool_name: "mindmap_apply_ops".into(),
|
||
|
|
invocation_kind: invocation_kind_label(&InvocationKind::Command).into(),
|
||
|
|
execution_mode: core_protocol::tool_mode_label(&ToolExecutionMode::Result).into(),
|
||
|
|
toolset_id: spec.toolset_id.into(),
|
||
|
|
effect: tool_effect_label(&spec.effect).into(),
|
||
|
|
requires_confirmation: spec.requires_confirmation,
|
||
|
|
normalized_input: json!({
|
||
|
|
"toolName": "mindmap_apply_ops",
|
||
|
|
"workspaceId": workspace_id,
|
||
|
|
"documentId": document_id,
|
||
|
|
"mindmapId": mindmap_id,
|
||
|
|
"ops": ops_value,
|
||
|
|
"reason": ctx.reason,
|
||
|
|
"validateOnly": bridge.validate_only,
|
||
|
|
"dryRun": bridge.dry_run,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn plan_tool_run(
|
||
|
|
ctx: &CliContext,
|
||
|
|
tool_name: &str,
|
||
|
|
invocation_kind: InvocationKind,
|
||
|
|
execution_mode: ToolExecutionMode,
|
||
|
|
args_json: &str,
|
||
|
|
) -> CliResult<CliJsonOutput> {
|
||
|
|
let args_value = parse_json_value(args_json, "args_json")?;
|
||
|
|
let bridge = build_bridge_context(ctx, None, "tool", "run", tool_name);
|
||
|
|
let spec = default_tool_registry()
|
||
|
|
.tool(tool_name)
|
||
|
|
.ok_or_else(|| CliError::validation(format!("未知 tool: {tool_name}")))?;
|
||
|
|
if spec.invocation_kind != invocation_kind {
|
||
|
|
return Err(CliError::validation(format!(
|
||
|
|
"tool {tool_name} 仅支持 {},当前传入 {}",
|
||
|
|
invocation_kind_label(&spec.invocation_kind),
|
||
|
|
invocation_kind_label(&invocation_kind),
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
let invocation = ToolInvocation {
|
||
|
|
tool: tool_name.into(),
|
||
|
|
kind: invocation_kind.clone(),
|
||
|
|
mode: execution_mode,
|
||
|
|
args_json: args_json.into(),
|
||
|
|
};
|
||
|
|
let operation = CliOperationOutput::Tool {
|
||
|
|
tool_name: invocation.tool,
|
||
|
|
invocation_kind: invocation_kind_label(&invocation.kind).into(),
|
||
|
|
execution_mode: core_protocol::tool_mode_label(&invocation.mode).into(),
|
||
|
|
toolset_id: spec.toolset_id.into(),
|
||
|
|
effect: tool_effect_label(&spec.effect).into(),
|
||
|
|
requires_confirmation: spec.requires_confirmation,
|
||
|
|
normalized_input: json!({
|
||
|
|
"toolName": tool_name,
|
||
|
|
"invocationKind": invocation_kind_label(&invocation.kind),
|
||
|
|
"executionMode": core_protocol::tool_mode_label(&invocation.mode),
|
||
|
|
"toolsetId": spec.toolset_id,
|
||
|
|
"effect": tool_effect_label(&spec.effect),
|
||
|
|
"requiresConfirmation": spec.requires_confirmation,
|
||
|
|
"validateOnly": bridge.validate_only,
|
||
|
|
"dryRun": bridge.dry_run,
|
||
|
|
"args": args_value,
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
Ok(CliJsonOutput {
|
||
|
|
ok: true,
|
||
|
|
entrypoint: "mnote-cli",
|
||
|
|
domain: "tool".into(),
|
||
|
|
action: "run".into(),
|
||
|
|
context: build_output_context(&bridge, ctx),
|
||
|
|
operation,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_query_output(
|
||
|
|
domain: &str,
|
||
|
|
action: &str,
|
||
|
|
bridge: &BridgeContext,
|
||
|
|
name: String,
|
||
|
|
normalized_input: Value,
|
||
|
|
transport: CliTransportPlan,
|
||
|
|
) -> CliJsonOutput {
|
||
|
|
CliJsonOutput {
|
||
|
|
ok: true,
|
||
|
|
entrypoint: "mnote-cli",
|
||
|
|
domain: domain.into(),
|
||
|
|
action: action.into(),
|
||
|
|
context: build_output_context(
|
||
|
|
bridge,
|
||
|
|
&CliContext {
|
||
|
|
actor_id: bridge.actor_id.clone(),
|
||
|
|
actor_type: bridge.actor_type.clone(),
|
||
|
|
session_id: bridge.session_id.clone(),
|
||
|
|
reason: None,
|
||
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
||
|
|
validate_only: bridge.validate_only,
|
||
|
|
dry_run: bridge.dry_run,
|
||
|
|
},
|
||
|
|
),
|
||
|
|
operation: CliOperationOutput::Query {
|
||
|
|
name,
|
||
|
|
normalized_input,
|
||
|
|
transport,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_command_output(
|
||
|
|
domain: &str,
|
||
|
|
action: &str,
|
||
|
|
bridge: &BridgeContext,
|
||
|
|
name: String,
|
||
|
|
command_id: String,
|
||
|
|
normalized_input: Value,
|
||
|
|
transport: CliTransportPlan,
|
||
|
|
) -> CliJsonOutput {
|
||
|
|
CliJsonOutput {
|
||
|
|
ok: true,
|
||
|
|
entrypoint: "mnote-cli",
|
||
|
|
domain: domain.into(),
|
||
|
|
action: action.into(),
|
||
|
|
context: build_output_context(
|
||
|
|
bridge,
|
||
|
|
&CliContext {
|
||
|
|
actor_id: bridge.actor_id.clone(),
|
||
|
|
actor_type: bridge.actor_type.clone(),
|
||
|
|
session_id: bridge.session_id.clone(),
|
||
|
|
reason: None,
|
||
|
|
idempotency_key: bridge.idempotency_key.clone(),
|
||
|
|
validate_only: bridge.validate_only,
|
||
|
|
dry_run: bridge.dry_run,
|
||
|
|
},
|
||
|
|
),
|
||
|
|
operation: CliOperationOutput::Command {
|
||
|
|
name,
|
||
|
|
command_id,
|
||
|
|
normalized_input,
|
||
|
|
transport,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_output_context(bridge: &BridgeContext, ctx: &CliContext) -> CliOutputContext {
|
||
|
|
CliOutputContext {
|
||
|
|
request_id: bridge.request_id.clone(),
|
||
|
|
trace_id: bridge.trace_id.clone(),
|
||
|
|
actor_id: bridge.actor_id.clone(),
|
||
|
|
actor_type: bridge.actor_type.clone(),
|
||
|
|
session_id: bridge.session_id.clone(),
|
||
|
|
workspace_id: bridge.workspace_id.clone(),
|
||
|
|
idempotency_key: ctx.idempotency_key.clone(),
|
||
|
|
validate_only: bridge.validate_only,
|
||
|
|
dry_run: bridge.dry_run,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_bridge_context(
|
||
|
|
ctx: &CliContext,
|
||
|
|
workspace_id: Option<&str>,
|
||
|
|
domain: &str,
|
||
|
|
action: &str,
|
||
|
|
target: &str,
|
||
|
|
) -> BridgeContext {
|
||
|
|
let key = sanitize_id(&format!("{domain}_{action}_{target}"));
|
||
|
|
BridgeContext {
|
||
|
|
deployment_id: None,
|
||
|
|
project_id: None,
|
||
|
|
workspace_id: workspace_id.map(|value| value.into()),
|
||
|
|
request_id: format!("req_cli_{key}"),
|
||
|
|
trace_id: format!("trace_cli_{key}"),
|
||
|
|
actor_type: ctx.actor_type.clone(),
|
||
|
|
actor_id: ctx.actor_id.clone(),
|
||
|
|
session_id: ctx.session_id.clone(),
|
||
|
|
tenant_id: None,
|
||
|
|
auth_token: None,
|
||
|
|
source_channel: "cli".into(),
|
||
|
|
source_client: "mnote-cli".into(),
|
||
|
|
idempotency_key: ctx.idempotency_key.clone(),
|
||
|
|
validate_only: ctx.validate_only,
|
||
|
|
dry_run: ctx.dry_run,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_actor_payload(ctx: &CliContext) -> ActorPayload {
|
||
|
|
ActorPayload {
|
||
|
|
actor_type: ctx.actor_type.clone(),
|
||
|
|
actor_id: ctx.actor_id.clone(),
|
||
|
|
session_id: ctx.session_id.clone(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_source_payload() -> SourcePayload {
|
||
|
|
SourcePayload {
|
||
|
|
channel: "cli".into(),
|
||
|
|
client: "mnote-cli".into(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_command_id(domain: &str, action: &str, target: &str) -> String {
|
||
|
|
format!(
|
||
|
|
"cmd_{}",
|
||
|
|
sanitize_id(&format!("{domain}_{action}_{target}"))
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn sanitize_id(input: &str) -> String {
|
||
|
|
input
|
||
|
|
.chars()
|
||
|
|
.map(|ch| {
|
||
|
|
if ch.is_ascii_alphanumeric() {
|
||
|
|
ch.to_ascii_lowercase()
|
||
|
|
} else {
|
||
|
|
'_'
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn parse_json_value(raw: &str, field: &str) -> CliResult<Value> {
|
||
|
|
serde_json::from_str::<Value>(raw)
|
||
|
|
.map_err(|error| CliError::validation(format!("{field} 不是合法 JSON: {error}")))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn invocation_kind_label(kind: &InvocationKind) -> &'static str {
|
||
|
|
match kind {
|
||
|
|
InvocationKind::Command => "command",
|
||
|
|
InvocationKind::Query => "query",
|
||
|
|
InvocationKind::Job => "job",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
|
||
|
|
CliError::validation(error.message)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn supported_surface_covers_phase2_minimum_domains() {
|
||
|
|
let commands = supported_command_surface();
|
||
|
|
assert!(commands.contains(&"page get"));
|
||
|
|
assert!(commands.contains(&"block patch"));
|
||
|
|
assert!(commands.contains(&"mindmap get"));
|
||
|
|
assert!(commands.contains(&"search documents"));
|
||
|
|
assert!(commands.contains(&"sidebar dataset"));
|
||
|
|
assert!(commands.contains(&"tool run"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn page_save_json_contract_uses_documents_save() {
|
||
|
|
let output = plan_page_save(
|
||
|
|
&CliContext::default(),
|
||
|
|
"page_1",
|
||
|
|
Some("ws_1"),
|
||
|
|
Some(7),
|
||
|
|
r#"[{"id":"block_1"}]"#,
|
||
|
|
Some("conflict_1"),
|
||
|
|
)
|
||
|
|
.expect("page save plan should build");
|
||
|
|
|
||
|
|
match output.operation {
|
||
|
|
CliOperationOutput::Command {
|
||
|
|
name,
|
||
|
|
command_id,
|
||
|
|
transport,
|
||
|
|
..
|
||
|
|
} => {
|
||
|
|
assert_eq!(name, "documents.save");
|
||
|
|
assert_eq!(command_id, "cmd_page_save_page_1");
|
||
|
|
assert_eq!(transport.function_name, "documents:updateContent");
|
||
|
|
assert_eq!(
|
||
|
|
transport.args_json,
|
||
|
|
json!({
|
||
|
|
"id": "page_1",
|
||
|
|
"content": [{"id": "block_1"}],
|
||
|
|
"expectedRevision": 7,
|
||
|
|
"conflictDetectionKey": "conflict_1",
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
_ => panic!("expected command output"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn sidebar_dataset_json_contract_uses_single_dataset_query() {
|
||
|
|
let output = plan_sidebar_dataset(&CliContext::default(), "ws_1")
|
||
|
|
.expect("sidebar dataset plan should build");
|
||
|
|
|
||
|
|
match output.operation {
|
||
|
|
CliOperationOutput::Query {
|
||
|
|
name, transport, ..
|
||
|
|
} => {
|
||
|
|
assert_eq!(name, "sidebar.dataset.list");
|
||
|
|
assert_eq!(transport.function_name, "sidebar:datasetList");
|
||
|
|
assert_eq!(transport.args_json, json!({ "workspaceId": "ws_1" }));
|
||
|
|
}
|
||
|
|
_ => panic!("expected query output"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tool_run_keeps_kind_and_args_json() {
|
||
|
|
let output = plan_tool_run(
|
||
|
|
&CliContext::default(),
|
||
|
|
"doc_get",
|
||
|
|
InvocationKind::Query,
|
||
|
|
ToolExecutionMode::Plan,
|
||
|
|
r#"{"pageId":"page_1"}"#,
|
||
|
|
)
|
||
|
|
.expect("tool plan should build");
|
||
|
|
|
||
|
|
match output.operation {
|
||
|
|
CliOperationOutput::Tool {
|
||
|
|
tool_name,
|
||
|
|
invocation_kind,
|
||
|
|
execution_mode,
|
||
|
|
toolset_id,
|
||
|
|
effect,
|
||
|
|
requires_confirmation,
|
||
|
|
normalized_input,
|
||
|
|
} => {
|
||
|
|
assert_eq!(tool_name, "doc_get");
|
||
|
|
assert_eq!(invocation_kind, "query");
|
||
|
|
assert_eq!(execution_mode, "plan");
|
||
|
|
assert_eq!(toolset_id, "toolset.doc_read");
|
||
|
|
assert_eq!(effect, "read");
|
||
|
|
assert!(!requires_confirmation);
|
||
|
|
assert_eq!(
|
||
|
|
normalized_input,
|
||
|
|
json!({
|
||
|
|
"toolName": "doc_get",
|
||
|
|
"invocationKind": "query",
|
||
|
|
"executionMode": "plan",
|
||
|
|
"toolsetId": "toolset.doc_read",
|
||
|
|
"effect": "read",
|
||
|
|
"requiresConfirmation": false,
|
||
|
|
"validateOnly": false,
|
||
|
|
"dryRun": false,
|
||
|
|
"args": {
|
||
|
|
"pageId": "page_1",
|
||
|
|
},
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
_ => panic!("expected tool output"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tool_run_rejects_unknown_tool() {
|
||
|
|
let error = plan_tool_run(
|
||
|
|
&CliContext::default(),
|
||
|
|
"doc_unknown",
|
||
|
|
InvocationKind::Query,
|
||
|
|
ToolExecutionMode::Plan,
|
||
|
|
r#"{}"#,
|
||
|
|
)
|
||
|
|
.expect_err("unknown tool should fail");
|
||
|
|
|
||
|
|
assert_eq!(error.code, "VALIDATION_ERROR");
|
||
|
|
assert!(error.message.contains("未知 tool"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn mindmap_get_json_contract_uses_mindmaps_get() {
|
||
|
|
let output = plan_mindmap_get(&CliContext::default(), Some("ws_1"), "page_1", "mind_1")
|
||
|
|
.expect("mindmap get plan should build");
|
||
|
|
|
||
|
|
match output.operation {
|
||
|
|
CliOperationOutput::Query {
|
||
|
|
name, transport, ..
|
||
|
|
} => {
|
||
|
|
assert_eq!(name, "mindmaps.get");
|
||
|
|
assert_eq!(transport.function_name, "mindmaps:get");
|
||
|
|
assert_eq!(
|
||
|
|
transport.args_json,
|
||
|
|
json!({
|
||
|
|
"docId": "page_1",
|
||
|
|
"mindmapId": "mind_1",
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
_ => panic!("expected query output"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn mindmap_put_json_contract_uses_mindmaps_put() {
|
||
|
|
let output = plan_mindmap_put(
|
||
|
|
&CliContext::default(),
|
||
|
|
Some("ws_1"),
|
||
|
|
"page_1",
|
||
|
|
"mind_1",
|
||
|
|
r#"{"data":{"text":"中心主题"},"children":[]}"#,
|
||
|
|
true,
|
||
|
|
)
|
||
|
|
.expect("mindmap put plan should build");
|
||
|
|
|
||
|
|
match output.operation {
|
||
|
|
CliOperationOutput::Command {
|
||
|
|
name, transport, ..
|
||
|
|
} => {
|
||
|
|
assert_eq!(name, "mindmaps.put");
|
||
|
|
assert_eq!(transport.function_name, "mindmaps:put");
|
||
|
|
assert_eq!(
|
||
|
|
transport.args_json,
|
||
|
|
json!({
|
||
|
|
"docId": "page_1",
|
||
|
|
"mindmapId": "mind_1",
|
||
|
|
"data": {
|
||
|
|
"data": {
|
||
|
|
"text": "中心主题"
|
||
|
|
},
|
||
|
|
"children": []
|
||
|
|
},
|
||
|
|
"createOnly": true,
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
_ => panic!("expected command output"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|