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
+165 -7
View File
@@ -5,7 +5,8 @@ use crate::routes::command_support::{
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
};
use crate::routes::local_folder_source::{
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
ensure_local_workspace_access, update_local_markdown_title, update_local_page_options,
write_local_markdown_page_body,
};
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
@@ -46,6 +47,10 @@ pub struct DocumentSaveRequest {
pub root_uri: Option<String>,
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub expected_file_version: Option<String>,
pub base_content_hash: Option<String>,
pub content_format: Option<String>,
pub editor_source: Option<String>,
pub editor_document: Option<Value>,
pub content: Value,
pub tiptap_document: Option<Value>,
@@ -408,6 +413,7 @@ async fn proxy_next_documents_save(
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version,
"editorDocument": body.editor_document,
"content": body.content,
"tiptapDocument": body.tiptap_document,
@@ -514,6 +520,38 @@ pub async fn content(
Ok(ok_response(&context, result))
}
pub async fn page_body_write(
Extension(context): Extension<RequestContext>,
Json(body): Json<core_protocol::PageBodyWriteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
if body.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder {
return Err(WebError::bad_request_code(
"page_body_write_source_unsupported",
"page.body.write 当前只支持 local_folder 本地写入",
)
.with_context(&context));
}
let root_uri = body.root_uri.trim();
if root_uri.is_empty() {
return Err(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))?;
let result = write_local_markdown_page_body(&body)?;
Ok(ok_response(&context, result))
}
pub async fn save(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -536,12 +574,29 @@ pub async fn save(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = save_local_markdown_page(
root_uri,
document_id,
body.conflict_detection_key.as_deref(),
&body.content,
)?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let expected_file_version = body
.expected_file_version
.as_deref()
.or(body.conflict_detection_key.as_deref());
let result = write_local_markdown_page_body(&core_protocol::PageBodyWriteRequest {
document_id: document_id.to_string(),
workspace_id: body.workspace_id.clone().unwrap_or_default(),
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(),
expected_file_version: expected_file_version.map(ToOwned::to_owned),
base_content_hash: body.base_content_hash.clone(),
content_format: body
.content_format
.clone()
.unwrap_or_else(|| "editorBlocks".into()),
content: body.content.clone(),
editor_source: body
.editor_source
.clone()
.or_else(|| Some("documents/save-compat".into())),
})?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
@@ -578,6 +633,7 @@ pub async fn save(
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version,
"editorDocument": body.editor_document,
"content": body.content,
"tiptapDocument": body.tiptap_document,
@@ -766,6 +822,8 @@ pub async fn title(
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))?;
let result = update_local_markdown_title(root_uri, document_id, title)?;
return Ok(ok_response(&context, result));
}
@@ -859,6 +917,8 @@ pub async fn options(
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))?;
let result = update_local_page_options(root_uri, document_id, &body.options)?;
return Ok(ok_response(&context, result));
}
@@ -1311,6 +1371,11 @@ mod tests {
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_test",
&root_uri,
)
.expect("init local workspace");
let document_id = "local-mdid:local-stable";
let title_response = app()
@@ -1319,6 +1384,8 @@ mod tests {
.method("POST")
.uri("/api/documents/title")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
@@ -1340,6 +1407,8 @@ mod tests {
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
@@ -1367,6 +1436,15 @@ mod tests {
.await
.expect("save response");
assert_eq!(save_response.status(), StatusCode::OK);
let save_body = to_bytes(save_response.into_body(), usize::MAX)
.await
.expect("save body");
let save_payload: Value = serde_json::from_slice(&save_body).expect("save json");
assert_eq!(
save_payload["result"]["canonicalCommand"],
"page.body.write"
);
assert_eq!(save_payload["result"]["compatCommand"], "page.body.save");
let options_response = app()
.oneshot(
@@ -1374,6 +1452,8 @@ mod tests {
.method("POST")
.uri("/api/documents/options")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
@@ -1406,4 +1486,82 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn local_folder_documents_save_rejects_stale_expected_file_version() {
let root = std::env::temp_dir().join(format!(
"mnote-local-documents-expected-file-version-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(
root.join("README.md"),
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_test",
&root_uri,
)
.expect("init local workspace");
let document_id = "local-mdid:expected-file-version";
let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate(
&root_uri,
document_id,
)
.expect("aggregate");
let stale_file_version = aggregate
.body
.conflict_detection_key
.as_str()
.expect("file version")
.to_string();
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(
root.join("README.md"),
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# External\n",
)
.expect("external write");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"expectedFileVersion": stale_file_version,
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "Editor" }]
}
],
"blockCount": 1
})
.to_string(),
))
.expect("request"),
)
.await
.expect("save response");
assert_eq!(response.status(), StatusCode::CONFLICT);
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("# External"));
assert!(!markdown.contains("# Editor"));
let _ = std::fs::remove_dir_all(&root);
}
}