mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
* refactor(setup): extract init logic from wizard into owning modules Move database, LLM model discovery, and secrets initialization logic out of the setup wizard and into their owning modules, following the CLAUDE.md principle that module-specific initialization must live in the owning module as a public factory function. Database (src/db/mod.rs, src/config/database.rs): - Add DatabaseConfig::from_postgres_url() and from_libsql_path() - Add connect_without_migrations() for connectivity testing - Add validate_postgres() returning structured PgDiagnostic results LLM (src/llm/models.rs — new file): - Extract 8 model-fetching functions from wizard.rs (~380 lines) - fetch_anthropic_models, fetch_openai_models, fetch_ollama_models, fetch_openai_compatible_models, build_nearai_model_fetch_config, and OpenAI sorting/filtering helpers Secrets (src/secrets/mod.rs): - Add resolve_master_key() unifying env var + keychain resolution - Add crypto_from_hex() convenience wrapper Wizard restructuring (src/setup/wizard.rs): - Replace cfg-gated db_pool/db_backend fields with generic db: Option<Arc<dyn Database>> + db_handles: Option<DatabaseHandles> - Delete 6 backend-specific methods (reconnect_postgres/libsql, test_database_connection_postgres/libsql, run_migrations_postgres/ libsql, create_postgres/libsql_secrets_store) - Simplify persist_settings, try_load_existing_settings, persist_session_to_db, init_secrets_context to backend-agnostic implementations using the new module factories - Eliminate all references to deadpool_postgres, PoolConfig, LibSqlBackend, Store::from_pool, refinery::embed_migrations Net: -878 lines from wizard, +395 lines in owning modules, +378 new. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(settings): add wizard re-run regression tests Add 10 tests covering settings preservation during wizard re-runs: - provider_only rerun preserves channels/embeddings/heartbeat - channels_only rerun preserves provider/model/embeddings - quick mode rerun preserves prior channels and heartbeat - full rerun same provider preserves model through merge - full rerun different provider clears model through merge - incremental persist doesn't clobber prior steps - switching DB backend allows fresh connection settings - merge preserves true booleans when overlay has default false - embeddings survive rerun that skips step 5 These cover the scenarios where re-running the wizard would previously risk resetting models, providers, or channel settings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(setup): eliminate cfg(feature) gates from wizard methods Replace compile-time #[cfg(feature)] dispatch in the wizard with runtime dispatch via DatabaseBackend enum and cfg!() macro constants. - Merge step_database_postgres + step_database_libsql into step_database using runtime backend selection - Rewrite auto_setup_database without feature gates - Remove cfg(feature = "postgres") from mask_password_in_url (pure fn) - Remove cfg(feature = "postgres") from test_mask_password_in_url Only one internal #[cfg(feature = "postgres")] remains: guarding the call to db::validate_postgres() which is itself feature-gated. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(db): fold PG validation into connect_without_migrations Move PostgreSQL prerequisite validation (version >= 15, pgvector) from the wizard into connect_without_migrations() in the db module. The validation now returns DatabaseError directly with user-facing messages, eliminating the PgDiagnostic enum and the last #[cfg(feature)] gate from the wizard. The wizard's test_database_connection() is now a 5-line method that calls the db module factory and stores the result. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review comments [skip-regression-check] - Use .as_ref().map() to avoid partial move of db_config.libsql_path (gemini-code-assist) - Default to available backend when DATABASE_BACKEND is invalid, not unconditionally to Postgres which may not be compiled (Copilot) - Match DatabaseBackend::Postgres explicitly instead of _ => wildcard in connect_with_handles, connect_without_migrations, and create_secrets_store to avoid silently routing LibSql configs through the Postgres path when libsql feature is disabled (Copilot) - Upgrade Ollama connection failure log from info to warn with the base URL for better visibility in wizard UX (Copilot) - Clarify crypto_from_hex doc: SecretsCrypto validates key length, not hex encoding (Copilot) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian's PR review feedback [skip-regression-check] - Update src/setup/README.md to reflect Arc<dyn Database> flow - Remove stale "Test PostgreSQL connection" doc comment - Replace unwrap_or(0) in validate_postgres with descriptive error - Add NearAiConfig::for_model_discovery() constructor - Narrow pub to pub(crate) for internal model helpers Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address Copilot review comments (quick-mode postgres gate, empty env vars) [skip-regression-check] - Gate DATABASE_URL auto-detection on POSTGRES_AVAILABLE in quick mode so libsql-only builds don't attempt a postgres connection - Match empty-env-var filtering in key source detection to align with resolve_master_key() behavior - Filter empty strings to None in DatabaseConfig::from_libsql_path() for turso_url/turso_token Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
262 lines
8.3 KiB
Rust
262 lines
8.3 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use secrecy::{ExposeSecret, SecretString};
|
|
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
use crate::config::helpers::{optional_env, parse_optional_env};
|
|
use crate::error::ConfigError;
|
|
|
|
/// Which database backend to use.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum DatabaseBackend {
|
|
/// PostgreSQL via deadpool-postgres (default).
|
|
#[default]
|
|
Postgres,
|
|
/// libSQL/Turso embedded database.
|
|
LibSql,
|
|
}
|
|
|
|
impl std::fmt::Display for DatabaseBackend {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Postgres => write!(f, "postgres"),
|
|
Self::LibSql => write!(f, "libsql"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for DatabaseBackend {
|
|
type Err = String;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s.to_lowercase().as_str() {
|
|
"postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
|
|
"libsql" | "turso" | "sqlite" => Ok(Self::LibSql),
|
|
_ => Err(format!(
|
|
"invalid database backend '{}', expected 'postgres' or 'libsql'",
|
|
s
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// PostgreSQL SSL/TLS mode, matching libpq semantics for the common cases.
|
|
///
|
|
/// Default is `Prefer`: attempt TLS, fall back to plaintext. This is the
|
|
/// safest non-breaking default — local Postgres without TLS keeps working
|
|
/// while managed providers (Neon, Supabase, RDS) automatically get TLS.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum SslMode {
|
|
/// Never use TLS (equivalent to libpq `sslmode=disable`).
|
|
Disable,
|
|
/// Try TLS first; fall back to plaintext on failure (default).
|
|
#[default]
|
|
Prefer,
|
|
/// Require TLS; fail if the server does not support it.
|
|
Require,
|
|
}
|
|
|
|
impl std::fmt::Display for SslMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Disable => write!(f, "disable"),
|
|
Self::Prefer => write!(f, "prefer"),
|
|
Self::Require => write!(f, "require"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for SslMode {
|
|
type Err = String;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s.to_lowercase().as_str() {
|
|
"disable" => Ok(Self::Disable),
|
|
"prefer" => Ok(Self::Prefer),
|
|
"require" => Ok(Self::Require),
|
|
_ => Err(format!(
|
|
"invalid DATABASE_SSLMODE '{}', expected 'disable', 'prefer', or 'require'",
|
|
s
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Database configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DatabaseConfig {
|
|
/// Which backend to use (default: Postgres).
|
|
pub backend: DatabaseBackend,
|
|
|
|
// -- PostgreSQL fields --
|
|
pub url: SecretString,
|
|
pub pool_size: usize,
|
|
/// TLS mode for PostgreSQL connections (default: Prefer).
|
|
pub ssl_mode: SslMode,
|
|
|
|
// -- libSQL fields --
|
|
/// Path to local libSQL database file (default: ~/.ironclaw/ironclaw.db).
|
|
pub libsql_path: Option<PathBuf>,
|
|
/// Turso cloud URL for remote sync (optional).
|
|
pub libsql_url: Option<String>,
|
|
/// Turso auth token (required when libsql_url is set).
|
|
pub libsql_auth_token: Option<SecretString>,
|
|
}
|
|
|
|
impl DatabaseConfig {
|
|
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
|
let backend: DatabaseBackend = if let Some(b) = optional_env("DATABASE_BACKEND")? {
|
|
b.parse().map_err(|e| ConfigError::InvalidValue {
|
|
key: "DATABASE_BACKEND".to_string(),
|
|
message: e,
|
|
})?
|
|
} else {
|
|
DatabaseBackend::default()
|
|
};
|
|
|
|
// PostgreSQL URL is required only when using the postgres backend.
|
|
// For libsql backend, default to an empty placeholder.
|
|
// DATABASE_URL is loaded from ~/.ironclaw/.env via dotenvy early in startup.
|
|
let url = optional_env("DATABASE_URL")?
|
|
.or_else(|| {
|
|
if backend == DatabaseBackend::LibSql {
|
|
Some("unused://libsql".to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.ok_or_else(|| ConfigError::MissingRequired {
|
|
key: "DATABASE_URL".to_string(),
|
|
hint: "Run 'ironclaw onboard' or set DATABASE_URL environment variable".to_string(),
|
|
})?;
|
|
|
|
let pool_size = parse_optional_env("DATABASE_POOL_SIZE", 10)?;
|
|
|
|
let ssl_mode: SslMode = if let Some(s) = optional_env("DATABASE_SSLMODE")? {
|
|
s.parse().map_err(|e| ConfigError::InvalidValue {
|
|
key: "DATABASE_SSLMODE".to_string(),
|
|
message: e,
|
|
})?
|
|
} else {
|
|
SslMode::default()
|
|
};
|
|
|
|
let libsql_path = optional_env("LIBSQL_PATH")?.map(PathBuf::from).or_else(|| {
|
|
if backend == DatabaseBackend::LibSql {
|
|
Some(default_libsql_path())
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let libsql_url = optional_env("LIBSQL_URL")?;
|
|
let libsql_auth_token = optional_env("LIBSQL_AUTH_TOKEN")?.map(SecretString::from);
|
|
|
|
if libsql_url.is_some() && libsql_auth_token.is_none() {
|
|
return Err(ConfigError::MissingRequired {
|
|
key: "LIBSQL_AUTH_TOKEN".to_string(),
|
|
hint: "LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set".to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(Self {
|
|
backend,
|
|
url: SecretString::from(url),
|
|
pool_size,
|
|
ssl_mode,
|
|
libsql_path,
|
|
libsql_url,
|
|
libsql_auth_token,
|
|
})
|
|
}
|
|
|
|
/// Create a config from a raw PostgreSQL URL (for wizard/testing).
|
|
pub fn from_postgres_url(url: &str, pool_size: usize) -> Self {
|
|
Self {
|
|
backend: DatabaseBackend::Postgres,
|
|
url: SecretString::from(url.to_string()),
|
|
pool_size,
|
|
ssl_mode: SslMode::from_env(),
|
|
libsql_path: None,
|
|
libsql_url: None,
|
|
libsql_auth_token: None,
|
|
}
|
|
}
|
|
|
|
/// Create a config for a libSQL database (for wizard/testing).
|
|
///
|
|
/// Empty strings for `turso_url` and `turso_token` are treated as `None`.
|
|
pub fn from_libsql_path(
|
|
path: &str,
|
|
turso_url: Option<&str>,
|
|
turso_token: Option<&str>,
|
|
) -> Self {
|
|
let turso_url = turso_url.filter(|s| !s.is_empty());
|
|
let turso_token = turso_token.filter(|s| !s.is_empty());
|
|
Self {
|
|
backend: DatabaseBackend::LibSql,
|
|
url: SecretString::from("unused://libsql".to_string()),
|
|
pool_size: 1,
|
|
ssl_mode: SslMode::default(),
|
|
libsql_path: Some(PathBuf::from(path)),
|
|
libsql_url: turso_url.map(String::from),
|
|
libsql_auth_token: turso_token.map(|t| SecretString::from(t.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Get the database URL (exposes the secret).
|
|
pub fn url(&self) -> &str {
|
|
self.url.expose_secret()
|
|
}
|
|
}
|
|
|
|
impl SslMode {
|
|
/// Read from `DATABASE_SSLMODE` env var, defaulting to `Prefer`.
|
|
///
|
|
/// Silently falls back to `Prefer` on missing or unparseable values.
|
|
/// Used by lightweight CLI tools (status, doctor) that don't run the
|
|
/// full config pipeline.
|
|
pub fn from_env() -> Self {
|
|
std::env::var("DATABASE_SSLMODE")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
/// Default libSQL database path (~/.ironclaw/ironclaw.db).
|
|
pub fn default_libsql_path() -> PathBuf {
|
|
ironclaw_base_dir().join("ironclaw.db")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn ssl_mode_default_is_prefer() {
|
|
assert_eq!(SslMode::default(), SslMode::Prefer);
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_mode_parse_roundtrip() {
|
|
for mode in [SslMode::Disable, SslMode::Prefer, SslMode::Require] {
|
|
let s = mode.to_string();
|
|
let parsed: SslMode = s.parse().expect("should parse");
|
|
assert_eq!(parsed, mode);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_mode_parse_case_insensitive() {
|
|
assert_eq!("DISABLE".parse::<SslMode>().unwrap(), SslMode::Disable);
|
|
assert_eq!("Prefer".parse::<SslMode>().unwrap(), SslMode::Prefer);
|
|
assert_eq!("REQUIRE".parse::<SslMode>().unwrap(), SslMode::Require);
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_mode_parse_invalid() {
|
|
assert!("invalid".parse::<SslMode>().is_err());
|
|
}
|
|
}
|