mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 08:39:24 +00:00
* fix(ci): use explicit features in WASM WIT compat test to avoid sqlite3 symbol conflicts The `import` feature (added in #903) brings in `rusqlite[bundled]` which conflicts with `libsql-ffi` — both bundle SQLite C code, causing duplicate symbol linker errors. Use explicit features matching the test matrix instead of `--all-features`. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replace rusqlite with libsql in import module to fix sqlite3 symbol conflict The `import` feature used `rusqlite[bundled]` which bundled its own SQLite C code, conflicting with `libsql-ffi` (also bundles SQLite). This caused duplicate `sqlite3_*` symbol linker errors when both features were enabled via `--all-features`. Replace `rusqlite` with `libsql` (already a dependency) in the import reader. The `import` feature now implies `libsql`. This eliminates the duplicate symbol conflict and allows `--all-features` to compile cleanly. Also restores `--all-features` in the WASM WIT compat CI test (now safe) and converts all import test helpers from rusqlite to libsql. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: apply cargo fmt formatting fixes Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
183 lines
6.7 KiB
Rust
183 lines
6.7 KiB
Rust
//! OpenClaw data migration orchestration and detection.
|
|
|
|
pub mod credentials;
|
|
pub mod history;
|
|
pub mod memory;
|
|
pub mod reader;
|
|
pub mod settings;
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use crate::db::Database;
|
|
use crate::import::{ImportError, ImportOptions, ImportStats};
|
|
use crate::secrets::SecretsStore;
|
|
use crate::workspace::Workspace;
|
|
|
|
pub use reader::OpenClawReader;
|
|
|
|
/// OpenClaw importer that coordinates migration of all data types.
|
|
pub struct OpenClawImporter {
|
|
db: Arc<dyn Database>,
|
|
workspace: Workspace,
|
|
secrets: Arc<dyn SecretsStore>,
|
|
opts: ImportOptions,
|
|
}
|
|
|
|
impl OpenClawImporter {
|
|
/// Create a new OpenClaw importer.
|
|
pub fn new(
|
|
db: Arc<dyn Database>,
|
|
workspace: Workspace,
|
|
secrets: Arc<dyn SecretsStore>,
|
|
opts: ImportOptions,
|
|
) -> Self {
|
|
Self {
|
|
db,
|
|
workspace,
|
|
secrets,
|
|
opts,
|
|
}
|
|
}
|
|
|
|
/// Detect if an OpenClaw installation exists at the default location (~/.openclaw).
|
|
pub fn detect() -> Option<PathBuf> {
|
|
if let Ok(home) = std::env::var("HOME") {
|
|
let openclaw_dir = PathBuf::from(home).join(".openclaw");
|
|
let config_file = openclaw_dir.join("openclaw.json");
|
|
if config_file.exists() {
|
|
return Some(openclaw_dir);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Run the import process for all data types.
|
|
///
|
|
/// Returns detailed statistics about what was imported.
|
|
/// If `dry_run` is enabled, no data is written to the database.
|
|
///
|
|
/// **Database Safety Note:** The Database trait does not currently expose explicit
|
|
/// transaction control (BEGIN/COMMIT/ROLLBACK). To minimize consistency risks:
|
|
/// - All configuration reading is done before any writes
|
|
/// - Writes are grouped by type (settings, credentials, documents, chunks, conversations)
|
|
/// - Conversations are handled atomically: creation + all messages added together
|
|
/// - Errors are logged but don't stop the entire import (fail-safe behavior)
|
|
pub async fn import(&self) -> Result<ImportStats, ImportError> {
|
|
let mut stats = ImportStats::default();
|
|
|
|
// === PHASE 1: READ ALL DATA BEFORE ANY WRITES ===
|
|
// This minimizes the window where the database could be left in a partial state
|
|
|
|
// Read OpenClaw data
|
|
let reader = OpenClawReader::new(&self.opts.openclaw_path)?;
|
|
let config = reader.read_config()?;
|
|
let agent_dbs = reader.list_agent_dbs()?;
|
|
|
|
// Pre-read all conversation data to validate before writing
|
|
let mut all_conversations = Vec::new();
|
|
for (_agent_name, db_path) in &agent_dbs {
|
|
match reader.read_conversations(db_path).await {
|
|
Ok(convs) => all_conversations.extend(convs),
|
|
Err(e) => {
|
|
tracing::warn!("Failed to read conversations: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-read all memory chunks
|
|
let mut all_chunks = Vec::new();
|
|
for (_agent_name, db_path) in &agent_dbs {
|
|
match reader.read_memory_chunks(db_path).await {
|
|
Ok(chunks) => all_chunks.extend(chunks),
|
|
Err(e) => {
|
|
tracing::warn!("Failed to read memory chunks: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prepare all settings and credentials
|
|
let settings_map = settings::map_openclaw_config_to_settings(&config);
|
|
let creds = settings::extract_credentials(&config);
|
|
|
|
// === PHASE 2: WRITE IN GROUPED ORDER ===
|
|
// If a crash occurs, earlier groups are fully committed
|
|
|
|
if !self.opts.dry_run {
|
|
// Group 1: Settings (should be idempotent via upsert)
|
|
for (key, value) in settings_map {
|
|
if let Err(e) = self.db.set_setting(&self.opts.user_id, &key, &value).await {
|
|
tracing::warn!("Failed to import setting {}: {}", key, e);
|
|
} else {
|
|
stats.settings += 1;
|
|
}
|
|
}
|
|
|
|
// Group 2: Credentials (should be idempotent via upsert)
|
|
for (name, value) in creds {
|
|
use secrecy::ExposeSecret;
|
|
let exposed = value.expose_secret().to_string();
|
|
let params = crate::secrets::CreateSecretParams::new(name, exposed);
|
|
if let Err(e) = self.secrets.create(&self.opts.user_id, params).await {
|
|
tracing::warn!("Failed to import credential: {}", e);
|
|
} else {
|
|
stats.secrets += 1;
|
|
}
|
|
}
|
|
|
|
// Group 3: Workspace documents
|
|
if let Ok(_count) = reader.list_workspace_files() {
|
|
match self
|
|
.workspace
|
|
.import_from_directory(&self.opts.openclaw_path.join("workspace"))
|
|
.await
|
|
{
|
|
Ok(imported) => stats.documents = imported,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to import workspace documents: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Group 4: Memory chunks (should be idempotent via path deduplication)
|
|
for chunk in all_chunks {
|
|
if let Err(e) = memory::import_chunk(&self.db, &chunk, &self.opts).await {
|
|
tracing::warn!("Failed to import memory chunk: {}", e);
|
|
} else {
|
|
stats.chunks += 1;
|
|
}
|
|
}
|
|
|
|
// Group 5: Conversations with messages
|
|
// CRITICAL: Each conversation + its messages form an atomic unit.
|
|
// If a crash occurs mid-conversation, only that conversation is incomplete.
|
|
// All previous conversations are fully committed.
|
|
for conv in all_conversations {
|
|
match history::import_conversation_atomic(&self.db, conv, &self.opts).await {
|
|
Ok((_conv_id, msg_count)) => {
|
|
stats.conversations += 1;
|
|
stats.messages += msg_count;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to import conversation: {}", e);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// DRY RUN: Count only
|
|
stats.settings = settings_map.len();
|
|
stats.secrets = creds.len();
|
|
if let Ok(count) = reader.list_workspace_files() {
|
|
stats.documents = count;
|
|
}
|
|
stats.chunks = all_chunks.len();
|
|
stats.conversations = all_conversations.len();
|
|
for conv in &all_conversations {
|
|
stats.messages += conv.messages.len();
|
|
}
|
|
}
|
|
|
|
Ok(stats)
|
|
}
|
|
}
|