feat: advance local-first workspace checklist

- add admin access-policy UI and local access control surfaces

- add local markdown conflict resolution UI and smoke coverage

- add ACP local agent changed-files audit scaffold and read-only write guard

- document current P0-P2 checklist progress and verification evidence
This commit is contained in:
lix-2026
2026-05-19 08:07:17 +08:00
parent a2cb1338c8
commit 68d321e297
36 changed files with 8643 additions and 235 deletions
@@ -3,10 +3,13 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
use crate::routes::ensure_local_workspace_access;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
pub async fn create_summary(
state: &AppState,
@@ -71,6 +74,85 @@ async fn create_artifact_node(
format!("ai_note_{}_{}", document_id, context.trace.request_id)
};
if input.effective_source_kind().as_deref() == Some("local_folder") {
let root_uri = input.effective_root_uri().ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(context)
})?;
ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
if input.dry_run.unwrap_or(false) {
return Ok(json!({
"dryRun": true,
"commandName": "tree.node.create",
"commandId": command_id,
"artifactType": node_type,
"artifactDocumentId": artifact_document_id,
"documentId": document_id,
"workspaceId": workspace_id,
"diff": [{"op": "create_artifact", "artifactType": node_type}]
}));
}
let root_path = parse_local_root_path(&root_uri)?;
let artifact_dir = root_path.join(".mnote").join("artifacts");
fs::create_dir_all(&artifact_dir).map_err(|error| {
WebError::bad_request_code(
"local_artifact_write_failed",
format!(
"无法创建本地 artifact 目录 {}: {error}",
artifact_dir.display()
),
)
.with_context(context)
})?;
let artifact_path = artifact_dir.join(format!(
"{}.json",
sanitize_local_artifact_file_name(&artifact_document_id)
));
let artifact_value = json!({
"schema": "mnote.local_artifact.v1",
"artifactType": node_type,
"artifactDocumentId": artifact_document_id,
"documentId": document_id,
"workspaceId": workspace_id,
"content": content,
"createdAt": context.trace.trace_id,
});
fs::write(
&artifact_path,
serde_json::to_string_pretty(&artifact_value).map_err(|error| {
WebError::internal(format!("本地 artifact 序列化失败: {error}"))
.with_context(context)
})?,
)
.map_err(|error| {
WebError::bad_request_code(
"local_artifact_write_failed",
format!(
"无法写入本地 artifact 文件 {}: {error}",
artifact_path.display()
),
)
.with_context(context)
})?;
return Ok(json!({
"dryRun": false,
"commandName": "tree.node.create",
"commandId": command_id,
"source": "local_folder",
"artifactType": node_type,
"artifactDocumentId": artifact_document_id,
"documentId": document_id,
"workspaceId": workspace_id,
"result": {
"ok": true,
"source": "local_folder",
"artifactPath": artifact_path,
"artifactDocumentId": artifact_document_id,
}
}));
}
if input.dry_run.unwrap_or(false) {
return Ok(json!({
"dryRun": true,
@@ -166,6 +248,33 @@ async fn create_artifact_node(
}))
}
fn parse_local_root_path(root_uri: &str) -> Result<PathBuf, WebError> {
let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") {
stripped.trim()
} else {
root_uri.trim()
};
if root_path.is_empty() {
return Err(WebError::bad_request_code(
"local_folder_root_required",
"缺少本地文件夹 rootUri",
));
}
Ok(PathBuf::from(root_path))
}
fn sanitize_local_artifact_file_name(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => ch,
})
.collect::<String>()
.trim()
.to_string()
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
@@ -707,6 +707,14 @@ pub(crate) fn ensure_write_contract(
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
Ok(())
}
@@ -1490,6 +1498,44 @@ fn build_insert_block(block_id: &str, value: &Value) -> Value {
mod tests {
use super::*;
#[test]
fn ensure_write_contract_rejects_read_only_ai_scope() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input = ToolCallInput {
tool_name: "mnote.block.replace".into(),
workspace_id: Some("ws_1".into()),
document_id: Some("doc_1".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote-readonly".into()),
actor_id: Some("user_1".into()),
profile: None,
session_id: Some("sess_1".into()),
run_id: Some("run_1".into()),
tool_call_id: Some("tool_1".into()),
trace_id: Some("trace_1".into()),
idempotency_key: Some("idem_1".into()),
dry_run: Some(false),
capability_scope: None,
args: Some(json!({
"aiAccessScope": {
"permissionLevel": "read_only",
"allowedRoots": ["file:///tmp/mnote-readonly"]
}
})),
};
let error = ensure_write_contract(&context, &input).expect_err("read only rejected");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(
error.message(),
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool"
);
}
#[test]
fn content_to_text_reads_projection_content_nodes() {
let value = json!([
+202 -20
View File
@@ -3,9 +3,38 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use axum::http::StatusCode;
use serde_json::{json, Value};
use std::collections::HashSet;
fn file_version_from_aggregate(aggregate: &Value) -> Value {
[
"/body/fileVersion",
"/body/file_version",
"/body/conflictDetectionKey",
"/body/conflict_detection_key",
]
.iter()
.find_map(|pointer| {
aggregate
.pointer(pointer)
.filter(|value| !value.is_null())
.cloned()
})
.unwrap_or(Value::Null)
}
fn conflict_detection_key_from_aggregate(aggregate: &Value) -> Option<&str> {
[
"/body/conflictDetectionKey",
"/body/conflict_detection_key",
"/body/fileVersion",
"/body/file_version",
]
.iter()
.find_map(|pointer| aggregate.pointer(pointer).and_then(Value::as_str))
}
pub async fn doc_fetch(
state: &AppState,
context: &RequestContext,
@@ -13,16 +42,28 @@ pub async fn doc_fetch(
) -> Result<Value, WebError> {
let document_id = input.effective_document_id().unwrap_or_default();
let workspace_id = input.effective_workspace_id();
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
// 本地文件路径检测:直接读取 .md 文件,不经过 Convex
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
// 本地文件路径检测:直接读取授权 root 内的 .md 文件,不经过 Convex
let is_local_file = document_id.starts_with('/')
|| document_id.starts_with("./")
|| document_id.starts_with("file://");
if is_local_file {
use std::fs;
let path = &document_id;
let root_uri = local_root_uri_for_tool(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"ai_scope_root_uri_required",
"本地文件读取需要授权 rootUri",
)
.with_context(context)
})?;
let path = crate::routes::ensure_local_path_read_access(context, &root_uri, &document_id)
.map_err(|error| error.with_context(context))?;
let content = fs::read_to_string(path).map_err(|error| {
WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法读取本地文件 {path}: {error}"),
format!("无法读取本地文件: {error}"),
)
.with_context(context)
})?;
@@ -42,6 +83,7 @@ pub async fn doc_fetch(
"source": "local_fs",
"documentId": document_id,
"workspaceId": workspace_id,
"rootUri": root_uri,
"format": "markdown",
"detail": "simple",
"scope": "full",
@@ -221,6 +263,7 @@ pub async fn doc_fetch(
"source": source,
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
"fileVersion": file_version_from_aggregate(&aggregate),
"format": format,
"detail": detail,
"scope": scope,
@@ -238,6 +281,9 @@ pub async fn doc_find(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
if let Some(document_id) = input.effective_document_id() {
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
}
let aggregate = aggregate_value(state, context, input).await?;
let query = input.arg_string("query").ok_or_else(|| {
WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query")
@@ -287,6 +333,7 @@ pub async fn doc_find(
"workspaceId": input.effective_workspace_id(),
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
"fileVersion": file_version_from_aggregate(&aggregate),
"matches": matches
}))
}
@@ -404,6 +451,7 @@ pub async fn plan_update(
"workspaceId": input.effective_workspace_id(),
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
"fileVersion": file_version_from_aggregate(&aggregate),
"command": command,
"diff": diff,
"warnings": if plan_blocked {
@@ -452,19 +500,81 @@ pub(crate) async fn aggregate_value(
WebError::bad_request_code("mnote_tool_bad_request", "页面工具缺少 documentId")
.with_context(context)
})?;
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let aggregate = build_page_aggregate_snapshot(
state,
context,
&document_id,
workspace_id.as_deref(),
None,
None,
source_kind.as_deref(),
root_uri.as_deref(),
)
.await?;
serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))
}
pub(crate) fn ensure_ai_scope_resource_allowed(
context: &RequestContext,
input: &ToolCallInput,
document_id: &str,
) -> Result<(), WebError> {
let Some(scope) = input.arg_value("aiAccessScope") else {
return Ok(());
};
let allowed = scope
.get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if allowed.is_empty() || allowed.contains(document_id) {
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_read_forbidden",
"当前 AI scope 不允许读取该资源",
)
.with_context(context))
}
fn local_root_uri_for_tool(input: &ToolCallInput) -> Option<String> {
input.effective_root_uri().or_else(|| {
input
.arg_value("aiAccessScope")
.and_then(|scope| {
scope
.get("allowedRoots")
.or_else(|| scope.get("allowed_roots"))
.cloned()
})
.and_then(|allowed_roots| {
allowed_roots.as_array().and_then(|roots| {
roots
.iter()
.filter_map(|root| {
root.get("rootUri")
.or_else(|| root.get("root_uri"))
.and_then(Value::as_str)
})
.map(str::trim)
.find(|root_uri| !root_uri.is_empty())
.map(ToOwned::to_owned)
})
})
})
}
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
aggregate
.pointer("/body/blockDocument/blocks")
@@ -1377,7 +1487,11 @@ pub async fn doc_markdown_edit(
) -> Result<Value, WebError> {
let document_id = input.effective_document_id().unwrap_or_default();
let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
let is_local_workspace =
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
crate::hermes_tools::block::ensure_write_contract(context, input)?;
// 1. 读取当前文档内容(markdown 形式)
@@ -1400,7 +1514,14 @@ pub async fn doc_markdown_edit(
} else {
let aggregate = aggregate_value(state, context, input).await?;
let blocks = block_projection_blocks(&aggregate);
(blocks_to_markdown(&blocks, true), "convex")
(
blocks_to_markdown(&blocks, true),
if is_local_workspace {
"local_folder"
} else {
"convex"
},
)
};
// 2. 解析 operations
@@ -1483,6 +1604,14 @@ pub async fn doc_markdown_edit(
}
}
if applied == 0 {
return Err(WebError::bad_request_code(
"mnote_markdown_edit_no_operations_applied",
"markdown_edit 没有任何 search/replace 操作命中,未执行写入",
)
.with_context(context));
}
// 4. 构建 changedText 摘要
let changed_text = if applied > 0 {
operations
@@ -1517,17 +1646,19 @@ pub async fn doc_markdown_edit(
} else {
// 7-27: 在线写回以最终 markdown 为真源,直接生成 block content
// 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径
let (blocks, original_content) = match aggregate_value(state, context, input).await {
Ok(ref agg) => (
block_projection_blocks(agg),
crate::hermes_tools::block::current_body_content(agg),
),
Err(_) if use_full_content.is_some() => {
// 空文档 + full_content:跳过读取
(vec![], json!([]))
}
Err(e) => return Err(e),
};
let (aggregate, blocks, original_content) =
match aggregate_value(state, context, input).await {
Ok(agg) => {
let blocks = block_projection_blocks(&agg);
let original_content = crate::hermes_tools::block::current_body_content(&agg);
(agg, blocks, original_content)
}
Err(_) if use_full_content.is_some() => {
// 空文档 + full_content:跳过读取
(Value::Null, vec![], json!([]))
}
Err(e) => return Err(e),
};
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
let next_content = build_page_content(&original_content, &parsed);
@@ -1549,11 +1680,62 @@ pub async fn doc_markdown_edit(
} else {
// 直接构造 RuntimeCommandEnvelopeWire(与 /api/documents/save 相同)
let command_id = format!("markdown_edit_{}", context.trace.request_id);
let file_version = file_version_from_aggregate(&aggregate);
let conflict_detection_key = conflict_detection_key_from_aggregate(&aggregate);
if is_local_workspace {
let root_uri = root_uri.as_deref().ok_or_else(|| {
WebError::bad_request_code(
"local_folder_root_required",
"缺少本地文件夹 rootUri",
)
.with_context(context)
})?;
crate::routes::ensure_local_workspace_access(context, root_uri)
.map_err(|error| error.with_context(context))?;
let expected_file_version = file_version
.as_str()
.or(conflict_detection_key)
.map(|value| value.to_string());
let result = crate::routes::write_local_markdown_page_body(
&core_protocol::PageBodyWriteRequest {
document_id: document_id.clone(),
workspace_id: workspace_id.clone().unwrap_or_default(),
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(),
expected_file_version,
base_content_hash: None,
content_format: "editorBlocks".into(),
content: next_content,
editor_source: Some("mnote.doc.markdown_edit".into()),
},
)?;
return Ok(json!({
"ok": true,
"schema": "mnote.doc.markdown_edit.v1",
"source": "local_folder",
"documentId": document_id,
"workspaceId": workspace_id,
"operationsApplied": applied,
"operationsFailed": failed.len(),
"failedOperations": failed,
"changedText": changed_text,
"fileVersion": file_version,
"applyResult": {
"commandName": "page.body.write",
"commandId": command_id,
"changedBlocks": changed_blocks,
"result": result
}
}));
}
let payload = json!({
"documentId": document_id,
"workspaceId": workspace_id,
"content": next_content,
"mode": "replace",
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": conflict_detection_key.map(Value::from).unwrap_or(Value::Null),
"fileVersion": file_version,
});
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
@@ -1573,8 +1755,8 @@ pub async fn doc_markdown_edit(
source: RuntimeSourceWire {
channel: "mnote-hermes".into(),
client: "mnote-hermes-plugin".into(),
source_kind: None,
root_uri: None,
source_kind,
root_uri,
workspace_id: None,
capabilities: Vec::new(),
},
@@ -173,7 +173,7 @@ fn doc_plan_update_tool() -> Value {
fn block_replace_tool() -> Value {
write_tool(
"mnote.block.replace",
"替换指定块内容;真实写入走 Rust page.body.save 链路",
"兼容块写工具:替换指定块内容;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,必要时再走 Rust page.body.write 兼容链路",
["block.write", "page.write"],
json!({
"blockId": { "type": "string" },
@@ -394,7 +394,7 @@ fn page_get_tool() -> Value {
fn page_save_tool() -> Value {
json!({
"name": "mnote.page.save",
"description": "保存当前页面正文;replace 覆盖正文,append/prepend 会先读取当前 Page Aggregate 后合成完整正文再保存",
"description": "粗粒度兼容兜底:保存当前页面正文;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,只有整页覆盖/追加且其它工具无法表达时使用",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["page.write"],
"status": "available",
@@ -445,7 +445,7 @@ fn available_tool(
fn doc_markdown_edit_tool() -> Value {
let mut tool = write_tool(
"mnote.doc.markdown_edit",
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId",
"兼容 / 远端代理 fallback通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 默认优先让 agent 原生 patch/diff 直接编辑授权文件;仅在需要 MNote 兼容工具、远端代理或结构校验时使用",
["block.write", "page.write"],
json!({
"operations": {
@@ -489,6 +489,8 @@ fn tool_annotations(
"readonly": readonly,
"destructive": destructive,
"idempotent": idempotent,
"readOnly": readonly,
"requiresWritePermission": !readonly,
"requiresApproval": requires_approval,
"approvalMode": if requires_approval { "review" } else { "yolo" },
"runtimeOwner": "mnote-web",
@@ -13,6 +13,8 @@ pub struct ToolCallInput {
pub tool_name: String,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub actor_id: Option<String>,
pub profile: Option<String>,
pub session_id: Option<String>,
@@ -58,6 +60,24 @@ impl ToolCallInput {
.or_else(|| self.arg_string("documentId"))
}
pub fn effective_source_kind(&self) -> Option<String> {
self.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| self.arg_string("sourceKind"))
}
pub fn effective_root_uri(&self) -> Option<String> {
self.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| self.arg_string("rootUri"))
}
pub fn effective_tool_call_id(&self) -> String {
self.tool_call_id
.as_deref()
@@ -91,4 +111,37 @@ impl ToolCallInput {
.filter(|value| !value.is_empty())
.is_some()
}
pub fn ai_access_scope(&self) -> Option<&Value> {
self.args.as_ref().and_then(|args| {
args.get("aiAccessScope")
.or_else(|| args.get("ai_access_scope"))
})
}
pub fn ai_access_permission_level(&self) -> Option<String> {
self.ai_access_scope()
.and_then(|scope| {
scope
.get("permissionLevel")
.or_else(|| scope.get("permission_level"))
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn ai_access_scope_is_read_only(&self) -> bool {
self.ai_access_permission_level()
.map(|level| {
let normalized = level.trim().to_ascii_lowercase();
normalized == "read"
|| normalized == "readonly"
|| normalized == "read_only"
|| normalized == "shared_read"
|| (normalized.contains("read") && !normalized.contains("write"))
})
.unwrap_or(false)
}
}
+81 -2
View File
@@ -18,14 +18,17 @@ pub async fn page_get(
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
.with_context(context)
})?;
crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let aggregate = build_page_aggregate_snapshot(
state,
context,
&document_id,
workspace_id.as_deref(),
None,
None,
source_kind.as_deref(),
root_uri.as_deref(),
)
.await?;
let aggregate_value =
@@ -83,6 +86,14 @@ fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Res
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
Ok(())
}
@@ -200,6 +211,74 @@ async fn page_command(
return Ok(result);
}
if input.effective_source_kind().as_deref() == Some("local_folder") {
let root_uri = input.effective_root_uri().ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(context)
})?;
crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let local_result = match command_name {
"page.body.save" => {
let content = payload.get("content").cloned().unwrap_or(Value::Null);
crate::routes::write_local_markdown_page_body(
&core_protocol::PageBodyWriteRequest {
document_id: document_id.clone(),
workspace_id: workspace_id.clone().unwrap_or_default(),
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.clone(),
expected_file_version: input.arg_string("expectedFileVersion"),
base_content_hash: input.arg_string("baseContentHash"),
content_format: "editorBlocks".into(),
content,
editor_source: Some("mnote.page.save".into()),
},
)?
}
"page.head.updateTitle" => {
let title = payload
.get("title")
.and_then(Value::as_str)
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.page.update_title 缺少 title",
)
.with_context(context)
})?;
crate::routes::update_local_markdown_title(&root_uri, &document_id, title)?
}
"page.layout.updateOptions" => {
let options = payload.get("options").cloned().ok_or_else(|| {
WebError::bad_request_code(
"mnote_tool_bad_request",
"mnote.page.update_options 缺少 options",
)
.with_context(context)
})?;
crate::routes::update_local_page_options(&root_uri, &document_id, &options)?
}
_ => {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
format!("local source 暂不支持页面命令 {command_name}"),
)
.with_context(context));
}
};
let mut result = json!({
"dryRun": false,
"source": "local_folder",
"commandName": if command_name == "page.body.save" { "page.body.write" } else { command_name },
"commandId": command_id,
"documentId": document_id,
"workspaceId": workspace_id,
"result": local_result
});
merge_result_extra(&mut result, result_extra);
return Ok(result);
}
let command = RuntimeCommandEnvelopeWire {
name: command_name.into(),
command_id: command_id.clone(),