Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion crates/aionui-app/src/bootstrap/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub fn init_environment(cli: &Cli, merged_path: &str) -> Result<ServerEnvironmen
app_version: cli.app_version.clone(),
local: cli.local,
dump_prompts: cli.dump_prompts,
recover_corrupted_database: cli.recover_corrupted_database,
};
info!(
"Running in {} mode — authentication is {}",
Expand Down Expand Up @@ -95,7 +96,14 @@ pub async fn init_data_layer(config: &AppConfig) -> Result<Database, BootstrapEr
.with_field("databasePath", db_path.display().to_string())
})?;
info!("Initializing database at {}", db_path.display());
let database = aionui_db::init_database_staged(&db_path).await.map_err(|e| {
let database = aionui_db::init_database_staged_with_options(
&db_path,
aionui_db::DatabaseInitOptions {
recover_corrupted_database: config.recover_corrupted_database,
},
)
.await
.map_err(|e| {
let stage = e.stage();
BootstrapError::new(
BootstrapErrorCode::DataInitFailed,
Expand Down Expand Up @@ -131,4 +139,17 @@ mod tests {

assert_eq!(err.stage(), "database.schema_repair");
}

#[test]
fn database_recoverable_corruption_stage_comes_from_db_boundary_error() {
let err = aionui_db::DatabaseInitError::new(
"database.recoverable_corruption",
aionui_db::DbError::Migration(sqlx::migrate::MigrateError::ExecuteMigration(
sqlx::Error::Protocol("database disk image is malformed".into()),
13,
)),
);

assert_eq!(err.stage(), "database.recoverable_corruption");
}
}
16 changes: 16 additions & 0 deletions crates/aionui-app/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ pub(crate) struct Cli {
#[arg(long)]
pub dump_prompts: bool,

/// Explicitly back up a corruption-like local database and create a fresh database during startup.
#[arg(long)]
pub recover_corrupted_database: bool,

/// Managed runtime resource source selection.
#[arg(long, value_enum, default_value_t = ManagedResourcesModeArg::Download)]
pub managed_resources_mode: ManagedResourcesModeArg,
Expand Down Expand Up @@ -257,6 +261,18 @@ mod tests {
assert!(cli.dump_prompts);
}

#[test]
fn recover_corrupted_database_flag_defaults_to_false() {
let cli = Cli::parse_from(["aioncore"]);
assert!(!cli.recover_corrupted_database);
}

#[test]
fn recover_corrupted_database_flag_is_accepted() {
let cli = Cli::parse_from(["aioncore", "--recover-corrupted-database"]);
assert!(cli.recover_corrupted_database);
}

#[test]
fn command_as_str_returns_clap_subcommand_names() {
let prepare_args = PrepareManagedResourcesArgs {
Expand Down
4 changes: 4 additions & 0 deletions crates/aionui-app/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pub struct AppConfig {
pub local: bool,
/// Dump prompt diagnostics under `data_dir/prompt-dumps`.
pub dump_prompts: bool,
/// Explicitly authorize backup and rebuild for corruption-like local databases.
pub recover_corrupted_database: bool,
}

impl AppConfig {
Expand Down Expand Up @@ -49,6 +51,7 @@ impl Default for AppConfig {
app_version: env!("CARGO_PKG_VERSION").to_string(),
local: false,
dump_prompts: false,
recover_corrupted_database: false,
}
}
}
Expand All @@ -73,6 +76,7 @@ mod tests {
assert_eq!(config.data_dir, PathBuf::from("data"));
assert_eq!(config.app_version, env!("CARGO_PKG_VERSION"));
assert!(!config.dump_prompts);
assert!(!config.recover_corrupted_database);
}

#[test]
Expand Down
76 changes: 72 additions & 4 deletions crates/aionui-db/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@ static DB_MIGRATOR: Migrator = sqlx::migrate!();
// Historical special-case for the MCP schema reconciliation fallback.
// Keep this pinned to migration version 7 even as newer migrations land.
const MCP_SCHEMA_RECONCILIATION_MIGRATION_VERSION: i64 = 7;
const RECOVERABLE_DATABASE_CORRUPTION_STAGE: &str = "database.recoverable_corruption";

/// Wraps a SQLite connection pool with lifecycle management.
#[derive(Clone, Debug)]
pub struct Database {
pool: SqlitePool,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DatabaseInitOptions {
pub recover_corrupted_database: bool,
}

#[derive(Debug)]
pub struct DatabaseInitError {
stage: &'static str,
Expand Down Expand Up @@ -94,10 +100,26 @@ impl Database {
/// failures attempt recovery by backing up the corrupted file and creating a
/// fresh database. Migration mismatches and lock contention fail fast.
pub async fn init_database(path: &Path) -> Result<Database, DbError> {
init_database_staged(path).await.map_err(DatabaseInitError::into_source)
init_database_with_options(path, DatabaseInitOptions::default())
.await
.map_err(DatabaseInitError::into_source)
}

pub async fn init_database_with_options(
path: &Path,
options: DatabaseInitOptions,
) -> Result<Database, DatabaseInitError> {
init_database_staged_with_options(path, options).await
}

pub async fn init_database_staged(path: &Path) -> Result<Database, DatabaseInitError> {
init_database_staged_with_options(path, DatabaseInitOptions::default()).await
}

pub async fn init_database_staged_with_options(
path: &Path,
options: DatabaseInitOptions,
) -> Result<Database, DatabaseInitError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
Expand All @@ -111,10 +133,25 @@ pub async fn init_database_staged(path: &Path) -> Result<Database, DatabaseInitE

match try_init_file_staged(path).await {
Ok(db) => Ok(db),
Err(e) if path.exists() && should_attempt_recovery(e.source()) => {
warn!("Database initialization failed, attempting recovery: {e}");
Err(e) if path.exists() && options.recover_corrupted_database && should_attempt_recovery(e.source()) => {
warn!(
code = "BOOTSTRAP_DATABASE_CORRUPTION_REBUILD_AUTHORIZED",
stage = e.stage(),
"Authorized corrupted database backup and rebuild"
);
recover_and_retry(path, e.into_source()).await
}
Err(e) if path.exists() && is_recoverable_migration_corruption(e.source()) => {
warn!(
code = "BOOTSTRAP_DATABASE_CORRUPTION_REQUIRES_USER_CONFIRMATION",
stage = e.stage(),
"Database corruption-like migration failure requires user confirmation before rebuild"
);
Err(DatabaseInitError::new(
RECOVERABLE_DATABASE_CORRUPTION_STAGE,
e.into_source(),
))
}
Err(e) => Err(e),
}
}
Expand Down Expand Up @@ -638,12 +675,21 @@ async fn recover_and_retry(path: &Path, original_error: DbError) -> Result<Datab

fn should_attempt_recovery(err: &DbError) -> bool {
match err {
DbError::Migration(_) => false,
DbError::Migration(sqlx::migrate::MigrateError::VersionMismatch(_)) => false,
DbError::Migration(_) => is_corruption_like_error(err),
DbError::NotFound(_) | DbError::Conflict(_) => false,
DbError::Query(_) | DbError::Init(_) => is_corruption_like_error(err),
}
}

fn is_recoverable_migration_corruption(err: &DbError) -> bool {
match err {
DbError::Migration(sqlx::migrate::MigrateError::VersionMismatch(_)) => false,
DbError::Migration(_) => is_corruption_like_error(err),
_ => false,
}
}

fn is_corruption_like_error(err: &DbError) -> bool {
let message = err.to_string().to_ascii_lowercase();

Expand Down Expand Up @@ -692,6 +738,28 @@ mod tests {
);
}

#[test]
fn recovery_allows_corruption_like_migration_errors_when_authorized() {
let err = DbError::Migration(sqlx::migrate::MigrateError::ExecuteMigration(
sqlx::Error::Protocol("database disk image is malformed".into()),
13,
));

assert!(should_attempt_recovery(&err));
assert!(is_recoverable_migration_corruption(&err));
}

#[test]
fn recovery_skips_non_corruption_migration_errors() {
let err = DbError::Migration(sqlx::migrate::MigrateError::ExecuteMigration(
sqlx::Error::Protocol("UNIQUE constraint failed: tasks.id".into()),
13,
));

assert!(!should_attempt_recovery(&err));
assert!(!is_recoverable_migration_corruption(&err));
}

#[tokio::test]
async fn migration_preserves_fk_references() {
let db = init_database_memory().await.unwrap();
Expand Down
3 changes: 2 additions & 1 deletion crates/aionui-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ pub use agent_binding::{
runtime_backend_for_agent,
};
pub use database::{
Database, DatabaseInitError, init_database, init_database_memory, init_database_staged, maybe_copy_legacy_database,
Database, DatabaseInitError, DatabaseInitOptions, init_database, init_database_memory, init_database_staged,
init_database_staged_with_options, init_database_with_options, maybe_copy_legacy_database,
};
pub use error::DbError;
pub use models::{
Expand Down
31 changes: 29 additions & 2 deletions crates/aionui-db/tests/db_lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use aionui_db::{init_database, init_database_memory, maybe_copy_legacy_database};
use aionui_db::{
DatabaseInitOptions, init_database, init_database_memory, init_database_with_options, maybe_copy_legacy_database,
};
use sqlx::Row;

// -- T1.1 Initialization --
Expand Down Expand Up @@ -231,7 +233,14 @@ async fn corruption_recovery_creates_backup() {
// Write invalid content to simulate corruption
std::fs::write(&path, b"not a valid sqlite database").unwrap();

let db = init_database(&path).await.unwrap();
let db = init_database_with_options(
&path,
DatabaseInitOptions {
recover_corrupted_database: true,
},
)
.await
.unwrap();

// Recovered database should work
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
Expand All @@ -250,6 +259,24 @@ async fn corruption_recovery_creates_backup() {
db.close().await;
}

#[tokio::test]
async fn corruption_without_recovery_authorization_preserves_original_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.db");
let original = b"not a valid sqlite database";
std::fs::write(&path, original).unwrap();

let result = init_database(&path).await;

assert!(result.is_err(), "unconfirmed startup must not rebuild corrupted DB");
assert_eq!(std::fs::read(&path).unwrap(), original);
let has_backup = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().contains("backup"));
assert!(!has_backup, "unconfirmed startup must not create a backup file");
}

// -- Directory creation --

#[tokio::test]
Expand Down
Loading