feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
+119 -46
View File
@@ -3,24 +3,25 @@ use crate::context::RequestContext;
use crate::document_buffer_store::{self, BufferKey, BufferStore};
use crate::error::WebError;
use crate::routes::command_support::{
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
execute_runtime_command_via_legacy_cloud,
execute_runtime_command_via_legacy_cloud_with_artifacts,
};
use crate::routes::local_folder_source::{
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
};
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
resolve_effective_workspace_id,
};
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::fs;
use std::time::Duration;
@@ -48,6 +49,17 @@ pub struct BufferStateQuery {
pub relative_path: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BufferDirtyRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub relative_path: Option<String>,
pub content_hash: Option<String>,
}
fn find_document_buffer_state(
buffer_store: &BufferStore,
query: &BufferStateQuery,
@@ -104,6 +116,20 @@ fn find_document_buffer_state(
None
}
fn buffer_state_payload(buf: core_protocol::DocumentBuffer) -> Value {
json!({
"documentId": buf.workspace_path.object_identity.document_id,
"workspaceId": buf.workspace_path.workspace_id,
"dirtyState": format!("{:?}", buf.dirty_state),
"fileVersion": buf.file_version,
"baseContentHash": buf.base_content_hash,
"currentContentHash": buf.current_content_hash,
"externalActor": buf.external_actor,
"lastLoadedAt": buf.last_loaded_at,
"lastSavedAt": buf.last_saved_at,
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSaveRequest {
@@ -114,6 +140,8 @@ pub struct DocumentSaveRequest {
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub expected_file_version: Option<String>,
pub write_intent_id: Option<String>,
pub save_operation_id: Option<String>,
pub base_content_hash: Option<String>,
pub content_format: Option<String>,
pub editor_source: Option<String>,
@@ -179,7 +207,9 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
)
}
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
fn execution_artifacts_json(
execution: &crate::transport::convex::RetiredCloudCommandExecution,
) -> Value {
execution
.artifacts
.as_ref()
@@ -480,6 +510,8 @@ async fn proxy_next_documents_save(
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version,
"writeIntentId": body.write_intent_id,
"saveOperationId": body.save_operation_id,
"editorDocument": body.editor_document,
"content": body.content,
"tiptapDocument": body.tiptap_document,
@@ -522,7 +554,7 @@ pub async fn load_document_meta_result(
)
.await;
}
fetch_documents_meta_via_convex(
fetch_documents_meta_via_legacy_cloud(
state.config(),
context,
effective_workspace_id.as_deref(),
@@ -553,7 +585,7 @@ pub async fn load_document_content_result(
)
.await;
}
execute_runtime_query_via_convex(
execute_runtime_query_via_legacy_cloud(
state.config(),
context,
effective_workspace_id.as_deref(),
@@ -599,20 +631,7 @@ pub async fn buffer_state(
let result = find_document_buffer_state(&state.buffer_store, &query);
match result {
Some(buf) => Ok(ok_response(
&context,
json!({
"documentId": buf.workspace_path.object_identity.document_id,
"workspaceId": buf.workspace_path.workspace_id,
"dirtyState": format!("{:?}", buf.dirty_state),
"fileVersion": buf.file_version,
"baseContentHash": buf.base_content_hash,
"currentContentHash": buf.current_content_hash,
"externalActor": buf.external_actor,
"lastLoadedAt": buf.last_loaded_at,
"lastSavedAt": buf.last_saved_at,
}),
)),
Some(buf) => Ok(ok_response(&context, buffer_state_payload(buf))),
None => Err(WebError::new(
StatusCode::NOT_FOUND,
"buffer_state_not_found",
@@ -622,6 +641,51 @@ pub async fn buffer_state(
}
}
/// 标记指定打开文档的 BufferStore 状态为 Dirty。
///
/// 浏览器仍负责具体保存调度;Rust BufferStore 只承担跨 watcher、AI、保存冲突的统一打开态仲裁。
pub async fn mark_buffer_dirty(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<BufferDirtyRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(WebError::new(
StatusCode::BAD_REQUEST,
"document_id_required",
"documentId 不能为空",
)
.with_context(&context));
}
let query = BufferStateQuery {
document_id: Some(document_id.to_string()),
workspace_id: body.workspace_id.clone(),
source_kind: body.source_kind.clone(),
root_uri: body.root_uri.clone(),
relative_path: body.relative_path.clone(),
};
let existing = find_document_buffer_state(&state.buffer_store, &query).ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"buffer_state_not_found",
"未找到该文档的 buffer 状态",
)
.with_context(&context)
})?;
let content_hash = body
.content_hash
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("browser-dirty")
.to_string();
let marked = state
.buffer_store
.mark_dirty(&existing.workspace_path, content_hash)
.unwrap_or(existing);
Ok(ok_response(&context, buffer_state_payload(marked)))
}
pub async fn page_body_write(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -690,6 +754,8 @@ pub async fn save(
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(),
expected_file_version: expected_file_version.map(ToOwned::to_owned),
write_intent_id: body.write_intent_id.clone(),
save_operation_id: body.save_operation_id.clone(),
base_content_hash: body.base_content_hash.clone(),
content_format: body
.content_format
@@ -740,6 +806,8 @@ pub async fn save(
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version,
"writeIntentId": body.write_intent_id,
"saveOperationId": body.save_operation_id,
"editorDocument": body.editor_document,
"content": body.content,
"tiptapDocument": body.tiptap_document,
@@ -752,7 +820,7 @@ pub async fn save(
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
effective_workspace_id.as_deref(),
@@ -815,7 +883,7 @@ pub async fn purge(
validate_only: false,
};
let result =
execute_runtime_command_via_convex(state.config(), &context, None, command).await?;
execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?;
Ok(ok_response(&context, result))
}
@@ -865,7 +933,7 @@ pub async fn empty_trash(
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
Some(workspace_id),
@@ -968,13 +1036,14 @@ pub async fn title(
}),
preflight_data: None,
reason: Some("mnote-web page title update".into()),
refs: vec![body
.command_name
.unwrap_or_else(|| "page.head.updateTitle".into())],
refs: vec![
body.command_name
.unwrap_or_else(|| "page.head.updateTitle".into()),
],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
effective_workspace_id.as_deref(),
@@ -1072,13 +1141,14 @@ pub async fn options(
}),
preflight_data: None,
reason: Some("mnote-web page layout update".into()),
refs: vec![body
.command_name
.unwrap_or_else(|| "page.layout.updateOptions".into())],
refs: vec![
body.command_name
.unwrap_or_else(|| "page.layout.updateOptions".into()),
],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
effective_workspace_id.as_deref(),
@@ -1111,9 +1181,9 @@ pub async fn options(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
use axum::body::{to_bytes, Body};
use crate::app::{AppConfig, AppState, build_app};
use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path};
use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -1445,8 +1515,7 @@ mod tests {
"upsert_document"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
["title"],
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"],
"服务端页面(改名)"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
@@ -1727,15 +1796,19 @@ mod tests {
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
Some(stale_file_version.as_str())
);
assert!(payload["details"]["conflict"]["currentDiskVersion"]
.as_str()
.unwrap_or("")
.starts_with("local-md:local-md:README.md:"));
assert!(payload["details"]["conflict"]["suggestedActions"]
.as_array()
.unwrap()
.iter()
.any(|action| action.as_str() == Some("merge")));
assert!(
payload["details"]["conflict"]["currentDiskVersion"]
.as_str()
.unwrap_or("")
.starts_with("local-md:local-md:README.md:")
);
assert!(
payload["details"]["conflict"]["suggestedActions"]
.as_array()
.unwrap()
.iter()
.any(|action| action.as_str() == Some("merge"))
);
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("# External"));
assert!(!markdown.contains("# Editor"));