feat(control-plane): add libSQL Turso backend
This commit is contained in:
@@ -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
@@ -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}"))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user