Files
mnote/rust/crates/control-plane/src/sqlite.rs
T

4228 lines
156 KiB
Rust

//! SQLite-backed control-plane store.
use std::collections::BTreeMap;
use std::sync::Mutex;
use rusqlite::{params, Connection, OptionalExtension};
use crate::error::ControlPlaneError;
use crate::migrations;
use crate::model::{
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord,
AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord,
AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
pub struct SqliteControlPlaneStore {
conn: Mutex<Connection>,
}
impl SqliteControlPlaneStore {
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, ControlPlaneError> {
let conn = Connection::open(path)?;
configure_connection(&conn)?;
migrations::run_migrations(&conn)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn in_memory() -> Result<Self, ControlPlaneError> {
let conn = Connection::open_in_memory()?;
configure_connection(&conn)?;
migrations::run_migrations(&conn)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
}
fn configure_connection(conn: &Connection) -> Result<(), ControlPlaneError> {
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;",
)?;
Ok(())
}
fn now_text() -> String {
chrono::Utc::now().to_rfc3339()
}
fn new_id(prefix: &str) -> String {
format!("{prefix}_{}", uuid::Uuid::new_v4().simple())
}
fn capabilities_json(capabilities: &[String]) -> Result<String, ControlPlaneError> {
serde_json::to_string(capabilities).map_err(ControlPlaneError::from)
}
fn file_uri_to_legacy_path(value: &str) -> String {
value
.trim()
.strip_prefix("file://")
.unwrap_or("")
.to_string()
}
fn row_to_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserRecord> {
Ok(UserRecord {
id: row.get(0)?,
email: row.get(1)?,
username: row.get(2)?,
display_name: row.get(3)?,
role: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
revision: row.get(8)?,
})
}
fn row_to_auth_session(row: &rusqlite::Row<'_>) -> rusqlite::Result<AuthSessionRecord> {
Ok(AuthSessionRecord {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
user_agent: row.get(3)?,
ip_hash: row.get(4)?,
created_at: row.get(5)?,
expires_at: row.get(6)?,
revoked_at: row.get(7)?,
last_seen_at: row.get(8)?,
})
}
fn row_to_workspace(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceRecord> {
Ok(WorkspaceRecord {
id: row.get(0)?,
owner_user_id: row.get(1)?,
name: row.get(2)?,
kind: row.get(3)?,
root_uri: row.get(4)?,
root_path: row.get(5)?,
source_kind: row.get(6)?,
status: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
revision: row.get(10)?,
})
}
fn row_to_grant(row: &rusqlite::Row<'_>) -> rusqlite::Result<DirectoryGrantRecord> {
Ok(DirectoryGrantRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
root_uri: row.get(3)?,
root_path: row.get(4)?,
permission: row.get(5)?,
recursive: row.get::<_, i64>(6)? != 0,
capabilities_json: row.get(7)?,
source: row.get(8)?,
status: row.get(9)?,
created_by: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
revision: row.get(13)?,
})
}
fn row_to_outbox(row: &rusqlite::Row<'_>) -> rusqlite::Result<OutboxEventRecord> {
Ok(OutboxEventRecord {
id: row.get(0)?,
topic: row.get(1)?,
event_type: row.get(2)?,
payload_json: row.get(3)?,
created_at: row.get(4)?,
delivered_at: row.get(5)?,
attempts: row.get(6)?,
})
}
fn row_to_share_link(row: &rusqlite::Row<'_>) -> rusqlite::Result<ShareLinkRecord> {
Ok(ShareLinkRecord {
id: row.get(0)?,
workspace_id: row.get(1)?,
resource_kind: row.get(2)?,
resource_id: row.get(3)?,
token_hash: row.get(4)?,
permission: row.get(5)?,
created_by: row.get(6)?,
expires_at: row.get(7)?,
revoked_at: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
revision: row.get(11)?,
})
}
fn row_to_audit_log(row: &rusqlite::Row<'_>) -> rusqlite::Result<AuditLogRecord> {
Ok(AuditLogRecord {
id: row.get(0)?,
actor_user_id: row.get(1)?,
action: row.get(2)?,
target_kind: row.get(3)?,
target_id: row.get(4)?,
metadata_json: row.get(5)?,
created_at: row.get(6)?,
})
}
fn row_to_sidebar_shortcut(row: &rusqlite::Row<'_>) -> rusqlite::Result<SidebarShortcutRecord> {
Ok(SidebarShortcutRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
root_uri: row.get(3)?,
kind: row.get(4)?,
source_kind: row.get(5)?,
target_id: row.get(6)?,
relative_path: row.get(7)?,
document_id: row.get(8)?,
title: row.get(9)?,
icon: row.get(10)?,
sort_order: row.get(11)?,
status: row.get(12)?,
metadata_json: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
revision: row.get(16)?,
})
}
fn empty_string_to_option(value: String) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}
fn option_to_stored_text(value: Option<String>) -> String {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.unwrap_or_default()
}
fn sanitize_profile_component(value: &str) -> String {
let mut sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
while sanitized.contains("--") {
sanitized = sanitized.replace("--", "-");
}
sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
"user".to_string()
} else {
sanitized
}
}
fn row_to_ai_agent_profile(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiAgentProfileRecord> {
let owner_user_id: String = row.get(3)?;
Ok(AiAgentProfileRecord {
id: row.get(0)?,
agent_id: row.get(1)?,
profile_kind: row.get(2)?,
owner_user_id: empty_string_to_option(owner_user_id),
base_profile_name: row.get(4)?,
isolated_profile_name: row.get(5)?,
display_name: row.get(6)?,
status: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
revision: row.get(10)?,
})
}
fn row_to_ai_agent_profile_access(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiAgentProfileAccessRecord> {
Ok(AiAgentProfileAccessRecord {
profile: row_to_ai_agent_profile(row)?,
grant: AiAgentProfileGrantRecord {
id: row.get(11)?,
profile_id: row.get(12)?,
user_id: empty_string_to_option(row.get(13)?),
role: row.get(14)?,
can_run: row.get::<_, i64>(15)? != 0,
can_manage_skills: row.get::<_, i64>(16)? != 0,
can_manage_config: row.get::<_, i64>(17)? != 0,
created_at: row.get(18)?,
updated_at: row.get(19)?,
revision: row.get(20)?,
},
})
}
fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUiPreferenceRecord> {
Ok(UserUiPreferenceRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: empty_string_to_option(row.get(2)?),
source_kind: empty_string_to_option(row.get(3)?),
scope_kind: row.get(4)?,
scope_id: row.get(5)?,
key: row.get(6)?,
value_json: row.get(7)?,
status: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
revision: row.get(11)?,
})
}
fn row_to_navigation_recent(row: &rusqlite::Row<'_>) -> rusqlite::Result<NavigationRecentRecord> {
Ok(NavigationRecentRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: empty_string_to_option(row.get(2)?),
kind: row.get(3)?,
source_kind: row.get(4)?,
root_uri: row.get(5)?,
target_key: row.get(6)?,
relative_path: row.get(7)?,
document_id: row.get(8)?,
title: row.get(9)?,
status: row.get(10)?,
metadata_json: row.get(11)?,
visited_at: row.get(12)?,
created_at: row.get(13)?,
updated_at: row.get(14)?,
revision: row.get(15)?,
})
}
fn navigation_recent_target_key(
kind: &str,
root_uri: &str,
relative_path: Option<&str>,
document_id: Option<&str>,
) -> Result<String, ControlPlaneError> {
match kind {
"folder" => Ok(format!(
"folder:{}:{}",
root_uri.trim(),
relative_path.map(str::trim).unwrap_or_default()
)),
"page" => {
let document_id = document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ControlPlaneError::InvalidInput(
"navigation recent page 需要 document_id".to_string(),
)
})?;
Ok(format!("page:{}:{document_id}", root_uri.trim()))
}
_ => Err(ControlPlaneError::InvalidInput(
"navigation recent kind 只能是 folder 或 page".to_string(),
)),
}
}
fn validate_ai_external_conversation_status(status: &str) -> Result<(), ControlPlaneError> {
match status {
"active"
| "local_deleted"
| "remote_deleted"
| "remote_delete_failed"
| "remote_missing" => Ok(()),
_ => Err(ControlPlaneError::InvalidInput(
"external conversation status 不合法".to_string(),
)),
}
}
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
Ok(AiPolicyRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
allowed_roots_json: row.get(3)?,
model_policy_json: row.get(4)?,
quota_json: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
revision: row.get(8)?,
})
}
fn row_to_sync_state(row: &rusqlite::Row<'_>) -> rusqlite::Result<SyncStateRecord> {
Ok(SyncStateRecord {
id: row.get(0)?,
workspace_id: row.get(1)?,
remote_kind: row.get(2)?,
remote_id: row.get(3)?,
cursor: row.get(4)?,
last_synced_at: row.get(5)?,
status: row.get(6)?,
error_json: row.get(7)?,
updated_at: row.get(8)?,
revision: row.get(9)?,
})
}
fn row_to_ai_runtime_run(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiRuntimeRunRecord> {
Ok(AiRuntimeRunRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
document_id: row.get(3)?,
session_id: row.get(4)?,
run_id: row.get(5)?,
title: row.get(6)?,
profile: row.get(7)?,
acp_runtime: row.get(8)?,
trace_id: row.get(9)?,
status: row.get(10)?,
runtime_json: row.get(11)?,
payload_json: row.get(12)?,
deleted_at: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
revision: row.get(16)?,
})
}
fn row_to_ai_runtime_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiRuntimeEventRecord> {
Ok(AiRuntimeEventRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
document_id: row.get(3)?,
session_id: row.get(4)?,
run_id: row.get(5)?,
profile: row.get(6)?,
acp_runtime: row.get(7)?,
event_type: row.get(8)?,
payload_json: row.get(9)?,
created_at: row.get(10)?,
})
}
fn row_to_ai_runtime_journal_event(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiRuntimeJournalEventRecord> {
Ok(AiRuntimeJournalEventRecord {
seq: row.get(0)?,
event: AiRuntimeEventRecord {
id: row.get(1)?,
user_id: row.get(2)?,
workspace_id: row.get(3)?,
document_id: row.get(4)?,
session_id: row.get(5)?,
run_id: row.get(6)?,
profile: row.get(7)?,
acp_runtime: row.get(8)?,
event_type: row.get(9)?,
payload_json: row.get(10)?,
created_at: row.get(11)?,
},
})
}
fn row_to_ai_external_conversation_binding(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiExternalConversationBindingRecord> {
Ok(AiExternalConversationBindingRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
mnote_session_id: row.get(3)?,
acp_session_id: row.get(4)?,
agent_id: row.get(5)?,
profile: row.get(6)?,
provider: row.get(7)?,
remote_conversation_id: row.get(8)?,
remote_url: row.get(9)?,
status: row.get(10)?,
metadata_json: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
deleted_at: row.get(14)?,
revision: row.get(15)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok()
.and_then(|payload| {
payload
.get("title")
.or_else(|| payload.get("message"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| fallback.to_string());
title.chars().take(80).collect()
}
fn prune_navigation_recent_for_kind(
conn: &Connection,
user_id: &str,
kind: &str,
) -> Result<(), ControlPlaneError> {
let keep = match kind {
"folder" => 10_i64,
"page" => 20_i64,
_ => 20_i64,
};
conn.execute(
"UPDATE user_navigation_recent
SET status = 'removed', updated_at = ?1, revision = revision + 1
WHERE user_id = ?2
AND kind = ?3
AND status = 'active'
AND id NOT IN (
SELECT id FROM user_navigation_recent
WHERE user_id = ?2 AND kind = ?3 AND status = 'active'
ORDER BY visited_at DESC, updated_at DESC
LIMIT ?4
)",
params![now_text(), user_id, kind, keep],
)?;
Ok(())
}
fn list_ai_agent_profile_access_rows(
conn: &Connection,
user_id: &str,
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
let mut stmt = conn.prepare(
"SELECT
p.id, p.agent_id, p.profile_kind, p.owner_user_id, p.base_profile_name,
p.isolated_profile_name, p.display_name, p.status, p.created_at, p.updated_at,
p.revision,
g.id, g.profile_id, g.user_id, g.role, g.can_run, g.can_manage_skills,
g.can_manage_config, g.created_at, g.updated_at, g.revision
FROM ai_agent_profiles p
JOIN ai_agent_profile_grants g ON g.profile_id = p.id
WHERE p.agent_id = 'hermes'
AND p.status = 'active'
AND g.can_run = 1
AND (
(p.profile_kind = 'personal' AND p.owner_user_id = ?1 AND g.user_id = ?1)
OR (p.profile_kind = 'shared' AND (g.user_id = '' OR g.user_id = ?1))
)
ORDER BY
CASE p.profile_kind WHEN 'personal' THEN 0 ELSE 1 END,
p.display_name ASC,
g.can_manage_skills DESC,
g.can_manage_config DESC",
)?;
let rows = stmt
.query_map(params![user_id], row_to_ai_agent_profile_access)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
let mut by_profile = BTreeMap::<String, AiAgentProfileAccessRecord>::new();
for row in rows {
by_profile
.entry(row.profile.id.clone())
.and_modify(|existing| {
if row.grant.can_manage_skills && !existing.grant.can_manage_skills {
*existing = row.clone();
}
})
.or_insert(row);
}
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() {
return Err(ControlPlaneError::InvalidInput(
"username 不能为空".to_string(),
));
}
if input.display_name.trim().is_empty() {
return Err(ControlPlaneError::InvalidInput(
"display_name 不能为空".to_string(),
));
}
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());
let existing = conn
.query_row(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users WHERE id = ?1",
params![id],
row_to_user,
)
.optional()?;
if let Some(existing) = existing {
let revision = existing.revision + 1;
conn.execute(
"UPDATE users
SET email = ?1, username = ?2, display_name = ?3, role = ?4, updated_at = ?5, revision = ?6
WHERE id = ?7",
params![
input.email,
input.username,
input.display_name,
role,
now,
revision,
existing.id
],
)?;
return Ok(UserRecord {
id: existing.id,
email: input.email,
username: input.username,
display_name: input.display_name,
role,
status: existing.status,
created_at: existing.created_at,
updated_at: now,
revision,
});
}
conn.execute(
"INSERT INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, 1)",
params![id, input.email, input.username, input.display_name, role, now, now],
)?;
Ok(UserRecord {
id,
email: input.email,
username: input.username,
display_name: input.display_name,
role,
status: "active".to_string(),
created_at: now.clone(),
updated_at: now,
revision: 1,
})
}
fn create_password_identity(
&self,
input: CreatePasswordIdentityInput,
) -> Result<(), ControlPlaneError> {
let username = input.username.trim();
if username.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"username 不能为空".to_string(),
));
}
if input.password.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"password 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let now = now_text();
let user_id = input.user_id;
let password_hash = password_hash_v1(&input.password);
conn.execute(
"INSERT INTO auth_identities (id, user_id, provider, provider_subject, password_hash, password_version, created_at, updated_at)
VALUES (?1, ?2, 'password_username', ?3, ?4, 1, ?5, ?6)
ON CONFLICT(provider, provider_subject)
DO UPDATE SET user_id = excluded.user_id, password_hash = excluded.password_hash, updated_at = excluded.updated_at",
params![
new_id("ident"),
user_id,
username,
password_hash,
now,
now
],
)?;
if let Some(email) = input
.email
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
conn.execute(
"INSERT INTO auth_identities (id, user_id, provider, provider_subject, password_hash, password_version, created_at, updated_at)
VALUES (?1, ?2, 'password_email', ?3, ?4, 1, ?5, ?6)
ON CONFLICT(provider, provider_subject)
DO UPDATE SET user_id = excluded.user_id, password_hash = excluded.password_hash, updated_at = excluded.updated_at",
params![
new_id("ident"),
user_id,
email,
password_hash_v1(&input.password),
now,
now
],
)?;
}
Ok(())
}
fn authenticate_password(
&self,
input: AuthenticatePasswordInput,
) -> Result<ResolvedAuthSession, ControlPlaneError> {
let account = input.account.trim();
if account.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"account 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let provider = if account.contains('@') {
"password_email"
} else {
"password_username"
};
let expected_hash = password_hash_v1(&input.password);
let user = conn
.query_row(
"SELECT u.id, u.email, u.username, u.display_name, u.role, u.status, u.created_at, u.updated_at, u.revision
FROM auth_identities i
JOIN users u ON u.id = i.user_id
WHERE i.provider = ?1
AND i.provider_subject = ?2
AND i.password_hash = ?3
AND u.status = 'active'
LIMIT 1",
params![provider, account, expected_hash],
row_to_user,
)
.optional()?
.ok_or_else(|| ControlPlaneError::Unauthorized("账号或密码错误".to_string()))?;
drop(conn);
let session = self.create_session(CreateSessionInput {
id: input.session_id,
user_id: user.id.clone(),
token_hash: input.token_hash,
user_agent: input.user_agent,
ip_hash: input.ip_hash,
expires_at: input.expires_at,
})?;
Ok(ResolvedAuthSession { session, user })
}
fn create_session(
&self,
input: CreateSessionInput,
) -> Result<AuthSessionRecord, ControlPlaneError> {
let token_hash = input.token_hash.trim();
if token_hash.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"token_hash 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let now = now_text();
let expires_at = input
.expires_at
.unwrap_or_else(|| (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339());
let record = AuthSessionRecord {
id: input.id.unwrap_or_else(|| new_id("sess")),
user_id: input.user_id,
token_hash: token_hash.to_string(),
user_agent: input.user_agent,
ip_hash: input.ip_hash,
created_at: now.clone(),
expires_at,
revoked_at: None,
last_seen_at: now.clone(),
};
conn.execute(
"INSERT INTO auth_sessions (id, user_id, token_hash, user_agent, ip_hash, created_at, expires_at, revoked_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)",
params![
record.id,
record.user_id,
record.token_hash,
record.user_agent,
record.ip_hash,
record.created_at,
record.expires_at,
record.last_seen_at
],
)?;
Ok(record)
}
fn get_session_by_token_hash(
&self,
token_hash: &str,
) -> Result<Option<ResolvedAuthSession>, ControlPlaneError> {
let token_hash = token_hash.trim();
if token_hash.is_empty() {
return Ok(None);
}
let conn = self.lock_conn()?;
let now = now_text();
let resolved = conn
.query_row(
"SELECT
s.id, s.user_id, s.token_hash, s.user_agent, s.ip_hash, s.created_at, s.expires_at, s.revoked_at, s.last_seen_at,
u.id, u.email, u.username, u.display_name, u.role, u.status, u.created_at, u.updated_at, u.revision
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?1
AND s.revoked_at IS NULL
AND s.expires_at > ?2
AND u.status = 'active'
LIMIT 1",
params![token_hash, now],
|row| {
Ok(ResolvedAuthSession {
session: row_to_auth_session(row)?,
user: UserRecord {
id: row.get(9)?,
email: row.get(10)?,
username: row.get(11)?,
display_name: row.get(12)?,
role: row.get(13)?,
status: row.get(14)?,
created_at: row.get(15)?,
updated_at: row.get(16)?,
revision: row.get(17)?,
},
})
},
)
.optional()?;
if let Some(resolved) = &resolved {
conn.execute(
"UPDATE auth_sessions SET last_seen_at = ?1 WHERE id = ?2",
params![now, resolved.session.id],
)?;
}
Ok(resolved)
}
fn revoke_session(&self, session_id: &str) -> Result<(), ControlPlaneError> {
let session_id = session_id.trim();
if session_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"session_id 不能为空".to_string(),
));
}
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],
)?;
Ok(())
}
fn ensure_default_workspace(
&self,
actor_id: &str,
) -> Result<WorkspaceRecord, ControlPlaneError> {
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
FROM workspaces
WHERE owner_user_id = ?1 AND kind = 'personal' AND status = 'active'
LIMIT 1",
params![actor_id],
row_to_workspace,
)
.optional()?;
if let Some(workspace) = existing {
return Ok(workspace);
}
let user = conn
.query_row(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users WHERE id = ?1",
params![actor_id],
row_to_user,
)
.optional()?
.ok_or_else(|| ControlPlaneError::NotFound(format!("user not found: {actor_id}")))?;
let now = now_text();
let workspace = WorkspaceRecord {
id: new_id("ws"),
owner_user_id: user.id.clone(),
name: format!("{} 的空间", user.display_name),
kind: "personal".to_string(),
root_uri: format!("local://users/{}/workspaces/my-space", user.id),
root_path: format!(
"/mnt/Data1T/Mnote_data/users/{}/workspaces/my-space",
user.id
),
source_kind: "local_folder".to_string(),
status: "active".to_string(),
created_at: now.clone(),
updated_at: now.clone(),
revision: 1,
};
conn.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, ?8, ?9, ?10, ?11)",
params![
workspace.id,
workspace.owner_user_id,
workspace.name,
workspace.kind,
workspace.root_uri,
workspace.root_path,
workspace.source_kind,
workspace.status,
workspace.created_at,
workspace.updated_at,
workspace.revision
],
)?;
conn.execute(
"INSERT 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"), workspace.id, user.id, now, now],
)?;
conn.execute(
"INSERT INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, 'write', 1, ?6, 'auto', 'active', ?7, ?8, ?9, 1)",
params![
new_id("grant"),
workspace.owner_user_id,
workspace.id,
workspace.root_uri,
workspace.root_path,
capabilities_json(&["ai".to_string(), "share".to_string()])?,
workspace.owner_user_id,
now,
now
],
)?;
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.lock_conn()?;
let now = now_text();
let record = DirectoryGrantRecord {
id: new_id("grant"),
user_id: input.user_id,
workspace_id: input.workspace_id,
root_uri: input.root_uri,
root_path: input.root_path,
permission: input.permission,
recursive: input.recursive,
capabilities_json: capabilities_json(&input.capabilities)?,
source: input.source,
status: "active".to_string(),
created_by: input.created_by,
created_at: now.clone(),
updated_at: now,
revision: 1,
};
conn.execute(
"INSERT INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
record.id,
record.user_id,
record.workspace_id,
record.root_uri,
record.root_path,
record.permission,
if record.recursive { 1 } else { 0 },
record.capabilities_json,
record.source,
record.status,
record.created_by,
record.created_at,
record.updated_at,
record.revision
],
)?;
Ok(record)
}
fn list_directory_grants(&self) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError> {
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
WHERE status = 'active'
ORDER BY created_at ASC",
)?;
let grants = stmt
.query_map([], row_to_grant)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(grants)
}
fn list_directory_grants_for_actor(
&self,
actor_id: &str,
) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError> {
let actor_id = actor_id.trim();
if actor_id.is_empty() {
return Ok(Vec::new());
}
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
WHERE user_id = ?1 AND status = 'active'
ORDER BY created_at ASC",
)?;
let grants = stmt
.query_map(params![actor_id], row_to_grant)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(grants)
}
fn find_directory_grants(
&self,
lookup: DirectoryGrantLookup,
) -> Result<Vec<DirectoryGrantRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let grant_id = lookup
.grant_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let user_id = lookup
.user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let root_uri = lookup
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let status = if lookup.include_revoked {
"%"
} else {
"active"
};
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
WHERE (?1 IS NULL OR id = ?1)
AND (?2 IS NULL OR user_id = ?2)
AND (?3 IS NULL OR root_uri = ?3)
AND status LIKE ?4
ORDER BY created_at ASC",
)?;
let grants = stmt
.query_map(params![grant_id, user_id, root_uri, status], row_to_grant)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(grants)
}
fn revoke_directory_grant(
&self,
grant_id: &str,
expected_revision: Option<i64>,
) -> Result<(), ControlPlaneError> {
let grant_id = grant_id.trim();
if grant_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"grant_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let existing_revision = conn
.query_row(
"SELECT revision FROM directory_grants WHERE id = ?1 AND status = 'active'",
params![grant_id],
|row| row.get::<_, i64>(0),
)
.optional()?
.ok_or_else(|| ControlPlaneError::NotFound(format!("grant not found: {grant_id}")))?;
if let Some(expected_revision) = expected_revision {
if expected_revision != existing_revision {
return Err(ControlPlaneError::Conflict(
"目录授权 revision 已变化".to_string(),
));
}
}
conn.execute(
"UPDATE directory_grants
SET status = 'revoked', updated_at = ?1, revision = revision + 1
WHERE id = ?2 AND status = 'active'",
params![now_text(), grant_id],
)?;
Ok(())
}
fn resolve_access(
&self,
actor_id: &str,
root_uri: &str,
) -> Result<ResolvedAccess, ControlPlaneError> {
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
FROM directory_grants
WHERE user_id = ?1
AND status = 'active'
AND (
?2 = root_uri
OR (recursive = 1 AND ?2 LIKE root_uri || '/%')
OR (recursive = 1 AND root_uri LIKE '%/' AND ?2 LIKE root_uri || '%')
OR (?3 != '' AND ?3 = root_path)
OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%')
)",
)?;
let grants = stmt
.query_map(params![actor_id, root_uri, root_path], row_to_grant)?
.collect::<Result<Vec<_>, _>>()?;
let mut permission = "none".to_string();
for grant in &grants {
permission = max_permission(&permission, &grant.permission).to_string();
}
Ok(ResolvedAccess {
user_id: actor_id.to_string(),
root_uri: root_uri.to_string(),
permission,
grant_ids: grants.into_iter().map(|grant| grant.id).collect(),
})
}
fn create_share_link(
&self,
input: CreateShareLinkInput,
) -> Result<CreatedShareLink, ControlPlaneError> {
let workspace_id = input.workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"workspace_id 不能为空".to_string(),
));
}
let resource_kind = input.resource_kind.trim().to_string();
let resource_id = input.resource_id.trim().to_string();
let permission = input.permission.trim().to_string();
if resource_kind.is_empty() || resource_id.is_empty() || permission.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"share_link 资源与权限不能为空".to_string(),
));
}
let token = input
.token
.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.lock_conn()?;
let now = now_text();
let link = ShareLinkRecord {
id: input.id.unwrap_or_else(|| new_id("share")),
workspace_id,
resource_kind,
resource_id,
token_hash: token_hash.clone(),
permission,
created_by: input.created_by,
expires_at: input.expires_at,
revoked_at: None,
created_at: now.clone(),
updated_at: now.clone(),
revision: 1,
};
conn.execute(
"INSERT INTO share_links (id, workspace_id, resource_kind, resource_id, token_hash, permission, created_by, expires_at, revoked_at, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, 1)",
params![
link.id,
link.workspace_id,
link.resource_kind,
link.resource_id,
link.token_hash,
link.permission,
link.created_by,
link.expires_at,
link.created_at,
link.updated_at
],
)?;
Ok(CreatedShareLink { link, token })
}
fn list_share_links(
&self,
workspace_id: &str,
) -> Result<Vec<ShareLinkRecord>, ControlPlaneError> {
let workspace_id = workspace_id.trim();
if workspace_id.is_empty() {
return Ok(Vec::new());
}
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
WHERE workspace_id = ?1
ORDER BY created_at ASC",
)?;
let links = stmt
.query_map(params![workspace_id], row_to_share_link)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(links)
}
fn resolve_share_link(
&self,
token_hash: &str,
) -> Result<Option<ShareLinkRecord>, ControlPlaneError> {
let token_hash = token_hash.trim();
if token_hash.is_empty() {
return Ok(None);
}
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
FROM share_links
WHERE token_hash = ?1 AND revoked_at IS NULL
LIMIT 1",
params![token_hash],
row_to_share_link,
)
.optional()?;
Ok(link)
}
fn revoke_share_link(&self, link_id: &str) -> Result<(), ControlPlaneError> {
let link_id = link_id.trim();
if link_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"link_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let existed = conn.execute(
"UPDATE share_links
SET revoked_at = ?1, updated_at = ?2, revision = revision + 1
WHERE id = ?3 AND revoked_at IS NULL",
params![now_text(), now_text(), link_id],
)?;
if existed == 0 {
return Err(ControlPlaneError::NotFound(format!(
"share link not found: {link_id}"
)));
}
Ok(())
}
fn append_audit(&self, input: AppendAuditInput) -> Result<(), ControlPlaneError> {
let action = input.action.trim();
let target_kind = input.target_kind.trim();
if action.is_empty() || target_kind.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"audit action/target_kind 不能为空".to_string(),
));
}
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)",
params![
new_id("audit"),
input.actor_user_id,
action,
target_kind,
input.target_id,
input.metadata_json,
now_text()
],
)?;
Ok(())
}
fn list_audit_log(&self, limit: usize) -> Result<Vec<AuditLogRecord>, ControlPlaneError> {
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
ORDER BY created_at DESC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_audit_log)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn append_outbox(
&self,
input: OutboxEventInput,
) -> Result<OutboxEventRecord, ControlPlaneError> {
let conn = self.lock_conn()?;
let record = OutboxEventRecord {
id: new_id("evt"),
topic: input.topic,
event_type: input.event_type,
payload_json: input.payload_json,
created_at: now_text(),
delivered_at: None,
attempts: 0,
};
conn.execute(
"INSERT INTO outbox_events (id, topic, event_type, payload_json, created_at, delivered_at, attempts)
VALUES (?1, ?2, ?3, ?4, ?5, NULL, 0)",
params![
record.id,
record.topic,
record.event_type,
record.payload_json,
record.created_at
],
)?;
Ok(record)
}
fn drain_outbox(&self, limit: usize) -> Result<Vec<OutboxEventRecord>, ControlPlaneError> {
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
WHERE delivered_at IS NULL
ORDER BY created_at ASC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_outbox)?
.collect::<Result<Vec<_>, _>>()?;
let delivered_at = now_text();
for row in &rows {
conn.execute(
"UPDATE outbox_events SET delivered_at = ?1, attempts = attempts + 1 WHERE id = ?2",
params![delivered_at, row.id],
)?;
}
Ok(rows)
}
fn mark_outbox_delivered(&self, event_id: &str) -> Result<(), ControlPlaneError> {
let event_id = event_id.trim();
if event_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"event_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let updated = conn.execute(
"UPDATE outbox_events
SET delivered_at = COALESCE(delivered_at, ?1), attempts = attempts + 1
WHERE id = ?2",
params![now_text(), event_id],
)?;
if updated == 0 {
return Err(ControlPlaneError::NotFound(format!(
"outbox event not found: {event_id}"
)));
}
Ok(())
}
fn upsert_sidebar_shortcut(
&self,
input: UpsertSidebarShortcutInput,
) -> Result<SidebarShortcutRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = input.workspace_id.trim().to_string();
let root_uri = input
.root_uri
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let kind = input.kind.trim().to_string();
let source_kind = input.source_kind.trim().to_string();
let target_id = input.target_id.trim().to_string();
let title = input.title.trim().to_string();
if user_id.is_empty()
|| workspace_id.is_empty()
|| kind.is_empty()
|| source_kind.is_empty()
|| target_id.is_empty()
|| title.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"sidebar shortcut user/workspace/kind/target/title 不能为空".to_string(),
));
}
if kind != "page" && kind != "folder" {
return Err(ControlPlaneError::InvalidInput(
"sidebar shortcut kind 只能是 page 或 folder".to_string(),
));
}
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO sidebar_shortcuts (
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, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'active', ?13, ?14, ?15, 1)
ON CONFLICT(user_id, workspace_id, kind, target_id)
DO UPDATE SET
root_uri = excluded.root_uri,
relative_path = excluded.relative_path,
document_id = excluded.document_id,
title = excluded.title,
icon = excluded.icon,
sort_order = excluded.sort_order,
status = 'active',
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at,
revision = sidebar_shortcuts.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("shortcut")),
user_id,
workspace_id,
root_uri,
kind,
source_kind,
target_id,
input.relative_path,
input.document_id,
title,
input.icon,
input.sort_order,
input.metadata_json,
now,
now
],
)?;
let record = conn.query_row(
"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,
updated_at, revision
FROM sidebar_shortcuts
WHERE user_id = ?1 AND workspace_id = ?2 AND kind = ?3 AND target_id = ?4
LIMIT 1",
params![user_id, workspace_id, kind, target_id],
row_to_sidebar_shortcut,
)?;
Ok(record)
}
fn list_sidebar_shortcuts(
&self,
user_id: &str,
workspace_id: &str,
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let workspace_id = workspace_id.trim();
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
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,
updated_at, revision
FROM sidebar_shortcuts
WHERE user_id = ?1 AND workspace_id = ?2 AND status = 'active'
ORDER BY sort_order ASC, created_at ASC",
)?;
let rows = stmt
.query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn list_sidebar_shortcuts_with_global_local(
&self,
user_id: &str,
workspace_id: &str,
) -> Result<Vec<SidebarShortcutRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let workspace_id = workspace_id.trim();
if user_id.is_empty() || workspace_id.is_empty() {
return Ok(Vec::new());
}
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,
updated_at, revision
FROM sidebar_shortcuts
WHERE user_id = ?1
AND status = 'active'
AND (workspace_id = ?2 OR source_kind = 'local_folder')
ORDER BY sort_order ASC, created_at ASC",
)?;
let rows = stmt
.query_map(params![user_id, workspace_id], row_to_sidebar_shortcut)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn delete_sidebar_shortcut(
&self,
user_id: &str,
shortcut_id: &str,
) -> Result<(), ControlPlaneError> {
let user_id = user_id.trim();
let shortcut_id = shortcut_id.trim();
if user_id.is_empty() || shortcut_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"sidebar shortcut user_id/shortcut_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE sidebar_shortcuts
SET status = 'removed', updated_at = ?1, revision = revision + 1
WHERE id = ?2 AND user_id = ?3 AND status = 'active'",
params![now_text(), shortcut_id, user_id],
)?;
if changed == 0 {
return Err(ControlPlaneError::NotFound(format!(
"sidebar shortcut not found: {shortcut_id}"
)));
}
Ok(())
}
fn upsert_user_ui_preference(
&self,
input: UpsertUserUiPreferenceInput,
) -> Result<UserUiPreferenceRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = option_to_stored_text(input.workspace_id);
let source_kind = option_to_stored_text(input.source_kind);
let scope_kind = input.scope_kind.trim().to_string();
let scope_id = input.scope_id.trim().to_string();
let key = input.key.trim().to_string();
if user_id.is_empty() || scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"user UI preference user/scope/key 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.value_json)?;
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO user_ui_preferences (
id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, 1)
ON CONFLICT(user_id, workspace_id, source_kind, scope_kind, scope_id, key)
DO UPDATE SET
value_json = excluded.value_json,
status = 'active',
updated_at = excluded.updated_at,
revision = user_ui_preferences.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("ui_pref")),
user_id,
workspace_id,
source_kind,
scope_kind,
scope_id,
key,
input.value_json,
now,
now
],
)?;
let record = conn.query_row(
"SELECT id, user_id, workspace_id, source_kind, scope_kind, scope_id, key,
value_json, status, created_at, updated_at, revision
FROM user_ui_preferences
WHERE user_id = ?1
AND workspace_id = ?2
AND source_kind = ?3
AND scope_kind = ?4
AND scope_id = ?5
AND key = ?6
LIMIT 1",
params![
user_id,
workspace_id,
source_kind,
scope_kind,
scope_id,
key
],
row_to_user_ui_preference,
)?;
Ok(record)
}
fn list_user_ui_preferences(
&self,
user_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(Vec::new());
}
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.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
FROM user_ui_preferences
WHERE user_id = ?1
AND status = 'active'
AND (workspace_id = '' OR workspace_id = ?2)
AND (source_kind = '' OR source_kind = ?3)
ORDER BY created_at ASC",
)?;
let rows = stmt
.query_map(
params![user_id, workspace_id, source_kind],
row_to_user_ui_preference,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn list_user_ui_preferences_for_scope(
&self,
workspace_id: Option<&str>,
source_kind: Option<&str>,
scope_kind: &str,
scope_id: &str,
key: &str,
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError> {
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
let source_kind = source_kind.map(str::trim).unwrap_or_default();
let scope_kind = scope_kind.trim();
let scope_id = scope_id.trim();
let key = key.trim();
if scope_kind.is_empty() || scope_id.is_empty() || key.is_empty() {
return Ok(Vec::new());
}
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
FROM user_ui_preferences
WHERE status = 'active'
AND workspace_id = ?1
AND source_kind = ?2
AND scope_kind = ?3
AND scope_id = ?4
AND key = ?5
ORDER BY updated_at ASC",
)?;
let rows = stmt
.query_map(
params![workspace_id, source_kind, scope_kind, scope_id, key],
row_to_user_ui_preference,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn ensure_ai_agent_profile_policy(
&self,
user_id: &str,
is_admin: bool,
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai agent profile user_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES ('shared_lite', 'hermes', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
params![now, now],
)?;
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES ('grant_shared_lite_all', 'shared_lite', '', 'user', 1, 0, 0, ?1, ?2, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![now, now],
)?;
// 旧的泛化 OpenClaw 网页问答入口已拆成明确的三类 chat agent。
conn.execute(
"UPDATE ai_agent_profiles
SET status = 'retired', updated_at = ?1
WHERE id = 'shared_openclaw_webqa'",
params![now],
)?;
let shared_chat_profiles = [
(
"shared_deepseek_chat",
"deepseek-chat",
"openclaw-deepseek-chat",
"DeepSeek Chat",
),
(
"shared_gemini_chat",
"gemini-chat",
"openclaw-gemini-chat",
"Gemini Chat",
),
(
"shared_doubao_chat",
"doubao-chat",
"openclaw-doubao-chat",
"豆包 Chat",
),
(
"shared_api_deepseek_flash_chat",
"api-deepseek-flash-chat",
"api-deepseek-flash-chat",
"DeepSeek Flash Chat",
),
(
"shared_api_deepseek_pro_chat",
"api-deepseek-pro-chat",
"api-deepseek-pro-chat",
"DeepSeek Pro Chat",
),
(
"shared_api_gpt_chat",
"api-gpt-chat",
"api-gpt-chat",
"GPT Chat",
),
(
"shared_api_kimi_chat",
"api-kimi-chat",
"api-kimi-chat",
"Kimi Chat",
),
(
"shared_api_gemini_chat",
"api-gemini-chat",
"api-gemini-chat",
"Gemini API Chat",
),
(
"shared_api_grok_chat",
"api-grok-chat",
"api-grok-chat",
"Grok API Chat",
),
];
for (profile_id, base_profile, isolated_profile, display_name) in shared_chat_profiles {
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES (?1, 'hermes', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', display_name = excluded.display_name,
isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at",
params![profile_id, base_profile, isolated_profile, display_name, now, now],
)?;
let grant_id = format!("grant_{profile_id}_all");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, '', 'user', 1, 0, 0, ?3, ?4, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![grant_id, profile_id, now, now],
)?;
}
let user_part = sanitize_profile_component(user_id);
let personal_profile_id = format!("usr_{user_part}_default");
let personal_profile_name = format!("mnote-u-{user_part}-default");
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES (?1, 'hermes', 'personal', ?2, 'default', ?3, '我的 Hermes', 'active', ?4, ?5, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
params![personal_profile_id, user_id, personal_profile_name, now, now],
)?;
let personal_grant_id = format!("grant_{personal_profile_id}_owner");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, 'owner', 1, 1, 1, ?4, ?5, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
updated_at = excluded.updated_at",
params![personal_grant_id, personal_profile_id, user_id, now, now],
)?;
if is_admin {
let admin_grant_id = format!("grant_shared_lite_admin_{user_part}");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, 'shared_lite', ?2, 'admin', 1, 1, 1, ?3, ?4, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
updated_at = excluded.updated_at",
params![admin_grant_id, user_id, now, now],
)?;
for (profile_id, _, _, _) in shared_chat_profiles {
let admin_chat_grant_id = format!("grant_{profile_id}_admin_{user_part}");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, 'admin', 1, 0, 0, ?4, ?5, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![admin_chat_grant_id, profile_id, user_id, now, now],
)?;
}
}
list_ai_agent_profile_access_rows(&conn, user_id)
}
fn resolve_ai_agent_profile(
&self,
user_id: &str,
is_admin: bool,
profile_id: &str,
) -> Result<Option<AiAgentProfileAccessRecord>, ControlPlaneError> {
let profile_id = profile_id.trim();
if profile_id.is_empty() {
return Ok(None);
}
let access = self.ensure_ai_agent_profile_policy(user_id, is_admin)?;
Ok(access
.into_iter()
.find(|item| item.profile.id == profile_id && item.grant.can_run))
}
fn upsert_navigation_recent(
&self,
input: UpsertNavigationRecentInput,
) -> Result<NavigationRecentRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = option_to_stored_text(input.workspace_id);
let kind = input.kind.trim().to_string();
let source_kind = input.source_kind.trim().to_string();
let root_uri = input.root_uri.trim().to_string();
let relative_path = input
.relative_path
.map(|value| value.trim().trim_matches('/').to_string())
.filter(|value| !value.is_empty());
let document_id = input
.document_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let title = input.title.trim().to_string();
if user_id.is_empty()
|| kind.is_empty()
|| source_kind.is_empty()
|| root_uri.is_empty()
|| title.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"navigation recent user/kind/source/root/title 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
let target_key = navigation_recent_target_key(
&kind,
&root_uri,
relative_path.as_deref(),
document_id.as_deref(),
)?;
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO user_navigation_recent (
id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, 1)
ON CONFLICT(user_id, kind, target_key)
DO UPDATE SET
workspace_id = excluded.workspace_id,
source_kind = excluded.source_kind,
root_uri = excluded.root_uri,
relative_path = excluded.relative_path,
document_id = excluded.document_id,
title = excluded.title,
status = 'active',
metadata_json = excluded.metadata_json,
visited_at = excluded.visited_at,
updated_at = excluded.updated_at,
revision = user_navigation_recent.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("nav_recent")),
user_id,
workspace_id,
kind,
source_kind,
root_uri,
target_key,
relative_path,
document_id,
title,
input.metadata_json,
now,
now,
now,
],
)?;
prune_navigation_recent_for_kind(&conn, &user_id, &kind)?;
let record = conn.query_row(
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
FROM user_navigation_recent
WHERE user_id = ?1 AND kind = ?2 AND target_key = ?3
LIMIT 1",
params![user_id, kind, target_key],
row_to_navigation_recent,
)?;
Ok(record)
}
fn list_navigation_recent(
&self,
user_id: &str,
kind: Option<&str>,
limit: usize,
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(Vec::new());
}
let limit = limit.clamp(1, 100) as i64;
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,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
FROM user_navigation_recent
WHERE user_id = ?1 AND kind = ?2 AND status = 'active'
ORDER BY visited_at DESC, updated_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(params![user_id, kind, limit], row_to_navigation_recent)?
.collect::<Result<Vec<_>, _>>()?;
rows
} else {
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
relative_path, document_id, title, status, metadata_json, visited_at,
created_at, updated_at, revision
FROM user_navigation_recent
WHERE user_id = ?1 AND status = 'active'
ORDER BY visited_at DESC, updated_at DESC
LIMIT ?2",
)?;
let rows = stmt
.query_map(params![user_id, limit], row_to_navigation_recent)?
.collect::<Result<Vec<_>, _>>()?;
rows
};
Ok(rows)
}
fn upsert_ai_policy(
&self,
input: UpsertAiPolicyInput,
) -> Result<AiPolicyRecord, ControlPlaneError> {
if input.user_id.is_none() && input.workspace_id.is_none() {
return Err(ControlPlaneError::InvalidInput(
"ai_policy 必须绑定 user_id 或 workspace_id".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.allowed_roots_json)?;
serde_json::from_str::<serde_json::Value>(&input.model_policy_json)?;
serde_json::from_str::<serde_json::Value>(&input.quota_json)?;
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
"SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision
FROM ai_policies
WHERE (?1 IS NULL OR user_id = ?1)
AND (?2 IS NULL OR workspace_id = ?2)
ORDER BY created_at DESC
LIMIT 1",
params![input.user_id, input.workspace_id],
row_to_ai_policy,
)
.optional()?;
if let Some(existing) = existing {
let revision = existing.revision + 1;
conn.execute(
"UPDATE ai_policies
SET allowed_roots_json = ?1, model_policy_json = ?2, quota_json = ?3, updated_at = ?4, revision = ?5
WHERE id = ?6",
params![
input.allowed_roots_json,
input.model_policy_json,
input.quota_json,
now,
revision,
existing.id
],
)?;
return Ok(AiPolicyRecord {
id: existing.id,
user_id: existing.user_id,
workspace_id: existing.workspace_id,
allowed_roots_json: input.allowed_roots_json,
model_policy_json: input.model_policy_json,
quota_json: input.quota_json,
created_at: existing.created_at,
updated_at: now,
revision,
});
}
let record = AiPolicyRecord {
id: input.id.unwrap_or_else(|| new_id("aip")),
user_id: input.user_id,
workspace_id: input.workspace_id,
allowed_roots_json: input.allowed_roots_json,
model_policy_json: input.model_policy_json,
quota_json: input.quota_json,
created_at: now.clone(),
updated_at: now,
revision: 1,
};
conn.execute(
"INSERT INTO ai_policies (id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
params![
record.id,
record.user_id,
record.workspace_id,
record.allowed_roots_json,
record.model_policy_json,
record.quota_json,
record.created_at,
record.updated_at
],
)?;
Ok(record)
}
fn get_ai_policy(
&self,
actor_id: &str,
workspace_id: Option<&str>,
) -> Result<Option<AiPolicyRecord>, ControlPlaneError> {
let actor_id = actor_id.trim();
let workspace_id = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty());
if actor_id.is_empty() && workspace_id.is_none() {
return Ok(None);
}
let conn = self.lock_conn()?;
if let Some(workspace_id) = workspace_id {
let workspace_policy = conn
.query_row(
"SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision
FROM ai_policies
WHERE workspace_id = ?1
ORDER BY updated_at DESC
LIMIT 1",
params![workspace_id],
row_to_ai_policy,
)
.optional()?;
if workspace_policy.is_some() {
return Ok(workspace_policy);
}
}
if actor_id.is_empty() {
return Ok(None);
}
conn.query_row(
"SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision
FROM ai_policies
WHERE user_id = ?1 AND workspace_id IS NULL
ORDER BY updated_at DESC
LIMIT 1",
params![actor_id],
row_to_ai_policy,
)
.optional()
.map_err(ControlPlaneError::from)
}
fn upsert_sync_state(
&self,
input: UpsertSyncStateInput,
) -> Result<SyncStateRecord, ControlPlaneError> {
let workspace_id = input.workspace_id.trim().to_string();
let remote_kind = input.remote_kind.trim().to_string();
let status = input.status.trim().to_string();
if workspace_id.is_empty() || remote_kind.is_empty() || status.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"sync_state workspace_id/remote_kind/status 不能为空".to_string(),
));
}
if let Some(error_json) = &input.error_json {
serde_json::from_str::<serde_json::Value>(error_json)?;
}
let conn = self.lock_conn()?;
let now = now_text();
let existing = conn
.query_row(
"SELECT id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision
FROM sync_state
WHERE workspace_id = ?1 AND remote_kind = ?2
LIMIT 1",
params![workspace_id, remote_kind],
row_to_sync_state,
)
.optional()?;
if let Some(existing) = existing {
let revision = existing.revision + 1;
conn.execute(
"UPDATE sync_state
SET remote_id = ?1, cursor = ?2, last_synced_at = ?3, status = ?4, error_json = ?5, updated_at = ?6, revision = ?7
WHERE id = ?8",
params![
input.remote_id,
input.cursor,
input.last_synced_at,
status,
input.error_json,
now,
revision,
existing.id
],
)?;
return Ok(SyncStateRecord {
id: existing.id,
workspace_id: existing.workspace_id,
remote_kind: existing.remote_kind,
remote_id: input.remote_id,
cursor: input.cursor,
last_synced_at: input.last_synced_at,
status,
error_json: input.error_json,
updated_at: now,
revision,
});
}
let record = SyncStateRecord {
id: input.id.unwrap_or_else(|| new_id("sync")),
workspace_id,
remote_kind,
remote_id: input.remote_id,
cursor: input.cursor,
last_synced_at: input.last_synced_at,
status,
error_json: input.error_json,
updated_at: now,
revision: 1,
};
conn.execute(
"INSERT INTO sync_state (id, workspace_id, remote_kind, remote_id, cursor, last_synced_at, status, error_json, updated_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
params![
record.id,
record.workspace_id,
record.remote_kind,
record.remote_id,
record.cursor,
record.last_synced_at,
record.status,
record.error_json,
record.updated_at
],
)?;
Ok(record)
}
fn get_sync_state(
&self,
workspace_id: &str,
remote_kind: &str,
) -> Result<Option<SyncStateRecord>, ControlPlaneError> {
let workspace_id = workspace_id.trim();
let remote_kind = remote_kind.trim();
if workspace_id.is_empty() || remote_kind.is_empty() {
return Ok(None);
}
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
WHERE workspace_id = ?1 AND remote_kind = ?2
LIMIT 1",
params![workspace_id, remote_kind],
row_to_sync_state,
)
.optional()
.map_err(ControlPlaneError::from)
}
fn upsert_ai_runtime_run(
&self,
input: UpsertAiRuntimeRunInput,
) -> Result<AiRuntimeRunRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.run_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai runtime run user_id/session_id/run_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.runtime_json)?;
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let now = now_text();
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
FROM ai_runtime_runs
WHERE run_id = ?1
LIMIT 1",
params![input.run_id],
row_to_ai_runtime_run,
)
.optional()?;
if let Some(existing) = existing {
let revision = existing.revision + 1;
conn.execute(
"UPDATE ai_runtime_runs
SET title = ?1, profile = ?2, acp_runtime = ?3, trace_id = ?4, status = ?5, runtime_json = ?6, payload_json = ?7, deleted_at = NULL, updated_at = ?8, revision = ?9
WHERE id = ?10",
params![
input.title,
input.profile,
input.acp_runtime,
input.trace_id,
input.status,
input.runtime_json,
input.payload_json,
now,
revision,
existing.id
],
)?;
return Ok(AiRuntimeRunRecord {
id: existing.id,
user_id: existing.user_id,
workspace_id: existing.workspace_id,
document_id: existing.document_id,
session_id: existing.session_id,
run_id: existing.run_id,
title: input.title,
profile: input.profile,
acp_runtime: input.acp_runtime,
trace_id: input.trace_id,
status: input.status,
runtime_json: input.runtime_json,
payload_json: input.payload_json,
deleted_at: None,
created_at: existing.created_at,
updated_at: now,
revision,
});
}
let record = AiRuntimeRunRecord {
id: input.id.unwrap_or_else(|| new_id("acr")),
user_id: input.user_id,
workspace_id: input.workspace_id,
document_id: input.document_id,
session_id: input.session_id,
run_id: input.run_id,
title: input.title,
profile: input.profile,
acp_runtime: input.acp_runtime,
trace_id: input.trace_id,
status: input.status,
runtime_json: input.runtime_json,
payload_json: input.payload_json,
deleted_at: None,
created_at: now.clone(),
updated_at: now,
revision: 1,
};
conn.execute(
"INSERT INTO ai_runtime_runs (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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL, ?14, ?15, 1)",
params![
record.id,
record.user_id,
record.workspace_id,
record.document_id,
record.session_id,
record.run_id,
record.title,
record.profile,
record.acp_runtime,
record.trace_id,
record.status,
record.runtime_json,
record.payload_json,
record.created_at,
record.updated_at
],
)?;
Ok(record)
}
fn list_ai_runtime_runs(
&self,
user_id: &str,
workspace_id: Option<&str>,
document_id: Option<&str>,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiRuntimeRunRecord>, ControlPlaneError> {
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
WHERE user_id = ?1
AND (?2 IS NULL OR workspace_id = ?2)
AND (?3 IS NULL OR document_id = ?3)
AND (?4 IS NULL OR session_id = ?4)
AND deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT ?5",
)?;
let rows = stmt
.query_map(
params![user_id, workspace_id, document_id, session_id, limit as i64],
row_to_ai_runtime_run,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn find_ai_runtime_run(
&self,
user_id: &str,
run_id: &str,
) -> Result<Option<AiRuntimeRunRecord>, ControlPlaneError> {
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
WHERE user_id = ?1 AND run_id = ?2 AND deleted_at IS NULL
LIMIT 1",
params![user_id, run_id],
row_to_ai_runtime_run,
)
.optional()
.map_err(ControlPlaneError::from)
}
fn append_ai_runtime_event(
&self,
input: AppendAiRuntimeEventInput,
) -> Result<AiRuntimeEventRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.run_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai runtime event user_id/session_id/run_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let record = AiRuntimeEventRecord {
id: input.id.unwrap_or_else(|| new_id("are")),
user_id: input.user_id,
workspace_id: input.workspace_id,
document_id: input.document_id,
session_id: input.session_id,
run_id: input.run_id,
profile: input.profile,
acp_runtime: input.acp_runtime,
event_type: input.event_type,
payload_json: input.payload_json,
created_at: now_text(),
};
conn.execute(
"INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
record.id,
record.user_id,
record.workspace_id,
record.document_id,
record.session_id,
record.run_id,
record.profile,
record.acp_runtime,
record.event_type,
record.payload_json,
record.created_at
],
)?;
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.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
WHERE user_id = ?1 AND run_id = ?2
ORDER BY created_at ASC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, run_id, limit as i64],
row_to_ai_runtime_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn list_ai_runtime_journal_events(
&self,
user_id: &str,
run_id: &str,
after_seq: i64,
limit: usize,
) -> Result<Vec<AiRuntimeJournalEventRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"WITH ordered AS (
SELECT
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS seq,
id, user_id, workspace_id, document_id, session_id, run_id, profile,
acp_runtime, event_type, payload_json, created_at
FROM ai_runtime_events
WHERE user_id = ?1 AND run_id = ?2
)
SELECT seq, id, user_id, workspace_id, document_id, session_id, run_id, profile,
acp_runtime, event_type, payload_json, created_at
FROM ordered
WHERE seq > ?3
ORDER BY seq ASC
LIMIT ?4",
)?;
let rows = stmt
.query_map(
params![user_id, run_id, after_seq.max(0), limit.max(1) as i64],
row_to_ai_runtime_journal_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn upsert_ai_external_conversation_binding(
&self,
input: UpsertAiExternalConversationBindingInput,
) -> Result<AiExternalConversationBindingRecord, ControlPlaneError> {
let user_id = input.user_id.trim();
let mnote_session_id = input.mnote_session_id.trim();
let provider = input.provider.trim();
let remote_conversation_id = input.remote_conversation_id.trim();
if user_id.is_empty()
|| mnote_session_id.is_empty()
|| provider.is_empty()
|| remote_conversation_id.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"external conversation binding user_id/session_id/provider/remote_id 不能为空"
.to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
validate_ai_external_conversation_status(&input.status)?;
let conn = self.lock_conn()?;
let now = now_text();
let existing = 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
WHERE user_id = ?1 AND mnote_session_id = ?2 AND provider = ?3
LIMIT 1",
params![user_id, mnote_session_id, provider],
row_to_ai_external_conversation_binding,
)
.optional()?;
if let Some(existing) = existing {
let revision = existing.revision + 1;
let deleted_at = if input.status == "active" {
None
} else {
existing.deleted_at.or_else(|| Some(now.clone()))
};
conn.execute(
"UPDATE ai_external_conversation_bindings
SET workspace_id = ?1, acp_session_id = ?2, agent_id = ?3, profile = ?4, remote_conversation_id = ?5, remote_url = ?6, status = ?7, metadata_json = ?8, updated_at = ?9, deleted_at = ?10, revision = ?11
WHERE id = ?12",
params![
input.workspace_id,
input.acp_session_id,
input.agent_id,
input.profile,
remote_conversation_id,
input.remote_url,
input.status,
input.metadata_json,
now,
deleted_at,
revision,
existing.id
],
)?;
return Ok(AiExternalConversationBindingRecord {
id: existing.id,
user_id: existing.user_id,
workspace_id: input.workspace_id,
mnote_session_id: existing.mnote_session_id,
acp_session_id: input.acp_session_id,
agent_id: input.agent_id,
profile: input.profile,
provider: existing.provider,
remote_conversation_id: remote_conversation_id.to_string(),
remote_url: input.remote_url,
status: input.status,
metadata_json: input.metadata_json,
created_at: existing.created_at,
updated_at: now,
deleted_at,
revision,
});
}
let deleted_at = if input.status == "active" {
None
} else {
Some(now.clone())
};
let record = AiExternalConversationBindingRecord {
id: input.id.unwrap_or_else(|| new_id("aecb")),
user_id: user_id.to_string(),
workspace_id: input.workspace_id,
mnote_session_id: mnote_session_id.to_string(),
acp_session_id: input.acp_session_id,
agent_id: input.agent_id,
profile: input.profile,
provider: provider.to_string(),
remote_conversation_id: remote_conversation_id.to_string(),
remote_url: input.remote_url,
status: input.status,
metadata_json: input.metadata_json,
created_at: now.clone(),
updated_at: now,
deleted_at,
revision: 1,
};
conn.execute(
"INSERT INTO ai_external_conversation_bindings (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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)",
params![
record.id,
record.user_id,
record.workspace_id,
record.mnote_session_id,
record.acp_session_id,
record.agent_id,
record.profile,
record.provider,
record.remote_conversation_id,
record.remote_url,
record.status,
record.metadata_json,
record.created_at,
record.updated_at,
record.deleted_at
],
)?;
Ok(record)
}
fn find_ai_external_conversation_binding(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
let provider = provider.trim();
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(None);
}
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
WHERE user_id = ?1
AND (?2 IS NULL OR workspace_id = ?2)
AND mnote_session_id = ?3
AND provider = ?4
LIMIT 1",
params![user_id, workspace_id, mnote_session_id, provider],
row_to_ai_external_conversation_binding,
)
.optional()
.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,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
status: &str,
metadata_json: Option<&str>,
) -> Result<usize, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
let provider = provider.trim();
validate_ai_external_conversation_status(status)?;
let metadata_json = metadata_json.unwrap_or("{}");
serde_json::from_str::<serde_json::Value>(metadata_json)?;
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(0);
}
let conn = self.lock_conn()?;
let now = now_text();
let deleted_at = if status == "active" {
None
} else {
Some(now.clone())
};
let changed = conn.execute(
"UPDATE ai_external_conversation_bindings
SET status = ?1, metadata_json = ?2, updated_at = ?3, deleted_at = COALESCE(?4, deleted_at), revision = revision + 1
WHERE user_id = ?5
AND (?6 IS NULL OR workspace_id = ?6)
AND mnote_session_id = ?7
AND provider = ?8",
params![
status,
metadata_json,
now,
deleted_at,
user_id,
workspace_id,
mnote_session_id,
provider
],
)?;
Ok(changed)
}
fn rename_ai_runtime_session(
&self,
user_id: &str,
session_id: &str,
workspace_id: Option<&str>,
title: &str,
) -> Result<Vec<AiRuntimeRunRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let session_id = session_id.trim();
let title = title.trim();
if user_id.is_empty() || session_id.is_empty() || title.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai runtime session user_id/session_id/title 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"UPDATE ai_runtime_runs
SET title = ?1, updated_at = ?2, revision = revision + 1
WHERE user_id = ?3
AND session_id = ?4
AND (?5 IS NULL OR workspace_id = ?5)
AND deleted_at IS NULL",
params![title, now, user_id, session_id, workspace_id],
)?;
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
WHERE user_id = ?1
AND session_id = ?2
AND (?3 IS NULL OR workspace_id = ?3)
AND deleted_at IS NULL
ORDER BY updated_at DESC",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, workspace_id],
row_to_ai_runtime_run,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn auto_title_ai_runtime_session(
&self,
user_id: &str,
session_id: &str,
workspace_id: Option<&str>,
) -> Result<Option<AiRuntimeRunRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let session_id = session_id.trim();
if user_id.is_empty() || session_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai runtime session user_id/session_id 不能为空".to_string(),
));
}
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
FROM ai_runtime_runs
WHERE user_id = ?1
AND session_id = ?2
AND (?3 IS NULL OR workspace_id = ?3)
AND deleted_at IS NULL
ORDER BY updated_at DESC
LIMIT 1",
params![user_id, session_id, workspace_id],
row_to_ai_runtime_run,
)
.optional()?;
let Some(existing) = existing else {
return Ok(None);
};
let title = existing
.title
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
derive_ai_runtime_title(&existing.payload_json, &existing.session_id)
});
let now = now_text();
let revision = existing.revision + 1;
conn.execute(
"UPDATE ai_runtime_runs
SET title = ?1, updated_at = ?2, revision = ?3
WHERE id = ?4",
params![title, now, revision, existing.id],
)?;
Ok(Some(AiRuntimeRunRecord {
title: Some(title),
updated_at: now,
revision,
..existing
}))
}
fn delete_ai_runtime_session(
&self,
user_id: &str,
session_id: &str,
workspace_id: Option<&str>,
) -> Result<usize, ControlPlaneError> {
let user_id = user_id.trim();
let session_id = session_id.trim();
if user_id.is_empty() || session_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai runtime session user_id/session_id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE ai_runtime_runs
SET deleted_at = ?1, updated_at = ?1, revision = revision + 1
WHERE user_id = ?2
AND session_id = ?3
AND (?4 IS NULL OR workspace_id = ?4)
AND deleted_at IS NULL",
params![now_text(), user_id, session_id, workspace_id],
)?;
Ok(changed)
}
}
fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str {
if permission_rank(right) > permission_rank(left) {
right
} else {
left
}
}
fn permission_rank(permission: &str) -> u8 {
match permission {
"admin" => 3,
"write" => 2,
"read" => 1,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserUiPreferenceInput,
};
fn store() -> SqliteControlPlaneStore {
SqliteControlPlaneStore::in_memory().expect("in-memory control plane")
}
fn create_user(store: &SqliteControlPlaneStore, id: &str) -> UserRecord {
store
.upsert_user(UpsertUserInput {
id: Some(id.to_string()),
email: Some(format!("{id}@example.com")),
username: id.to_string(),
display_name: id.to_string(),
role: None,
password_hash: None,
})
.expect("upsert user")
}
fn create_password_identity(
store: &SqliteControlPlaneStore,
username: &str,
email: &str,
password: &str,
) -> UserRecord {
let user = create_user(store, username);
store
.create_password_identity(CreatePasswordIdentityInput {
user_id: user.id.clone(),
email: Some(email.to_string()),
username: username.to_string(),
password: password.to_string(),
})
.expect("create password identity");
user
}
#[test]
fn upsert_user_inserts_and_updates_revision() {
let store = store();
let user = create_user(&store, "alice");
assert_eq!(user.revision, 1);
let updated = store
.upsert_user(UpsertUserInput {
id: Some("alice".to_string()),
email: Some("alice2@example.com".to_string()),
username: "alice".to_string(),
display_name: "Alice".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("update user");
assert_eq!(updated.email.as_deref(), Some("alice2@example.com"));
assert_eq!(updated.role, "admin");
assert_eq!(updated.revision, 2);
}
#[test]
fn ensure_default_workspace_creates_owner_grant() {
let store = store();
create_user(&store, "shujuan");
let workspace = store
.ensure_default_workspace("shujuan")
.expect("default workspace");
assert_eq!(workspace.name, "shujuan 的空间");
let access = store
.resolve_access("shujuan", &workspace.root_uri)
.expect("resolve access");
assert_eq!(access.permission, "write");
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();
create_user(&store, "bob");
store
.grant_directory_access(DirectoryGrantInput {
user_id: "bob".to_string(),
workspace_id: None,
root_uri: "local://shared".to_string(),
root_path: "/tmp/shared".to_string(),
permission: "read".to_string(),
recursive: true,
capabilities: vec![],
source: "admin".to_string(),
created_by: None,
})
.expect("read grant");
store
.grant_directory_access(DirectoryGrantInput {
user_id: "bob".to_string(),
workspace_id: None,
root_uri: "local://shared".to_string(),
root_path: "/tmp/shared".to_string(),
permission: "write".to_string(),
recursive: true,
capabilities: vec!["ai".to_string()],
source: "admin".to_string(),
created_by: None,
})
.expect("write grant");
let access = store
.resolve_access("bob", "local://shared/page.md")
.expect("resolve access");
assert_eq!(access.permission, "write");
assert_eq!(access.grant_ids.len(), 2);
}
#[test]
fn list_and_revoke_directory_grant_updates_resolved_access() {
let store = store();
create_user(&store, "reader");
let grant = store
.grant_directory_access(DirectoryGrantInput {
user_id: "reader".to_string(),
workspace_id: None,
root_uri: "local://shared".to_string(),
root_path: "/tmp/shared".to_string(),
permission: "read".to_string(),
recursive: true,
capabilities: vec![],
source: "admin".to_string(),
created_by: None,
})
.expect("read grant");
let grants = store
.list_directory_grants_for_actor("reader")
.expect("list grants");
assert_eq!(grants.len(), 1);
assert_eq!(grants[0].id, grant.id);
store
.revoke_directory_grant(&grant.id, None)
.expect("revoke grant");
let access = store
.resolve_access("reader", "local://shared/page.md")
.expect("resolve revoked access");
assert_eq!(access.permission, "none");
}
#[test]
fn resolve_access_supports_legacy_directory_grants_with_plain_path_root_uri() {
let store = store();
create_user(&store, "legacy_reader");
store
.grant_directory_access(DirectoryGrantInput {
user_id: "legacy_reader".to_string(),
workspace_id: None,
root_uri: "/tmp/shared".to_string(),
root_path: "/tmp/shared".to_string(),
permission: "read".to_string(),
recursive: true,
capabilities: vec![],
source: "legacy".to_string(),
created_by: None,
})
.expect("legacy grant");
let access = store
.resolve_access("legacy_reader", "file:///tmp/shared/page.md")
.expect("resolve access");
assert_eq!(access.permission, "read");
assert_eq!(access.grant_ids.len(), 1);
}
#[test]
fn resolve_access_does_not_match_sibling_uri_prefix() {
let store = store();
create_user(&store, "reader");
store
.grant_directory_access(DirectoryGrantInput {
user_id: "reader".to_string(),
workspace_id: None,
root_uri: "file:///tmp/mnote-ai-root".to_string(),
root_path: "/tmp/mnote-ai-root".to_string(),
permission: "write".to_string(),
recursive: true,
capabilities: vec!["ai".to_string()],
source: "admin".to_string(),
created_by: None,
})
.expect("grant root");
let inside = store
.resolve_access("reader", "file:///tmp/mnote-ai-root/page.md")
.expect("resolve inside");
assert_eq!(inside.permission, "write");
let sibling = store
.resolve_access("reader", "file:///tmp/mnote-ai-root-sibling/page.md")
.expect("resolve sibling");
assert_eq!(sibling.permission, "none");
assert!(sibling.grant_ids.is_empty());
}
#[test]
fn share_link_token_is_hashed_and_revocable() {
let store = store();
create_user(&store, "owner");
create_user(&store, "ws_owner");
let workspace = store
.ensure_default_workspace("ws_owner")
.expect("workspace");
let created = store
.create_share_link(CreateShareLinkInput {
id: None,
workspace_id: workspace.id,
resource_kind: "page".to_string(),
resource_id: "page_1".to_string(),
token: Some("plain-token".to_string()),
permission: "read".to_string(),
created_by: "ws_owner".to_string(),
expires_at: None,
})
.expect("create share link");
assert_eq!(created.token, "plain-token");
assert!(created.link.token_hash.starts_with("sha256-v1:"));
assert_ne!(created.link.token_hash, "plain-token");
let resolved = store
.resolve_share_link(&created.link.token_hash)
.expect("resolve share link")
.expect("active share link");
assert_eq!(resolved.id, created.link.id);
let listed = store
.list_share_links(&created.link.workspace_id)
.expect("list links");
assert_eq!(listed.len(), 1);
store
.revoke_share_link(&created.link.id)
.expect("revoke share");
assert!(store
.resolve_share_link(&created.link.token_hash)
.expect("resolve revoked share")
.is_none());
}
#[test]
fn audit_log_appends_and_lists_latest_first() {
let store = store();
create_user(&store, "owner");
store
.append_audit(AppendAuditInput {
actor_user_id: Some("owner".to_string()),
action: "control.share.created".to_string(),
target_kind: "share_link".to_string(),
target_id: Some("share_1".to_string()),
metadata_json: "{}".to_string(),
})
.expect("append audit");
store
.append_audit(AppendAuditInput {
actor_user_id: Some("owner".to_string()),
action: "control.share.revoked".to_string(),
target_kind: "share_link".to_string(),
target_id: Some("share_1".to_string()),
metadata_json: "{\"reason\":\"test\"}".to_string(),
})
.expect("append audit 2");
let rows = store.list_audit_log(10).expect("list audit");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].action, "control.share.revoked");
assert_eq!(rows[1].action, "control.share.created");
}
#[test]
fn drain_outbox_marks_events_delivered() {
let store = store();
store
.append_outbox(OutboxEventInput {
topic: "control".to_string(),
event_type: "control.workspace.updated".to_string(),
payload_json: "{}".to_string(),
})
.expect("append event");
let first = store.drain_outbox(10).expect("first drain");
assert_eq!(first.len(), 1);
let second = store.drain_outbox(10).expect("second drain");
assert!(second.is_empty());
}
#[test]
fn mark_outbox_delivered_marks_one_pending_event() {
let store = store();
let first = store
.append_outbox(OutboxEventInput {
topic: "control".to_string(),
event_type: "control.grant.created".to_string(),
payload_json: "{}".to_string(),
})
.expect("first event");
let second = store
.append_outbox(OutboxEventInput {
topic: "control".to_string(),
event_type: "control.share.created".to_string(),
payload_json: "{}".to_string(),
})
.expect("second event");
store
.mark_outbox_delivered(&first.id)
.expect("mark first delivered");
let pending = store.drain_outbox(10).expect("drain pending");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, second.id);
}
#[test]
fn sidebar_shortcuts_are_user_scoped_and_upserted_by_target() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
let workspace = store.ensure_default_workspace("alice").expect("workspace");
let first = store
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
id: None,
user_id: "alice".to_string(),
workspace_id: workspace.id.clone(),
root_uri: Some("file:///tmp/mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
target_id: "local-dir:design".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "design".to_string(),
icon: Some("folder_open".to_string()),
sort_order: 0,
metadata_json: "{}".to_string(),
})
.expect("insert shortcut");
let updated = store
.upsert_sidebar_shortcut(UpsertSidebarShortcutInput {
id: None,
user_id: "alice".to_string(),
workspace_id: workspace.id.clone(),
root_uri: Some("file:///tmp/mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
target_id: "local-dir:design".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "Design".to_string(),
icon: Some("folder_open".to_string()),
sort_order: 4,
metadata_json: "{\"scope\":\"filetree\"}".to_string(),
})
.expect("upsert shortcut");
assert_eq!(updated.id, first.id);
assert_eq!(updated.title, "Design");
assert_eq!(updated.root_uri.as_deref(), Some("file:///tmp/mnote"));
assert_eq!(updated.sort_order, 4);
assert_eq!(updated.revision, first.revision + 1);
let alice_shortcuts = store
.list_sidebar_shortcuts("alice", &workspace.id)
.expect("list alice shortcuts");
assert_eq!(alice_shortcuts.len(), 1);
assert_eq!(alice_shortcuts[0].target_id, "local-dir:design");
let alice_global_local_shortcuts = store
.list_sidebar_shortcuts_with_global_local("alice", "cloud-workspace")
.expect("list alice global local shortcuts");
assert_eq!(alice_global_local_shortcuts.len(), 1);
assert_eq!(alice_global_local_shortcuts[0].workspace_id, workspace.id);
let bob_shortcuts = store
.list_sidebar_shortcuts("bob", &workspace.id)
.expect("list bob shortcuts");
assert!(bob_shortcuts.is_empty());
store
.delete_sidebar_shortcut("alice", &updated.id)
.expect("delete shortcut");
let after_delete = store
.list_sidebar_shortcuts("alice", &workspace.id)
.expect("list deleted shortcuts");
assert!(after_delete.is_empty());
}
#[test]
fn user_ui_preferences_are_scoped_upserted_and_user_isolated() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
let first = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "source_family".to_string(),
scope_id: "external_local_folder".to_string(),
key: "hideTitleHeader".to_string(),
value_json: "false".to_string(),
})
.expect("insert preference");
let updated = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "source_family".to_string(),
scope_id: "external_local_folder".to_string(),
key: "hideTitleHeader".to_string(),
value_json: "true".to_string(),
})
.expect("upsert preference");
assert_eq!(updated.id, first.id);
assert_eq!(updated.value_json, "true");
assert_eq!(updated.revision, first.revision + 1);
let alice_preferences = store
.list_user_ui_preferences(
"alice",
Some("local:_mnt_Data1T_mnote"),
Some("local_folder"),
)
.expect("list alice preferences");
assert_eq!(alice_preferences.len(), 1);
assert_eq!(alice_preferences[0].scope_kind, "source_family");
assert_eq!(alice_preferences[0].scope_id, "external_local_folder");
let bob_preferences = store
.list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder"))
.expect("list bob preferences");
assert!(bob_preferences.is_empty());
let sidebar_scope = "filetree:root-hash:design-hash";
let sidebar_value = serde_json::json!({
"schemaVersion": 1,
"treeKind": "filetree",
"rootUri": "file:///mnt/Data1T/mnote",
"scope": "design",
"expandedRelativePaths": ["design/05-editor-mainline"],
"expandedIds": [],
"selectedId": "",
"focusedId": "",
"activeId": "",
"scrollTop": 0
})
.to_string();
let sidebar_first = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "sidebar_tree".to_string(),
scope_id: sidebar_scope.to_string(),
key: "sidebarTreeViewState.v1".to_string(),
value_json: sidebar_value,
})
.expect("insert sidebar tree view state");
let sidebar_updated = store
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_mnt_Data1T_mnote".to_string()),
source_kind: Some("local_folder".to_string()),
scope_kind: "sidebar_tree".to_string(),
scope_id: sidebar_scope.to_string(),
key: "sidebarTreeViewState.v1".to_string(),
value_json: serde_json::json!({
"schemaVersion": 1,
"treeKind": "filetree",
"rootUri": "file:///mnt/Data1T/mnote",
"scope": "design",
"expandedRelativePaths": ["design/05-editor-mainline", "design/05-editor-mainline/process"],
"expandedIds": [],
"selectedId": "",
"focusedId": "",
"activeId": "",
"scrollTop": 0
})
.to_string(),
})
.expect("upsert sidebar tree view state");
assert_eq!(sidebar_updated.id, sidebar_first.id);
assert_eq!(sidebar_updated.revision, sidebar_first.revision + 1);
let alice_sidebar_preferences = store
.list_user_ui_preferences(
"alice",
Some("local:_mnt_Data1T_mnote"),
Some("local_folder"),
)
.expect("list alice sidebar preferences");
assert!(alice_sidebar_preferences.iter().any(|preference| {
preference.scope_kind == "sidebar_tree"
&& preference.scope_id == sidebar_scope
&& preference.key == "sidebarTreeViewState.v1"
}));
let bob_sidebar_preferences = store
.list_user_ui_preferences("bob", Some("local:_mnt_Data1T_mnote"), Some("local_folder"))
.expect("list bob sidebar preferences");
assert!(bob_sidebar_preferences.is_empty());
}
#[test]
fn ai_agent_profile_policy_provisions_personal_and_shared_boundaries() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
store
.upsert_user(UpsertUserInput {
id: Some("admin".to_string()),
email: Some("admin@example.com".to_string()),
username: "admin".to_string(),
display_name: "admin".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("admin user");
let alice_profiles = store
.ensure_ai_agent_profile_policy("alice", false)
.expect("alice profiles");
assert_eq!(alice_profiles.len(), 11);
let alice_personal = alice_profiles
.iter()
.find(|item| item.profile.profile_kind == "personal")
.expect("alice personal profile");
assert_eq!(
alice_personal.profile.owner_user_id.as_deref(),
Some("alice")
);
assert!(alice_personal.grant.can_manage_skills);
let alice_shared = alice_profiles
.iter()
.find(|item| item.profile.id == "shared_lite")
.expect("alice shared profile");
assert!(alice_shared.grant.can_run);
assert!(!alice_shared.grant.can_manage_skills);
for (profile_id, isolated_profile, display_name) in [
(
"shared_deepseek_chat",
"openclaw-deepseek-chat",
"DeepSeek Chat",
),
("shared_gemini_chat", "openclaw-gemini-chat", "Gemini Chat"),
("shared_doubao_chat", "openclaw-doubao-chat", "豆包 Chat"),
(
"shared_api_deepseek_flash_chat",
"api-deepseek-flash-chat",
"DeepSeek Flash Chat",
),
(
"shared_api_deepseek_pro_chat",
"api-deepseek-pro-chat",
"DeepSeek Pro Chat",
),
("shared_api_gpt_chat", "api-gpt-chat", "GPT Chat"),
("shared_api_kimi_chat", "api-kimi-chat", "Kimi Chat"),
(
"shared_api_gemini_chat",
"api-gemini-chat",
"Gemini API Chat",
),
("shared_api_grok_chat", "api-grok-chat", "Grok API Chat"),
] {
let alice_chat = alice_profiles
.iter()
.find(|item| item.profile.id == profile_id)
.expect("alice shared chat profile");
assert_eq!(alice_chat.profile.isolated_profile_name, isolated_profile);
assert_eq!(alice_chat.profile.display_name, display_name);
assert!(alice_chat.grant.can_run);
assert!(!alice_chat.grant.can_manage_skills);
assert!(!alice_chat.grant.can_manage_config);
}
let bob_profiles = store
.ensure_ai_agent_profile_policy("bob", false)
.expect("bob profiles");
let bob_personal = bob_profiles
.iter()
.find(|item| item.profile.profile_kind == "personal")
.expect("bob personal profile");
assert_ne!(bob_personal.profile.id, alice_personal.profile.id);
assert!(store
.resolve_ai_agent_profile("bob", false, &alice_personal.profile.id)
.expect("resolve cross user")
.is_none());
let admin_shared = store
.resolve_ai_agent_profile("admin", true, "shared_lite")
.expect("admin shared")
.expect("admin can see shared");
assert!(admin_shared.grant.can_manage_skills);
assert!(admin_shared.grant.can_manage_config);
for profile_id in [
"shared_deepseek_chat",
"shared_gemini_chat",
"shared_doubao_chat",
"shared_api_deepseek_flash_chat",
"shared_api_deepseek_pro_chat",
"shared_api_gpt_chat",
"shared_api_kimi_chat",
"shared_api_gemini_chat",
"shared_api_grok_chat",
] {
let admin_chat = store
.resolve_ai_agent_profile("admin", true, profile_id)
.expect("admin chat")
.expect("admin can see shared chat profile");
assert!(admin_chat.grant.can_run);
assert!(!admin_chat.grant.can_manage_skills);
assert!(!admin_chat.grant.can_manage_config);
}
}
#[test]
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
let first = store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "design".to_string(),
metadata_json: "{}".to_string(),
})
.expect("insert folder recent");
let updated = store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some("design".to_string()),
document_id: None,
title: "Design".to_string(),
metadata_json: "{\"from\":\"test\"}".to_string(),
})
.expect("upsert folder recent");
assert_eq!(updated.id, first.id);
assert_eq!(updated.title, "Design");
assert_eq!(updated.revision, first.revision + 1);
store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "page".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some("design/Plan.md".to_string()),
document_id: Some("local-md:design~2FPlan.md".to_string()),
title: "Plan".to_string(),
metadata_json: "{}".to_string(),
})
.expect("insert page recent");
for index in 0..12 {
store
.upsert_navigation_recent(UpsertNavigationRecentInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local:_tmp_mnote".to_string()),
kind: "folder".to_string(),
source_kind: "local_folder".to_string(),
root_uri: "file:///tmp/mnote".to_string(),
relative_path: Some(format!("folder-{index}")),
document_id: None,
title: format!("folder-{index}"),
metadata_json: "{}".to_string(),
})
.expect("insert bounded folder recent");
}
let alice_folders = store
.list_navigation_recent("alice", Some("folder"), 50)
.expect("list alice folders");
assert_eq!(alice_folders.len(), 10);
assert_eq!(alice_folders[0].kind, "folder");
assert_eq!(alice_folders[0].title, "folder-11");
assert!(alice_folders.iter().all(|record| record.user_id == "alice"));
assert!(!alice_folders.iter().any(|record| record.title == "Design"));
let alice_pages = store
.list_navigation_recent("alice", Some("page"), 20)
.expect("list alice pages");
assert_eq!(alice_pages.len(), 1);
assert_eq!(
alice_pages[0].document_id.as_deref(),
Some("local-md:design~2FPlan.md")
);
let bob_recent = store
.list_navigation_recent("bob", None, 20)
.expect("list bob recent");
assert!(bob_recent.is_empty());
}
#[test]
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
let store = store();
create_user(&store, "ai_user");
let workspace = store
.ensure_default_workspace("ai_user")
.expect("default workspace");
let user_policy = store
.upsert_ai_policy(UpsertAiPolicyInput {
id: None,
user_id: Some("ai_user".to_string()),
workspace_id: None,
allowed_roots_json: "[\"file:///user-root\"]".to_string(),
model_policy_json: "{\"default\":\"local\"}".to_string(),
quota_json: "{}".to_string(),
})
.expect("user policy");
assert_eq!(user_policy.revision, 1);
let workspace_policy = store
.upsert_ai_policy(UpsertAiPolicyInput {
id: None,
user_id: Some("ai_user".to_string()),
workspace_id: Some(workspace.id.clone()),
allowed_roots_json: "[\"file:///workspace-root\"]".to_string(),
model_policy_json: "{\"default\":\"workspace\"}".to_string(),
quota_json: "{}".to_string(),
})
.expect("workspace policy");
let resolved = store
.get_ai_policy("ai_user", Some(&workspace.id))
.expect("get workspace policy")
.expect("workspace policy exists");
assert_eq!(resolved.id, workspace_policy.id);
assert!(resolved.allowed_roots_json.contains("workspace-root"));
let user_only = store
.get_ai_policy("ai_user", None)
.expect("get user policy")
.expect("user policy exists");
assert_eq!(user_only.id, user_policy.id);
}
#[test]
fn sync_state_upsert_tracks_cursor_status_and_revision() {
let store = store();
create_user(&store, "sync_user");
let workspace = store
.ensure_default_workspace("sync_user")
.expect("default workspace");
let first = store
.upsert_sync_state(UpsertSyncStateInput {
id: None,
workspace_id: workspace.id.clone(),
remote_kind: "local_worker".to_string(),
remote_id: Some("worker-1".to_string()),
cursor: Some("cursor-1".to_string()),
last_synced_at: None,
status: "idle".to_string(),
error_json: None,
})
.expect("first sync state");
assert_eq!(first.revision, 1);
let second = store
.upsert_sync_state(UpsertSyncStateInput {
id: None,
workspace_id: workspace.id.clone(),
remote_kind: "local_worker".to_string(),
remote_id: Some("worker-1".to_string()),
cursor: Some("cursor-2".to_string()),
last_synced_at: Some("2026-05-22T00:00:00Z".to_string()),
status: "error".to_string(),
error_json: Some("{\"code\":\"test\"}".to_string()),
})
.expect("second sync state");
assert_eq!(second.id, first.id);
assert_eq!(second.revision, 2);
assert_eq!(second.cursor.as_deref(), Some("cursor-2"));
let loaded = store
.get_sync_state(&workspace.id, "local_worker")
.expect("get sync state")
.expect("sync state exists");
assert_eq!(loaded.status, "error");
assert_eq!(loaded.error_json.as_deref(), Some("{\"code\":\"test\"}"));
}
#[test]
fn ai_runtime_run_upsert_lists_updates_and_events() {
let store = store();
create_user(&store, "ai_runtime_user");
let first = store
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: None,
user_id: "ai_runtime_user".to_string(),
workspace_id: Some("ws_1".to_string()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
title: Some("初始标题".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\":\"读取当前页面\"}".to_string(),
})
.expect("insert runtime run");
assert_eq!(first.revision, 1);
let updated = store
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: None,
user_id: "ai_runtime_user".to_string(),
workspace_id: Some("ws_1".to_string()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
title: Some("更新标题".to_string()),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
trace_id: Some("trace_2".to_string()),
status: "completed".to_string(),
runtime_json: "{\"status\":\"completed\"}".to_string(),
payload_json: "{\"message\":\"完成\"}".to_string(),
})
.expect("update runtime run");
assert_eq!(updated.id, first.id);
assert_eq!(updated.revision, 2);
let runs = store
.list_ai_runtime_runs(
"ai_runtime_user",
Some("ws_1"),
Some("doc_1"),
Some("sess_1"),
10,
)
.expect("list runtime runs");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].title.as_deref(), Some("更新标题"));
let event = store
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: None,
user_id: "ai_runtime_user".to_string(),
workspace_id: Some("ws_1".to_string()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
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!(event.event_type, "message.delta");
store
.append_ai_runtime_event(AppendAiRuntimeEventInput {
id: None,
user_id: "ai_runtime_user".to_string(),
workspace_id: Some("ws_1".to_string()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
event_type: "run.completed".to_string(),
payload_json: "{\"status\":\"completed\"}".to_string(),
})
.expect("append second runtime event");
let events = store
.list_ai_runtime_events("ai_runtime_user", "run_1", 10)
.expect("list runtime events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].payload_json, "{\"text\":\"hello\"}");
let found = store
.find_ai_runtime_run("ai_runtime_user", "run_1")
.expect("find runtime run")
.expect("runtime run exists");
assert_eq!(found.status, "completed");
let journal_events = store
.list_ai_runtime_journal_events("ai_runtime_user", "run_1", 1, 10)
.expect("list runtime journal events");
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]
fn ai_runtime_session_management_renames_auto_titles_and_soft_deletes() {
let store = store();
create_user(&store, "ai_runtime_user");
store
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
id: None,
user_id: "ai_runtime_user".to_string(),
workspace_id: Some("ws_1".to_string()),
document_id: Some("doc_1".to_string()),
session_id: "sess_1".to_string(),
run_id: "run_1".to_string(),
title: None,
profile: "reasonix".to_string(),
acp_runtime: "reasonix".to_string(),
trace_id: None,
status: "completed".to_string(),
runtime_json: "{\"status\":\"completed\"}".to_string(),
payload_json: "{\"message\":\"请总结这篇文档\"}".to_string(),
})
.expect("insert runtime run");
let titled = store
.auto_title_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1"))
.expect("auto title")
.expect("titled run");
assert_eq!(titled.title.as_deref(), Some("请总结这篇文档"));
let renamed = store
.rename_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1"), "人工标题")
.expect("rename session");
assert_eq!(renamed.len(), 1);
assert_eq!(renamed[0].title.as_deref(), Some("人工标题"));
let deleted = store
.delete_ai_runtime_session("ai_runtime_user", "sess_1", Some("ws_1"))
.expect("delete session");
assert_eq!(deleted, 1);
let runs = store
.list_ai_runtime_runs("ai_runtime_user", Some("ws_1"), None, Some("sess_1"), 10)
.expect("list after delete");
assert!(runs.is_empty());
}
#[test]
fn ai_external_conversation_binding_is_user_scoped_and_statused() {
let store = store();
create_user(&store, "doubao_user_a");
create_user(&store, "doubao_user_b");
let binding = store
.upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput {
id: None,
user_id: "doubao_user_a".to_string(),
workspace_id: Some("ws_1".to_string()),
mnote_session_id: "chatonly_session_1".to_string(),
acp_session_id: Some("acp_session_1".to_string()),
agent_id: "chat_only".to_string(),
profile: "openclaw-doubao-chat".to_string(),
provider: "doubao-web".to_string(),
remote_conversation_id: "38428454119180290".to_string(),
remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()),
status: "active".to_string(),
metadata_json: "{\"source\":\"provider.conversation.bound\"}".to_string(),
})
.expect("upsert external conversation binding");
assert_eq!(binding.status, "active");
assert_eq!(binding.remote_conversation_id, "38428454119180290");
let found = store
.find_ai_external_conversation_binding(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find binding")
.expect("binding exists");
assert_eq!(found.id, binding.id);
assert_eq!(found.acp_session_id.as_deref(), Some("acp_session_1"));
let other_user = store
.find_ai_external_conversation_binding(
"doubao_user_b",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find other user binding");
assert!(other_user.is_none());
let changed = store
.mark_ai_external_conversation_binding_status(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
"local_deleted",
Some("{\"reason\":\"mnote_session_deleted\"}"),
)
.expect("mark local deleted");
assert_eq!(changed, 1);
let deleted = store
.find_ai_external_conversation_binding(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find deleted binding")
.expect("binding remains auditable");
assert_eq!(deleted.status, "local_deleted");
assert!(deleted.deleted_at.is_some());
}
#[test]
fn session_lookup_resolves_active_user_by_token_hash() {
let store = store();
create_user(&store, "shujuan");
let session = store
.create_session(CreateSessionInput {
id: None,
user_id: "shujuan".to_string(),
token_hash: session_token_hash("raw-session-token"),
user_agent: Some("mnote-test".to_string()),
ip_hash: None,
expires_at: None,
})
.expect("create session");
let resolved = store
.get_session_by_token_hash(&session.token_hash)
.expect("lookup session")
.expect("active session");
assert_eq!(resolved.session.user_id, "shujuan");
assert_eq!(resolved.user.id, "shujuan");
assert_eq!(resolved.user.email.as_deref(), Some("shujuan@example.com"));
}
#[test]
fn revoked_session_is_not_resolved() {
let store = store();
create_user(&store, "shujuan");
let session = store
.create_session(CreateSessionInput {
id: None,
user_id: "shujuan".to_string(),
token_hash: session_token_hash("revoked-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create session");
store.revoke_session(&session.id).expect("revoke session");
let resolved = store
.get_session_by_token_hash(&session.token_hash)
.expect("lookup session");
assert!(resolved.is_none());
}
#[test]
fn password_hash_v1_is_sha256_hex_placeholder() {
assert!(password_hash_v1("secret").starts_with("sha256-v1:"));
}
#[test]
fn authenticate_password_accepts_email_login_and_creates_session() {
let store = store();
let test_password = ["correct", "horse"].join(" ");
create_password_identity(&store, "alice", "alice@example.com", &test_password);
let resolved = store
.authenticate_password(AuthenticatePasswordInput {
account: "alice@example.com".to_string(),
password: test_password,
session_id: None,
token_hash: session_token_hash("alice-email-session"),
user_agent: Some("mnote-test".to_string()),
ip_hash: None,
expires_at: None,
})
.expect("authenticate by email");
assert_eq!(resolved.user.username, "alice");
assert_eq!(
resolved.session.token_hash,
session_token_hash("alice-email-session")
);
let lookup = store
.get_session_by_token_hash(&resolved.session.token_hash)
.expect("lookup created session")
.expect("created session");
assert_eq!(lookup.user.id, resolved.user.id);
}
#[test]
fn authenticate_password_accepts_username_login() {
let store = store();
let test_password = ["sword", "fish"].join("");
create_password_identity(&store, "bob", "bob@example.com", &test_password);
let resolved = store
.authenticate_password(AuthenticatePasswordInput {
account: "bob".to_string(),
password: test_password,
session_id: None,
token_hash: session_token_hash("bob-username-session"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("authenticate by username");
assert_eq!(resolved.user.email.as_deref(), Some("bob@example.com"));
assert_eq!(
resolved.session.token_hash,
session_token_hash("bob-username-session")
);
}
#[test]
fn authenticate_password_rejects_wrong_password() {
let store = store();
let right_password = ["right", "password"].join("-");
let wrong_password = ["wrong", "password"].join("-");
create_password_identity(&store, "chris", "chris@example.com", &right_password);
let err = store
.authenticate_password(AuthenticatePasswordInput {
account: "chris@example.com".to_string(),
password: wrong_password,
session_id: None,
token_hash: session_token_hash("chris-failed-session"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect_err("wrong password should fail");
assert!(matches!(err, ControlPlaneError::Unauthorized(_)));
assert!(store
.get_session_by_token_hash(&session_token_hash("chris-failed-session"))
.expect("lookup failed session")
.is_none());
}
}