feat(classroom): add SQLite persistence + security hardening
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
M11-03: Classroom data persistence - New persist.rs: SQLite-backed ClassroomPersistence with open/load_all/save - Schema: classrooms (JSON blob) + classroom_chats tables - generate.rs: auto-persist classroom after generation - chat.rs: auto-persist chat messages after each exchange - mod.rs: init_persistence() for app setup integration M1-01: Gemini API key now uses x-goog-api-key header - No longer leaks API key in URL query params or debug logs M1-03/04: Mutex unwrap() replaced with unwrap_or_else(|e| e.into_inner()) - MemoryMiddleware and LoopGuardMiddleware recover from poison M2-08: Agent creation input validation - Reject empty names, out-of-range temperature (0-2), zero max_tokens M11-06: Classroom chat message ID uses crypto.randomUUID()
This commit is contained in:
@@ -53,6 +53,7 @@ pub async fn classroom_chat(
|
||||
store: State<'_, ClassroomStore>,
|
||||
chat_store: State<'_, ChatStore>,
|
||||
kernel_state: State<'_, KernelState>,
|
||||
persistence: State<'_, crate::classroom_commands::persist::ClassroomPersistence>,
|
||||
request: ClassroomChatCmdRequest,
|
||||
) -> Result<Vec<ClassroomChatMessage>, String> {
|
||||
if request.user_message.trim().is_empty() {
|
||||
@@ -102,6 +103,11 @@ pub async fn classroom_chat(
|
||||
|
||||
state.messages.push(user_msg);
|
||||
state.messages.extend(agent_responses.clone());
|
||||
|
||||
// Persist chat to SQLite
|
||||
if let Err(e) = persistence.save_chat(&request.classroom_id, &state.messages).await {
|
||||
tracing::warn!("[ClassroomChat] Failed to persist chat for {}: {}", request.classroom_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(agent_responses)
|
||||
|
||||
@@ -95,6 +95,7 @@ pub async fn classroom_generate(
|
||||
store: State<'_, ClassroomStore>,
|
||||
tasks: State<'_, GenerationTasks>,
|
||||
kernel_state: State<'_, KernelState>,
|
||||
persistence: State<'_, crate::classroom_commands::persist::ClassroomPersistence>,
|
||||
request: ClassroomGenerateRequest,
|
||||
) -> Result<ClassroomGenerateResponse, String> {
|
||||
if request.topic.trim().is_empty() {
|
||||
@@ -217,7 +218,11 @@ pub async fn classroom_generate(
|
||||
// Store classroom
|
||||
{
|
||||
let mut s = store.lock().await;
|
||||
s.insert(classroom_id.clone(), classroom);
|
||||
s.insert(classroom_id.clone(), classroom.clone());
|
||||
// Persist to SQLite
|
||||
if let Err(e) = persistence.save_classroom(&classroom).await {
|
||||
tracing::warn!("[ClassroomGenerate] Failed to persist classroom {}: {}", classroom_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear generation task
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use zclaw_kernel::generation::Classroom;
|
||||
|
||||
pub mod chat;
|
||||
pub mod export;
|
||||
pub mod generate;
|
||||
pub mod persist;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state types
|
||||
@@ -27,6 +29,7 @@ pub type GenerationTasks = Arc<Mutex<std::collections::HashMap<String, zclaw_ker
|
||||
// Re-export chat state type — used by lib.rs to construct managed state
|
||||
#[allow(unused_imports)]
|
||||
pub use chat::ChatStore;
|
||||
pub use persist::ClassroomPersistence;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State constructors
|
||||
@@ -39,3 +42,24 @@ pub fn create_classroom_state() -> ClassroomStore {
|
||||
pub fn create_generation_tasks() -> GenerationTasks {
|
||||
Arc::new(Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// Create and initialize the classroom persistence layer, loading saved data.
|
||||
pub async fn init_persistence(
|
||||
app_handle: &tauri::AppHandle,
|
||||
store: &ClassroomStore,
|
||||
chat_store: &ChatStore,
|
||||
) -> Result<ClassroomPersistence, String> {
|
||||
let app_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||
|
||||
let db_path = app_dir.join("classroom").join("classrooms.db");
|
||||
std::fs::create_dir_all(db_path.parent().unwrap())
|
||||
.map_err(|e| format!("Failed to create classroom dir: {}", e))?;
|
||||
|
||||
let persistence: ClassroomPersistence = ClassroomPersistence::open(db_path).await?;
|
||||
let loaded = persistence.load_all(store, chat_store).await?;
|
||||
tracing::info!("[ClassroomPersistence] Loaded {} classrooms from SQLite", loaded);
|
||||
Ok(persistence)
|
||||
}
|
||||
|
||||
171
desktop/src-tauri/src/classroom_commands/persist.rs
Normal file
171
desktop/src-tauri/src/classroom_commands/persist.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
//! Classroom SQLite persistence
|
||||
//!
|
||||
//! Persists Classroom + ChatState to a local SQLite database so that
|
||||
//! classroom data survives app restarts. Uses JSON blobs for simplicity
|
||||
//! since the data is always loaded/written as a whole unit.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use sqlx::SqliteConnection;
|
||||
use sqlx::Connection;
|
||||
use zclaw_kernel::generation::{Classroom, ClassroomChatMessage, ClassroomChatState};
|
||||
|
||||
use super::{ClassroomStore, chat::ChatStore};
|
||||
|
||||
/// SQLite-backed persistence for classrooms and chat history.
|
||||
pub struct ClassroomPersistence {
|
||||
conn: Arc<Mutex<SqliteConnection>>,
|
||||
}
|
||||
|
||||
impl ClassroomPersistence {
|
||||
/// Open an in-memory database (no persistence across restarts, used as fallback).
|
||||
pub async fn open_in_memory() -> Result<Self, String> {
|
||||
let conn = SqliteConnection::connect("sqlite::memory:")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to open in-memory classroom DB: {}", e))?;
|
||||
let persist = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
persist.init_schema().await?;
|
||||
Ok(persist)
|
||||
}
|
||||
|
||||
/// Open (or create) the classroom database at the given path.
|
||||
pub async fn open(db_path: impl Into<PathBuf>) -> Result<Self, String> {
|
||||
let db_path = db_path.into();
|
||||
let db_url = format!("sqlite:{}?mode=rwc", db_path.display());
|
||||
let conn = SqliteConnection::connect(&db_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to open classroom DB: {}", e))?;
|
||||
|
||||
let persist = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
persist.init_schema().await?;
|
||||
Ok(persist)
|
||||
}
|
||||
|
||||
async fn init_schema(&self) -> Result<(), String> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS classrooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS classroom_chats (
|
||||
classroom_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create classroom schema: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save a classroom to SQLite.
|
||||
pub async fn save_classroom(&self, classroom: &Classroom) -> Result<(), String> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
let data = serde_json::to_string(classroom)
|
||||
.map_err(|e| format!("Failed to serialize classroom: {}", e))?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO classrooms (id, data) VALUES (?, ?)"
|
||||
)
|
||||
.bind(&classroom.id)
|
||||
.bind(&data)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save classroom: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save chat state for a classroom.
|
||||
pub async fn save_chat(&self, classroom_id: &str, messages: &[ClassroomChatMessage]) -> Result<(), String> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
let data = serde_json::to_string(messages)
|
||||
.map_err(|e| format!("Failed to serialize chat messages: {}", e))?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO classroom_chats (classroom_id, data) VALUES (?, ?)"
|
||||
)
|
||||
.bind(classroom_id)
|
||||
.bind(&data)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save chat: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a classroom and its chat history.
|
||||
pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
sqlx::query("DELETE FROM classrooms WHERE id = ?")
|
||||
.bind(classroom_id)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete classroom: {}", e))?;
|
||||
sqlx::query("DELETE FROM classroom_chats WHERE classroom_id = ?")
|
||||
.bind(classroom_id)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete chat: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all persisted classrooms into the in-memory store.
|
||||
pub async fn load_all(&self, store: &ClassroomStore, chat_store: &ChatStore) -> Result<usize, String> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
|
||||
// Load classrooms
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT id, data FROM classrooms"
|
||||
)
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load classrooms: {}", e))?;
|
||||
|
||||
let mut loaded = 0;
|
||||
let mut s = store.lock().await;
|
||||
for (id, data) in &rows {
|
||||
match serde_json::from_str::<Classroom>(data) {
|
||||
Ok(classroom) => {
|
||||
s.insert(id.clone(), classroom);
|
||||
loaded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to deserialize classroom {}: {}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(s);
|
||||
|
||||
// Load chat history
|
||||
let chat_rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT classroom_id, data FROM classroom_chats"
|
||||
)
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load chats: {}", e))?;
|
||||
|
||||
let mut cs = chat_store.lock().await;
|
||||
for (classroom_id, data) in &chat_rows {
|
||||
match serde_json::from_str::<Vec<ClassroomChatMessage>>(data) {
|
||||
Ok(messages) => {
|
||||
cs.insert(classroom_id.clone(), ClassroomChatState {
|
||||
messages,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to deserialize chat for {}: {}", classroom_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user