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:
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||
|
||||
@@ -20,7 +20,8 @@ use std::pin::Pin;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
type BoxedEventStream = Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
type BoxedEventStream =
|
||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -138,8 +139,8 @@ async fn build_tree_live_stream(
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let file_tree_snapshot = load_local_folder_file_tree_snapshot(&root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let revision = local_folder_watch_revision(&root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let revision =
|
||||
local_folder_watch_revision(&root_uri).map_err(|error| error.with_context(&context))?;
|
||||
|
||||
let initial_payload = build_tree_snapshot_payload(
|
||||
&root_uri,
|
||||
@@ -285,8 +286,7 @@ mod tests {
|
||||
|
||||
fn app_with_local_workspace(root: &std::path::Path) -> axum::Router {
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
initialize_local_workspace_for_actor("dev-user", &root_uri)
|
||||
.expect("init local workspace");
|
||||
initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace");
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::web_shell::{
|
||||
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
|
||||
load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html,
|
||||
@@ -30,7 +31,7 @@ pub async fn mindmap_object_shell(
|
||||
Path((doc_id, mindmap_id)): Path<(String, String)>,
|
||||
Query(query): Query<MindmapShellQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let source_kind = query.source_kind.as_deref();
|
||||
let root_uri = query.root_uri.as_deref();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
|
||||
@@ -131,6 +131,26 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/admin/access-policy/grants/{grant_id}",
|
||||
delete(local_folder_source::delete_local_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/user/access-policy",
|
||||
get(local_folder_source::get_user_access_policy),
|
||||
)
|
||||
.route(
|
||||
"/api/user/access-policy/grants",
|
||||
post(local_folder_source::create_user_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/user/access-policy/grants/{grant_id}",
|
||||
delete(local_folder_source::delete_user_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/share-links",
|
||||
get(local_folder_source::get_share_links).post(local_folder_source::create_share_link),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/share-links/{link_id}",
|
||||
delete(local_folder_source::delete_share_link),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/share-grants",
|
||||
get(local_folder_source::get_share_grants)
|
||||
|
||||
@@ -5,10 +5,12 @@ use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use control_plane::session_token_hash;
|
||||
use serde::Serialize;
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
|
||||
const COOKIE_MNOTE_SESSION: &str = "mnote_session";
|
||||
const COOKIE_ACTOR_EMAIL: &str = "mnote_actor_email";
|
||||
const COOKIE_ACTOR_NAME: &str = "mnote_actor_name";
|
||||
|
||||
@@ -65,6 +67,28 @@ pub async fn refresh_session(
|
||||
}
|
||||
|
||||
fn build_session_response(state: &AppState, context: RequestContext) -> SessionResponse {
|
||||
if let Some(raw_token) = context
|
||||
.auth
|
||||
.cookie_header
|
||||
.as_deref()
|
||||
.and_then(|cookies| raw_cookie_value(cookies, COOKIE_MNOTE_SESSION))
|
||||
{
|
||||
let token_hash = session_token_hash(&raw_token);
|
||||
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
|
||||
return SessionResponse {
|
||||
ok: true,
|
||||
owner: "mnote-web",
|
||||
user_id: resolved.user.id,
|
||||
email: resolved.user.email.unwrap_or_default(),
|
||||
name: resolved.user.display_name,
|
||||
actor_type: "user".to_string(),
|
||||
auth_mode: "sqliteSession",
|
||||
request_id: context.trace.request_id,
|
||||
trace_id: context.trace.trace_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let actor_id = context.auth.actor_id.trim();
|
||||
let has_forwarded_actor = !actor_id.is_empty() && actor_id != "anonymous";
|
||||
let actor_email = context
|
||||
@@ -179,6 +203,7 @@ mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -282,6 +307,75 @@ mod tests {
|
||||
assert!(payload.get("convexAdminKey").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_prefers_sqlite_cookie_identity_over_dev_fallback() {
|
||||
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: true,
|
||||
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_user(UpsertUserInput {
|
||||
id: Some("shujuan".into()),
|
||||
email: Some("shujuan@163.com".into()),
|
||||
username: "shujuan".into(),
|
||||
display_name: "shujuan".into(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.create_session(CreateSessionInput {
|
||||
id: None,
|
||||
user_id: "shujuan".into(),
|
||||
token_hash: session_token_hash("raw-session-token"),
|
||||
user_agent: None,
|
||||
ip_hash: None,
|
||||
expires_at: None,
|
||||
})
|
||||
.expect("create session");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
.header("cookie", "mnote_session=raw-session-token")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["owner"], "mnote-web");
|
||||
assert_eq!(payload["userId"], "shujuan");
|
||||
assert_eq!(payload["email"], "shujuan@163.com");
|
||||
assert_eq!(payload["name"], "shujuan");
|
||||
assert_eq!(payload["actorType"], "user");
|
||||
assert_eq!(payload["authMode"], "sqliteSession");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_whoami_alias_prefers_forwarded_actor_identity() {
|
||||
let response = app()
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::gateway::default_workspace_name_for_context;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
@@ -125,7 +126,7 @@ pub async fn document_page_shell(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let default_workspace_name = default_workspace_name_for_context(&state, &context);
|
||||
let mut workspace_projection = load_workspace_shell_projection(
|
||||
state.config(),
|
||||
&context,
|
||||
|
||||
Reference in New Issue
Block a user