chore: checkpoint turso and ai runtime work

This commit is contained in:
Agent Board
2026-07-03 23:20:16 +08:00
parent a75b3d11f9
commit 36d027a4a1
67 changed files with 4224 additions and 914 deletions
+7 -23
View File
@@ -8,7 +8,9 @@ use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use control_plane::ControlPlaneStore;
#[cfg(test)]
use control_plane::SqliteControlPlaneStore;
#[cfg(not(test))]
use control_plane::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
use std::env;
@@ -201,7 +203,7 @@ impl AppState {
#[cfg(test)]
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面"))
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 control-plane"))
}
#[cfg(not(test))]
@@ -210,9 +212,8 @@ pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "sqlite".to_string());
.unwrap_or_else(|| "libsql-local".to_string());
match backend.as_str() {
"sqlite" => open_sqlite_control_plane_store(),
"libsql-local" | "turso-local" | "turso" => open_turso_local_control_plane_store(),
"turso-remote" => open_turso_remote_control_plane_store(),
"turso-local-replica" | "turso-remote-replica" => {
@@ -220,33 +221,16 @@ pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
}
"turso-synced" => open_turso_synced_control_plane_store(),
other => panic!(
"不支持的控制面后端 {other},支持 sqlite/libsql-local/turso-remote/turso-local-replica/turso-synced"
"不支持的控制面后端 {other}mnote-web 运行时只支持 libsql-local/turso-remote/turso-local-replica/turso-syncedSQLite 仅保留给 control-plane-admin 迁移/导出和测试"
),
}
}
#[cfg(not(test))]
fn open_sqlite_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 SQLite 控制面目录");
}
Arc::new(SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面"))
}
#[cfg(not(test))]
fn open_turso_local_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let db_path = env::var("MNOTE_TURSO_LOCAL_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db".to_string()
});
@@ -412,7 +396,7 @@ mod tests {
}
#[test]
fn app_state_initializes_sqlite_control_plane_store() {
fn app_state_initializes_control_plane_store_for_tests() {
let state = AppState::new(test_config());
state
@@ -70,7 +70,7 @@ pub async fn query(
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid",
format!("资料库问答参数无效: {error}"),
format!("知识库问答参数无效: {error}"),
)
.with_context(context)
})?;
@@ -95,7 +95,7 @@ pub async fn open_reference(
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_open_reference_payload_invalid",
format!("资料库引用打开参数无效: {error}"),
format!("知识库引用打开参数无效: {error}"),
)
.with_context(context)
})?;
@@ -119,7 +119,7 @@ pub async fn section_context(
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_section_context_payload_invalid",
format!("资料库章节上下文参数无效: {error}"),
format!("知识库章节上下文参数无效: {error}"),
)
.with_context(context)
})?;
@@ -57,10 +57,6 @@ fn doc_tools() -> Vec<Value> {
fn knowledge_rag_tools() -> Vec<Value> {
vec![
knowledge_rag_status_tool(),
weknora_search_tool(),
weknora_list_sources_tool(),
weknora_get_source_status_tool(),
weknora_open_reference_tool(),
knowledge_rag_query_tool(),
knowledge_rag_section_context_tool(),
knowledge_rag_open_reference_tool(),
@@ -359,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看当前资料库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 WeKnoraLightRAG 仅作为 legacy fallback。",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 RAGFlowWeKnora/LightRAG 仅作为 legacy fallback。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -371,114 +367,6 @@ fn knowledge_rag_status_tool() -> Value {
})
}
fn weknora_scope_properties() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("scope".into(), json!({ "type": "object" }));
map.insert(
"allowlist".into(),
json!({ "type": "array", "items": { "type": "string" } }),
);
map.insert("allowedRoots".into(), json!({ "type": "array" }));
map.insert("aiAccessScope".into(), json!({ "type": "object" }));
map.insert(
"sourcePaths".into(),
json!({ "type": "array", "items": { "type": "string" } }),
);
}
properties
}
fn weknora_search_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("query".into(), json!({ "type": "string" }));
map.insert("topK".into(), json!({ "type": "integer", "default": 10 }));
map.insert(
"includeChunkContent".into(),
json!({ "type": "boolean", "default": true }),
);
}
json!({
"name": "mnote.weknora.search",
"description": "只读调用 WeKnora 检索。必须带 rootUri 和 scope/allowlist,返回 provider ids、MNote source registry 映射以及 locatorDegraded;不要把 WeKnora filename/chunk id 当成本地 path。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "query"],
"properties": properties
}
})
}
fn weknora_list_sources_tool() -> Value {
json!({
"name": "mnote.weknora.list_sources",
"description": "只读列出 MNote source registry 与 WeKnora provider 状态。必须带 rootUri 和 scope/allowlist。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": weknora_scope_properties()
}
})
}
fn weknora_get_source_status_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("sourcePath".into(), json!({ "type": "string" }));
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
map.insert("providerSourceId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.weknora.get_source_status",
"description": "只读查询某个 MNote source 或 WeKnora provider knowledge id 的映射状态。必须带 rootUri 和 scope/allowlist。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": properties
}
})
}
fn weknora_open_reference_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("reference".into(), json!({ "type": "object" }));
map.insert(
"providerKnowledgeBaseId".into(),
json!({ "type": "string" }),
);
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
map.insert("providerChunkId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.weknora.open_reference",
"description": "只读把 WeKnora reference 映射为 MNote open action。provider ids 独立返回;未命中 source registry 时必须视为 locatorDegraded。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "reference"],
"properties": properties
}
})
}
fn knowledge_rag_query_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -517,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向当前资料库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 WeKnoraLightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 RAGFlowWeKnora/LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -37,8 +37,8 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
},
MnoteCapabilityPack {
id: "mnote-knowledge-rag",
title: "资料库问答",
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源;默认 provider 是 WeKnora",
title: "知识库问答",
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 RAGFlow",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: true,
@@ -46,10 +46,6 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.weknora.search",
"mnote.weknora.list_sources",
"mnote.weknora.get_source_status",
"mnote.weknora.open_reference",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
+24 -24
View File
@@ -134,10 +134,10 @@ pub async fn auth_api(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
if action == "auth:signOut" {
return Ok(build_sqlite_sign_out_response(&state, &context));
return Ok(build_control_plane_sign_out_response(&state, &context));
}
handle_sqlite_auth_action(&state, &context, &payload).await
handle_control_plane_auth_action(&state, &context, &payload).await
}
pub async fn auth_entry(
@@ -2070,7 +2070,7 @@ fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool {
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
}
async fn handle_sqlite_auth_action(
async fn handle_control_plane_auth_action(
state: &AppState,
context: &RequestContext,
payload: &serde_json::Value,
@@ -2131,7 +2131,7 @@ async fn handle_sqlite_auth_action(
role: None,
password_hash: None,
})
.map_err(|error| sqlite_auth_error(context, error))?;
.map_err(|error| control_plane_auth_error(context, error))?;
state
.control_plane()
.create_password_identity(CreatePasswordIdentityInput {
@@ -2140,11 +2140,11 @@ async fn handle_sqlite_auth_action(
username: user.username.clone(),
password: password.clone(),
})
.map_err(|error| sqlite_auth_error(context, error))?;
.map_err(|error| control_plane_auth_error(context, error))?;
state
.control_plane()
.ensure_default_workspace(&user.id)
.map_err(|error| sqlite_auth_error(context, error))?;
.map_err(|error| control_plane_auth_error(context, error))?;
state
.control_plane()
.authenticate_password(AuthenticatePasswordInput {
@@ -2156,7 +2156,7 @@ async fn handle_sqlite_auth_action(
ip_hash: None,
expires_at: None,
})
.map_err(|error| sqlite_auth_error(context, error))?
.map_err(|error| control_plane_auth_error(context, error))?
} else {
let account = params
.get("account")
@@ -2181,7 +2181,7 @@ async fn handle_sqlite_auth_action(
ip_hash: None,
expires_at: None,
})
.map_err(|error| sqlite_auth_error(context, error))?
.map_err(|error| control_plane_auth_error(context, error))?
};
let audit_action = if flow == "signUp" {
@@ -2202,7 +2202,7 @@ async fn handle_sqlite_auth_action(
.unwrap_or_else(|_| "{}".to_string()),
});
let provider_sync_results = if flow == "signUp" {
let provider_sync_results = if matches!(flow, "signUp" | "signIn") {
let directory_grants = state
.control_plane()
.list_directory_grants_for_actor(&resolved.user.id)
@@ -2233,13 +2233,13 @@ async fn handle_sqlite_auth_action(
None
};
let mut response = build_sqlite_auth_response(
let mut response = build_control_plane_auth_response(
context,
&session_token,
&resolved.user.id,
resolved.user.email.as_deref().unwrap_or_default(),
&resolved.user.display_name,
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
&effective_control_plane_auth_actor_type(&resolved.user.id, &resolved.user.role),
);
if let Some(results) = provider_sync_results {
let all_ok = results.iter().all(|item| item.ok);
@@ -2253,7 +2253,7 @@ async fn handle_sqlite_auth_action(
Ok(response)
}
fn sqlite_auth_error(
fn control_plane_auth_error(
context: &RequestContext,
error: control_plane::ControlPlaneError,
) -> WebError {
@@ -2273,7 +2273,7 @@ fn sqlite_auth_error(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
}
fn build_sqlite_auth_response(
fn build_control_plane_auth_response(
context: &RequestContext,
session_token: &str,
user_id: &str,
@@ -2286,7 +2286,7 @@ fn build_sqlite_auth_response(
"userId": user_id,
"email": email,
"name": name,
"authMode": "sqliteSession",
"authMode": "controlPlaneSession",
"actorType": actor_type
}))
.into_response();
@@ -2307,7 +2307,7 @@ fn build_sqlite_auth_response(
response
}
fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
fn effective_control_plane_auth_actor_type(user_id: &str, stored_role: &str) -> String {
let role = stored_role.trim();
let fallback_role = if role.is_empty() { "user" } else { role };
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
@@ -2320,7 +2320,7 @@ fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String
}
}
fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
fn build_control_plane_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
if let Some(raw_token) = extract_cookie_value(context, 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) {
@@ -3062,7 +3062,7 @@ mod tests {
}
#[tokio::test]
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
async fn root_entry_uses_control_plane_session_display_name_for_workspace_label() {
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
let app_state = AppState::new(AppConfig {
@@ -3095,7 +3095,7 @@ mod tests {
role: None,
password_hash: None,
})
.expect("upsert sqlite user");
.expect("upsert control-plane user");
app_state
.control_plane()
.create_session(CreateSessionInput {
@@ -3106,7 +3106,7 @@ mod tests {
ip_hash: None,
expires_at: None,
})
.expect("create sqlite session");
.expect("create control-plane session");
let response = build_app(app_state)
.oneshot(
@@ -3745,7 +3745,7 @@ mod tests {
}
#[tokio::test]
async fn auth_api_signup_sets_sqlite_session_cookie_when_compat_disabled() {
async fn auth_api_signup_sets_control_plane_session_cookie_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
@@ -3786,11 +3786,11 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "new-user");
assert_eq!(payload["email"], "new-user@example.com");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
async fn auth_api_signin_accepts_sqlite_username_after_signup() {
async fn auth_api_signin_accepts_control_plane_username_after_signup() {
let app = app_with_config("http://127.0.0.1:3100".into(), false);
let signup = app
.clone()
@@ -3829,7 +3829,7 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "mnote-e2e");
assert_eq!(payload["email"], "mnote.e2e@example.com");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
@@ -3865,7 +3865,7 @@ mod tests {
}
#[tokio::test]
async fn auth_api_signout_clears_sqlite_session_cookie() {
async fn auth_api_signout_clears_control_plane_session_cookie() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
@@ -34,7 +34,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_SQLITE_STORE: &str = "sqlite_acp_runtime_store";
const ACP_RUNTIME_CONTROL_PLANE_STORE: &str = "control_plane_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";
@@ -493,7 +493,7 @@ pub async fn search_sessions(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"results": results
})),
));
@@ -590,24 +590,24 @@ async fn list_acp_sessions(
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let sqlite_runs = state
let control_plane_runs = state
.control_plane()
.list_ai_runtime_runs(&user_id, workspace_id, document_id, session_id, limit)
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 列表读取失败: {error}"))
.with_context(context)
})?;
if !sqlite_runs.is_empty() {
if !control_plane_runs.is_empty() {
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": "local_ai_session_jsonl",
"sessions": sqlite_runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
"sessions": control_plane_runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
})),
));
}
@@ -669,7 +669,7 @@ async fn list_acp_sessions(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessions": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
})),
));
@@ -897,8 +897,8 @@ pub async fn create_session(
"profile": profile,
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": "local_ai_session_jsonl",
"legacySessionStorage": session_storage,
"permissionLevel": permission_level,
@@ -973,8 +973,8 @@ pub async fn create_session(
"providerKind": "api-chat",
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane"
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane"
})),
));
}
@@ -1004,7 +1004,7 @@ pub async fn create_session(
&runtime_payload,
)
.await?;
persistence = ACP_RUNTIME_SQLITE_STORE;
persistence = ACP_RUNTIME_CONTROL_PLANE_STORE;
}
Ok((
StatusCode::OK,
@@ -1450,7 +1450,7 @@ pub async fn toggle_skill(
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_sqlite"})),
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_control_plane"})),
));
}
let access =
@@ -2220,7 +2220,7 @@ pub async fn toggle_capability(
"id": capability_id,
"enabled": enabled,
"profile": profile,
"configScope": "user_sqlite+profile_tool_policy"
"configScope": "user_control_plane+profile_tool_policy"
})),
))
}
@@ -2324,7 +2324,7 @@ pub async fn resume_session(
payload["resumeSource"] = payload
.get("persistence")
.cloned()
.unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into()));
.unwrap_or_else(|| Value::String(ACP_RUNTIME_CONTROL_PLANE_STORE.into()));
return Ok((result.0, result.1, Json(payload)));
}
get_session(
@@ -2422,7 +2422,7 @@ pub async fn delete_session(
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
@@ -2494,7 +2494,7 @@ pub async fn rename_session(
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
@@ -2579,7 +2579,7 @@ pub async fn auto_title_session(
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
@@ -2720,8 +2720,8 @@ async fn get_acp_session(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": "local_ai_session_jsonl",
"sessionId": session_id,
"session": {
@@ -2804,7 +2804,7 @@ async fn get_acp_session(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionId": session_id,
"session": {
"sessionId": session_id,
@@ -2983,7 +2983,7 @@ pub async fn create_run(
"persistence": persistence_result
.get("persistence")
.and_then(Value::as_str)
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
.unwrap_or(ACP_RUNTIME_CONTROL_PLANE_STORE),
"sessionStorage": persistence_result
.get("sessionStorage")
.cloned()
@@ -3050,7 +3050,7 @@ pub async fn create_run(
"persistence": persistence_result
.get("persistence")
.and_then(Value::as_str)
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
.unwrap_or(ACP_RUNTIME_CONTROL_PLANE_STORE),
"sessionStorage": persistence_result
.get("sessionStorage")
.cloned()
@@ -4997,7 +4997,7 @@ fn stamp_mnote_builtin_skill_payload_policy(
Value::Bool(ai_preference_bool(state, &actor_id, &key, true)?);
skill["builtin"] = Value::Bool(true);
skill["configurable"] = Value::Bool(true);
skill["configScope"] = Value::String("user_sqlite".to_string());
skill["configScope"] = Value::String("user_control_plane".to_string());
skill["skillKind"] = Value::String("mnote_builtin".to_string());
}
}
@@ -5411,7 +5411,7 @@ fn mnote_capabilities_payload(
"toggleable": skill.get("toggleable").cloned().unwrap_or_else(|| json!(true)),
"builtin": true,
"configurable": true,
"configScope": "user_sqlite+profile_tool_policy",
"configScope": "user_control_plane+profile_tool_policy",
"skillKind": "mnote_capability",
"source": "mnote",
"origin": "builtin",
@@ -6319,7 +6319,7 @@ fn mnote_builtin_skills_payload(agent_id: Option<&str>) -> Value {
"toggleable": true,
"builtin": true,
"configurable": true,
"configScope": "user_sqlite",
"configScope": "user_control_plane",
"skillKind": "mnote_builtin",
"source": "mnote",
"origin": "builtin",
@@ -6853,7 +6853,7 @@ fn enforce_local_ai_run_access(
payload["allowedRoots"] = json!([{
"rootUri": root_uri,
"permission": if chat_only { "read" } else { "write" },
"source": "sqlite_directory_grant",
"source": "control_plane_directory_grant",
"grantIds": access.grant_ids
}]);
Ok(())
@@ -9903,7 +9903,7 @@ fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value {
"payload": payload,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10078,7 +10078,7 @@ fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Val
"eventType": record.event_type,
"payload": payload,
"createdAt": record.created_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10210,7 +10210,7 @@ fn page_ai_run_to_journal_json(record: &control_plane::AiRuntimeRunRecord) -> Va
"payload": payload,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10587,7 +10587,7 @@ fn page_ai_journal_event_to_json(record: &control_plane::AiRuntimeJournalEventRe
"acpRuntime": event.acp_runtime,
"payload": payload,
"createdAt": event.created_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10615,7 +10615,7 @@ fn page_ai_synthetic_terminal_event_to_json(
"reason": "terminal_status_without_terminal_event"
},
"createdAt": run.updated_at.clone(),
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"synthetic": true,
})
}
@@ -11645,8 +11645,8 @@ async fn persist_acp_runtime_run(
})?;
Ok(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": legacy_persistence,
"legacySessionStorage": legacy_session_storage,
"sessionId": record.session_id,
@@ -11824,8 +11824,8 @@ async fn persist_acp_runtime_event(
}
Ok(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": legacy_persistence,
"legacySessionStorage": legacy_session_storage,
"sessionId": registration.session_id,
@@ -12636,7 +12636,7 @@ mod tests {
}
#[tokio::test]
async fn page_ai_agent_profiles_are_sqlite_user_scoped() {
async fn page_ai_agent_profiles_are_control_plane_user_scoped() {
let response = app()
.oneshot(
Request::builder()
@@ -12743,7 +12743,7 @@ mod tests {
.find(|skill| skill["id"] == "mnote-current-page")
.expect("current page skill");
assert_eq!(current_page["enabled"], false);
assert_eq!(current_page["configScope"], "user_sqlite");
assert_eq!(current_page["configScope"], "user_control_plane");
let mindmap_skill = payload["categories"]
.as_array()
.expect("categories")
@@ -14830,7 +14830,7 @@ mod tests {
}
#[tokio::test]
async fn api_chat_create_session_persists_sqlite_without_acp_session() {
async fn api_chat_create_session_persists_control_plane_without_acp_session() {
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
clear_runtime_registry();
clear_acp_run_payloads();
@@ -14861,8 +14861,8 @@ 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["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["providerKind"], "api-chat");
assert_eq!(payload["profile"], "api-gpt-chat");
}
@@ -14998,7 +14998,7 @@ mod tests {
}
#[tokio::test]
async fn api_chat_session_detail_restores_messages_from_sqlite_events() {
async fn api_chat_session_detail_restores_messages_from_control_plane_events() {
let _env_guard = env_lock().lock().expect("env lock");
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
clear_runtime_registry();
@@ -15855,12 +15855,12 @@ 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"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert!(payload.get("messages").is_none());
}
#[tokio::test]
async fn hermes_client_local_acp_session_create_writes_sqlite_and_private_jsonl() {
async fn hermes_client_local_acp_session_create_writes_control_plane_and_private_jsonl() {
let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-private-{}",
std::process::id()
@@ -15931,8 +15931,8 @@ 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["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
assert_eq!(payload["legacySessionStorage"], "local_private");
let session_id = payload["sessionId"].as_str().expect("session id");
@@ -15965,8 +15965,8 @@ mod tests {
.await
.expect("list body");
let list_payload: Value = serde_json::from_slice(&list_body).expect("list json");
assert_eq!(list_payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(list_payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(list_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(list_payload["sessionStorage"], "control_plane");
assert_eq!(list_payload["sessions"][0]["sessionId"], session_id);
let detail_uri = format!(
@@ -15991,7 +15991,7 @@ mod tests {
.await
.expect("detail body");
let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json");
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(
detail_payload["session"]["runs"][0]["sessionId"],
session_id
@@ -16001,7 +16001,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_local_acp_run_writes_sqlite_and_private_jsonl_without_convex() {
async fn hermes_client_local_acp_run_writes_control_plane_and_private_jsonl_without_convex() {
let root =
std::env::temp_dir().join(format!("mnote-local-ai-run-private-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
@@ -16069,8 +16069,8 @@ 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["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
assert_eq!(payload["legacySessionStorage"], "local_private");
let jsonl_path = root
@@ -16085,9 +16085,9 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_local_acp_session_create_accepts_sqlite_directory_grant() {
async fn hermes_client_local_acp_session_create_accepts_control_plane_directory_grant() {
let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-sqlite-grant-{}",
"mnote-local-ai-session-control-plane-grant-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
@@ -16136,7 +16136,7 @@ mod tests {
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"traceId": "trace_sqlite_grant_session_1",
"traceId": "trace_control_plane_grant_session_1",
"profile": "reasonix",
"title": "授权本地会话"
})
@@ -16152,8 +16152,8 @@ 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["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
let _ = fs::remove_dir_all(&root);
@@ -16234,7 +16234,7 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacySessionStorage"], "local_shared");
let session_id = payload["sessionId"].as_str().expect("session id");
let jsonl_path = root
@@ -16435,7 +16435,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["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(
captured_body.lock().expect("captured convex body").clone(),
@@ -16444,7 +16444,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() {
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_control_plane() {
let _guard = runtime_lock().lock().expect("runtime lock");
clear_runtime_registry();
clear_run_queue();
@@ -16482,7 +16482,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["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
let response = router
.oneshot(
@@ -16500,7 +16500,7 @@ mod tests {
.await
.expect("list body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
let run = &payload["sessions"][0];
assert_eq!(run["sessionId"], "sess_1");
assert_eq!(run["runId"], "run_trace_1");
@@ -16753,7 +16753,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_rename_uses_sqlite_store() {
async fn hermes_client_acp_session_rename_uses_control_plane_store() {
let response = build_app(seeded_acp_state())
.oneshot(
Request::builder()
@@ -16772,13 +16772,13 @@ mod tests {
.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["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["result"]["title"], "新标题");
assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1");
}
#[tokio::test]
async fn hermes_client_acp_session_export_uses_sqlite_store() {
async fn hermes_client_acp_session_export_uses_control_plane_store() {
let response = build_app(seeded_acp_state())
.oneshot(
Request::builder()
@@ -16796,7 +16796,7 @@ mod tests {
.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["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(
payload["export"]["schema"],
"mnote.page_ai_session_export.v1"
@@ -16818,7 +16818,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() {
async fn hermes_client_acp_session_delete_and_auto_title_use_control_plane_store() {
let router = build_app(seeded_acp_state());
let response = router
@@ -16840,7 +16840,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["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["result"]["title"], "自动标题");
let response = router
@@ -16859,7 +16859,7 @@ mod tests {
.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["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["result"]["deleted"], 1);
}
@@ -17687,7 +17687,7 @@ mod tests {
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间",
"permission": "write",
"recursive": true,
"source": "sqlite_directory_grant",
"source": "control_plane_directory_grant",
"absolutePath": "/mnt/Data1T/Mnote_data/users/user_1/我的空间"
}],
"editorTarget": {
@@ -18069,7 +18069,7 @@ mod tests {
}
#[test]
fn local_ai_run_access_rewrites_allowed_roots_from_sqlite_grant() {
fn local_ai_run_access_rewrites_allowed_roots_from_control_plane_grant() {
let state = test_state();
state
.control_plane()
@@ -18107,7 +18107,7 @@ mod tests {
let mut payload = json!({
"workspaceId": "local-ws:alice:test",
"documentId": "local-md:README.md",
"sessionId": "sess_sqlite_roots",
"sessionId": "sess_control_plane_roots",
"sourceKind": "local_folder",
"rootUri": "file:///tmp/mnote-ai-allowed",
"allowedRoots": [{"rootUri": "file:///tmp/evil", "permission": "write", "source": "client"}]
@@ -18124,7 +18124,7 @@ mod tests {
assert_eq!(payload["allowedRoots"][0]["permission"], "write");
assert_eq!(
payload["allowedRoots"][0]["source"],
"sqlite_directory_grant"
"control_plane_directory_grant"
);
assert!(payload["allowedRoots"][0]["grantIds"]
.as_array()
@@ -18134,7 +18134,7 @@ mod tests {
}
#[test]
fn local_ai_run_access_rejects_other_user_without_sqlite_grant() {
fn local_ai_run_access_rejects_other_user_without_control_plane_grant() {
let state = test_state();
state
.control_plane()
@@ -368,21 +368,20 @@ pub(crate) async fn execute_mnote_tool_call(
Err(WebError::new(
StatusCode::GONE,
"mnote_evidence_tools_retired",
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.weknora.search/open_reference 或兼容 mnote.knowledge_rag.query/open_reference",
"旧 docs/evidence/LiteParse tools 已退役;请使用兼容 mnote.knowledge_rag.query/open_reference",
)
.with_context(&context))
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.weknora.search" => knowledge_rag::search(&state, &context, &input).await,
"mnote.weknora.list_sources" => {
knowledge_rag::list_sources(&state, &context, &input).await
}
"mnote.weknora.get_source_status" => {
knowledge_rag::get_source_status(&state, &context, &input).await
}
"mnote.weknora.open_reference" => {
knowledge_rag::open_reference(&state, &context, &input).await
}
"mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference" => Err(WebError::new(
StatusCode::GONE,
"mnote_weknora_tools_retired",
"WeKnora 专用工具已从默认 agent manifest 移除;请改用 provider-neutral mnote.knowledge_rag.*,或显式启动 legacy WeKnora provider 调试。",
)
.with_context(&context)),
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.section_context" => {
knowledge_rag::section_context(&state, &context, &input).await
@@ -738,10 +737,6 @@ fn is_read_tool(tool_name: &str) -> bool {
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
@@ -775,10 +770,6 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
File diff suppressed because it is too large Load Diff
@@ -264,11 +264,14 @@ fn build_local_folder_watch_batch_payload(
root_uri.hash(&mut hasher);
workspace_id.hash(&mut hasher);
for payload in watcher_payloads {
let relative_path = payload
let Some(relative_path) = payload
.get("relativePath")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
.filter(|value| !value.is_empty())
else {
continue;
};
let event_kind = payload
.get("eventKind")
.and_then(Value::as_str)
@@ -286,7 +289,36 @@ fn build_local_folder_watch_batch_payload(
changed_paths.push(json!({
"relativePath": relative_path,
"kind": event_kind,
"eventKind": event_kind,
"changeType": event_kind,
"revision": event_revision,
"documentId": payload.get("documentId").cloned().unwrap_or(Value::Null),
"sourceKind": payload.get("sourceKind").cloned().unwrap_or(Value::Null),
"observedFileVersion": payload
.get("observedFileVersion")
.cloned()
.unwrap_or(Value::Null),
"bufferFileVersion": payload
.get("bufferFileVersion")
.cloned()
.unwrap_or(Value::Null),
"fileVersion": payload
.get("observedFileVersion")
.cloned()
.or_else(|| payload.get("bufferFileVersion").cloned())
.unwrap_or(Value::Null),
"lastWriteIntentId": payload
.get("lastWriteIntentId")
.cloned()
.unwrap_or(Value::Null),
"lastSaveOperationId": payload
.get("lastSaveOperationId")
.cloned()
.unwrap_or(Value::Null),
"selfWriteEcho": payload
.get("selfWriteEcho")
.and_then(Value::as_bool)
.unwrap_or(false),
}));
}
if seen_kinds.insert(event_kind.to_string()) {
@@ -655,7 +687,13 @@ mod tests {
vec![
json!({
"relativePath": "docs/README.md",
"documentId": "local-md:docs~2FREADME.md",
"eventKind": "Modify(Data)",
"observedFileVersion": "sha256:observed",
"bufferFileVersion": "sha256:buffer",
"lastWriteIntentId": "write-1",
"lastSaveOperationId": "save-1",
"selfWriteEcho": true,
}),
json!({
"relativePath": "docs/New.md",
@@ -671,6 +709,15 @@ mod tests {
assert_eq!(payload["watchRevision"]["scope"], "changed_paths");
assert_eq!(payload["watchRevision"]["entryCount"], 2);
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
let first_path = &payload["changedPaths"][0];
assert_eq!(first_path["documentId"], "local-md:docs~2FREADME.md");
assert_eq!(first_path["eventKind"], "Modify(Data)");
assert_eq!(first_path["observedFileVersion"], "sha256:observed");
assert_eq!(first_path["bufferFileVersion"], "sha256:buffer");
assert_eq!(first_path["fileVersion"], "sha256:observed");
assert_eq!(first_path["lastWriteIntentId"], "write-1");
assert_eq!(first_path["lastSaveOperationId"], "save-1");
assert_eq!(first_path["selfWriteEcho"], true);
assert!(
payload["affectedParents"]
.as_array()
@@ -658,7 +658,7 @@ pub(crate) fn ensure_local_workspace_access_with_state(
}
let actor_id = context.auth.actor_id.trim();
let canonical_root_uri = file_uri_for_path(&canonical_root);
let sqlite_access = state
let control_plane_access = state
.control_plane()
.resolve_access(actor_id, &canonical_root_uri)
.unwrap_or_else(|error| {
@@ -675,7 +675,7 @@ pub(crate) fn ensure_local_workspace_access_with_state(
grant_ids: Vec::new(),
}
});
if local_access_permission_allows(&sqlite_access.permission, mode) {
if local_access_permission_allows(&control_plane_access.permission, mode) {
return Ok(canonical_root);
}
ensure_local_workspace_access_for_actor_with_mode(
@@ -785,22 +785,8 @@ pub(crate) fn control_plane_status_display() -> String {
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "sqlite".to_string());
.unwrap_or_else(|| "libsql-local".to_string());
match backend.as_str() {
"sqlite" => {
let db_path = std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
default_local_workspace_base_dir()
.join("control-plane")
.join("control-plane.db")
.display()
.to_string()
});
format!("backend=sqlite; db={db_path}")
}
"libsql-local" | "turso-local" | "turso" => {
let db_path = std::env::var("MNOTE_TURSO_LOCAL_PATH")
.ok()
@@ -850,6 +836,7 @@ pub(crate) fn control_plane_status_display() -> String {
});
format!("backend=turso-synced; local={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
}
"sqlite" => "backend=sqlite; unsupported-runtime; use control-plane-admin only".to_string(),
other => format!("backend={other}; unsupported"),
}
}
@@ -1211,7 +1198,7 @@ fn append_control_plane_outbox_event(state: &AppState, event_type: &str, payload
let _ = state.stream_delta_tx.send(delta);
}
fn list_sqlite_share_links_for_context(
fn list_control_plane_share_links_for_context(
state: &AppState,
context: &RequestContext,
query: ShareLinkListQuery,
@@ -1227,15 +1214,15 @@ fn list_sqlite_share_links_for_context(
let links = state
.control_plane()
.list_share_links(workspace_id)
.map_err(|error| WebError::internal(format!("SQLite 分享链接读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane 分享链接读取失败: {error}")))?;
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"links": links.iter().map(share_link_payload).collect::<Vec<_>>(),
}))
}
fn create_sqlite_share_link_for_context(
fn create_control_plane_share_link_for_context(
state: &AppState,
context: &RequestContext,
request: LocalShareLinkRequest,
@@ -1263,7 +1250,7 @@ fn create_sqlite_share_link_for_context(
created_by: context.auth.actor_id.trim().to_string(),
expires_at: request.expires_at,
})
.map_err(|error| WebError::internal(format!("SQLite 分享链接写入失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane 分享链接写入失败: {error}")))?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
action: "control.share.created".to_string(),
@@ -1286,13 +1273,13 @@ fn create_sqlite_share_link_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"token": created.token,
"link": share_link_payload(&created.link),
}))
}
fn revoke_sqlite_share_link_for_context(
fn revoke_control_plane_share_link_for_context(
state: &AppState,
context: &RequestContext,
link_id: &str,
@@ -1312,7 +1299,7 @@ fn revoke_sqlite_share_link_for_context(
control_plane::ControlPlaneError::NotFound(message) => {
WebError::new(StatusCode::NOT_FOUND, "share_link_not_found", message)
}
other => WebError::internal(format!("SQLite 分享链接撤销失败: {other}")),
other => WebError::internal(format!("control-plane 分享链接撤销失败: {other}")),
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
@@ -1330,7 +1317,7 @@ fn revoke_sqlite_share_link_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"revokedLinkId": link_id,
}))
}
@@ -1879,7 +1866,7 @@ fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool {
&& grant.root_uri.ends_with("/workspaces/my-space")
}
fn sqlite_access_policy_payload(
fn control_plane_access_policy_payload(
state: &AppState,
context: &RequestContext,
) -> Result<Value, WebError> {
@@ -1887,14 +1874,14 @@ fn sqlite_access_policy_payload(
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
let grant_values = grants
.iter()
.map(control_plane_grant_payload)
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policyPath": local_access_policy_path().display().to_string(),
"policy": {
"admins": policy.admins,
@@ -1907,7 +1894,7 @@ fn sqlite_access_policy_payload(
}))
}
fn sqlite_user_access_policy_payload(
fn control_plane_user_access_policy_payload(
state: &AppState,
context: &RequestContext,
) -> Result<Value, WebError> {
@@ -1922,7 +1909,7 @@ fn sqlite_user_access_policy_payload(
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
let grant_values = grants
.iter()
.filter(|grant| {
@@ -1933,7 +1920,7 @@ fn sqlite_user_access_policy_payload(
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policy": {
"grants": grant_values,
},
@@ -1956,7 +1943,7 @@ fn validate_local_access_root_for_context(
}))
}
fn add_sqlite_user_access_grant_for_context(
fn add_control_plane_user_access_grant_for_context(
state: &AppState,
context: &RequestContext,
mut request: LocalAccessGrantRequest,
@@ -1973,19 +1960,19 @@ fn add_sqlite_user_access_grant_for_context(
ensure_path_inside_owned_local_workspace(actor_id, &canonical)?;
request.root_uri = file_uri_for_path(&canonical);
request.root_path = canonical.display().to_string();
add_sqlite_local_access_grant_for_context_inner(state, context, request, "user")
add_control_plane_local_access_grant_for_context_inner(state, context, request, "user")
}
fn add_sqlite_local_access_grant_for_context(
fn add_control_plane_local_access_grant_for_context(
state: &AppState,
context: &RequestContext,
request: LocalAccessGrantRequest,
) -> Result<Value, WebError> {
let _policy = require_local_access_policy_admin(context)?;
add_sqlite_local_access_grant_for_context_inner(state, context, request, "admin")
add_control_plane_local_access_grant_for_context_inner(state, context, request, "admin")
}
fn add_sqlite_local_access_grant_for_context_inner(
fn add_control_plane_local_access_grant_for_context_inner(
state: &AppState,
context: &RequestContext,
request: LocalAccessGrantRequest,
@@ -2010,7 +1997,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
root_uri: Some(root_uri.clone()),
include_revoked: false,
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查重失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权查重失败: {error}")))?;
if duplicates.iter().any(|grant| {
grant.permission.trim() == permission
&& grant.recursive == request.recursive
@@ -2035,7 +2022,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
source: source.to_string(),
created_by: Some(context.auth.actor_id.trim().to_string()),
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权写入失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权写入失败: {error}")))?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
action: "control.grant.created".to_string(),
@@ -2058,14 +2045,14 @@ fn add_sqlite_local_access_grant_for_context_inner(
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
let grant_values = grants
.iter()
.map(control_plane_grant_payload)
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policyPath": local_access_policy_path().display().to_string(),
"grant": control_plane_grant_payload(&grant),
"policy": {
@@ -2074,7 +2061,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
}))
}
fn delete_sqlite_user_access_grant_for_context(
fn delete_control_plane_user_access_grant_for_context(
state: &AppState,
context: &RequestContext,
grant_id: &str,
@@ -2102,7 +2089,7 @@ fn delete_sqlite_user_access_grant_for_context(
root_uri: None,
include_revoked: false,
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权查找失败: {error}")))?;
let grant = grants.first().ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
@@ -2134,7 +2121,7 @@ fn delete_sqlite_user_access_grant_for_context(
"local_access_policy_grant_not_found",
message,
),
other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")),
other => WebError::internal(format!("control-plane授权撤销失败: {other}")),
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id.to_string()),
@@ -2152,14 +2139,14 @@ fn delete_sqlite_user_access_grant_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"deletedGrantId": grant_id,
"revokedUserId": revoked_user_id,
"policy": {
"grants": state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?
.iter()
.filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id))
.map(control_plane_grant_payload)
@@ -2168,7 +2155,7 @@ fn delete_sqlite_user_access_grant_for_context(
}))
}
fn delete_sqlite_local_access_grant_for_context(
fn delete_control_plane_local_access_grant_for_context(
state: &AppState,
context: &RequestContext,
grant_id: &str,
@@ -2189,7 +2176,7 @@ fn delete_sqlite_local_access_grant_for_context(
root_uri: None,
include_revoked: false,
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权查找失败: {error}")))?;
if grants
.first()
.map(is_default_workspace_auto_grant)
@@ -2211,7 +2198,7 @@ fn delete_sqlite_local_access_grant_for_context(
"local_access_policy_grant_not_found",
message,
),
other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")),
other => WebError::internal(format!("control-plane授权撤销失败: {other}")),
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
@@ -2229,7 +2216,7 @@ fn delete_sqlite_local_access_grant_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policyPath": local_access_policy_path().display().to_string(),
"deletedGrantId": grant_id,
"revokedUserId": revoked_user_id,
@@ -2237,7 +2224,7 @@ fn delete_sqlite_local_access_grant_for_context(
"grants": state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?
.iter()
.map(control_plane_grant_payload)
.collect::<Vec<_>>(),
@@ -2420,7 +2407,7 @@ pub async fn get_local_access_policy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = sqlite_access_policy_payload(&state, &context)
let payload = control_plane_access_policy_payload(&state, &context)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2429,7 +2416,7 @@ pub async fn get_user_access_policy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = sqlite_user_access_policy_payload(&state, &context)
let payload = control_plane_user_access_policy_payload(&state, &context)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2448,7 +2435,7 @@ pub async fn create_local_access_grant(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalAccessGrantRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = add_sqlite_local_access_grant_for_context(&state, &context, request)
let payload = add_control_plane_local_access_grant_for_context(&state, &context, request)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.pointer("/grant/userId")
@@ -2466,7 +2453,7 @@ pub async fn create_user_access_grant(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalAccessGrantRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = add_sqlite_user_access_grant_for_context(&state, &context, request)
let payload = add_control_plane_user_access_grant_for_context(&state, &context, request)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.pointer("/grant/userId")
@@ -2484,7 +2471,7 @@ pub async fn delete_local_access_grant(
Extension(context): Extension<RequestContext>,
AxumPath(grant_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = delete_sqlite_local_access_grant_for_context(&state, &context, &grant_id)
let payload = delete_control_plane_local_access_grant_for_context(&state, &context, &grant_id)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.get("revokedUserId")
@@ -2502,7 +2489,7 @@ pub async fn delete_user_access_grant(
Extension(context): Extension<RequestContext>,
AxumPath(grant_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = delete_sqlite_user_access_grant_for_context(&state, &context, &grant_id)
let payload = delete_control_plane_user_access_grant_for_context(&state, &context, &grant_id)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.get("revokedUserId")
@@ -2536,7 +2523,7 @@ pub async fn get_share_links(
Extension(context): Extension<RequestContext>,
Query(query): Query<ShareLinkListQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = list_sqlite_share_links_for_context(&state, &context, query)
let payload = list_control_plane_share_links_for_context(&state, &context, query)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2546,7 +2533,7 @@ pub async fn create_share_link(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalShareLinkRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = create_sqlite_share_link_for_context(&state, &context, request)
let payload = create_control_plane_share_link_for_context(&state, &context, request)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2556,7 +2543,7 @@ pub async fn delete_share_link(
Extension(context): Extension<RequestContext>,
AxumPath(link_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = revoke_sqlite_share_link_for_context(&state, &context, &link_id)
let payload = revoke_control_plane_share_link_for_context(&state, &context, &link_id)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -9578,7 +9565,7 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
#[cfg(test)]
mod tests {
use super::{
add_sqlite_local_access_grant_for_context,
add_control_plane_local_access_grant_for_context,
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
delete_share_link, delete_user_access_grant, editor_blocks_to_markdown_for_file,
@@ -11054,7 +11041,7 @@ fn main() {}
.expect("created grant id")
.to_string();
assert_eq!(created["grant"]["permission"], "read");
assert_eq!(created["controlPlane"], "sqlite");
assert_eq!(created["controlPlane"], "control-plane");
let created_events = state
.control_plane()
.drain_outbox(10)
@@ -11080,7 +11067,7 @@ fn main() {}
.expect_err("created read grant cannot write");
assert_eq!(write_error.status(), StatusCode::FORBIDDEN);
let duplicate_error = add_sqlite_local_access_grant_for_context(
let duplicate_error = add_control_plane_local_access_grant_for_context(
&state,
&request_context("admin_1", "user"),
LocalAccessGrantRequest {
@@ -11262,7 +11249,7 @@ fn main() {}
}
#[tokio::test]
async fn user_access_policy_grant_uses_sqlite_directory_grants_for_owned_folder() {
async fn user_access_policy_grant_uses_control_plane_directory_grants_for_owned_folder() {
let _guard = env_lock().lock().expect("env lock");
let owned_root = temp_root("mnote-user-access-policy-owned-root");
let outside_root = temp_root("mnote-user-access-policy-outside-root");
@@ -11304,7 +11291,7 @@ fn main() {}
.as_str()
.expect("grant id")
.to_string();
assert_eq!(created["controlPlane"], "sqlite");
assert_eq!(created["controlPlane"], "control-plane");
assert_eq!(created["grant"]["userId"], "user_target");
assert_eq!(created["grant"]["createdBy"], "user_owner");
assert_eq!(created["grant"]["permission"], "write");
@@ -11324,7 +11311,7 @@ fn main() {}
)
.await
.expect("owner can list self-created directory grants");
assert_eq!(listed["controlPlane"], "sqlite");
assert_eq!(listed["controlPlane"], "control-plane");
assert_eq!(listed["grants"][0]["id"], grant_id);
assert_eq!(listed["grants"][0]["permission"], "write");
@@ -11334,7 +11321,7 @@ fn main() {}
)
.await
.expect("target can list incoming directory grants");
assert_eq!(target_listed["controlPlane"], "sqlite");
assert_eq!(target_listed["controlPlane"], "control-plane");
assert_eq!(target_listed["grants"][0]["id"], grant_id);
assert_eq!(target_listed["grants"][0]["userId"], "user_target");
@@ -11452,7 +11439,7 @@ fn main() {}
}
#[tokio::test]
async fn share_link_api_creates_lists_and_revokes_sqlite_record() {
async fn share_link_api_creates_lists_and_revokes_control_plane_record() {
let _guard = env_lock().lock().expect("env lock");
let state = test_state();
std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1");
@@ -11488,7 +11475,7 @@ fn main() {}
.await
.expect("admin can create share link");
let link_id = created["link"]["id"].as_str().expect("link id").to_string();
assert_eq!(created["controlPlane"], "sqlite");
assert_eq!(created["controlPlane"], "control-plane");
assert_eq!(created["token"], "visible-token");
assert!(created["link"].get("tokenHash").is_none());
assert_eq!(created["link"]["permission"], "read");
@@ -11507,7 +11494,7 @@ fn main() {}
let stored = state
.control_plane()
.list_share_links(&workspace.id)
.expect("list sqlite share links");
.expect("list control-plane share links");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].id, link_id);
assert_ne!(stored[0].token_hash, "visible-token");
@@ -12691,30 +12678,30 @@ fn main() {}
}
#[tokio::test]
async fn local_file_open_allows_sqlite_directory_read_grant_without_json_policy() {
async fn local_file_open_allows_control_plane_directory_read_grant_without_json_policy() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-local-file-open-sqlite-read-grant-root");
let policy_root = temp_root("mnote-local-file-open-sqlite-read-grant-config");
let root = temp_root("mnote-local-file-open-control-plane-read-grant-root");
let policy_root = temp_root("mnote-local-file-open-control-plane-read-grant-config");
let policy_file = policy_root.join("missing-access-policy.json");
let state = test_state();
let root_uri = format!("file://{}", root.display());
std::fs::write(root.join("README.txt"), "hello sqlite").expect("write file");
std::fs::write(root.join("README.txt"), "hello control plane").expect("write file");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("sqlite_reader".into()),
email: Some("sqlite-reader@example.com".into()),
username: "sqlite_reader".into(),
display_name: "sqlite_reader".into(),
id: Some("control_plane_reader".into()),
email: Some("control-plane-reader@example.com".into()),
username: "control_plane_reader".into(),
display_name: "control_plane_reader".into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
.expect("upsert control-plane reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "sqlite_reader".into(),
user_id: "control_plane_reader".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
@@ -12728,9 +12715,9 @@ fn main() {}
source: "test".into(),
created_by: None,
})
.expect("grant sqlite read");
.expect("grant control-plane read");
let context = request_context("sqlite_reader", "user");
let context = request_context("control_plane_reader", "user");
let (_, _, bytes) = open_local_file(
State(state),
Extension(context),
@@ -12741,8 +12728,8 @@ fn main() {}
}),
)
.await
.expect("sqlite read grant can open local file");
assert_eq!(bytes, b"hello sqlite");
.expect("control-plane read grant can open local file");
assert_eq!(bytes, b"hello control plane");
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
@@ -12751,32 +12738,32 @@ fn main() {}
}
#[tokio::test]
async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() {
async fn local_file_open_allows_legacy_control_plane_directory_grant_with_path_root_uri() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root");
let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config");
let root = temp_root("mnote-local-file-open-legacy-control-plane-grant-root");
let policy_root = temp_root("mnote-local-file-open-legacy-control-plane-grant-config");
let policy_file = policy_root.join("missing-access-policy.json");
let state = test_state();
let canonical_root = root.canonicalize().expect("canonical root");
let canonical_root_path = canonical_root.display().to_string();
let root_uri = format!("file://{canonical_root_path}");
std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file");
std::fs::write(root.join("README.txt"), "hello legacy control plane").expect("write file");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("legacy_sqlite_reader".into()),
email: Some("legacy-sqlite-reader@example.com".into()),
username: "legacy_sqlite_reader".into(),
display_name: "legacy_sqlite_reader".into(),
id: Some("legacy_control_plane_reader".into()),
email: Some("legacy-control-plane-reader@example.com".into()),
username: "legacy_control_plane_reader".into(),
display_name: "legacy_control_plane_reader".into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
.expect("upsert control-plane reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "legacy_sqlite_reader".into(),
user_id: "legacy_control_plane_reader".into(),
workspace_id: None,
root_uri: canonical_root_path.clone(),
root_path: canonical_root_path,
@@ -12786,9 +12773,9 @@ fn main() {}
source: "legacy-test".into(),
created_by: None,
})
.expect("grant legacy sqlite read");
.expect("grant legacy control-plane read");
let context = request_context("legacy_sqlite_reader", "user");
let context = request_context("legacy_control_plane_reader", "user");
let (_, _, bytes) = open_local_file(
State(state),
Extension(context),
@@ -12799,8 +12786,8 @@ fn main() {}
}),
)
.await
.expect("legacy sqlite read grant can open local file");
assert_eq!(bytes, b"hello legacy sqlite");
.expect("legacy control-plane read grant can open local file");
assert_eq!(bytes, b"hello legacy control plane");
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
@@ -12809,10 +12796,10 @@ fn main() {}
}
#[tokio::test]
async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() {
async fn local_resource_write_allows_control_plane_directory_write_grant_without_json_policy() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-local-resource-write-sqlite-grant-root");
let policy_root = temp_root("mnote-local-resource-write-sqlite-grant-config");
let root = temp_root("mnote-local-resource-write-control-plane-grant-root");
let policy_root = temp_root("mnote-local-resource-write-control-plane-grant-config");
let policy_file = policy_root.join("missing-access-policy.json");
let state = test_state();
let root_uri = format!("file://{}", root.display());
@@ -12822,18 +12809,18 @@ fn main() {}
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("sqlite_writer".into()),
email: Some("sqlite-writer@example.com".into()),
username: "sqlite_writer".into(),
display_name: "sqlite_writer".into(),
id: Some("control_plane_writer".into()),
email: Some("control-plane-writer@example.com".into()),
username: "control_plane_writer".into(),
display_name: "control_plane_writer".into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite writer");
.expect("upsert control-plane writer");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "sqlite_writer".into(),
user_id: "control_plane_writer".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
@@ -12847,9 +12834,9 @@ fn main() {}
source: "test".into(),
created_by: None,
})
.expect("grant sqlite write");
.expect("grant control-plane write");
let context = request_context("sqlite_writer", "user");
let context = request_context("control_plane_writer", "user");
let (_, _, payload) = write_local_resource(
State(state),
Extension(context),
@@ -12868,7 +12855,7 @@ fn main() {}
}),
)
.await
.expect("sqlite write grant can write local resource");
.expect("control-plane write grant can write local resource");
assert_eq!(payload["ok"], true);
let written = std::fs::read_to_string(root.join("README.md")).expect("read file");
assert!(written.contains("new content"));
@@ -9,12 +9,15 @@ use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceIn
use core_protocol::EvidenceSearchMatchInfo;
#[cfg(test)]
use core_protocol::{EvidenceLocator, EvidenceSearchResult};
#[cfg(test)]
use core_protocol::{
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
};
#[cfg(test)]
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[cfg(test)]
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -28,6 +31,7 @@ const LOCAL_INDEX_SETTINGS_SCOPE_KIND: &str = "localIndex";
const LOCAL_INDEX_SOURCE_KIND: &str = "local_folder";
const DEFAULT_INDEX_SCHEDULE_MODE: &str = "daily";
const DEFAULT_INDEX_SCHEDULE_TIME: &str = "02:00";
#[cfg(test)]
const EVIDENCE_SQLITE_SCHEMA_VERSION: u32 = 1;
fn local_index_scope_id(root_path: &Path) -> String {
@@ -460,7 +464,6 @@ fn local_index_status_for_settings(
.join(".mnote")
.join("index")
.join("search-index.json");
let evidence_path = evidence_sqlite_path(root_path);
let mut document_count = 0usize;
let mut resource_count = 0usize;
let mut built_at = Value::Null;
@@ -478,21 +481,15 @@ fn local_index_status_for_settings(
cache_matches_settings = settings.include_paths.is_empty();
local_index_schedule_is_due(&settings, 0)
};
let evidence_block_count = if evidence_path.exists() {
count_evidence_blocks(&evidence_path).unwrap_or(0)
} else {
0
};
Ok(json!({
"schema": "mnote.local_index.status.v1",
"rootUri": root_uri,
"workspaceId": workspace_id,
"settings": settings,
"indexPath": ".mnote/index/search-index.json",
"evidenceIndexPath": ".mnote/index/evidence.sqlite",
"settingsPath": ".mnote/index/local-index-settings.json",
"indexExists": index_path.exists(),
"evidenceIndexExists": evidence_path.exists(),
"evidenceIndexRetired": true,
"cacheMatchesSettings": cache_matches_settings,
"scheduledDue": scheduled_due,
"builtAt": built_at,
@@ -502,7 +499,6 @@ fn local_index_status_for_settings(
.unwrap_or_default(),
"documentCount": document_count,
"resourceCount": resource_count,
"evidenceBlockCount": evidence_block_count,
}))
}
@@ -1233,6 +1229,7 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
index.built_at = now_ms();
write_local_search_index_json(root_path, &index)?;
let _ = included;
#[cfg(test)]
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
return Ok(json!({
"version": index.version,
@@ -1283,6 +1280,7 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
.sort_by(|left, right| left.path.cmp(&right.path));
index.built_at = now_ms();
write_local_search_index_json(root_path, &index)?;
#[cfg(test)]
if included {
refresh_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
} else {
@@ -1518,6 +1516,7 @@ fn load_or_rebuild_local_search_index_with_settings(
settings,
);
}
#[cfg(test)]
ensure_evidence_sqlite_index(root_path, &index)?;
Ok(index)
}
@@ -2108,7 +2107,11 @@ fn index_resource_file(root_path: &Path, path: &Path) -> Result<LocalSearchResou
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
write_local_search_index_json(root_path, index)?;
write_evidence_sqlite_index(root_path, index)
#[cfg(test)]
{
write_evidence_sqlite_index(root_path, index)?;
}
Ok(())
}
fn write_local_search_index_json(
@@ -2138,15 +2141,32 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
.join(".mnote")
.join("index")
.join("search-index.json");
let evidence_path = evidence_sqlite_path(root_path);
for path in [index_path, evidence_path] {
match fs::remove_file(&path) {
match fs::remove_file(&index_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(WebError::bad_request_code(
"local_search_index_delete_failed",
format!(
"无法删除本地搜索索引文件 {}: {error}",
index_path.display()
),
));
}
}
#[cfg(test)]
{
let evidence_path = evidence_sqlite_path(root_path);
match fs::remove_file(&evidence_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(WebError::bad_request_code(
"local_search_index_delete_failed",
format!("无法删除本地搜索索引文件 {}: {error}", path.display()),
format!(
"无法删除本地 evidence 测试索引文件 {}: {error}",
evidence_path.display()
),
));
}
}
@@ -2154,6 +2174,7 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
Ok(())
}
#[cfg(test)]
fn ensure_evidence_sqlite_index(
root_path: &Path,
index: &LocalSearchIndex,
@@ -2165,6 +2186,7 @@ fn ensure_evidence_sqlite_index(
write_evidence_sqlite_index(root_path, index)
}
#[cfg(test)]
fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
let Some(parent) = evidence_sqlite_path(root_path)
.parent()
@@ -2220,6 +2242,7 @@ fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Re
tx.commit().map_err(sqlite_error)
}
#[cfg(test)]
fn create_evidence_schema(connection: &Connection) -> Result<(), WebError> {
connection
.execute_batch(
@@ -2279,6 +2302,7 @@ CREATE TABLE IF NOT EXISTS evidence_edge(
.map_err(sqlite_error)
}
#[cfg(test)]
fn refresh_evidence_sqlite_index_for_path(
root_path: &Path,
index: &LocalSearchIndex,
@@ -2338,6 +2362,7 @@ fn refresh_evidence_sqlite_index_for_path(
tx.commit().map_err(sqlite_error)
}
#[cfg(test)]
fn remove_evidence_sqlite_index_for_path(
root_path: &Path,
index: &LocalSearchIndex,
@@ -2365,6 +2390,7 @@ fn remove_evidence_sqlite_index_for_path(
tx.commit().map_err(sqlite_error)
}
#[cfg(test)]
fn affected_evidence_resource_ids(index: &LocalSearchIndex, relative_path: &str) -> Vec<String> {
let mut ids = Vec::new();
let document_id = format!("local-md:{}", encode_local_id_segment(relative_path));
@@ -2393,6 +2419,7 @@ fn affected_evidence_resource_ids(index: &LocalSearchIndex, relative_path: &str)
ids
}
#[cfg(test)]
fn existing_evidence_resource_ids_for_path(
connection: &Connection,
relative_path: &str,
@@ -2413,6 +2440,7 @@ fn existing_evidence_resource_ids_for_path(
Ok(resource_ids)
}
#[cfg(test)]
fn delete_evidence_resource_projection(
connection: &Connection,
resource_id: &str,
@@ -2466,6 +2494,7 @@ fn delete_evidence_resource_projection(
.map_err(sqlite_error)
}
#[cfg(test)]
fn insert_document_evidence(
connection: &Connection,
index: &LocalSearchIndex,
@@ -2521,6 +2550,7 @@ fn insert_document_evidence(
Ok(())
}
#[cfg(test)]
fn markdown_parsed_artifact(document: &LocalSearchDocument) -> ParsedResourceArtifact {
ParsedResourceArtifact {
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
@@ -2536,6 +2566,7 @@ fn markdown_parsed_artifact(document: &LocalSearchDocument) -> ParsedResourceArt
}
}
#[cfg(test)]
fn insert_evidence_resource_from_artifact(
connection: &Connection,
resource_id: &str,
@@ -2555,6 +2586,7 @@ fn insert_evidence_resource_from_artifact(
)
}
#[cfg(test)]
fn insert_resource_evidence(
connection: &Connection,
root_path: &Path,
@@ -2606,6 +2638,7 @@ fn insert_resource_evidence(
Ok(())
}
#[cfg(test)]
fn parse_resource_evidence_artifact(
_root_path: &Path,
_index: &LocalSearchIndex,
@@ -2614,10 +2647,12 @@ fn parse_resource_evidence_artifact(
Ok(None)
}
#[cfg(test)]
fn parsed_resource_id(resource: &LocalSearchResource) -> String {
format!("{}#parse", resource.resource_id)
}
#[cfg(test)]
fn insert_source_map_artifact_evidence(
connection: &Connection,
index: &LocalSearchIndex,
@@ -2682,6 +2717,7 @@ fn insert_source_map_artifact_evidence(
Ok(())
}
#[cfg(test)]
fn insert_source_map_evidence_section(
connection: &Connection,
resource_id: &str,
@@ -2709,6 +2745,7 @@ fn insert_source_map_evidence_section(
.map_err(sqlite_error)
}
#[cfg(test)]
fn source_map_section_path_for_block(
source_map: &ResourceSourceMap,
block_id: &str,
@@ -2721,6 +2758,7 @@ fn source_map_section_path_for_block(
.unwrap_or_default()
}
#[cfg(test)]
fn source_map_block_locator(
index: &LocalSearchIndex,
artifact: &ParsedResourceArtifact,
@@ -2766,6 +2804,7 @@ fn source_map_block_locator(
})
}
#[cfg(test)]
fn empty_source_map_block(text: &str) -> SourceMapBlock {
SourceMapBlock {
id: "artifact".into(),
@@ -2776,6 +2815,7 @@ fn empty_source_map_block(text: &str) -> SourceMapBlock {
}
}
#[cfg(test)]
fn insert_evidence_resource(
connection: &Connection,
resource_id: &str,
@@ -2807,6 +2847,7 @@ fn insert_evidence_resource(
.map_err(sqlite_error)
}
#[cfg(test)]
fn insert_evidence_block(
connection: &Connection,
block_id: &str,
@@ -2825,6 +2866,7 @@ fn insert_evidence_block(
)
}
#[cfg(test)]
fn insert_evidence_block_with_metadata(
connection: &Connection,
block_id: &str,
@@ -2871,6 +2913,7 @@ fn insert_evidence_block_with_metadata(
}
#[derive(Debug, Clone)]
#[cfg(test)]
struct MarkdownEvidenceBlock {
block_id: String,
text: String,
@@ -2879,6 +2922,7 @@ struct MarkdownEvidenceBlock {
}
#[derive(Debug, Clone)]
#[cfg(test)]
struct MarkdownEvidenceSection {
section_id: String,
title: String,
@@ -2887,6 +2931,7 @@ struct MarkdownEvidenceSection {
parent_section_id: Option<String>,
}
#[cfg(test)]
fn markdown_evidence_blocks(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceBlock> {
let mut headings: Vec<String> = Vec::new();
let mut blocks = Vec::new();
@@ -2910,6 +2955,7 @@ fn markdown_evidence_blocks(document: &LocalSearchDocument) -> Vec<MarkdownEvide
blocks
}
#[cfg(test)]
fn markdown_evidence_sections(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceSection> {
let mut headings: Vec<String> = Vec::new();
let mut sections = Vec::new();
@@ -2945,6 +2991,7 @@ fn markdown_evidence_sections(document: &LocalSearchDocument) -> Vec<MarkdownEvi
sections
}
#[cfg(test)]
fn markdown_heading(line: &str) -> Option<(usize, String)> {
let trimmed = line.trim_start();
let marker_count = trimmed.chars().take_while(|value| *value == '#').count();
@@ -2958,6 +3005,7 @@ fn markdown_heading(line: &str) -> Option<(usize, String)> {
Some((marker_count, rest.trim_matches('#').trim().to_string()))
}
#[cfg(test)]
fn insert_markdown_evidence_section(
connection: &Connection,
resource_id: &str,
@@ -2985,6 +3033,7 @@ fn insert_markdown_evidence_section(
.map_err(sqlite_error)
}
#[cfg(test)]
fn insert_document_graph_edges(
connection: &Connection,
document: &LocalSearchDocument,
@@ -3029,6 +3078,7 @@ fn insert_document_graph_edges(
Ok(())
}
#[cfg(test)]
fn first_markdown_evidence_block_id(document: &LocalSearchDocument) -> String {
document
.raw_text
@@ -3039,6 +3089,7 @@ fn first_markdown_evidence_block_id(document: &LocalSearchDocument) -> String {
.unwrap_or_else(|| format!("{}#line1", document.document_id))
}
#[cfg(test)]
fn insert_evidence_edge(
connection: &Connection,
from_id: &str,
@@ -3073,6 +3124,7 @@ fn insert_evidence_edge(
.map_err(sqlite_error)
}
#[cfg(test)]
fn markdown_reference_target_id(owner_path: &str, target: &str) -> String {
let target = target.trim();
if target.starts_with("local-md:") {
@@ -3087,6 +3139,7 @@ fn markdown_reference_target_id(owner_path: &str, target: &str) -> String {
format!("markdown-title:{}", encode_local_id_segment(target))
}
#[cfg(test)]
fn resource_reference_target_id(owner_path: &str, target: &str) -> String {
format!(
"local-resource:{}",
@@ -3095,11 +3148,13 @@ fn resource_reference_target_id(owner_path: &str, target: &str) -> String {
}
#[derive(Debug, Clone)]
#[cfg(test)]
struct MarkdownMentionEdge {
target_id: String,
source_block_id: String,
}
#[cfg(test)]
fn markdown_mention_edges(document: &LocalSearchDocument) -> Vec<MarkdownMentionEdge> {
let mut seen = std::collections::BTreeSet::new();
let mut mentions = Vec::new();
@@ -3118,6 +3173,7 @@ fn markdown_mention_edges(document: &LocalSearchDocument) -> Vec<MarkdownMention
mentions
}
#[cfg(test)]
fn extract_line_mentions(line: &str) -> Vec<String> {
let chars = line.chars().collect::<Vec<_>>();
let mut mentions = Vec::new();
@@ -3151,6 +3207,7 @@ fn extract_line_mentions(line: &str) -> Vec<String> {
mentions
}
#[cfg(test)]
fn markdown_locator(
index: &LocalSearchIndex,
document_id: &str,
@@ -3177,6 +3234,7 @@ fn markdown_locator(
})
}
#[cfg(test)]
fn evidence_sqlite_path(root_path: &Path) -> PathBuf {
root_path
.join(".mnote")
@@ -3184,6 +3242,8 @@ fn evidence_sqlite_path(root_path: &Path) -> PathBuf {
.join("evidence.sqlite")
}
#[cfg(test)]
#[allow(dead_code)]
fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
let connection = Connection::open(path).map_err(|error| {
WebError::bad_request_code(
@@ -3198,6 +3258,7 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
.map_err(sqlite_error)
}
#[cfg(test)]
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub(crate) struct LocalEvidenceSourceStatuses {
@@ -3205,6 +3266,7 @@ pub(crate) struct LocalEvidenceSourceStatuses {
pub(crate) failed_paths: BTreeSet<String>,
}
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn local_evidence_source_statuses(
root_path: &Path,
@@ -3251,6 +3313,7 @@ pub(crate) fn local_evidence_source_statuses(
Ok(statuses)
}
#[cfg(test)]
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
match resource_type {
"mindmap" => "mindmap",
@@ -3262,6 +3325,7 @@ fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
}
}
#[cfg(test)]
fn evidence_resource_kind_for_path(path: &str) -> &'static str {
match Path::new(path)
.extension()
@@ -3315,6 +3379,7 @@ fn is_local_evidence_sidecar_relative_path(relative_path: &str) -> bool {
&& (file_name.ends_with(".parse.md") || file_name.ends_with(".source-map.json"))
}
#[cfg(test)]
fn normalize_local_reference_path(owner_path: &str, target: &str) -> String {
let target = target
.split('#')
@@ -3333,6 +3398,7 @@ fn normalize_local_reference_path(owner_path: &str, target: &str) -> String {
normalize_path_components(owner_parent.join(target))
}
#[cfg(test)]
fn normalize_path_components(path: PathBuf) -> String {
let mut parts = Vec::new();
for component in path.components() {
@@ -3348,6 +3414,7 @@ fn normalize_path_components(path: PathBuf) -> String {
parts.join("/")
}
#[cfg(test)]
fn sqlite_error(error: rusqlite::Error) -> WebError {
WebError::bad_request_code(
"evidence_index_sqlite_error",
+3
View File
@@ -86,6 +86,9 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell))
.route("/knowledge", get(gateway::root_entry))
.route("/knowledge/{*path}", get(gateway::root_entry))
.route("/debug/knowledge-rag", get(gateway::root_entry))
.route("/api/evidence/search", post(evidence::search))
.route("/api/evidence/read", post(evidence::read))
.route("/api/evidence/open", post(evidence::open))
@@ -9,6 +9,7 @@ use axum::response::Response;
use axum::{Extension, Json};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use control_plane::session_token_hash;
use futures_util::TryStreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::hash_map::DefaultHasher;
@@ -364,6 +365,29 @@ fn mnote_user_id(state: &AppState, context: &RequestContext) -> String {
"anonymous".to_string()
}
fn build_allowed_roots(state: &AppState, user_id: &str) -> Vec<Value> {
state
.control_plane()
.list_directory_grants_for_actor(user_id)
.unwrap_or_default()
.into_iter()
.filter(|grant| grant.status.trim() == "active")
.filter(|grant| !grant.root_uri.trim().is_empty())
.map(|grant| {
json!({
"grantId": grant.id,
"workspaceId": grant.workspace_id,
"rootUri": grant.root_uri,
"rootPath": grant.root_path,
"permission": grant.permission,
"recursive": grant.recursive,
"capabilities": serde_json::from_str::<Vec<String>>(&grant.capabilities_json).unwrap_or_default(),
"source": grant.source,
})
})
.collect()
}
fn text_or_default(value: Option<&str>, fallback: &str) -> String {
value
.map(str::trim)
@@ -378,6 +402,56 @@ fn file_path_from_root_uri(root_uri: &str) -> Option<PathBuf> {
(!file_path.trim().is_empty()).then(|| PathBuf::from(file_path))
}
fn default_openhub_workspace_root_uri(user_id: &str) -> String {
format!("file:///mnt/Data1T/Mnote_data/users/{user_id}/workspaces/my-space")
}
fn preferred_openhub_root_uri(root: &Value) -> Option<String> {
root.get("rootUri")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with("file://"))
.map(ToOwned::to_owned)
.or_else(|| {
root.get("rootPath")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("file://{value}"))
})
.or_else(|| {
root.get("rootUri")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn default_allowed_roots_for_scope(default_allowed_roots: &[Value], root_uri: &str) -> Value {
let root_uri = root_uri.trim();
let matched_root = default_allowed_roots
.iter()
.find(|root| {
preferred_openhub_root_uri(root)
.as_deref()
.is_some_and(|candidate| candidate == root_uri)
})
.cloned();
if let Some(root) = matched_root {
return json!([root]);
}
json!([{
"rootUri": root_uri,
"permission": "write",
"recursive": true,
"capabilities": ["ai", "read", "write"],
"source": "mnote_openhub_default_root",
}])
}
fn artifact_index_store_path(root_uri: Option<&str>) -> PathBuf {
if let Ok(path) = std::env::var("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") {
let path = path.trim();
@@ -554,6 +628,12 @@ fn build_scope(
request: &OpenHubBootstrapRequest,
) -> Value {
let mnote_user_id = mnote_user_id(state, context);
let mnote_display_name = crate::routes::gateway::current_actor_display_name(state, context);
let default_allowed_roots = build_allowed_roots(state, &mnote_user_id);
let default_root_uri = default_allowed_roots
.iter()
.find_map(preferred_openhub_root_uri)
.unwrap_or_else(|| default_openhub_workspace_root_uri(&mnote_user_id));
let workspace_id = text_or_default(
request
.workspace_id
@@ -561,7 +641,7 @@ fn build_scope(
.or(context.workspace.workspace_id.as_deref()),
"default-workspace",
);
let root_uri = text_or_default(request.root_uri.as_deref(), "");
let root_uri = text_or_default(request.root_uri.as_deref(), &default_root_uri);
let page_resource_id = text_or_default(
request
.page_id
@@ -583,13 +663,14 @@ fn build_scope(
"pageAbsolutePath": request.page_absolute_path.as_deref(),
"pageTitle": request.page_title.as_deref(),
});
let allowed_roots = request
.allowed_roots
.as_ref()
.cloned()
.unwrap_or_else(|| default_allowed_roots_for_scope(&default_allowed_roots, &root_uri));
let weknora_seed = format!(
"{openhub_user_key}\n{openhub_workspace_key}\n{}",
request
.allowed_roots
.as_ref()
.map(Value::to_string)
.unwrap_or_else(|| "[]".to_string())
allowed_roots.to_string()
);
let weknora_tool_scope = stable_hash("weknora_tool", &weknora_seed);
json!({
@@ -597,6 +678,8 @@ fn build_scope(
"authTruth": "mnote_session",
"rejectsFrontendAuthTruth": ["openhub_jwt", "openhub_localStorage", "openhub_login_page"],
"mnoteUserId": mnote_user_id,
"mnoteActorId": context.auth.actor_id,
"mnoteDisplayName": mnote_display_name,
"openhubUserKey": openhub_user_key,
"openhub_user_key": openhub_user_key,
"openhubWorkspaceKey": openhub_workspace_key,
@@ -612,7 +695,7 @@ fn build_scope(
"weknoraToolScope": weknora_tool_scope,
"weknora_tool_scope": weknora_tool_scope,
"workspaceScope": root_scope,
"allowedRoots": request.allowed_roots.as_ref().cloned().unwrap_or_else(|| json!([])),
"allowedRoots": allowed_roots,
"selectionPresent": request.selection.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
})
}
@@ -683,6 +766,12 @@ fn add_mnote_scope_headers(
if let Some(value) = header_string(scope.get("openhub_user_key")) {
request = request.header(HeaderName::from_static("x-mnote-user-key"), value);
}
if let Some(value) = header_string(scope.get("mnoteUserId")) {
request = request.header(HeaderName::from_static("x-mnote-user-id"), value);
}
if let Some(value) = header_string(scope.get("mnoteDisplayName")) {
request = request.header(HeaderName::from_static("x-mnote-display-name"), value);
}
if let Some(value) = header_string(scope.get("workspace_key")) {
request = request.header(HeaderName::from_static("x-mnote-workspace-key"), value);
}
@@ -754,7 +843,7 @@ pub async fn status(
let openhub_fastapi = probe_service(
"openhub_fastapi",
openhub_base.clone(),
"/global/health",
"/api/health",
openhub_configured,
)
.await;
@@ -1095,6 +1184,22 @@ fn response_with_status(status: StatusCode, body: String) -> Response {
.unwrap_or_else(|_| Response::new(Body::from("openhub proxy response build failed")))
}
fn is_streaming_response(headers: &HeaderMap) -> bool {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
content_type.contains("text/event-stream")
|| content_type.contains("application/x-ndjson")
|| headers
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase()
.contains("no-cache")
}
pub async fn ai_proxy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1116,7 +1221,7 @@ pub async fn ai_proxy(
let upstream_method =
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
@@ -1162,6 +1267,28 @@ pub async fn ai_proxy(
})?;
let status = upstream.status();
let headers = upstream.headers().clone();
if is_streaming_response(&headers) {
let mut builder = Response::builder().status(status);
let response_headers = strip_hop_headers(&headers);
for (name, value) in response_headers.iter() {
builder = builder.header(name, value);
}
builder = builder
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header("x-accel-buffering", "no");
let stream = upstream.bytes_stream().map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("OpenHub AI proxy 流式响应读取失败:{error}"),
)
});
return Ok(builder.body(Body::from_stream(stream)).unwrap_or_else(|_| {
response_with_status(
StatusCode::BAD_GATEWAY,
"OpenHub AI proxy 流式响应构造失败".to_string(),
)
}));
}
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
+6 -6
View File
@@ -82,7 +82,7 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
email: resolved.user.email.unwrap_or_default(),
name: resolved.user.display_name,
actor_type: effective_actor_type_for_user(&resolved.user.id, &resolved.user.role),
auth_mode: "sqliteSession",
auth_mode: "controlPlaneSession",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
@@ -321,7 +321,7 @@ mod tests {
}
#[tokio::test]
async fn session_prefers_sqlite_cookie_identity_over_dev_fallback() {
async fn session_prefers_control_plane_cookie_identity_over_dev_fallback() {
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
@@ -386,11 +386,11 @@ mod tests {
assert_eq!(payload["email"], "shujuan@163.com");
assert_eq!(payload["name"], "shujuan");
assert_eq!(payload["actorType"], "user");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
async fn session_returns_admin_for_sqlite_admin_user() {
async fn session_returns_admin_for_control_plane_admin_user() {
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
@@ -452,7 +452,7 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
@@ -535,7 +535,7 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
@@ -79,7 +79,7 @@ pub(crate) async fn get_tree_view_state(
Some(&scope.source_kind),
)
.map_err(|error| {
WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
WebError::internal(format!("control-plane tree view state 读取失败: {error}"))
.with_context(&context)
})?;
let Some(record) = find_tree_view_state_record(&preferences, &scope) else {
@@ -92,14 +92,14 @@ pub(crate) async fn get_tree_view_state(
)));
};
let parsed = serde_json::from_str::<Value>(&record.value_json).map_err(|error| {
WebError::internal(format!("SQLite tree view state JSON 无效: {error}"))
WebError::internal(format!("control-plane tree view state JSON 无效: {error}"))
.with_context(&context)
})?;
let normalized = normalize_state(&context, &scope, parsed)?;
Ok(Json(response_payload(
&actor_id,
&scope,
"sqlite",
"control-plane",
Some(record.revision),
normalized,
)))
@@ -143,7 +143,7 @@ pub(crate) async fn put_tree_view_state(
Ok(Json(response_payload(
&actor_id,
&scope,
"sqlite",
"control-plane",
Some(record.revision),
normalized,
)))
@@ -3287,7 +3287,7 @@ fn annotate_local_attachment_refs_authorization(
.control_plane()
.resolve_access(&context.auth.actor_id, resolved_uri)
.map_err(|error| {
WebError::internal(format!("SQLite 控制面附件授权解析失败: {error}"))
WebError::internal(format!("control-plane 附件授权解析失败: {error}"))
})?;
object.insert(
"authorized".to_string(),
@@ -3816,7 +3816,7 @@ mod tests {
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
.expect("upsert control-plane reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
@@ -3834,7 +3834,7 @@ mod tests {
source: "unit-test".into(),
created_by: None,
})
.expect("grant sqlite read");
.expect("grant control-plane read");
}
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
@@ -4706,7 +4706,7 @@ mod tests {
}
#[tokio::test]
async fn page_aggregate_endpoint_allows_sqlite_granted_local_folder_read_access() {
async fn page_aggregate_endpoint_allows_control_plane_granted_local_folder_read_access() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-grant-{}",
std::process::id()
@@ -4753,7 +4753,7 @@ mod tests {
Some(&root_uri),
)
.await
.expect("sqlite read grant can open local page aggregate");
.expect("control-plane read grant can open local page aggregate");
let _ = std::fs::remove_dir_all(&root);
@@ -4766,7 +4766,7 @@ mod tests {
}
#[tokio::test]
async fn local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants() {
async fn local_page_aggregate_marks_attachment_refs_authorization_from_control_plane_grants() {
let root = temp_root("mnote-local-page-aggregate-attachment-auth-root");
let allowed_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-allowed");
let denied_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-denied");
@@ -4935,7 +4935,7 @@ mod tests {
}
#[tokio::test]
async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() {
async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-ui-pref-{}",
std::process::id()
@@ -5118,7 +5118,7 @@ mod tests {
.await
.expect("body");
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
assert_eq!(get_payload["result"]["source"], "sqlite");
assert_eq!(get_payload["result"]["source"], "control-plane");
assert_eq!(
get_payload["result"]["state"]["expandedRelativePaths"][0],
"design/05-editor-mainline"
+2 -2
View File
@@ -20,7 +20,7 @@ pub fn AuthPage() -> impl IntoView {
<h1 id="mnote-auth-title">"账号登录"</h1>
<p>"登录后进入你的工作区"</p>
</div>
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="sqlite-session">
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="control-plane-session">
<input type="hidden" name="action" value="auth:signIn" />
<input type="hidden" name="provider" value="password" />
<input type="hidden" name="flow" value="signIn" data-auth-flow />
@@ -95,7 +95,7 @@ const AUTH_SCRIPT: &str = r#"
(function () {
var root = document.querySelector('[data-testid="mnote-auth-page"]');
if (!root) return;
var form = root.querySelector('form[data-auth-mode="sqlite-session"]');
var form = root.querySelector('form[data-auth-mode="control-plane-session"]');
var flowInput = root.querySelector('[data-auth-flow]');
var accountInput = root.querySelector('#account');
var emailInput = root.querySelector('#email');
+13 -6
View File
@@ -192,7 +192,6 @@ pub fn PageLayout(
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-state="closed" aria-haspopup="true" aria-expanded="false" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="资料库问答" aria-label="资料库问答" data-testid="mnote-knowledge-rag-settings-toggle" data-mnote-action="open-knowledge-rag-settings"><span class="material-symbols-outlined" data-icon="travel_explore" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
@@ -204,6 +203,7 @@ pub fn PageLayout(
{children()}
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<a href="/knowledge" data-testid="mnote-floating-knowledge" class="wolai-floating-button wolai-floating-button--knowledge" aria-label="知识库" title="打开知识库" data-mnote-action="open-knowledge-host"><span class="material-symbols-outlined material-symbols-filled" data-icon="travel_explore" aria-hidden="true"></span></a>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" data-state="closed" aria-haspopup="dialog" aria-expanded="false" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
</div>
</div>
@@ -547,8 +547,15 @@ mod tests {
assert!(!html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
assert!(!html.contains(r#"data-mnote-action="open-index-settings""#));
assert!(html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(!html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#));
assert!(html.contains(r#"data-testid="mnote-floating-knowledge""#));
assert!(html.contains(r#"href="/knowledge""#));
assert!(html.contains(r#"data-mnote-action="open-knowledge-host""#));
assert!(
html.find(r#"data-testid="mnote-floating-knowledge""#)
< html.find(r#"data-testid="wolai-floating-ai""#)
);
assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(html.contains(r#"data-icon="travel_explore""#));
assert!(crate::ssr::styles::MNOTE_CSS.contains(r#"data-icon="travel_explore""#));
assert!(!html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
@@ -595,9 +602,9 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localOcrAutoEnabled"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-mnote-local-ocr-auto-retired"));
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]")
);
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.href = '/knowledge'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("open-knowledge-rag-debug-settings"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/debug/knowledge-rag"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-knowledge-rag-action]"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("useKnowledgeRagFileTreeSelection"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("reindex-source"));
@@ -2340,9 +2340,11 @@
}
.wolai-floating-actions {
position: fixed;
display: flex;
right: 24px;
bottom: 44px;
flex-direction: column-reverse;
flex-direction: column;
gap: 16px;
z-index: var(--wolai-z-dock);
}
@@ -2366,7 +2368,8 @@
font-size: 18px;
}
.wolai-floating-button--ai {
.wolai-floating-button--ai,
.wolai-floating-button--knowledge {
width: 40px;
height: 40px;
border: 1px solid rgba(27, 28, 28, 0.1);
@@ -2376,6 +2379,10 @@
font-size: 18px;
}
.wolai-floating-button--knowledge {
text-decoration: none;
}
.document-shell[data-page-wide-layout="true"] {
width: min(100%, 980px);
}
@@ -2614,7 +2621,7 @@
.mnote-weknora-kb-page-body {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
min-height: 620px;
min-height: min(620px, calc(100vh - 190px));
max-height: calc(100vh - 150px);
}
@@ -2869,6 +2876,39 @@
line-height: 18px;
}
.mnote-knowledge-rag-detail-tabs {
display: inline-flex;
width: fit-content;
max-width: 100%;
gap: 3px;
padding: 3px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-knowledge-rag-detail-tab {
min-height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: #4B5563;
cursor: pointer;
font-size: 12px;
line-height: 18px;
padding: 0 10px;
}
.mnote-knowledge-rag-detail-tab.is-active {
background: #EAF7EA;
color: #166534;
font-weight: 600;
}
.mnote-knowledge-rag-detail-panel[hidden] {
display: none;
}
.mnote-weknora-doc-toolbar,
.mnote-weknora-doc-actions {
display: flex;
@@ -3063,21 +3103,52 @@
}
.mnote-knowledge-rag-meta {
display: flex;
flex-direction: column;
gap: 5px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px;
}
.mnote-knowledge-rag-meta div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
color: #8B8782;
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
color: #6B7280;
font-size: 12px;
line-height: 18px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
padding: 0;
overflow: hidden;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div {
border: 0;
border-right: 1px solid #E5E7EB;
border-radius: 0;
background: transparent;
padding: 10px 12px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div:last-child {
border-right: 0;
}
.mnote-knowledge-rag-meta strong {
color: #111827;
font-size: 15px;
line-height: 22px;
}
.mnote-knowledge-rag-meta code {
min-width: 0;
overflow: hidden;
@@ -3140,6 +3211,15 @@
gap: 8px;
}
.mnote-knowledge-rag-source-row {
padding: 10px 12px;
border-bottom: 1px solid #EEF0F2;
}
.mnote-knowledge-rag-source-row:last-child {
border-bottom: 0;
}
.mnote-knowledge-rag-source-input-row {
grid-template-columns: minmax(0, 1fr) 56px 28px;
}
@@ -3249,6 +3329,7 @@
.mnote-knowledge-rag-source-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
@@ -3274,6 +3355,23 @@
opacity: 0.45;
}
@media (max-width: 900px) {
.mnote-weknora-kb-page-body,
.mnote-weknora-kb-detail-hero,
.mnote-weknora-doc-layout {
grid-template-columns: 1fr;
}
.mnote-weknora-kb-list-pane {
border-right: 0;
border-bottom: 1px solid #E5E7EB;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.wolai-page-settings-tabs {
display: flex;
gap: 6px;