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