chore: align sqlite control plane architecture

- replace default Convex control-plane wording with Rust SQLite control-plane across architecture, AGENTS, Reasonix, and design docs

- retire root Convex functions source and deploy script into recycle while keeping explicit cloud/compat/sync-replica boundaries

- add control-plane migration guard/docs and keep CodeGraph refreshed after the SQLite control-plane cutover
This commit is contained in:
lix-2026
2026-05-22 17:45:22 +08:00
parent 531e845600
commit 47e224d419
79 changed files with 7634 additions and 2600 deletions
+497 -249
View File
@@ -9,6 +9,7 @@ use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
use axum::Json;
use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput};
use futures_util::TryStreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
@@ -27,8 +28,7 @@ use tracing::{info, warn};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun";
const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent";
const ACP_RUNTIME_SQLITE_STORE: &str = "sqlite_acp_runtime_store";
const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500;
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
@@ -252,6 +252,50 @@ pub async fn search_sessions(
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(20)
.clamp(1, 50);
if !use_legacy_convex_acp_store(&query) {
let runs = state
.control_plane()
.list_ai_runtime_runs(
&user_id,
workspace_id.as_deref(),
None,
None,
limit as usize,
)
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 搜索失败: {error}"))
.with_context(&context)
})?;
let needle = q.to_lowercase();
let results = runs
.iter()
.filter(|run| {
run.title
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(&needle)
|| run.payload_json.to_lowercase().contains(&needle)
})
.map(|run| {
let mut value = ai_runtime_run_to_json(run);
value["snippet"] =
Value::String(run.title.clone().unwrap_or_else(|| run.session_id.clone()));
value["score"] = Value::from(1);
value
})
.collect::<Vec<_>>();
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"results": results
})),
));
}
let mut args = serde_json::Map::new();
args.insert("userId".into(), Value::String(user_id));
args.insert("q".into(), Value::String(q.to_string()));
@@ -364,6 +408,31 @@ async fn list_acp_sessions(
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(50)
.clamp(1, 100);
if !use_legacy_convex_acp_store(query) {
let runs = state
.control_plane()
.list_ai_runtime_runs(
&user_id,
workspace_id.as_deref(),
document_id.as_deref(),
session_id.as_deref(),
limit as usize,
)
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 列表读取失败: {error}"))
.with_context(context)
})?;
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessions": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
})),
));
}
let mut args = serde_json::Map::new();
args.insert("userId".into(), Value::String(user_id));
if let Some(workspace_id) = workspace_id {
@@ -582,7 +651,7 @@ pub async fn create_session(
&runtime_payload,
)
.await?;
persistence = "convex_acp_runtime_store";
persistence = ACP_RUNTIME_SQLITE_STORE;
}
Ok((
StatusCode::OK,
@@ -978,7 +1047,10 @@ pub async fn resume_session(
let result = get_acp_session(&state, &context, &session_id, &query).await?;
let mut payload = result.2 .0;
payload["resumed"] = Value::Bool(true);
payload["resumeSource"] = Value::String("convex_acp_runtime_store".into());
payload["resumeSource"] = payload
.get("persistence")
.cloned()
.unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into()));
return Ok((result.0, result.1, Json(payload)));
}
get_session(
@@ -997,6 +1069,36 @@ pub async fn delete_session(
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
if !use_legacy_convex_acp_store(&query) {
let user_id = effective_session_store_user_id(&state, &context).await?;
let workspace_id = query
.get("workspaceId")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone());
let deleted = state
.control_plane()
.delete_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref())
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 删除失败: {error}"))
.with_context(&context)
})?;
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
"deleted": deleted
}
})),
));
}
let result = execute_acp_session_mutation(
&state,
&context,
@@ -1035,6 +1137,37 @@ pub async fn rename_session(
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client"),
);
}
if !use_legacy_convex_acp_store(&query) {
let user_id = effective_session_store_user_id(&state, &context).await?;
let workspace_id = query
.get("workspaceId")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone());
let runs = state
.control_plane()
.rename_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref(), title)
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 重命名失败: {error}"))
.with_context(&context)
})?;
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
"title": title,
"runs": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
}
})),
));
}
let result = execute_acp_session_mutation(
&state,
&context,
@@ -1063,6 +1196,38 @@ pub async fn auto_title_session(
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
if !use_legacy_convex_acp_store(&query) {
let user_id = effective_session_store_user_id(&state, &context).await?;
let workspace_id = query
.get("workspaceId")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone());
let titled = state
.control_plane()
.auto_title_ai_runtime_session(&user_id, &session_id, workspace_id.as_deref())
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 自动标题失败: {error}"))
.with_context(&context)
})?;
let title = titled.as_ref().and_then(|run| run.title.clone());
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
"title": title,
"run": titled.as_ref().map(ai_runtime_run_to_json)
}
})),
));
}
let result = execute_acp_session_mutation(
&state,
&context,
@@ -1177,6 +1342,54 @@ async fn get_acp_session(
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone());
if !use_legacy_convex_acp_store(query) {
let runs = state
.control_plane()
.list_ai_runtime_runs(
&user_id,
workspace_id.as_deref(),
None,
Some(session_id),
20,
)
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 详情读取失败: {error}"))
.with_context(context)
})?;
let latest_run = runs.first().cloned();
let events = if let Some(run) = latest_run.as_ref() {
state
.control_plane()
.list_ai_runtime_events(&user_id, &run.run_id, 200)
.map_err(|error| {
WebError::internal(format!("SQLite ACP runtime events 读取失败: {error}"))
.with_context(context)
})?
} else {
Vec::new()
};
let runtime = latest_run
.as_ref()
.and_then(|run| serde_json::from_str::<Value>(&run.runtime_json).ok())
.unwrap_or_else(|| runtime_state_for_session(session_id));
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionId": session_id,
"session": {
"sessionId": session_id,
"messages": [],
"runs": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
},
"runtime": runtime,
"events": events.iter().map(ai_runtime_event_to_json).collect::<Vec<_>>()
})),
));
}
let mut run_args = serde_json::Map::new();
run_args.insert("userId".into(), Value::String(user_id.clone()));
run_args.insert("sessionId".into(), Value::String(session_id.to_string()));
@@ -1327,7 +1540,7 @@ pub async fn create_run(
"persistence": persistence_result
.get("persistence")
.and_then(Value::as_str)
.unwrap_or("convex_acp_runtime_store"),
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
"sessionStorage": persistence_result
.get("sessionStorage")
.cloned()
@@ -4232,6 +4445,61 @@ fn acp_runtime_run_store_args(
})
}
fn json_string(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
}
fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value {
let runtime = serde_json::from_str::<Value>(&record.runtime_json).unwrap_or(Value::Null);
let payload = serde_json::from_str::<Value>(&record.payload_json).unwrap_or(Value::Null);
json!({
"sessionId": record.session_id,
"runId": record.run_id,
"workspaceId": record.workspace_id,
"documentId": record.document_id,
"title": record.title,
"profile": record.profile,
"acpRuntime": record.acp_runtime,
"traceId": record.trace_id,
"status": record.status,
"runtime": runtime,
"payload": payload,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
})
}
fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Value {
let payload = serde_json::from_str::<Value>(&record.payload_json).unwrap_or(Value::Null);
json!({
"eventId": record.id,
"sessionId": record.session_id,
"runId": record.run_id,
"workspaceId": record.workspace_id,
"documentId": record.document_id,
"profile": record.profile,
"acpRuntime": record.acp_runtime,
"eventType": record.event_type,
"payload": payload,
"createdAt": record.created_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
})
}
fn query_bool(query: &HashMap<String, String>, key: &str) -> bool {
query
.get(key)
.map(String::as_str)
.map(str::trim)
.map(|value| matches!(value, "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
}
fn use_legacy_convex_acp_store(query: &HashMap<String, String>) -> bool {
query_bool(query, "legacyConvex") || query_bool(query, "convex")
}
fn local_session_share_id(payload: &Value) -> Option<String> {
payload
.get("shareId")
@@ -4868,19 +5136,57 @@ async fn persist_acp_runtime_run(
"runId": run_id
}));
}
execute_convex_mutation_by_name(
state.config(),
context,
ACP_RUNTIME_RUN_MUTATION,
args,
payload
.get("workspaceId")
.and_then(Value::as_str)
.or(context.workspace.workspace_id.as_deref()),
Some(run_id),
"acp_runtime_run_store",
)
.await
let workspace_id = args
.get("workspaceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let document_id = args
.get("documentId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let title = args
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let status = args
.get("status")
.and_then(Value::as_str)
.unwrap_or("acp_pending")
.to_string();
let record = state
.control_plane()
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: None,
user_id: runtime_store_user_id(context, payload),
workspace_id,
document_id,
session_id: registration.session_id.clone(),
run_id: run_id.to_string(),
title,
profile: registration.profile.clone(),
acp_runtime: acp_runtime.to_string(),
trace_id: Some(registration.trace_id.clone()),
status,
runtime_json: json_string(runtime_state),
payload_json: json_string(payload),
})
.map_err(|error| {
WebError::internal(format!("SQLite ACP runtime run 写入失败: {error}"))
.with_context(context)
})?;
Ok(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"sessionId": record.session_id,
"runId": record.run_id
}))
}
fn acp_runtime_event_store_args(
@@ -4990,19 +5296,41 @@ async fn persist_acp_runtime_event(
"runId": run_id
}));
}
execute_convex_mutation_by_name(
state.config(),
context,
ACP_RUNTIME_EVENT_MUTATION,
args,
run_payload
.get("workspaceId")
.and_then(Value::as_str)
.or(context.workspace.workspace_id.as_deref()),
None,
"acp_runtime_event_store",
)
.await
state
.control_plane()
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: None,
user_id: runtime_store_user_id(context, run_payload),
workspace_id: args
.get("workspaceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
document_id: args
.get("documentId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
session_id: registration.session_id.clone(),
run_id: run_id.to_string(),
profile: registration.profile.clone(),
acp_runtime: acp_runtime.to_string(),
event_type: event_type.to_string(),
payload_json: json_string(event_payload),
})
.map_err(|error| {
WebError::internal(format!("SQLite ACP runtime event 写入失败: {error}"))
.with_context(context)
})?;
Ok(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"sessionId": registration.session_id,
"runId": run_id
}))
}
fn register_runtime_from_create_run_response(
@@ -5652,6 +5980,7 @@ mod tests {
use axum::body::{to_bytes, Body};
use axum::http::Request;
use axum::routing::{get, post};
use control_plane::UpsertAiRuntimeRunInput;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use tower::util::ServiceExt;
@@ -5776,6 +6105,48 @@ mod tests {
build_app(AppState::new(config))
}
fn seeded_acp_state() -> AppState {
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
state
.control_plane()
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: None,
user_id: "user_1".to_string(),
workspace_id: Some("ws_1".to_string()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
title: None,
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
trace_id: Some("trace_1".to_string()),
status: "completed".to_string(),
runtime_json: "{\"status\":\"completed\",\"runId\":\"run_1\"}".to_string(),
payload_json: "{\"message\":\"自动标题\"}".to_string(),
})
.expect("seed acp runtime run");
state
}
fn test_state() -> AppState {
AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -6079,7 +6450,7 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert!(payload.get("messages").is_none());
}
@@ -6519,109 +6890,82 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
let mutation = captured_body.lock().expect("captured convex body").clone();
assert_eq!(mutation["path"], "aiSessions:upsertRuntimeRun");
assert_eq!(mutation["args"][0]["userId"], "user_1");
assert_eq!(mutation["args"][0]["workspaceId"], "ws_1");
assert_eq!(mutation["args"][0]["documentId"], "doc_1");
assert_eq!(mutation["args"][0]["sessionId"], "mnote_doc_1_trace_1");
assert_eq!(mutation["args"][0]["runId"], "mnote_doc_1_trace_1_session");
assert_eq!(mutation["args"][0]["status"], "session.created");
assert_eq!(
captured_body.lock().expect("captured convex body").clone(),
Value::Null
);
}
#[tokio::test]
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_convex() {
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() {
let _guard = runtime_lock().lock().expect("runtime lock");
clear_runtime_registry();
clear_run_queue();
let router = app();
let captured_body = Arc::new(Mutex::new(Value::Null));
let captured_for_route = Arc::clone(&captured_body);
let mock = axum::Router::new().route(
"/api/mutation",
post(move |Json(body): Json<Value>| {
let captured = Arc::clone(&captured_for_route);
async move {
*captured.lock().expect("captured convex body") = body;
Json(json!({
"status": "success",
"value": {"ok": true, "stored": true}
}))
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/client/runs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-workspace-id", "ws_header")
.body(Body::from(
json!({
"workspaceId": "ws_1",
"documentId": "doc_1",
"sessionId": "sess_1",
"message": "读取当前页面",
"profile": "reasonix",
"acpRuntime": "reasonix",
"runId": "run_trace_1",
"traceId": "trace_1",
"pageContext": {"title": "页面标题"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("listener");
let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
tokio::spawn(async move {
axum::serve(listener, mock).await.expect("mock convex");
});
let response = app_with_config(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
convex_admin_key: Some("admin-demo".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
})
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/client/runs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-workspace-id", "ws_header")
.body(Body::from(
json!({
"workspaceId": "ws_1",
"documentId": "doc_1",
"sessionId": "sess_1",
"message": "读取当前页面",
"profile": "reasonix",
"acpRuntime": "reasonix",
"runId": "run_trace_1",
"traceId": "trace_1",
"pageContext": {"title": "页面标题"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
let body = captured_body.lock().expect("captured convex body").clone();
assert_eq!(body["path"], "aiSessions:upsertRuntimeRun");
let args = &body["args"][0];
assert_eq!(args["schema"], "mnote.acp_runtime_run.v1");
assert_eq!(args["source"], "acp");
assert_eq!(args["userId"], "user_1");
assert_eq!(args["workspaceId"], "ws_1");
assert_eq!(args["documentId"], "doc_1");
assert_eq!(args["sessionId"], "sess_1");
assert_eq!(args["runId"], "run_trace_1");
assert_eq!(args["profile"], "reasonix");
assert_eq!(args["acpRuntime"], "reasonix");
assert_eq!(args["runtime"]["status"], "acp_pending");
assert_eq!(args["payload"]["message"], "读取当前页面");
assert!(args.get("messages").is_none());
let response = router
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("list response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("list body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
let run = &payload["sessions"][0];
assert_eq!(run["sessionId"], "sess_1");
assert_eq!(run["runId"], "run_trace_1");
assert_eq!(run["workspaceId"], "ws_1");
assert_eq!(run["documentId"], "doc_1");
assert_eq!(run["profile"], "reasonix");
assert_eq!(run["acpRuntime"], "reasonix");
assert_eq!(run["runtime"]["status"], "acp_pending");
assert_eq!(run["payload"]["message"], "读取当前页面");
assert!(run.get("messages").is_none());
}
#[tokio::test]
@@ -6683,7 +7027,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1")
.uri("/api/hermes/client/sessions?profile=reasonix&workspaceId=ws_1&documentId=doc_1&legacyConvex=1")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request"),
@@ -6775,7 +7119,9 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1")
.uri(
"/api/hermes/client/sessions/sess_1?source=acp&workspaceId=ws_1&legacyConvex=1",
)
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request"),
@@ -6861,7 +7207,7 @@ mod tests {
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1")
.uri("/api/hermes/client/sessions/sess_1/resume?source=acp&workspaceId=ws_1&legacyConvex=1")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request"),
@@ -6880,131 +7226,33 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_rename_calls_convex_store() {
let captured_body = Arc::new(Mutex::new(Value::Null));
let captured_for_route = Arc::clone(&captured_body);
let mock = axum::Router::new().route(
"/api/mutation",
post(move |Json(body): Json<Value>| {
let captured = Arc::clone(&captured_for_route);
async move {
*captured.lock().expect("captured convex body") = body;
Json(json!({
"status": "success",
"value": {"ok": true, "sessionId": "sess_1", "title": "新标题"}
}))
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
async fn hermes_client_acp_session_rename_uses_sqlite_store() {
let response = build_app(seeded_acp_state())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(json!({"title": "新标题"}).to_string()))
.expect("request"),
)
.await
.expect("listener");
let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
tokio::spawn(async move {
axum::serve(listener, mock).await.expect("mock convex");
});
let response = app_with_config(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
convex_admin_key: Some("admin-demo".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
})
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/client/sessions/sess_1/rename?source=acp&workspaceId=ws_1")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(json!({"title": "新标题"}).to_string()))
.expect("request"),
)
.await
.expect("response");
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["result"]["title"], "新标题");
let mutation = captured_body.lock().expect("captured convex body").clone();
assert_eq!(mutation["path"], "aiSessions:renameRuntimeSession");
assert_eq!(mutation["args"][0]["userId"], "user_1");
assert_eq!(mutation["args"][0]["workspaceId"], "ws_1");
assert_eq!(mutation["args"][0]["sessionId"], "sess_1");
assert_eq!(mutation["args"][0]["title"], "新标题");
assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1");
}
#[tokio::test]
async fn hermes_client_acp_session_delete_and_auto_title_call_convex_store() {
let captured_bodies = Arc::new(Mutex::new(Vec::<Value>::new()));
let captured_for_route = Arc::clone(&captured_bodies);
let mock = axum::Router::new().route(
"/api/mutation",
post(move |Json(body): Json<Value>| {
let captured = Arc::clone(&captured_for_route);
async move {
captured
.lock()
.expect("captured convex bodies")
.push(body.clone());
let path = body["path"].as_str().unwrap_or_default();
let value = match path {
"aiSessions:autoTitleRuntimeSession" => {
json!({"ok": true, "sessionId": "sess_1", "title": "自动标题"})
}
"aiSessions:deleteRuntimeSession" => {
json!({"ok": true, "sessionId": "sess_1", "deleted": 1})
}
_ => Value::Null,
};
Json(json!({"status": "success", "value": value}))
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener");
let convex_url = format!("http://{}", listener.local_addr().expect("addr"));
tokio::spawn(async move {
axum::serve(listener, mock).await.expect("mock convex");
});
let router = app_with_config(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url),
convex_admin_key: Some("admin-demo".into()),
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() {
let router = build_app(seeded_acp_state());
let response = router
.clone()
@@ -7025,6 +7273,7 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["result"]["title"], "自动标题");
let response = router
@@ -7039,13 +7288,12 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let bodies = captured_bodies.lock().expect("captured convex bodies");
assert_eq!(bodies[0]["path"], "aiSessions:autoTitleRuntimeSession");
assert_eq!(bodies[0]["args"][0]["userId"], "user_1");
assert_eq!(bodies[0]["args"][0]["sessionId"], "sess_1");
assert_eq!(bodies[1]["path"], "aiSessions:deleteRuntimeSession");
assert_eq!(bodies[1]["args"][0]["workspaceId"], "ws_1");
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("delete body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["result"]["deleted"], 1);
}
#[tokio::test]
@@ -7104,7 +7352,7 @@ mod tests {
.oneshot(
Request::builder()
.method("GET")
.uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5")
.uri("/api/hermes/client/sessions/search?source=acp&workspaceId=ws_1&q=%E5%8C%96%E5%AD%A6&limit=5&legacyConvex=1")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request"),