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:
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user