feat(control-plane): add libSQL Turso backend

This commit is contained in:
Agent Board
2026-07-03 13:24:20 +08:00
parent ee6612028d
commit a75b3d11f9
44 changed files with 11070 additions and 666 deletions
@@ -0,0 +1,600 @@
use crate::app::AppState;
use crate::error::WebError;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use control_plane::{
AppendAiRuntimeEventInput, CreatePasswordIdentityInput, DirectoryGrantInput,
UpsertAiRuntimeRunInput, UpsertUserInput, UpsertWorkspaceInput,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DevSeedRequest {
seeds: Vec<DevSeedOperation>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum DevSeedOperation {
SetupWorkspace {
user_id: String,
#[serde(default)]
email: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
password: Option<String>,
workspace_id: String,
workspace_name: String,
root_uri: String,
root_path: String,
#[serde(default)]
source_kind: Option<String>,
#[serde(default)]
permission: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
grant_source: Option<String>,
#[serde(default)]
grant_created_by: Option<String>,
},
SeedAiRuntime {
#[serde(default)]
id: Option<String>,
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
#[serde(default)]
document_id: Option<String>,
session_id: String,
run_id: String,
#[serde(default)]
title: Option<String>,
profile: String,
acp_runtime: String,
#[serde(default)]
trace_id: Option<String>,
status: String,
#[serde(default = "empty_json_object")]
runtime_json: Value,
#[serde(default = "empty_json_object")]
payload_json: Value,
#[serde(default)]
events: Vec<DevSeedRuntimeEvent>,
},
ClearRuntimeEvents {
user_id: String,
run_id: String,
},
GetAiRuntimeRun {
user_id: String,
run_id: String,
},
ListAiRuntimeRuns {
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
#[serde(default)]
document_id: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default = "default_dev_seed_limit")]
limit: usize,
},
ListAiRuntimeEvents {
user_id: String,
run_id: String,
#[serde(default = "default_dev_seed_limit")]
limit: usize,
#[serde(default)]
event_type: Option<String>,
},
CountAiRuntimeEvents {
user_id: String,
run_id: String,
#[serde(default)]
event_type: Option<String>,
},
FindExternalConversationBinding {
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
mnote_session_id: String,
provider: String,
},
ListExternalConversationBindings {
user_id: String,
#[serde(default)]
workspace_id: Option<String>,
mnote_session_id: String,
#[serde(default = "default_dev_seed_limit")]
limit: usize,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DevSeedRuntimeEvent {
#[serde(default)]
id: Option<String>,
event_type: String,
#[serde(default = "empty_json_object")]
payload_json: Value,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DevSeedResponse {
ok: bool,
results: Vec<Value>,
}
fn empty_json_object() -> Value {
json!({})
}
fn default_dev_seed_limit() -> usize {
200
}
pub async fn seed(
State(state): State<AppState>,
Json(request): Json<DevSeedRequest>,
) -> Result<Json<DevSeedResponse>, WebError> {
if !state.config().allow_dev_fixtures {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"dev_seed_disabled",
"dev seed API 默认关闭,需要 MNOTE_WEB_ALLOW_DEV_FIXTURES=1",
));
}
let mut results = Vec::with_capacity(request.seeds.len());
for operation in request.seeds {
results.push(apply_seed_operation(&state, operation)?);
}
Ok(Json(DevSeedResponse { ok: true, results }))
}
fn apply_seed_operation(
state: &AppState,
operation: DevSeedOperation,
) -> Result<Value, WebError> {
match operation {
DevSeedOperation::SetupWorkspace {
user_id,
email,
username,
display_name,
role,
password,
workspace_id,
workspace_name,
root_uri,
root_path,
source_kind,
permission,
capabilities,
grant_source,
grant_created_by,
} => {
let username = username.unwrap_or_else(|| user_id.clone());
let display_name = display_name.unwrap_or_else(|| username.clone());
let email = email.or_else(|| Some(format!("{username}@example.com")));
let user = state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(user_id.clone()),
email: email.clone(),
username: username.clone(),
display_name: display_name.clone(),
role,
password_hash: None,
})
.map_err(dev_seed_error)?;
if let Some(password) = password.filter(|value| !value.is_empty()) {
state
.control_plane()
.create_password_identity(CreatePasswordIdentityInput {
user_id: user.id.clone(),
email,
username,
password,
})
.map_err(dev_seed_error)?;
}
let workspace = state
.control_plane()
.upsert_workspace(UpsertWorkspaceInput {
id: Some(workspace_id),
owner_user_id: user.id.clone(),
name: workspace_name,
kind: Some("personal".to_string()),
root_uri: root_uri.clone(),
root_path: root_path.clone(),
source_kind,
})
.map_err(dev_seed_error)?;
let grant = state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: user.id.clone(),
workspace_id: Some(workspace.id.clone()),
root_uri,
root_path,
permission: permission.unwrap_or_else(|| "write".to_string()),
recursive: true,
capabilities: if capabilities.is_empty() {
vec!["ai".to_string()]
} else {
capabilities
},
source: grant_source.unwrap_or_else(|| "dev_seed".to_string()),
created_by: grant_created_by.or_else(|| Some(user.id.clone())),
})
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "setupWorkspace",
"user": user,
"workspace": workspace,
"grant": grant,
}))
}
DevSeedOperation::SeedAiRuntime {
id,
user_id,
workspace_id,
document_id,
session_id,
run_id,
title,
profile,
acp_runtime,
trace_id,
status,
runtime_json,
payload_json,
events,
} => {
let run = state
.control_plane()
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id,
user_id: user_id.clone(),
workspace_id: workspace_id.clone(),
document_id: document_id.clone(),
session_id: session_id.clone(),
run_id: run_id.clone(),
title,
profile: profile.clone(),
acp_runtime: acp_runtime.clone(),
trace_id,
status,
runtime_json: runtime_json.to_string(),
payload_json: payload_json.to_string(),
})
.map_err(dev_seed_error)?;
let mut event_records = Vec::with_capacity(events.len());
for event in events {
event_records.push(
state
.control_plane()
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: event.id,
user_id: user_id.clone(),
workspace_id: workspace_id.clone(),
document_id: document_id.clone(),
session_id: session_id.clone(),
run_id: run_id.clone(),
profile: profile.clone(),
acp_runtime: acp_runtime.clone(),
event_type: event.event_type,
payload_json: event.payload_json.to_string(),
})
.map_err(dev_seed_error)?,
);
}
Ok(json!({
"kind": "seedAiRuntime",
"run": run,
"events": event_records,
}))
}
DevSeedOperation::ClearRuntimeEvents { user_id, run_id } => {
let deleted = state
.control_plane()
.delete_ai_runtime_events_for_run(&user_id, &run_id)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "clearRuntimeEvents",
"deleted": deleted,
}))
}
DevSeedOperation::GetAiRuntimeRun { user_id, run_id } => {
let run = state
.control_plane()
.find_ai_runtime_run(&user_id, &run_id)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "getAiRuntimeRun",
"run": run,
}))
}
DevSeedOperation::ListAiRuntimeRuns {
user_id,
workspace_id,
document_id,
session_id,
limit,
} => {
let runs = state
.control_plane()
.list_ai_runtime_runs(
&user_id,
workspace_id.as_deref(),
document_id.as_deref(),
session_id.as_deref(),
limit.clamp(1, 1_000),
)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "listAiRuntimeRuns",
"runs": runs,
}))
}
DevSeedOperation::ListAiRuntimeEvents {
user_id,
run_id,
limit,
event_type,
} => {
let events = state
.control_plane()
.list_ai_runtime_events(&user_id, &run_id, limit.clamp(1, 10_000))
.map_err(dev_seed_error)?;
let events: Vec<_> = events
.into_iter()
.filter(|event| {
event_type
.as_deref()
.map(|expected| event.event_type == expected)
.unwrap_or(true)
})
.collect();
Ok(json!({
"kind": "listAiRuntimeEvents",
"events": events,
}))
}
DevSeedOperation::CountAiRuntimeEvents {
user_id,
run_id,
event_type,
} => {
let events = state
.control_plane()
.list_ai_runtime_events(&user_id, &run_id, 10_000)
.map_err(dev_seed_error)?;
let count = events
.iter()
.filter(|event| {
event_type
.as_deref()
.map(|expected| event.event_type == expected)
.unwrap_or(true)
})
.count();
Ok(json!({
"kind": "countAiRuntimeEvents",
"count": count,
}))
}
DevSeedOperation::FindExternalConversationBinding {
user_id,
workspace_id,
mnote_session_id,
provider,
} => {
let binding = state
.control_plane()
.find_ai_external_conversation_binding(
&user_id,
workspace_id.as_deref(),
&mnote_session_id,
&provider,
)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "findExternalConversationBinding",
"binding": binding,
}))
}
DevSeedOperation::ListExternalConversationBindings {
user_id,
workspace_id,
mnote_session_id,
limit,
} => {
let bindings = state
.control_plane()
.list_ai_external_conversation_bindings(
&user_id,
workspace_id.as_deref(),
&mnote_session_id,
limit.clamp(1, 1_000),
)
.map_err(dev_seed_error)?;
Ok(json!({
"kind": "listExternalConversationBindings",
"bindings": bindings,
}))
}
}
}
fn dev_seed_error(error: control_plane::ControlPlaneError) -> WebError {
match error {
control_plane::ControlPlaneError::InvalidInput(message) => {
WebError::bad_request_code("dev_seed_invalid", message)
}
control_plane::ControlPlaneError::NotFound(message) => {
WebError::new(StatusCode::NOT_FOUND, "dev_seed_not_found", message)
}
control_plane::ControlPlaneError::Conflict(message) => {
WebError::bad_request_code("dev_seed_conflict", message)
}
other => WebError::internal(format!("dev seed failed: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::AppConfig;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::routing::post;
use axum::Router;
use serde_json::{json, Value};
use tower::ServiceExt;
fn test_router(allow_dev_fixtures: bool) -> Router {
Router::new()
.route("/api/dev/seed", post(super::seed))
.with_state(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
async fn send_seed(router: Router, body: Value) -> (StatusCode, Value) {
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/api/dev/seed")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("valid request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes");
let body_str = String::from_utf8_lossy(&body);
let payload: Value = serde_json::from_slice(&body)
.unwrap_or_else(|e| panic!("send_seed: status={status} body={body_str} error={e}"));
(status, payload)
}
#[tokio::test]
async fn dev_seed_returns_forbidden_when_disabled() {
let (status, payload) = send_seed(test_router(false), json!({ "seeds": [] })).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(payload["code"], "dev_seed_disabled");
}
#[tokio::test]
async fn dev_seed_setup_workspace_writes_to_control_plane() {
let router = test_router(true);
// Step 1: setupWorkspace → 创建用户 + workspace + directory grant
let (status, payload) = send_seed(
router.clone(),
json!({
"seeds": [{
"kind": "setupWorkspace",
"user_id": "dev-seed-test-user",
"workspace_id": "dev-seed-test-ws",
"workspace_name": "测试开发空间",
"root_uri": "file:///tmp/dev-seed-test",
"root_path": "/tmp/dev-seed-test"
}]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "setupWorkspace 应成功: {payload}");
assert!(payload["ok"].as_bool().unwrap_or(false));
assert_eq!(payload["results"][0]["kind"], "setupWorkspace");
assert_eq!(payload["results"][0]["user"]["id"], "dev-seed-test-user");
assert_eq!(payload["results"][0]["workspace"]["id"], "dev-seed-test-ws");
assert!(payload["results"][0]["grant"].is_object());
// Step 2: seedAiRuntime → 创建 AI runtime run + event
let (status, payload) = send_seed(
router.clone(),
json!({
"seeds": [{
"kind": "seedAiRuntime",
"user_id": "dev-seed-test-user",
"session_id": "test-session-1",
"run_id": "test-run-1",
"profile": "test-profile",
"acp_runtime": "reasonix",
"status": "running",
"events": [{
"eventType": "message",
"payloadJson": { "text": "hello" }
}]
}]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "seedAiRuntime 应成功: {payload}");
assert!(payload["ok"].as_bool().unwrap_or(false));
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["status"], "running");
assert_eq!(
payload["results"][0]["events"].as_array().unwrap().len(),
1
);
// Step 3: getAiRuntimeRun → 回读验证
let (status, payload) = send_seed(
router,
json!({
"seeds": [{
"kind": "getAiRuntimeRun",
"user_id": "dev-seed-test-user",
"run_id": "test-run-1"
}]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "getAiRuntimeRun 应成功: {payload}");
assert!(payload["results"][0]["run"].is_object());
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["status"], "running");
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::provider_identity_sync::sync_provider_identities;
use crate::routes::local_folder_source::{
control_plane_db_path_display, create_default_local_workspace_for_actor,
control_plane_status_display, create_default_local_workspace_for_actor,
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
@@ -198,7 +198,7 @@ pub async fn admin_access_policy_entry(
.with_context(&context));
}
let workspace_name = default_workspace_name_for_context(&state, &context);
let share_grants_path = control_plane_db_path_display();
let share_grants_path = control_plane_status_display();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} share_grants_path={share_grants_path} is_admin=true />
});
@@ -15,7 +15,7 @@ use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::project_legacy_content_to_block_document;
use control_plane::AppendAuditInput;
use control_plane::{AppendAuditInput, ResolvedAccess};
use control_plane::{
CreateShareLinkInput, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord,
OutboxEventInput, ShareLinkRecord,
@@ -657,10 +657,24 @@ 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
.control_plane()
.resolve_access(actor_id, &file_uri_for_path(&canonical_root))
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查询失败: {error}")))?;
.resolve_access(actor_id, &canonical_root_uri)
.unwrap_or_else(|error| {
tracing::warn!(
%actor_id,
%error,
root_uri = %canonical_root_uri,
"控制面授权查询失败,回退到本地文件系统权限检查"
);
ResolvedAccess {
user_id: actor_id.to_string(),
root_uri: canonical_root_uri.clone(),
permission: String::new(),
grant_ids: Vec::new(),
}
});
if local_access_permission_allows(&sqlite_access.permission, mode) {
return Ok(canonical_root);
}
@@ -766,18 +780,78 @@ fn local_access_policy_path() -> PathBuf {
})
}
pub(crate) fn control_plane_db_path_display() -> String {
std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
pub(crate) fn control_plane_status_display() -> String {
let backend = std::env::var("MNOTE_CONTROL_PLANE_BACKEND")
.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()
})
.unwrap_or_else(|| "sqlite".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()
.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-libsql.db")
.display()
.to_string()
});
format!("backend={backend}; local={db_path}")
}
"turso-local-replica" | "turso-remote-replica" => {
let db_path = std::env::var("MNOTE_TURSO_LOCAL_REPLICA_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-replica.db")
.display()
.to_string()
});
format!("backend={backend}; replica={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
}
"turso-remote" => "backend=turso-remote; remote=env:MNOTE_TURSO_DATABASE_URL".to_string(),
"turso-synced" => {
let db_path = std::env::var("MNOTE_TURSO_SYNCED_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("MNOTE_TURSO_LOCAL_REPLICA_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-synced.db")
.display()
.to_string()
});
format!("backend=turso-synced; local={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
}
other => format!("backend={other}; unsupported"),
}
}
fn local_share_grants_path() -> PathBuf {
@@ -3304,7 +3378,7 @@ fn save_local_markdown_page_inner(
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
let control_plane = open_control_plane_store();
let _ = local_search_index::refresh_local_search_index_for_change_with_store(
&control_plane,
control_plane.as_ref(),
root,
root_uri,
workspace_id,
+7 -4
View File
@@ -1,6 +1,7 @@
mod bridge;
pub(crate) mod command_support;
mod compat;
mod dev_seed;
pub(crate) mod dev_hot;
mod documents;
mod editor;
@@ -45,10 +46,11 @@ mod ws;
pub(crate) use gateway::current_actor_id;
pub(crate) use local_folder_source::{
decode_local_id_segment, ensure_local_path_read_access, ensure_local_workspace_access,
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key,
local_workspace_id_from_root_uri, update_local_markdown_title,
write_local_markdown_page_body,
};
#[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings;
@@ -87,6 +89,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/evidence/search", post(evidence::search))
.route("/api/evidence/read", post(evidence::read))
.route("/api/evidence/open", post(evidence::open))
.route("/api/dev/seed", post(dev_seed::seed))
.route("/api/knowledge-rag/status", get(knowledge_rag::status))
.route(
"/api/knowledge-rag/pipeline-events",