feat: add pi lab ai management integration

This commit is contained in:
Agent Board
2026-07-04 21:47:42 +08:00
parent 36d027a4a1
commit c8472bb898
62 changed files with 15077 additions and 443 deletions
@@ -0,0 +1,56 @@
-- P2: AI tool events (receipts) and file patches for per-user/per-session query.
-- See design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md
CREATE TABLE IF NOT EXISTS ai_tool_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT,
session_id TEXT NOT NULL,
run_id TEXT,
provider TEXT NOT NULL,
provider_session_id TEXT,
tool_name TEXT NOT NULL,
allowed INTEGER NOT NULL DEFAULT 1,
deny_reason TEXT,
root_uri TEXT NOT NULL,
page_path TEXT,
normalized_file_path TEXT,
diff_summary TEXT,
citation_count INTEGER NOT NULL DEFAULT 0,
before_file_version TEXT,
after_file_version TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_ai_tool_events_user_session
ON ai_tool_events(user_id, session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_tool_events_user_run
ON ai_tool_events(user_id, run_id, created_at);
CREATE TABLE IF NOT EXISTS ai_file_patches (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT,
session_id TEXT NOT NULL,
run_id TEXT,
tool_event_id TEXT NOT NULL REFERENCES ai_tool_events(id) ON DELETE CASCADE,
root_uri TEXT NOT NULL DEFAULT '',
relative_path TEXT NOT NULL,
before_file_version TEXT,
after_file_version TEXT,
patch_summary_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_user_session
ON ai_file_patches(user_id, session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_user_run
ON ai_file_patches(user_id, run_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_tool_event
ON ai_file_patches(tool_event_id);
@@ -33,6 +33,8 @@ const TABLE_ORDER: &[&str] = &[
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -78,6 +80,16 @@ impl ControlPlaneStore for StoreHandle {
}
}
fn list_users(
&self,
limit: usize,
) -> Result<Vec<control_plane::UserRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_users(limit),
StoreHandle::Turso(store) => store.list_users(limit),
}
}
fn create_password_identity(
&self,
input: control_plane::CreatePasswordIdentityInput,
@@ -702,6 +714,50 @@ impl ControlPlaneStore for StoreHandle {
}
}
}
fn append_ai_tool_event(
&self,
input: control_plane::AppendAiToolEventInput,
) -> Result<control_plane::AiToolEventRecord, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.append_ai_tool_event(input),
StoreHandle::Turso(store) => store.append_ai_tool_event(input),
}
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<control_plane::AiToolEventRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_ai_tool_events(user_id, session_id, limit),
StoreHandle::Turso(store) => store.list_ai_tool_events(user_id, session_id, limit),
}
}
fn append_ai_file_patch(
&self,
input: control_plane::AppendAiFilePatchInput,
) -> Result<control_plane::AiFilePatchRecord, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.append_ai_file_patch(input),
StoreHandle::Turso(store) => store.append_ai_file_patch(input),
}
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<control_plane::AiFilePatchRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_ai_file_patches(user_id, session_id, limit),
StoreHandle::Turso(store) => store.list_ai_file_patches(user_id, session_id, limit),
}
}
}
#[derive(Debug)]
@@ -39,6 +39,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
"v8-ai-external-conversation-bindings",
include_str!("../migrations/008-ai-external-conversation-bindings.sql"),
),
(
"v9-ai-tool-events-file-patches",
include_str!("../migrations/009-ai-tool-events-file-patches.sql"),
),
];
/// Create the `_migrations` meta-table if it does not exist.
@@ -169,6 +173,8 @@ mod tests {
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
] {
assert!(table_exists(&conn, table), "table {table} should exist");
}
@@ -224,6 +230,8 @@ mod tests {
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
];
for table in tables {
let exists = rt.block_on(async { libsql_table_exists(&conn, table).await });
+82
View File
@@ -534,3 +534,85 @@ pub struct AppendAuditInput {
pub target_id: Option<EntityId>,
pub metadata_json: String,
}
// ---------------------------------------------------------------------------
// P2: AI tool events (receipts) and file patches — 7-71
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiToolEventRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub provider: String,
pub provider_session_id: Option<String>,
pub tool_name: String,
pub allowed: bool,
pub deny_reason: Option<String>,
pub root_uri: String,
pub page_path: Option<String>,
pub normalized_file_path: Option<String>,
pub diff_summary: Option<String>,
pub citation_count: i64,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub payload_json: String,
pub created_at: Timestamp,
pub deleted_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppendAiToolEventInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub provider: String,
pub provider_session_id: Option<String>,
pub tool_name: String,
pub allowed: bool,
pub deny_reason: Option<String>,
pub root_uri: String,
pub page_path: Option<String>,
pub normalized_file_path: Option<String>,
pub diff_summary: Option<String>,
pub citation_count: i64,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub payload_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiFilePatchRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub tool_event_id: EntityId,
pub root_uri: String,
pub relative_path: String,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub patch_summary_json: String,
pub created_at: Timestamp,
pub deleted_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppendAiFilePatchInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub tool_event_id: EntityId,
pub root_uri: String,
pub relative_path: String,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub patch_summary_json: String,
}
+395 -13
View File
@@ -9,17 +9,17 @@ 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,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiFilePatchRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiToolEventRecord,
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, 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;
@@ -469,6 +469,49 @@ fn row_to_ai_external_conversation_binding(
})
}
fn row_to_ai_tool_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiToolEventRecord> {
Ok(AiToolEventRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
provider: row.get(5)?,
provider_session_id: row.get(6)?,
tool_name: row.get(7)?,
allowed: row.get(8)?,
deny_reason: row.get(9)?,
root_uri: row.get(10)?,
page_path: row.get(11)?,
normalized_file_path: row.get(12)?,
diff_summary: row.get(13)?,
citation_count: row.get(14)?,
before_file_version: row.get(15)?,
after_file_version: row.get(16)?,
payload_json: row.get(17)?,
created_at: row.get(18)?,
deleted_at: row.get(19)?,
})
}
fn row_to_ai_file_patch(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiFilePatchRecord> {
Ok(AiFilePatchRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
tool_event_id: row.get(5)?,
root_uri: row.get(6)?,
relative_path: row.get(7)?,
before_file_version: row.get(8)?,
after_file_version: row.get(9)?,
patch_summary_json: row.get(10)?,
created_at: row.get(11)?,
deleted_at: row.get(12)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok()
@@ -557,7 +600,9 @@ fn list_ai_agent_profile_access_rows(
}
impl SqliteControlPlaneStore {
fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> {
fn lock_conn(
&self,
) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> {
self.conn
.lock()
.map_err(|e| ControlPlaneError::Storage(format!("sqlite lock poisoned: {e}")))
@@ -639,6 +684,22 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
})
}
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let limit = limit.min(1000).max(1);
let mut stmt = conn.prepare(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users
ORDER BY created_at ASC, id ASC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_user)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn create_password_identity(
&self,
input: CreatePasswordIdentityInput,
@@ -950,7 +1011,10 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
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()
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(),
@@ -2964,6 +3028,154 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
)?;
Ok(changed)
}
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError> {
if input.user_id.trim().is_empty() || input.session_id.trim().is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai tool event user_id/session_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let record = AiToolEventRecord {
id: input.id.unwrap_or_else(|| new_id("ate")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
provider: input.provider,
provider_session_id: input.provider_session_id,
tool_name: input.tool_name,
allowed: input.allowed,
deny_reason: input.deny_reason,
root_uri: input.root_uri,
page_path: input.page_path,
normalized_file_path: input.normalized_file_path,
diff_summary: input.diff_summary,
citation_count: input.citation_count,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
payload_json: input.payload_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_tool_events (id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.provider, record.provider_session_id, record.tool_name, record.allowed, record.deny_reason,
record.root_uri, record.page_path, record.normalized_file_path, record.diff_summary, record.citation_count,
record.before_file_version, record.after_file_version, record.payload_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at, deleted_at
FROM ai_tool_events
WHERE user_id = ?1 AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_tool_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.tool_event_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai file patch user_id/session_id/tool_event_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.patch_summary_json)?;
let conn = self.lock_conn()?;
let record = AiFilePatchRecord {
id: input.id.unwrap_or_else(|| new_id("afp")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
tool_event_id: input.tool_event_id,
root_uri: input.root_uri,
relative_path: input.relative_path,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
patch_summary_json: input.patch_summary_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_file_patches (id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.tool_event_id, record.root_uri, record.relative_path,
record.before_file_version, record.after_file_version, record.patch_summary_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at, deleted_at
FROM ai_file_patches
WHERE user_id = ?1
AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_file_patch,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
}
fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str {
@@ -3050,6 +3262,48 @@ mod tests {
assert_eq!(updated.revision, 2);
}
#[test]
fn list_users_returns_stable_ordering() {
let store = store();
// Create 3 users in known order
let u1 = create_user(&store, "bravo");
let u2 = create_user(&store, "alpha");
let u3 = create_user(&store, "charlie");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 3, "should return all three users");
// Order must be by created_at ASC, id ASC
// u1 (bravo) created first, u2 (alpha) second, u3 (charlie) third
assert_eq!(users[0].id, u1.id, "first created should be first");
assert_eq!(users[1].id, u2.id, "second created should be second");
assert_eq!(users[2].id, u3.id, "third created should be third");
}
#[test]
fn list_users_respects_limit() {
let store = store();
for i in 0..5usize {
create_user(&store, &format!("user{i}"));
}
let users = store.list_users(3).expect("list users with limit");
assert_eq!(users.len(), 3, "should respect limit=3");
}
#[test]
fn list_users_excludes_password_data() {
let store = store();
create_user(&store, "nopassword");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 1);
// UserRecord has no password_hash field — this is a compile-time
// guarantee that list_users cannot leak password data.
assert!(users[0].email.is_some());
}
#[test]
fn ensure_default_workspace_creates_owner_grant() {
let store = store();
@@ -4224,4 +4478,132 @@ mod tests {
.expect("lookup failed session")
.is_none());
}
#[test]
fn ai_tool_events_append_and_list() {
let store = store();
create_user(&store, "tool_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_1".to_string()),
provider: "doubao-web".to_string(),
provider_session_id: Some("ps_1".to_string()),
tool_name: "mnote.local_file.read".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("page.md".to_string()),
normalized_file_path: Some("/tmp/page.md".to_string()),
diff_summary: Some("read file".to_string()),
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append tool event");
assert_eq!(event.tool_name, "mnote.local_file.read");
assert!(event.allowed);
assert_eq!(event.citation_count, 0);
// Append a denied event
store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_2".to_string()),
provider: "reasonix".to_string(),
provider_session_id: None,
tool_name: "fs.write".to_string(),
allowed: false,
deny_reason: Some("path not in allowed roots".to_string()),
root_uri: "file:///etc".to_string(),
page_path: None,
normalized_file_path: Some("/etc/passwd".to_string()),
diff_summary: None,
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append denied event");
let events = store
.list_ai_tool_events("tool_user", Some("sess_1"), 10)
.expect("list events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool_name, "fs.write");
assert!(!events[0].allowed);
let all_events = store
.list_ai_tool_events("tool_user", None, 10)
.expect("list all events");
assert_eq!(all_events.len(), 2);
}
#[test]
fn ai_file_patches_append_and_list() {
let store = store();
create_user(&store, "patch_user");
// First create a tool event to reference
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: Some("ate_patch_ref".to_string()),
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
provider: "openclaw".to_string(),
provider_session_id: None,
tool_name: "mnote.local_file.patch".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("test.md".to_string()),
normalized_file_path: Some("/tmp/test.md".to_string()),
diff_summary: Some("edit file".to_string()),
citation_count: 0,
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
payload_json: "{}".to_string(),
})
.expect("append reference tool event");
let patch = store
.append_ai_file_patch(AppendAiFilePatchInput {
id: None,
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
tool_event_id: event.id.clone(),
root_uri: "file:///tmp".to_string(),
relative_path: "test.md".to_string(),
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
patch_summary_json: r#"{"insertions":5,"deletions":2}"#.to_string(),
})
.expect("append file patch");
assert_eq!(patch.tool_event_id, event.id);
assert_eq!(patch.relative_path, "test.md");
assert!(patch.deleted_at.is_none());
let patches = store
.list_ai_file_patches("patch_user", Some("sess_p1"), 10)
.expect("list patches");
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].tool_event_id, event.id);
let all_patches = store
.list_ai_file_patches("patch_user", None, 10)
.expect("list all patches");
assert_eq!(all_patches.len(), 1);
}
}
+47 -11
View File
@@ -2,22 +2,26 @@
use crate::error::ControlPlaneError;
use crate::model::{
AiAgentProfileAccessRecord, 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,
AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiFilePatchRecord,
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord,
AiToolEventRecord, AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput,
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,
};
pub trait ControlPlaneStore: Send + Sync {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError>;
/// List users with stable ordering by created_at (ASC), id (ASC) as tiebreaker.
/// Returns at most `limit` records. Passwords are never included.
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError>;
fn create_password_identity(
&self,
input: CreatePasswordIdentityInput,
@@ -308,4 +312,36 @@ pub trait ControlPlaneStore: Send + Sync {
session_id: &str,
workspace_id: Option<&str>,
) -> Result<usize, ControlPlaneError>;
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError>;
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError>;
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError>;
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError>;
}
+386 -15
View File
@@ -15,17 +15,17 @@ 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,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiFilePatchRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiToolEventRecord,
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, 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;
@@ -486,9 +486,9 @@ impl TursoControlPlaneStore {
}
fn lock_conn(&self) -> Result<MutexGuard<'_, TursoConnection>, ControlPlaneError> {
self.conn
.lock()
.map_err(|error| ControlPlaneError::Storage(format!("libSQL control-plane lock poisoned: {error}")))
self.conn.lock().map_err(|error| {
ControlPlaneError::Storage(format!("libSQL control-plane lock poisoned: {error}"))
})
}
}
@@ -1003,6 +1003,49 @@ fn row_to_ai_external_conversation_binding(
})
}
fn row_to_ai_tool_event(row: &libsql::Row) -> libsql::Result<AiToolEventRecord> {
Ok(AiToolEventRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
provider: row.get(5)?,
provider_session_id: row.get(6)?,
tool_name: row.get(7)?,
allowed: row.get(8)?,
deny_reason: row.get(9)?,
root_uri: row.get(10)?,
page_path: row.get(11)?,
normalized_file_path: row.get(12)?,
diff_summary: row.get(13)?,
citation_count: row.get(14)?,
before_file_version: row.get(15)?,
after_file_version: row.get(16)?,
payload_json: row.get(17)?,
created_at: row.get(18)?,
deleted_at: row.get(19)?,
})
}
fn row_to_ai_file_patch(row: &libsql::Row) -> libsql::Result<AiFilePatchRecord> {
Ok(AiFilePatchRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
tool_event_id: row.get(5)?,
root_uri: row.get(6)?,
relative_path: row.get(7)?,
before_file_version: row.get(8)?,
after_file_version: row.get(9)?,
patch_summary_json: row.get(10)?,
created_at: row.get(11)?,
deleted_at: row.get(12)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok()
@@ -1165,6 +1208,22 @@ impl ControlPlaneStore for TursoControlPlaneStore {
})
}
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let limit = limit.min(1000).max(1);
let mut stmt = conn.prepare(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users
ORDER BY created_at ASC, id ASC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_user)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn create_password_identity(
&self,
input: CreatePasswordIdentityInput,
@@ -3493,6 +3552,153 @@ impl ControlPlaneStore for TursoControlPlaneStore {
)?;
Ok(changed)
}
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError> {
if input.user_id.trim().is_empty() || input.session_id.trim().is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai tool event user_id/session_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let record = AiToolEventRecord {
id: input.id.unwrap_or_else(|| new_id("ate")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
provider: input.provider,
provider_session_id: input.provider_session_id,
tool_name: input.tool_name,
allowed: input.allowed,
deny_reason: input.deny_reason,
root_uri: input.root_uri,
page_path: input.page_path,
normalized_file_path: input.normalized_file_path,
diff_summary: input.diff_summary,
citation_count: input.citation_count,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
payload_json: input.payload_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_tool_events (id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.provider, record.provider_session_id, record.tool_name, record.allowed, record.deny_reason,
record.root_uri, record.page_path, record.normalized_file_path, record.diff_summary, record.citation_count,
record.before_file_version, record.after_file_version, record.payload_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at, deleted_at
FROM ai_tool_events
WHERE user_id = ?1 AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_tool_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.tool_event_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai file patch user_id/session_id/tool_event_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.patch_summary_json)?;
let conn = self.lock_conn()?;
let record = AiFilePatchRecord {
id: input.id.unwrap_or_else(|| new_id("afp")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
tool_event_id: input.tool_event_id,
root_uri: input.root_uri,
relative_path: input.relative_path,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
patch_summary_json: input.patch_summary_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_file_patches (id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.tool_event_id, record.root_uri, record.relative_path,
record.before_file_version, record.after_file_version, record.patch_summary_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at, deleted_at
FROM ai_file_patches
WHERE user_id = ?1
AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_file_patch,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
}
fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str {
@@ -3582,6 +3788,48 @@ mod tests {
assert_eq!(updated.revision, 2);
}
#[test]
fn list_users_returns_stable_ordering() {
let store = store();
// Create 3 users in known order
let u1 = create_user(&store, "bravo");
let u2 = create_user(&store, "alpha");
let u3 = create_user(&store, "charlie");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 3, "should return all three users");
// Order must be by created_at ASC, id ASC
// u1 (bravo) created first, u2 (alpha) second, u3 (charlie) third
assert_eq!(users[0].id, u1.id, "first created should be first");
assert_eq!(users[1].id, u2.id, "second created should be second");
assert_eq!(users[2].id, u3.id, "third created should be third");
}
#[test]
fn list_users_respects_limit() {
let store = store();
for i in 0..5usize {
create_user(&store, &format!("user{i}"));
}
let users = store.list_users(3).expect("list users with limit");
assert_eq!(users.len(), 3, "should respect limit=3");
}
#[test]
fn list_users_excludes_password_data() {
let store = store();
create_user(&store, "nopassword");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 1);
// UserRecord has no password_hash field — this is a compile-time
// guarantee that list_users cannot leak password data.
assert!(users[0].email.is_some());
}
#[test]
fn ensure_default_workspace_creates_owner_grant() {
let store = store();
@@ -4757,7 +5005,6 @@ mod tests {
.is_none());
}
// --- Fault injection tests ---
#[test]
@@ -4900,4 +5147,128 @@ mod tests {
"expected RateLimit: prefix, got {display}"
);
}
#[test]
fn ai_tool_events_append_and_list() {
let store = store();
create_user(&store, "tool_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_1".to_string()),
provider: "doubao-web".to_string(),
provider_session_id: Some("ps_1".to_string()),
tool_name: "mnote.local_file.read".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("page.md".to_string()),
normalized_file_path: Some("/tmp/page.md".to_string()),
diff_summary: Some("read file".to_string()),
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append tool event");
assert_eq!(event.tool_name, "mnote.local_file.read");
assert!(event.allowed);
assert_eq!(event.citation_count, 0);
store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_2".to_string()),
provider: "reasonix".to_string(),
provider_session_id: None,
tool_name: "fs.write".to_string(),
allowed: false,
deny_reason: Some("path not in allowed roots".to_string()),
root_uri: "file:///etc".to_string(),
page_path: None,
normalized_file_path: Some("/etc/passwd".to_string()),
diff_summary: None,
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append denied event");
let events = store
.list_ai_tool_events("tool_user", Some("sess_1"), 10)
.expect("list events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool_name, "fs.write");
assert!(!events[0].allowed);
let all_events = store
.list_ai_tool_events("tool_user", None, 10)
.expect("list all");
assert_eq!(all_events.len(), 2);
}
#[test]
fn ai_file_patches_append_and_list() {
let store = store();
create_user(&store, "patch_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: Some("ate_turso_patch_ref".to_string()),
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
provider: "openclaw".to_string(),
provider_session_id: None,
tool_name: "mnote.local_file.patch".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("test.md".to_string()),
normalized_file_path: Some("/tmp/test.md".to_string()),
diff_summary: Some("edit file".to_string()),
citation_count: 0,
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
payload_json: "{}".to_string(),
})
.expect("append reference tool event");
let patch = store
.append_ai_file_patch(AppendAiFilePatchInput {
id: None,
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
tool_event_id: event.id.clone(),
root_uri: "file:///tmp".to_string(),
relative_path: "test.md".to_string(),
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
patch_summary_json: r#"{"insertions":5}"#.to_string(),
})
.expect("append file patch");
assert_eq!(patch.tool_event_id, event.id);
assert!(patch.deleted_at.is_none());
let patches = store
.list_ai_file_patches("patch_user", Some("sess_p1"), 10)
.expect("list patches");
assert_eq!(patches.len(), 1);
let all_patches = store
.list_ai_file_patches("patch_user", None, 10)
.expect("list all");
assert_eq!(all_patches.len(), 1);
}
}
@@ -257,7 +257,9 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
}
for handle in handles {
handle.join().expect("parallel writer thread should not panic");
handle
.join()
.expect("parallel writer thread should not panic");
}
assert_eq!(
@@ -275,10 +277,7 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
8
);
assert_eq!(
store
.list_audit_log(20)
.expect("list parallel audit")
.len(),
store.list_audit_log(20).expect("list parallel audit").len(),
8
);
assert_eq!(