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
+6
View File
@@ -5,10 +5,16 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
[features]
default = []
turso-unit-tests = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.34", features = ["bundled"] }
libsql = "0.10.0-pre.4"
tokio = { version = "1", features = ["rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
File diff suppressed because it is too large Load Diff
+39
View File
@@ -8,6 +8,8 @@ pub enum ControlPlaneError {
Storage(String),
Unauthorized(String),
SessionExpired(String),
Timeout(String),
RateLimit(String),
}
impl fmt::Display for ControlPlaneError {
@@ -19,6 +21,8 @@ impl fmt::Display for ControlPlaneError {
ControlPlaneError::Storage(msg) => write!(f, "Storage: {msg}"),
ControlPlaneError::Unauthorized(msg) => write!(f, "Unauthorized: {msg}"),
ControlPlaneError::SessionExpired(msg) => write!(f, "SessionExpired: {msg}"),
ControlPlaneError::Timeout(msg) => write!(f, "Timeout: {msg}"),
ControlPlaneError::RateLimit(msg) => write!(f, "RateLimit: {msg}"),
}
}
}
@@ -31,6 +35,41 @@ impl From<rusqlite::Error> for ControlPlaneError {
}
}
impl From<libsql::Error> for ControlPlaneError {
fn from(e: libsql::Error) -> Self {
let message = e.to_string();
let lower = message.to_lowercase();
if message.contains("UNIQUE constraint failed")
|| message.contains("constraint failed")
|| message.contains("constraint violation")
{
ControlPlaneError::Conflict(message)
} else if message.contains("Unauthorized")
|| message.contains("authorization")
|| message.contains("401")
{
ControlPlaneError::Unauthorized(message)
} else if lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("deadline")
|| lower.contains("connection refused")
|| lower.contains("connection reset")
|| lower.contains("eof")
{
ControlPlaneError::Timeout(message)
} else if lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("too many requests")
|| lower.contains("429")
{
ControlPlaneError::RateLimit(message)
} else {
ControlPlaneError::Storage(message)
}
}
}
impl From<serde_json::Error> for ControlPlaneError {
fn from(e: serde_json::Error) -> Self {
ControlPlaneError::Storage(format!("JSON error: {e}"))
+2
View File
@@ -3,9 +3,11 @@ pub mod migrations;
pub mod model;
pub mod sqlite;
pub mod store;
pub mod turso;
pub use error::ControlPlaneError;
pub use migrations::run_migrations;
pub use model::*;
pub use sqlite::SqliteControlPlaneStore;
pub use store::*;
pub use turso::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
@@ -3,6 +3,7 @@
//! Each migration is a named step that runs inside a transaction.
//! The `migrations` table tracks which steps have been applied.
use crate::error::ControlPlaneError;
use rusqlite::{Connection, Result as SqlResult};
const MIGRATIONS: &[(&str, &str)] = &[
@@ -72,6 +73,34 @@ pub fn run_migrations(conn: &Connection) -> SqlResult<()> {
Ok(())
}
pub async fn run_libsql_migrations(conn: &libsql::Connection) -> Result<(), ControlPlaneError> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);",
)
.await?;
let mut rows = conn.query("SELECT name FROM _migrations", ()).await?;
let mut already_applied = Vec::new();
while let Some(row) = rows.next().await? {
already_applied.push(row.get::<String>(0)?);
}
for (name, sql) in MIGRATIONS {
if already_applied.iter().any(|value| value == name) {
continue;
}
tracing::info!("Running libSQL migration: {name}");
conn.execute_batch(sql).await?;
conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [*name])
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -96,6 +125,22 @@ mod tests {
> 0
}
#[cfg(feature = "turso-unit-tests")]
async fn libsql_table_exists(conn: &libsql::Connection, name: &str) -> bool {
let mut rows = conn
.query(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
[name],
)
.await
.unwrap_or_else(|_| panic!("query libSQL table {name}"));
if let Some(row) = rows.next().await.unwrap_or(None) {
row.get::<i64>(0).unwrap_or(0) > 0
} else {
false
}
}
#[test]
fn migration_is_idempotent() {
let conn = fresh_db();
@@ -136,4 +181,53 @@ mod tests {
assert!(table_exists(&conn, "users"));
assert!(table_exists(&conn, "_migrations"));
}
#[cfg(feature = "turso-unit-tests")]
#[test]
fn libsql_migration_is_idempotent() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build tokio runtime");
let database = rt
.block_on(async { libsql::Builder::new_local(":memory:").build().await })
.expect("build libSQL in-memory database");
let conn = database.connect().expect("connect libSQL");
rt.block_on(async {
run_libsql_migrations(&conn)
.await
.expect("first libSQL migration run");
run_libsql_migrations(&conn)
.await
.expect("second libSQL migration run (idempotent)");
});
let tables: &[&str] = &[
"users",
"auth_identities",
"auth_sessions",
"workspaces",
"workspace_members",
"directory_grants",
"share_links",
"sync_state",
"ai_policies",
"audit_log",
"outbox_events",
"legacy_id_map",
"ai_runtime_runs",
"ai_runtime_events",
"sidebar_shortcuts",
"user_ui_preferences",
"user_navigation_recent",
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
];
for table in tables {
let exists = rt.block_on(async { libsql_table_exists(&conn, table).await });
assert!(exists, "libSQL table {table} should exist");
}
}
}
+11
View File
@@ -112,6 +112,17 @@ pub struct WorkspaceRecord {
pub revision: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpsertWorkspaceInput {
pub id: Option<EntityId>,
pub owner_user_id: EntityId,
pub name: String,
pub kind: Option<String>,
pub root_uri: String,
pub root_path: String,
pub source_kind: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirectoryGrantRecord {
pub id: EntityId,
+246 -49
View File
@@ -18,7 +18,8 @@ use crate::model::{
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -555,6 +556,14 @@ fn list_ai_agent_profile_access_rows(
Ok(by_profile.into_values().collect())
}
impl SqliteControlPlaneStore {
fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> {
self.conn
.lock()
.map_err(|e| ControlPlaneError::Storage(format!("sqlite lock poisoned: {e}")))
}
}
impl ControlPlaneStore for SqliteControlPlaneStore {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
if input.username.trim().is_empty() {
@@ -568,7 +577,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let id = input.id.unwrap_or_else(|| new_id("usr"));
let role = input.role.unwrap_or_else(|| "user".to_string());
@@ -646,7 +655,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let user_id = input.user_id;
let password_hash = password_hash_v1(&input.password);
@@ -698,7 +707,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let provider = if account.contains('@') {
"password_email"
} else {
@@ -744,7 +753,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let expires_at = input
.expires_at
@@ -786,7 +795,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let resolved = conn
.query_row(
@@ -837,7 +846,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"session_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.execute(
"UPDATE auth_sessions SET revoked_at = ?1 WHERE id = ?2 AND revoked_at IS NULL",
params![now_text(), session_id],
@@ -849,7 +858,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
&self,
actor_id: &str,
) -> Result<WorkspaceRecord, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existing = conn
.query_row(
"SELECT id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision
@@ -933,11 +942,89 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(workspace)
}
fn upsert_workspace(
&self,
input: UpsertWorkspaceInput,
) -> Result<WorkspaceRecord, ControlPlaneError> {
let owner_user_id = input.owner_user_id.trim().to_string();
let name = input.name.trim().to_string();
let root_uri = input.root_uri.trim().to_string();
let root_path = input.root_path.trim().to_string();
if owner_user_id.is_empty() || name.is_empty() || root_uri.is_empty() || root_path.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"workspace owner/name/root 不能为空".to_string(),
));
}
let kind = input
.kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("personal")
.to_string();
let source_kind = input
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("local_folder")
.to_string();
let now = now_text();
let id = input.id.unwrap_or_else(|| new_id("ws"));
let mut conn = self.lock_conn()?;
let tx = conn.transaction()?;
tx.query_row(
"SELECT id FROM users WHERE id = ?1",
params![&owner_user_id],
|row| row.get::<_, String>(0),
)
.optional()?
.ok_or_else(|| ControlPlaneError::NotFound(format!("user not found: {owner_user_id}")))?;
tx.execute(
"INSERT INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, 1)
ON CONFLICT(id) DO UPDATE SET
owner_user_id = excluded.owner_user_id,
name = excluded.name,
kind = excluded.kind,
root_uri = excluded.root_uri,
root_path = excluded.root_path,
source_kind = excluded.source_kind,
updated_at = excluded.updated_at,
revision = workspaces.revision + 1",
params![
&id,
&owner_user_id,
&name,
&kind,
&root_uri,
&root_path,
&source_kind,
&now,
&now
],
)?;
tx.execute(
"INSERT OR IGNORE INTO workspace_members (id, workspace_id, user_id, role, status, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, 'owner', 'active', ?4, ?5, 1)",
params![new_id("wsm"), &id, &owner_user_id, &now, &now],
)?;
let workspace = tx.query_row(
"SELECT id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision
FROM workspaces WHERE id = ?1",
params![&id],
row_to_workspace,
)?;
tx.commit()?;
Ok(workspace)
}
fn grant_directory_access(
&self,
input: DirectoryGrantInput,
) -> Result<DirectoryGrantRecord, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let record = DirectoryGrantRecord {
id: new_id("grant"),
@@ -979,7 +1066,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
fn list_directory_grants(&self) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision
FROM directory_grants
@@ -1001,7 +1088,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if actor_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision
FROM directory_grants
@@ -1019,7 +1106,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
&self,
lookup: DirectoryGrantLookup,
) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let grant_id = lookup
.grant_id
.as_deref()
@@ -1067,7 +1154,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"grant_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existing_revision = conn
.query_row(
"SELECT revision FROM directory_grants WHERE id = ?1 AND status = 'active'",
@@ -1097,7 +1184,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
actor_id: &str,
root_uri: &str,
) -> Result<ResolvedAccess, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let root_path = file_uri_to_legacy_path(root_uri);
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision
@@ -1150,7 +1237,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let token_hash = share_token_hash_v1(&token);
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let link = ShareLinkRecord {
id: input.id.unwrap_or_else(|| new_id("share")),
@@ -1193,7 +1280,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision
FROM share_links
@@ -1215,7 +1302,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if token_hash.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let link = conn
.query_row(
"SELECT id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision
@@ -1236,7 +1323,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"link_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existed = conn.execute(
"UPDATE share_links
SET revoked_at = ?1, updated_at = ?2, revision = revision + 1
@@ -1259,7 +1346,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"audit action/target_kind 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.execute(
"INSERT INTO audit_log (id, actor_user_id, action, target_kind, target_id, metadata_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
@@ -1277,7 +1364,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
fn list_audit_log(&self, limit: usize) -> Result<Vec<AuditLogRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, actor_user_id, action, target_kind, target_id, metadata_json, created_at
FROM audit_log
@@ -1295,7 +1382,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
&self,
input: OutboxEventInput,
) -> Result<OutboxEventRecord, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let record = OutboxEventRecord {
id: new_id("evt"),
topic: input.topic,
@@ -1320,7 +1407,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
fn drain_outbox(&self, limit: usize) -> Result<Vec<OutboxEventRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, topic, event_type, payload_json, created_at, delivered_at, attempts
FROM outbox_events
@@ -1348,7 +1435,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"event_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let updated = conn.execute(
"UPDATE outbox_events
SET delivered_at = COALESCE(delivered_at, ?1), attempts = attempts + 1
@@ -1393,7 +1480,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"sidebar shortcut kind 只能是 page 或 folder".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO sidebar_shortcuts (
@@ -1454,7 +1541,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at,
@@ -1480,7 +1567,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, kind, source_kind, target_id, relative_path,
document_id, title, icon, sort_order, status, metadata_json, created_at,
@@ -1510,7 +1597,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"sidebar shortcut user_id/shortcut_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE sidebar_shortcuts
SET status = 'removed', updated_at = ?1, revision = revision + 1
@@ -1541,7 +1628,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
));
}
serde_json::from_str::<serde_json::Value>(&input.value_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO user_ui_preferences (
@@ -1604,7 +1691,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
let source_kind = source_kind.map(str::trim).unwrap_or_default();
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
@@ -1641,7 +1728,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
@@ -1675,7 +1762,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai agent profile user_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO ai_agent_profiles (
@@ -1897,7 +1984,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
relative_path.as_deref(),
document_id.as_deref(),
)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO user_navigation_recent (
@@ -1961,7 +2048,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
return Ok(Vec::new());
}
let limit = limit.clamp(1, 100) as i64;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let rows = if let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) {
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
@@ -2007,7 +2094,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(&input.model_policy_json)?;
serde_json::from_str::<serde_json::Value>(&input.quota_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2090,7 +2177,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if actor_id.is_empty() && workspace_id.is_none() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
if let Some(workspace_id) = workspace_id {
let workspace_policy = conn
.query_row(
@@ -2139,7 +2226,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(error_json)?;
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2222,7 +2309,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if workspace_id.is_empty() || remote_kind.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.query_row(
"SELECT id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision
FROM sync_state
@@ -2250,7 +2337,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(&input.runtime_json)?;
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2354,7 +2441,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiRuntimeRunRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision
FROM ai_runtime_runs
@@ -2381,7 +2468,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
user_id: &str,
run_id: &str,
) -> Result<Option<AiRuntimeRunRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.query_row(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision
FROM ai_runtime_runs
@@ -2408,7 +2495,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let record = AiRuntimeEventRecord {
id: input.id.unwrap_or_else(|| new_id("are")),
user_id: input.user_id,
@@ -2442,13 +2529,33 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(record)
}
fn delete_ai_runtime_events_for_run(
&self,
user_id: &str,
run_id: &str,
) -> Result<usize, ControlPlaneError> {
let user_id = user_id.trim();
let run_id = run_id.trim();
if user_id.is_empty() || run_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"user_id/run_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let rows = conn.execute(
"DELETE FROM ai_runtime_events WHERE user_id = ?1 AND run_id = ?2",
params![user_id, run_id],
)?;
Ok(rows)
}
fn list_ai_runtime_events(
&self,
user_id: &str,
run_id: &str,
limit: usize,
) -> Result<Vec<AiRuntimeEventRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at
FROM ai_runtime_events
@@ -2473,7 +2580,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
after_seq: i64,
limit: usize,
) -> Result<Vec<AiRuntimeJournalEventRecord>, ControlPlaneError> {
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"WITH ordered AS (
SELECT
@@ -2521,7 +2628,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
validate_ai_external_conversation_status(&input.status)?;
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
@@ -2639,7 +2746,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
conn.query_row(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
@@ -2655,6 +2762,38 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
.map_err(ControlPlaneError::from)
}
fn list_ai_external_conversation_bindings(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
limit: usize,
) -> Result<Vec<AiExternalConversationBindingRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
if user_id.is_empty() || mnote_session_id.is_empty() {
return Ok(Vec::new());
}
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
WHERE user_id = ?1
AND (?2 IS NULL OR workspace_id = ?2)
AND mnote_session_id = ?3
ORDER BY updated_at DESC
LIMIT ?4",
)?;
let rows = stmt
.query_map(
params![user_id, workspace_id, mnote_session_id, limit.max(1) as i64],
row_to_ai_external_conversation_binding,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn mark_ai_external_conversation_binding_status(
&self,
user_id: &str,
@@ -2673,7 +2812,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(0);
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
let deleted_at = if status == "active" {
None
@@ -2716,7 +2855,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai runtime session user_id/session_id/title 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"UPDATE ai_runtime_runs
@@ -2759,7 +2898,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai runtime session user_id/session_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let existing = conn
.query_row(
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision
@@ -2813,7 +2952,7 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
"ai runtime session user_id/session_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE ai_runtime_runs
SET deleted_at = ?1, updated_at = ?1, revision = revision + 1
@@ -2928,6 +3067,55 @@ mod tests {
assert_eq!(access.grant_ids.len(), 1);
}
#[test]
fn upsert_workspace_supports_explicit_local_root_for_dev_seed() {
let store = store();
create_user(&store, "seed_owner");
let workspace = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:seed-owner:custom".to_string()),
owner_user_id: "seed_owner".to_string(),
name: "Seed Workspace".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-seed-workspace".to_string(),
root_path: "/tmp/mnote-seed-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("upsert workspace");
assert_eq!(workspace.id, "local-ws:seed-owner:custom");
assert_eq!(workspace.status, "active");
assert_eq!(workspace.root_uri, "file:///tmp/mnote-seed-workspace");
let updated = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some(workspace.id.clone()),
owner_user_id: "seed_owner".to_string(),
name: "Seed Workspace Renamed".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-seed-workspace".to_string(),
root_path: "/tmp/mnote-seed-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("update workspace");
assert_eq!(updated.name, "Seed Workspace Renamed");
assert_eq!(updated.revision, workspace.revision + 1);
assert_eq!(updated.status, "active");
let missing_user = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:missing:custom".to_string()),
owner_user_id: "missing".to_string(),
name: "Missing".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/missing".to_string(),
root_path: "/tmp/missing".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect_err("missing owner should fail");
assert!(matches!(missing_user, ControlPlaneError::NotFound(_)));
}
#[test]
fn explicit_write_grant_out_ranks_read_grant() {
let store = store();
@@ -3772,6 +3960,15 @@ mod tests {
assert_eq!(journal_events.len(), 1);
assert_eq!(journal_events[0].seq, 2);
assert_eq!(journal_events[0].event.event_type, "run.completed");
let deleted = store
.delete_ai_runtime_events_for_run("ai_runtime_user", "run_1")
.expect("delete runtime events");
assert_eq!(deleted, 2);
assert!(store
.list_ai_runtime_events("ai_runtime_user", "run_1", 10)
.expect("list after delete")
.is_empty());
}
#[test]
+31 -1
View File
@@ -11,7 +11,8 @@ use crate::model::{
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
};
pub trait ControlPlaneStore: Send + Sync {
@@ -44,6 +45,16 @@ pub trait ControlPlaneStore: Send + Sync {
actor_id: &str,
) -> Result<WorkspaceRecord, ControlPlaneError>;
/// Upsert a workspace record after the caller has authorized the operation.
///
/// The store only enforces schema-level invariants and user existence. It does
/// not validate filesystem paths or decide whether the actor may manage the
/// workspace.
fn upsert_workspace(
&self,
input: UpsertWorkspaceInput,
) -> Result<WorkspaceRecord, ControlPlaneError>;
fn grant_directory_access(
&self,
input: DirectoryGrantInput,
@@ -219,6 +230,17 @@ pub trait ControlPlaneStore: Send + Sync {
input: AppendAiRuntimeEventInput,
) -> Result<AiRuntimeEventRecord, ControlPlaneError>;
/// Hard-delete AI runtime events for a run after the caller has authorized it.
///
/// This is intended for controlled test/dev seeding and recovery workflows.
/// Product flows should prefer append-only events or higher-level session
/// deletion APIs.
fn delete_ai_runtime_events_for_run(
&self,
user_id: &str,
run_id: &str,
) -> Result<usize, ControlPlaneError>;
fn list_ai_runtime_events(
&self,
user_id: &str,
@@ -247,6 +269,14 @@ pub trait ControlPlaneStore: Send + Sync {
provider: &str,
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError>;
fn list_ai_external_conversation_bindings(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
limit: usize,
) -> Result<Vec<AiExternalConversationBindingRecord>, ControlPlaneError>;
fn mark_ai_external_conversation_binding_status(
&self,
user_id: &str,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
use std::fs;
use std::sync::Arc;
use std::thread;
use control_plane::{
AppendAiRuntimeEventInput, AppendAuditInput, ControlPlaneError, ControlPlaneStore,
CreateSessionInput, DirectoryGrantInput, OutboxEventInput, TursoControlPlaneConfig,
TursoControlPlaneMode, TursoControlPlaneStore, UpsertAiExternalConversationBindingInput,
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertUserInput, UpsertWorkspaceInput,
};
fn local_turso_store() -> (TursoControlPlaneStore, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!(
"mnote-control-plane-libsql-{}.db",
uuid::Uuid::new_v4().simple()
));
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("db-wal"));
let _ = fs::remove_file(path.with_extension("db-shm"));
(
TursoControlPlaneStore::open_local(&path).expect("open libSQL local control plane"),
path,
)
}
#[test]
fn libsql_local_store_covers_control_plane_core_flows() {
let (store, path) = local_turso_store();
let user = store
.upsert_user(UpsertUserInput {
id: Some("libsql_user".to_string()),
email: Some("libsql_user@example.com".to_string()),
username: "libsql_user".to_string(),
display_name: "libSQL User".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("upsert user");
assert_eq!(user.id, "libsql_user");
let workspace = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:libsql-user:workspace".to_string()),
owner_user_id: user.id.clone(),
name: "libSQL Workspace".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-libsql-workspace".to_string(),
root_path: "/tmp/mnote-libsql-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("upsert workspace");
assert_eq!(workspace.owner_user_id, user.id);
store
.grant_directory_access(DirectoryGrantInput {
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
root_uri: workspace.root_uri.clone(),
root_path: workspace.root_path.clone(),
permission: "write".to_string(),
recursive: true,
capabilities: vec!["ai".to_string()],
source: "test".to_string(),
created_by: Some("libsql_user".to_string()),
})
.expect("grant workspace access");
let access = store
.resolve_access("libsql_user", "file:///tmp/mnote-libsql-workspace/page.md")
.expect("resolve workspace access");
assert_eq!(access.permission, "write");
let run = store
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: None,
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
title: Some("libSQL run".to_string()),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
trace_id: Some("trace_1".to_string()),
status: "running".to_string(),
runtime_json: "{\"status\":\"running\"}".to_string(),
payload_json: "{\"message\":\"hello\"}".to_string(),
})
.expect("upsert ai runtime run");
assert_eq!(run.revision, 1);
store
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: None,
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
event_type: "message.delta".to_string(),
payload_json: "{\"text\":\"hello\"}".to_string(),
})
.expect("append runtime event");
assert_eq!(
store
.list_ai_runtime_events("libsql_user", "run_1", 10)
.expect("list runtime events")
.len(),
1
);
let binding = store
.upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput {
id: None,
user_id: "libsql_user".to_string(),
workspace_id: Some(workspace.id.clone()),
mnote_session_id: "sess_1".to_string(),
acp_session_id: Some("acp_1".to_string()),
agent_id: "chat_only".to_string(),
profile: "openclaw-doubao-chat".to_string(),
provider: "doubao-web".to_string(),
remote_conversation_id: "remote_1".to_string(),
remote_url: Some("https://www.doubao.com/chat/remote_1".to_string()),
status: "active".to_string(),
metadata_json: "{\"source\":\"test\"}".to_string(),
})
.expect("upsert external conversation binding");
assert_eq!(binding.status, "active");
let found = store
.find_ai_external_conversation_binding(
"libsql_user",
Some(&workspace.id),
"sess_1",
"doubao-web",
)
.expect("find binding")
.expect("binding exists");
assert_eq!(found.id, binding.id);
drop(store);
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("db-wal"));
let _ = fs::remove_file(path.with_extension("db-shm"));
}
#[test]
fn libsql_local_store_handles_parallel_control_plane_writes() {
let (store, path) = local_turso_store();
let store = Arc::new(store);
let user = store
.upsert_user(UpsertUserInput {
id: Some("parallel_user".to_string()),
email: Some("parallel_user@example.com".to_string()),
username: "parallel_user".to_string(),
display_name: "Parallel User".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("upsert parallel user");
let workspace = store
.upsert_workspace(UpsertWorkspaceInput {
id: Some("local-ws:parallel-user:workspace".to_string()),
owner_user_id: user.id.clone(),
name: "Parallel Workspace".to_string(),
kind: Some("personal".to_string()),
root_uri: "file:///tmp/mnote-libsql-parallel-workspace".to_string(),
root_path: "/tmp/mnote-libsql-parallel-workspace".to_string(),
source_kind: Some("local_folder".to_string()),
})
.expect("upsert parallel workspace");
store
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: Some("parallel_run_record".to_string()),
user_id: user.id.clone(),
workspace_id: Some(workspace.id.clone()),
document_id: Some("parallel_doc".to_string()),
session_id: "parallel_session".to_string(),
run_id: "parallel_run".to_string(),
title: Some("Parallel run".to_string()),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
trace_id: None,
status: "running".to_string(),
runtime_json: "{}".to_string(),
payload_json: "{}".to_string(),
})
.expect("seed parallel run");
let mut handles = Vec::new();
for index in 0..8 {
let store = store.clone();
let workspace_id = workspace.id.clone();
handles.push(thread::spawn(move || {
let suffix = index.to_string();
store
.create_session(CreateSessionInput {
id: Some(format!("parallel_session_{suffix}")),
user_id: "parallel_user".to_string(),
token_hash: format!("parallel_token_hash_{suffix}"),
user_agent: Some("parallel-test".to_string()),
ip_hash: None,
expires_at: None,
})
.expect("create session in parallel");
store
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
id: Some(format!("parallel_shortcut_{suffix}")),
user_id: "parallel_user".to_string(),
workspace_id: workspace_id.clone(),
root_uri: Some("file:///tmp/mnote-libsql-parallel-workspace".to_string()),
kind: "page".to_string(),
source_kind: "local_folder".to_string(),
target_id: format!("doc_{suffix}"),
relative_path: Some(format!("doc_{suffix}.md")),
document_id: Some(format!("local-md:doc_{suffix}.md")),
title: format!("Doc {suffix}"),
icon: None,
sort_order: index,
metadata_json: "{}".to_string(),
})
.expect("upsert shortcut in parallel");
store
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: Some(format!("parallel_event_{suffix}")),
user_id: "parallel_user".to_string(),
workspace_id: Some(workspace_id.clone()),
document_id: Some("parallel_doc".to_string()),
session_id: "parallel_session".to_string(),
run_id: "parallel_run".to_string(),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
event_type: "message.delta".to_string(),
payload_json: format!("{{\"index\":{index}}}"),
})
.expect("append runtime event in parallel");
store
.append_audit(AppendAuditInput {
actor_user_id: Some("parallel_user".to_string()),
action: "parallel.write".to_string(),
target_kind: "control_plane".to_string(),
target_id: Some(format!("target_{suffix}")),
metadata_json: "{}".to_string(),
})
.expect("append audit in parallel");
store
.append_outbox(OutboxEventInput {
topic: "parallel".to_string(),
event_type: "parallel.write".to_string(),
payload_json: format!("{{\"index\":{index}}}"),
})
.expect("append outbox in parallel");
}));
}
for handle in handles {
handle.join().expect("parallel writer thread should not panic");
}
assert_eq!(
store
.list_ai_runtime_events("parallel_user", "parallel_run", 100)
.expect("list parallel events")
.len(),
8
);
assert_eq!(
store
.list_sidebar_shortcuts("parallel_user", &workspace.id)
.expect("list parallel shortcuts")
.len(),
8
);
assert_eq!(
store
.list_audit_log(20)
.expect("list parallel audit")
.len(),
8
);
assert_eq!(
store.drain_outbox(20).expect("drain parallel outbox").len(),
8
);
drop(store);
let _ = fs::remove_file(&path);
let _ = fs::remove_file(path.with_extension("db-wal"));
let _ = fs::remove_file(path.with_extension("db-shm"));
}
#[test]
fn turso_config_rejects_missing_remote_credentials_without_panic() {
let result = TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig {
mode: TursoControlPlaneMode::Remote,
path: None,
remote_url: Some("libsql://example.turso.io".to_string()),
auth_token: None,
sync_interval: None,
});
let Err(error) = result else {
panic!("missing token should return an error");
};
assert!(matches!(error, ControlPlaneError::InvalidInput(_)));
}
+153 -6
View File
@@ -9,9 +9,13 @@ use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
#[cfg(not(test))]
use control_plane::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
use std::env;
use std::fs;
use std::sync::Arc;
#[cfg(not(test))]
use std::time::Duration;
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
@@ -130,6 +134,21 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
None
}
#[cfg(not(test))]
fn parse_turso_sync_interval_ms() -> Option<Duration> {
let value = env::var("MNOTE_TURSO_SYNC_INTERVAL_MS")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())?;
let ms: u64 = value
.parse()
.expect("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数(毫秒),例如 5000");
if ms == 0 {
panic!("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数,当前值: {ms}");
}
Some(Duration::from_millis(ms))
}
use tokio::sync::broadcast;
#[derive(Clone)]
@@ -141,7 +160,7 @@ pub struct AppState {
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
pub acp_runtime: Arc<AcpRuntimeManager>,
pub buffer_store: BufferStore,
control_plane: Arc<SqliteControlPlaneStore>,
control_plane: Arc<dyn ControlPlaneStore>,
}
impl AppState {
@@ -151,7 +170,7 @@ impl AppState {
let actor = EditorRuntimeActor::new();
actor.set_block_delta_tx(block_delta_tx.clone());
let buffer_store = BufferStore::new();
let control_plane = Arc::new(open_control_plane_store());
let control_plane = open_control_plane_store();
Self {
config: Arc::new(config),
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(
@@ -181,12 +200,33 @@ impl AppState {
}
#[cfg(test)]
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面")
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面"))
}
#[cfg(not(test))]
pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let backend = env::var("MNOTE_CONTROL_PLANE_BACKEND")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "sqlite".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" => {
open_turso_remote_replica_control_plane_store()
}
"turso-synced" => open_turso_synced_control_plane_store(),
other => panic!(
"不支持的控制面后端 {other},支持 sqlite/libsql-local/turso-remote/turso-local-replica/turso-synced"
),
}
}
#[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())
@@ -194,7 +234,114 @@ pub(crate) fn open_control_plane_store() -> SqliteControlPlaneStore {
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 SQLite 控制面目录");
}
SqliteControlPlaneStore::open(&db_path).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()
});
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 libSQL local 控制面目录");
}
Arc::new(TursoControlPlaneStore::open_local(&db_path).expect("初始化 libSQL local 控制面"))
}
#[cfg(not(test))]
fn open_turso_remote_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(TursoControlPlaneStore::open_remote(url, token).expect("初始化 Turso remote 控制面"))
}
#[cfg(not(test))]
fn open_turso_remote_replica_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let replica_path = env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db".to_string()
});
if let Some(parent) = std::path::Path::new(&replica_path).parent() {
fs::create_dir_all(parent).expect("创建 Turso local replica 控制面目录");
}
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(
TursoControlPlaneStore::open_remote_replica(
replica_path,
url,
token,
parse_turso_sync_interval_ms(),
)
.expect("初始化 Turso local replica 控制面"),
)
}
#[cfg(not(test))]
fn open_turso_synced_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let local_path = env::var("MNOTE_TURSO_SYNCED_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db".to_string()
});
if let Some(parent) = std::path::Path::new(&local_path).parent() {
fs::create_dir_all(parent).expect("创建 Turso synced 控制面目录");
}
let url = env::var("MNOTE_TURSO_DATABASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_DATABASE_URL");
let token = env::var("MNOTE_TURSO_AUTH_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_AUTH_TOKEN");
Arc::new(
TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig {
mode: TursoControlPlaneMode::Synced,
path: Some(local_path.into()),
remote_url: Some(url),
auth_token: Some(token),
sync_interval: parse_turso_sync_interval_ms(),
})
.expect("初始化 Turso synced 控制面"),
)
}
pub fn build_app(state: AppState) -> Router {
@@ -4,7 +4,7 @@ use crate::routes::{
refresh_local_search_index_for_change_path_with_store,
refresh_local_search_index_if_scheduled_due_with_store,
};
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use control_plane::ControlPlaneStore;
use notify::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value};
@@ -32,7 +32,7 @@ impl std::fmt::Debug for LocalFolderWatcherRegistry {
}
impl LocalFolderWatcherRegistry {
pub fn new(buffer_store: BufferStore, control_plane: Arc<SqliteControlPlaneStore>) -> Self {
pub fn new(buffer_store: BufferStore, control_plane: Arc<dyn ControlPlaneStore>) -> Self {
Self {
inner: Arc::new(LocalFolderWatcherRegistryInner {
entries: Mutex::new(HashMap::new()),
@@ -381,7 +381,12 @@ fn is_local_search_index_path(path: &Path) -> bool {
component
.as_os_str()
.to_str()
.map(|value| value == ".mnote")
.map(|value| {
matches!(
value,
".mnote" | ".git" | "node_modules" | "target" | "dist" | "build"
)
})
.unwrap_or(false)
}) {
return false;
@@ -573,6 +578,15 @@ mod tests {
assert!(!is_local_search_index_path(&std::path::Path::new(
".mnote/page-options.json"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
"node_modules/pkg/README.md"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
"target/doc/README.md"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
".git/HEAD"
)));
assert!(!is_local_search_index_path(&std::path::Path::new(
"docs/.DS_Store"
)));
@@ -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",
+3 -3
View File
@@ -1,5 +1,6 @@
//! MNOTE 文件夹授权管理组件
use crate::routes::control_plane_status_display;
use leptos::prelude::*;
#[component]
@@ -13,8 +14,7 @@ pub fn AdminAccessPolicyPanel(
.unwrap_or_else(|| "开发用户 的空间".to_string())
.trim()
.to_string();
let share_grants_path = share_grants_path
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
let share_grants_path = share_grants_path.unwrap_or_else(control_plane_status_display);
let scope_text = if is_admin {
"管理员可以授权任意本地文件夹。"
} else {
@@ -40,7 +40,7 @@ pub fn AdminAccessPolicyPanel(
<strong data-testid="mnote-admin-workspace-name">{workspace_name.clone()}</strong>
</div>
<div class="mnote-admin-policy-summary-item">
<span class="mnote-admin-policy-summary-label">"授权记录"</span>
<span class="mnote-admin-policy-summary-label">"控制面"</span>
<code data-testid="mnote-admin-share-grants-path">{share_grants_path.clone()}</code>
</div>
</section>