diff --git a/clients/rook/migrations/0002_settings.sql b/clients/rook/migrations/0002_settings.sql new file mode 100644 index 000000000..6be2310cd --- /dev/null +++ b/clients/rook/migrations/0002_settings.sql @@ -0,0 +1,16 @@ +-- Migration 0002: settings singleton +-- +-- A single-row table that holds the global Rook runtime settings. +-- The row is always upserted via the primary-key value 1, so there +-- can never be more than one settings record. + +CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + gateway_port INTEGER NOT NULL DEFAULT 11434, + default_routing_policy TEXT NOT NULL DEFAULT 'priority', + max_retries INTEGER NOT NULL DEFAULT 3, + cooldown_seconds INTEGER NOT NULL DEFAULT 60, + log_json INTEGER NOT NULL DEFAULT 0, + log_level TEXT NOT NULL DEFAULT 'info', + updated_at TEXT NOT NULL +); diff --git a/clients/rook/src/db/mod.rs b/clients/rook/src/db/mod.rs index 0fb1f00ba..09f0b3f18 100644 --- a/clients/rook/src/db/mod.rs +++ b/clients/rook/src/db/mod.rs @@ -8,6 +8,7 @@ pub mod account; pub mod pool; pub mod route; +pub mod settings; use crate::domain::RookError; use chrono::Utc; @@ -20,6 +21,11 @@ const MIGRATION_SQL: &str = include_str!(concat!( "/migrations/0001_initial.sql" )); +const MIGRATION_SQL_0002: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/migrations/0002_settings.sql" +)); + /// A handle to the Rook SQLite database. /// /// Cheap to clone — cloning shares the underlying connection pool. @@ -124,6 +130,35 @@ impl SqliteDb { .map_err(|e| RookError::Registry(format!("failed to record migration: {e}")))?; } + // ── Migration 0002: settings ────────────────────────────────────────── + let version_0002 = "0002_settings"; + let row_0002: Option<(String,)> = sqlx::query_as( + "SELECT version FROM schema_migrations WHERE version = ?" + ) + .bind(version_0002) + .fetch_optional(pool) + .await + .map_err(|e| RookError::Registry(format!("failed to check migration 0002 status: {e}")))?; + + if row_0002.is_none() { + sqlx::raw_sql(MIGRATION_SQL_0002) + .execute(pool) + .await + .map_err(|e| RookError::Registry(format!("migration 0002 failed: {e}")))?; + + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)" + ) + .bind(version_0002) + .bind(&now) + .execute(pool) + .await + .map_err(|e| { + RookError::Registry(format!("failed to record migration 0002: {e}")) + })?; + } + Ok(()) } } \ No newline at end of file diff --git a/clients/rook/src/db/settings.rs b/clients/rook/src/db/settings.rs new file mode 100644 index 000000000..6c16b03f0 --- /dev/null +++ b/clients/rook/src/db/settings.rs @@ -0,0 +1,177 @@ +//! SQLite persistence for the [`RookSettings`] singleton. +//! +//! The `settings` table uses a single row (always `id = 1`) that is upserted +//! on every [`SqliteDb::save_settings`] call. + +use crate::db::SqliteDb; +use crate::domain::{RookError, RookSettings, RoutingPolicy, SelectionStrategy}; +use chrono::Utc; +use sqlx::Row; + +// ── Serialization helpers ───────────────────────────────────────────────────── + +fn strategy_to_str(s: &SelectionStrategy) -> &'static str { + match s { + SelectionStrategy::Priority => "priority", + SelectionStrategy::RoundRobin => "round_robin", + SelectionStrategy::Weighted => "weighted", + SelectionStrategy::Failover => "failover", + } +} + +fn str_to_strategy(s: &str) -> Result { + match s { + "priority" => Ok(SelectionStrategy::Priority), + "round_robin" => Ok(SelectionStrategy::RoundRobin), + "weighted" => Ok(SelectionStrategy::Weighted), + "failover" => Ok(SelectionStrategy::Failover), + other => Err(RookError::Registry(format!( + "unknown selection strategy '{other}'" + ))), + } +} + +// ── Row mapping ─────────────────────────────────────────────────────────────── + +fn row_to_settings(row: &sqlx::sqlite::SqliteRow) -> Result { + let gateway_port: i64 = row + .try_get("gateway_port") + .map_err(|e| RookError::Registry(format!("missing gateway_port: {e}")))?; + + let strategy_str: String = row + .try_get("default_routing_policy") + .map_err(|e| RookError::Registry(format!("missing default_routing_policy: {e}")))?; + let strategy = str_to_strategy(&strategy_str)?; + + let max_retries: i64 = row + .try_get("max_retries") + .map_err(|e| RookError::Registry(format!("missing max_retries: {e}")))?; + + let cooldown_seconds: i64 = row + .try_get("cooldown_seconds") + .map_err(|e| RookError::Registry(format!("missing cooldown_seconds: {e}")))?; + + let log_json: i64 = row + .try_get("log_json") + .map_err(|e| RookError::Registry(format!("missing log_json: {e}")))?; + + let log_level: String = row + .try_get("log_level") + .map_err(|e| RookError::Registry(format!("missing log_level: {e}")))?; + + Ok(RookSettings { + gateway_port: gateway_port as u16, + default_routing_policy: RoutingPolicy { + strategy, + max_retries: max_retries as u32, + cooldown_seconds: cooldown_seconds as u64, + }, + log_json: log_json != 0, + log_level, + }) +} + +// ── SqliteDb methods ────────────────────────────────────────────────────────── + +impl SqliteDb { + /// Load the settings singleton. + /// + /// Returns `None` if no settings row exists yet (caller should use + /// [`RookSettings::default`]). + pub async fn load_settings(&self) -> Option { + let result = sqlx::query( + "SELECT gateway_port, default_routing_policy, max_retries, + cooldown_seconds, log_json, log_level + FROM settings + WHERE id = 1", + ) + .fetch_optional(self.pool()) + .await; + + match result { + Ok(Some(row)) => row_to_settings(&row).ok(), + _ => None, + } + } + + /// Upsert the settings singleton. + pub async fn save_settings(&self, s: RookSettings) -> Result<(), RookError> { + let strategy = strategy_to_str(&s.default_routing_policy.strategy); + let now = Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO settings + (id, gateway_port, default_routing_policy, max_retries, + cooldown_seconds, log_json, log_level, updated_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + gateway_port = excluded.gateway_port, + default_routing_policy = excluded.default_routing_policy, + max_retries = excluded.max_retries, + cooldown_seconds = excluded.cooldown_seconds, + log_json = excluded.log_json, + log_level = excluded.log_level, + updated_at = excluded.updated_at", + ) + .bind(s.gateway_port as i64) + .bind(strategy) + .bind(s.default_routing_policy.max_retries as i64) + .bind(s.default_routing_policy.cooldown_seconds as i64) + .bind(if s.log_json { 1i64 } else { 0i64 }) + .bind(&s.log_level) + .bind(&now) + .execute(self.pool()) + .await + .map_err(|e| RookError::Registry(format!("failed to save settings: {e}")))?; + + Ok(()) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn load_returns_none_when_no_row() { + let db = SqliteDb::open_in_memory().await.unwrap(); + assert!(db.load_settings().await.is_none()); + } + + #[tokio::test] + async fn save_and_load_round_trip() { + let db = SqliteDb::open_in_memory().await.unwrap(); + + let mut s = RookSettings::default(); + s.gateway_port = 9090; + s.log_json = true; + s.log_level = "debug".to_owned(); + s.default_routing_policy.max_retries = 5; + + db.save_settings(s).await.unwrap(); + + let loaded = db.load_settings().await.unwrap(); + assert_eq!(loaded.gateway_port, 9090); + assert!(loaded.log_json); + assert_eq!(loaded.log_level, "debug"); + assert_eq!(loaded.default_routing_policy.max_retries, 5); + } + + #[tokio::test] + async fn save_twice_upserts() { + let db = SqliteDb::open_in_memory().await.unwrap(); + + let mut s1 = RookSettings::default(); + s1.gateway_port = 8080; + db.save_settings(s1).await.unwrap(); + + let mut s2 = RookSettings::default(); + s2.gateway_port = 9999; + db.save_settings(s2).await.unwrap(); + + let loaded = db.load_settings().await.unwrap(); + assert_eq!(loaded.gateway_port, 9999); + } +} diff --git a/clients/rook/src/domain/mod.rs b/clients/rook/src/domain/mod.rs index 46d44395a..16631aba5 100644 --- a/clients/rook/src/domain/mod.rs +++ b/clients/rook/src/domain/mod.rs @@ -302,6 +302,37 @@ pub struct RoutingPolicy { pub cooldown_seconds: u64, } +/// Global runtime settings for the Rook gateway. +/// +/// Stored as a single row in the `settings` table (key/value or single-row +/// schema). Defaults are applied when no row is present. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RookSettings { + /// TCP port the HTTP gateway listens on. + pub gateway_port: u16, + /// Default routing policy applied when no per-route policy is set. + pub default_routing_policy: RoutingPolicy, + /// Whether to emit structured JSON logs (false = human-readable). + pub log_json: bool, + /// Minimum log level filter (e.g., `"info"`, `"debug"`). + pub log_level: String, +} + +impl Default for RookSettings { + fn default() -> Self { + Self { + gateway_port: 11434, + default_routing_policy: RoutingPolicy { + strategy: SelectionStrategy::Priority, + max_retries: 3, + cooldown_seconds: 60, + }, + log_json: false, + log_level: "info".to_owned(), + } + } +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/clients/rook/src/registry/mod.rs b/clients/rook/src/registry/mod.rs index 5fdcbbad7..62c7e62c4 100644 --- a/clients/rook/src/registry/mod.rs +++ b/clients/rook/src/registry/mod.rs @@ -1,10 +1,152 @@ -//! Registry — persistence layer for Rook domain objects. +//! Registry — the composition root for Rook's persistence layer. //! -//! Owns all SQLite read/write operations for [`ProviderAccount`], -//! [`ProviderPool`], [`ModelRoute`], and [`RoutingPolicy`]. +//! [`RookRegistry`] owns a [`SqliteDb`] handle and exposes each domain +//! service as a concrete `SqliteXxxService` (or `InMemory` for health). //! -//! Consumers (gateway, TUI, dashboard) interact with higher-level service -//! types; they must never call SQLite directly. +//! Consumers (gateway, TUI, dashboard) depend on the service traits via +//! generic bounds or direct method calls on the concrete types returned here. +//! They must never touch SQLite directly. //! -//! FIXME: implement CRUD operations backed by rusqlite. -//! FIXME: add migration runner (embed SQL migrations via `include_str!`). +//! # Example +//! +//! ```no_run +//! # async fn example() -> Result<(), rook::domain::RookError> { +//! use rook::registry::RookRegistry; +//! use rook::services::account::AccountService as _; +//! +//! let registry = RookRegistry::open("./rook.db").await?; +//! let accounts = registry.accounts().list().await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::db::SqliteDb; +use crate::domain::RookError; +use crate::services::{ + account::SqliteAccountService, + health::InMemoryHealthService, + pool::SqlitePoolService, + route::SqliteRouteService, + settings::SqliteSettingsService, +}; + +/// Composition root — holds all service singletons for a Rook instance. +/// +/// Cheap to clone; all inner state lives behind `Arc` inside each service. +#[derive(Clone)] +pub struct RookRegistry { + accounts: SqliteAccountService, + pools: SqlitePoolService, + routes: SqliteRouteService, + settings: SqliteSettingsService, + health: InMemoryHealthService, +} + +impl RookRegistry { + /// Open (or create) the Rook database at `path` and wire all services. + pub async fn open(path: &str) -> Result { + let db = SqliteDb::open(path).await?; + Ok(Self::from_db(db)) + } + + /// Create a registry backed by an in-memory database. + /// + /// Intended for tests only. Each call produces an isolated database. + pub async fn open_in_memory() -> Result { + let db = SqliteDb::open_in_memory().await?; + Ok(Self::from_db(db)) + } + + /// Wire all services from an existing [`SqliteDb`] handle. + fn from_db(db: SqliteDb) -> Self { + Self { + accounts: SqliteAccountService::new(db.clone()), + pools: SqlitePoolService::new(db.clone()), + routes: SqliteRouteService::new(db.clone()), + settings: SqliteSettingsService::new(db.clone()), + health: InMemoryHealthService::new(), + } + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + /// Account service — manage provider accounts. + pub fn accounts(&self) -> &SqliteAccountService { + &self.accounts + } + + /// Pool service — manage provider pools. + pub fn pools(&self) -> &SqlitePoolService { + &self.pools + } + + /// Route service — manage model routes. + pub fn routes(&self) -> &SqliteRouteService { + &self.routes + } + + /// Settings service — global runtime configuration. + pub fn settings(&self) -> &SqliteSettingsService { + &self.settings + } + + /// Health service — track per-account health state. + pub fn health(&self) -> &InMemoryHealthService { + &self.health + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::RookSettings; + use crate::services::{ + account::AccountService as _, + pool::PoolService as _, + route::RouteService as _, + settings::SettingsService as _, + }; + + async fn registry() -> RookRegistry { + RookRegistry::open_in_memory().await.unwrap() + } + + #[tokio::test] + async fn registry_opens_and_accounts_empty() { + let r = registry().await; + let list = r.accounts().list().await; + assert!(list.is_empty()); + } + + #[tokio::test] + async fn registry_opens_and_pools_empty() { + let r = registry().await; + let list = r.pools().list().await; + assert!(list.is_empty()); + } + + #[tokio::test] + async fn registry_opens_and_routes_empty() { + let r = registry().await; + let list = r.routes().list().await; + assert!(list.is_empty()); + } + + #[tokio::test] + async fn registry_settings_default_on_fresh_db() { + let r = registry().await; + let s = r.settings().load().await; + assert_eq!(s.gateway_port, RookSettings::default().gateway_port); + } + + #[tokio::test] + async fn registry_settings_round_trip() { + let r = registry().await; + let mut s = RookSettings::default(); + s.gateway_port = 7777; + r.settings().save(s).await.unwrap(); + assert_eq!(r.settings().load().await.gateway_port, 7777); + } +} diff --git a/clients/rook/src/services/account.rs b/clients/rook/src/services/account.rs index 850b56f88..4ce595b4c 100644 --- a/clients/rook/src/services/account.rs +++ b/clients/rook/src/services/account.rs @@ -2,6 +2,7 @@ //! lifecycle management. use std::collections::HashMap; +use std::future::Future; use std::sync::{Arc, Mutex}; use crate::domain::{AccountId, ProviderAccount, RookError}; @@ -11,23 +12,29 @@ use crate::domain::{AccountId, ProviderAccount, RookError}; /// Port for managing [`ProviderAccount`] lifecycle. pub trait AccountService: Send + Sync { /// Return all accounts. - fn list(&self) -> Vec; + fn list(&self) -> impl Future> + Send; /// Return a single account by ID, or `None` if not found. - fn get(&self, id: AccountId) -> Option; + fn get(&self, id: AccountId) -> impl Future> + Send; /// Persist a new account and return its assigned [`AccountId`]. - fn create(&self, account: ProviderAccount) -> Result; + fn create( + &self, + account: ProviderAccount, + ) -> impl Future> + Send; /// Overwrite an existing account. /// /// Returns [`RookError::Registry`] if the ID is unknown. - fn update(&self, account: ProviderAccount) -> Result<(), RookError>; + fn update( + &self, + account: ProviderAccount, + ) -> impl Future> + Send; /// Remove an account by ID. /// /// Returns [`RookError::Registry`] if the ID is unknown. - fn delete(&self, id: AccountId) -> Result<(), RookError>; + fn delete(&self, id: AccountId) -> impl Future> + Send; } // ── In-memory implementation ────────────────────────────────────────────────── @@ -48,20 +55,21 @@ impl InMemoryAccountService { } impl AccountService for InMemoryAccountService { - fn list(&self) -> Vec { + async fn list(&self) -> Vec { self.store .lock() .map(|g| g.values().cloned().collect()) .unwrap_or_default() } - fn get(&self, id: AccountId) -> Option { + async fn get(&self, id: AccountId) -> Option { self.store.lock().ok()?.get(&id).cloned() } - fn create(&self, account: ProviderAccount) -> Result { + async fn create(&self, account: ProviderAccount) -> Result { let id = account.id; - let mut guard = self.store + let mut guard = self + .store .lock() .map_err(|e| RookError::Registry(e.to_string()))?; @@ -73,7 +81,7 @@ impl AccountService for InMemoryAccountService { Ok(id) } - fn update(&self, account: ProviderAccount) -> Result<(), RookError> { + async fn update(&self, account: ProviderAccount) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; if !guard.contains_key(&account.id) { @@ -86,7 +94,7 @@ impl AccountService for InMemoryAccountService { Ok(()) } - fn delete(&self, id: AccountId) -> Result<(), RookError> { + async fn delete(&self, id: AccountId) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; if guard.remove(&id).is_none() { @@ -96,6 +104,49 @@ impl AccountService for InMemoryAccountService { } } +// ── SQLite implementation ───────────────────────────────────────────────────── + +/// SQLite-backed [`AccountService`]. +/// +/// Delegates all storage to the Rook [`crate::db::SqliteDb`] connection pool. +#[derive(Clone, Debug)] +pub struct SqliteAccountService { + db: crate::db::SqliteDb, +} + +impl SqliteAccountService { + /// Wrap an existing [`crate::db::SqliteDb`]. + pub fn new(db: crate::db::SqliteDb) -> Self { + Self { db } + } +} + +impl AccountService for SqliteAccountService { + async fn list(&self) -> Vec { + self.db.list_accounts().await.unwrap_or_default() + } + + async fn get(&self, id: AccountId) -> Option { + self.db.get_account(&id).await.ok().flatten() + } + + async fn create(&self, account: ProviderAccount) -> Result { + let id = account.id; + self.db.insert_account(&account).await?; + Ok(id) + } + + async fn update(&self, account: ProviderAccount) -> Result<(), RookError> { + // No update_account in db layer — implement as delete + re-insert. + self.db.delete_account(&account.id).await.map(|_| ())?; + self.db.insert_account(&account).await + } + + async fn delete(&self, id: AccountId) -> Result<(), RookError> { + self.db.delete_account(&id).await.map(|_| ()) + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -123,66 +174,66 @@ mod tests { let _ = SelectionStrategy::Priority; }; - #[test] - fn crud_round_trip() { + #[tokio::test] + async fn crud_round_trip() { let svc = InMemoryAccountService::new(); let account = make_account("test"); let id = account.id; // Create - let returned_id = svc.create(account.clone()).unwrap(); + let returned_id = svc.create(account.clone()).await.unwrap(); assert_eq!(returned_id, id); // Read - let fetched = svc.get(id).unwrap(); + let fetched = svc.get(id).await.unwrap(); assert_eq!(fetched.display_name, "test"); // List - assert_eq!(svc.list().len(), 1); + assert_eq!(svc.list().await.len(), 1); // Update let mut updated = fetched.clone(); updated.display_name = "updated".to_owned(); - svc.update(updated).unwrap(); - assert_eq!(svc.get(id).unwrap().display_name, "updated"); + svc.update(updated).await.unwrap(); + assert_eq!(svc.get(id).await.unwrap().display_name, "updated"); // Delete - svc.delete(id).unwrap(); - assert!(svc.get(id).is_none()); - assert!(svc.list().is_empty()); + svc.delete(id).await.unwrap(); + assert!(svc.get(id).await.is_none()); + assert!(svc.list().await.is_empty()); } - #[test] - fn get_nonexistent_returns_none() { + #[tokio::test] + async fn get_nonexistent_returns_none() { let svc = InMemoryAccountService::new(); - assert!(svc.get(AccountId::generate()).is_none()); + assert!(svc.get(AccountId::generate()).await.is_none()); } - #[test] - fn delete_nonexistent_returns_error() { + #[tokio::test] + async fn delete_nonexistent_returns_error() { let svc = InMemoryAccountService::new(); - let err = svc.delete(AccountId::generate()).unwrap_err(); + let err = svc.delete(AccountId::generate()).await.unwrap_err(); assert!(err.to_string().contains("not found")); } - #[test] - fn update_nonexistent_returns_error() { + #[tokio::test] + async fn update_nonexistent_returns_error() { let svc = InMemoryAccountService::new(); let account = make_account("ghost"); - let err = svc.update(account).unwrap_err(); + let err = svc.update(account).await.unwrap_err(); assert!(err.to_string().contains("not found")); } - #[test] - fn create_duplicate_returns_error() { + #[tokio::test] + async fn create_duplicate_returns_error() { let svc = InMemoryAccountService::new(); let account = make_account("test"); // First create should succeed - svc.create(account.clone()).unwrap(); + svc.create(account.clone()).await.unwrap(); // Second create with same ID should fail - let err = svc.create(account).unwrap_err(); + let err = svc.create(account).await.unwrap_err(); assert!(err.to_string().contains("duplicate")); } -} \ No newline at end of file +} diff --git a/clients/rook/src/services/health.rs b/clients/rook/src/services/health.rs index f08911955..f51e03305 100644 --- a/clients/rook/src/services/health.rs +++ b/clients/rook/src/services/health.rs @@ -2,6 +2,7 @@ //! availability and cooldown state. use std::collections::HashMap; +use std::future::Future; use std::sync::{Arc, Mutex}; use chrono::{DateTime, Utc}; @@ -59,21 +60,28 @@ pub trait HealthService: Send + Sync { /// /// Always returns a record — creates a default `Unknown` entry if no probe /// has run yet. - fn get(&self, account_id: AccountId) -> AccountHealth; + fn get(&self, account_id: AccountId) -> impl Future + Send; /// Record a successful probe for `account_id`, clearing any cooldown. - fn mark_success(&self, account_id: AccountId); + fn mark_success(&self, account_id: AccountId) -> impl Future + Send; /// Record a failed probe for `account_id` and set a cooldown window of /// `cooldown_seconds` from now. - fn mark_failure(&self, account_id: AccountId, cooldown_seconds: u64); + fn mark_failure( + &self, + account_id: AccountId, + cooldown_seconds: u64, + ) -> impl Future + Send; /// Return `true` when the account is healthy and any previous cooldown has /// expired. - fn is_available(&self, account_id: AccountId) -> bool; + fn is_available(&self, account_id: AccountId) -> impl Future + Send; /// Filter `account_ids` to those that are currently available. - fn list_healthy(&self, account_ids: &[AccountId]) -> Vec; + fn list_healthy( + &self, + account_ids: &[AccountId], + ) -> impl Future> + Send; } // ── In-memory implementation ────────────────────────────────────────────────── @@ -81,7 +89,7 @@ pub trait HealthService: Send + Sync { /// In-memory [`HealthService`] backed by a `HashMap`. /// /// No persistence — used for tests and bootstrap scenarios. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct InMemoryHealthService { store: Arc>>, } @@ -101,14 +109,18 @@ impl InMemoryHealthService { } impl HealthService for InMemoryHealthService { - fn get(&self, account_id: AccountId) -> AccountHealth { + async fn get(&self, account_id: AccountId) -> AccountHealth { self.store .lock() - .map(|mut g| g.entry(account_id).or_insert_with(|| AccountHealth::new(account_id)).clone()) + .map(|mut g| { + g.entry(account_id) + .or_insert_with(|| AccountHealth::new(account_id)) + .clone() + }) .unwrap_or_else(|_| AccountHealth::new(account_id)) } - fn mark_success(&self, account_id: AccountId) { + async fn mark_success(&self, account_id: AccountId) { match self.lock() { Ok(mut guard) => { let entry = @@ -119,12 +131,16 @@ impl HealthService for InMemoryHealthService { entry.cooldown_until = None; } Err(e) => { - tracing::warn!("mark_success: poisoned mutex for account {}: {}", account_id, e); + tracing::warn!( + "mark_success: poisoned mutex for account {}: {}", + account_id, + e + ); } } } - fn mark_failure(&self, account_id: AccountId, cooldown_seconds: u64) { + async fn mark_failure(&self, account_id: AccountId, cooldown_seconds: u64) { match self.lock() { Ok(mut guard) => { let entry = @@ -133,18 +149,21 @@ impl HealthService for InMemoryHealthService { entry.last_checked = Some(Utc::now()); entry.status = HealthStatus::Unhealthy; let cooldown_secs = i64::try_from(cooldown_seconds).unwrap_or(i64::MAX); - entry.cooldown_until = Some( - Utc::now() + chrono::Duration::seconds(cooldown_secs), - ); + entry.cooldown_until = + Some(Utc::now() + chrono::Duration::seconds(cooldown_secs)); } Err(e) => { - tracing::warn!("mark_failure: poisoned mutex for account {}: {}", account_id, e); + tracing::warn!( + "mark_failure: poisoned mutex for account {}: {}", + account_id, + e + ); } } } - fn is_available(&self, account_id: AccountId) -> bool { - let health = self.get(account_id); + async fn is_available(&self, account_id: AccountId) -> bool { + let health = self.get(account_id).await; if health.status == HealthStatus::Unhealthy { return false; } @@ -156,8 +175,14 @@ impl HealthService for InMemoryHealthService { true } - fn list_healthy(&self, account_ids: &[AccountId]) -> Vec { - account_ids.iter().filter(|id| self.is_available(**id)).copied().collect() + async fn list_healthy(&self, account_ids: &[AccountId]) -> Vec { + let mut result = Vec::new(); + for id in account_ids { + if self.is_available(*id).await { + result.push(*id); + } + } + result } } @@ -167,92 +192,92 @@ impl HealthService for InMemoryHealthService { mod tests { use super::*; - #[test] - fn initial_health_is_unknown_and_available() { + #[tokio::test] + async fn initial_health_is_unknown_and_available() { let svc = InMemoryHealthService::new(); let id = AccountId::generate(); - let health = svc.get(id); + let health = svc.get(id).await; assert_eq!(health.status, HealthStatus::Unknown); // Unknown accounts are treated as available (no cooldown, not explicitly unhealthy). - assert!(svc.is_available(id)); + assert!(svc.is_available(id).await); } - #[test] - fn mark_success_sets_healthy_and_clears_cooldown() { + #[tokio::test] + async fn mark_success_sets_healthy_and_clears_cooldown() { let svc = InMemoryHealthService::new(); let id = AccountId::generate(); // First mark as failed to set a cooldown. - svc.mark_failure(id, 300); - assert!(!svc.is_available(id)); + svc.mark_failure(id, 300).await; + assert!(!svc.is_available(id).await); // Then recover. - svc.mark_success(id); - let health = svc.get(id); + svc.mark_success(id).await; + let health = svc.get(id).await; assert_eq!(health.status, HealthStatus::Healthy); assert_eq!(health.consecutive_failures, 0); assert!(health.cooldown_until.is_none()); - assert!(svc.is_available(id)); + assert!(svc.is_available(id).await); } - #[test] - fn mark_failure_increments_failures_and_sets_cooldown() { + #[tokio::test] + async fn mark_failure_increments_failures_and_sets_cooldown() { let svc = InMemoryHealthService::new(); let id = AccountId::generate(); - svc.mark_failure(id, 60); - let health = svc.get(id); + svc.mark_failure(id, 60).await; + let health = svc.get(id).await; assert_eq!(health.status, HealthStatus::Unhealthy); assert_eq!(health.consecutive_failures, 1); assert!(health.cooldown_until.is_some()); assert!(health.cooldown_until.unwrap() > Utc::now()); // Second failure increments counter. - svc.mark_failure(id, 60); - assert_eq!(svc.get(id).consecutive_failures, 2); + svc.mark_failure(id, 60).await; + assert_eq!(svc.get(id).await.consecutive_failures, 2); } - #[test] - fn is_available_false_during_cooldown() { + #[tokio::test] + async fn is_available_false_during_cooldown() { let svc = InMemoryHealthService::new(); let id = AccountId::generate(); // Large cooldown — will not expire within the test. - svc.mark_failure(id, 9999); - assert!(!svc.is_available(id)); + svc.mark_failure(id, 9999).await; + assert!(!svc.is_available(id).await); } - #[test] - fn list_healthy_excludes_unhealthy_accounts() { + #[tokio::test] + async fn list_healthy_excludes_unhealthy_accounts() { let svc = InMemoryHealthService::new(); let good1 = AccountId::generate(); let good2 = AccountId::generate(); let bad = AccountId::generate(); - svc.mark_success(good1); - svc.mark_success(good2); - svc.mark_failure(bad, 9999); + svc.mark_success(good1).await; + svc.mark_success(good2).await; + svc.mark_failure(bad, 9999).await; - let healthy = svc.list_healthy(&[good1, bad, good2]); + let healthy = svc.list_healthy(&[good1, bad, good2]).await; assert_eq!(healthy.len(), 2); assert!(healthy.contains(&good1)); assert!(healthy.contains(&good2)); assert!(!healthy.contains(&bad)); } - #[test] - fn crud_round_trip_via_get() { + #[tokio::test] + async fn crud_round_trip_via_get() { let svc = InMemoryHealthService::new(); let id = AccountId::generate(); // get creates a default entry - let h = svc.get(id); + let h = svc.get(id).await; assert_eq!(h.account_id, id); // mark failure then success - svc.mark_failure(id, 1); - svc.mark_success(id); - assert_eq!(svc.get(id).status, HealthStatus::Healthy); + svc.mark_failure(id, 1).await; + svc.mark_success(id).await; + assert_eq!(svc.get(id).await.status, HealthStatus::Healthy); } -} \ No newline at end of file +} diff --git a/clients/rook/src/services/mod.rs b/clients/rook/src/services/mod.rs index 0ef9ac30f..655df701a 100644 --- a/clients/rook/src/services/mod.rs +++ b/clients/rook/src/services/mod.rs @@ -7,3 +7,4 @@ pub mod account; pub mod health; pub mod pool; pub mod route; +pub mod settings; diff --git a/clients/rook/src/services/pool.rs b/clients/rook/src/services/pool.rs index 448bffcf3..56d68ee5e 100644 --- a/clients/rook/src/services/pool.rs +++ b/clients/rook/src/services/pool.rs @@ -2,6 +2,7 @@ //! lifecycle management, including member add/remove operations. use std::collections::HashMap; +use std::future::Future; use std::sync::{Arc, Mutex}; use crate::domain::{AccountId, PoolId, ProviderPool, RookError}; @@ -11,34 +12,45 @@ use crate::domain::{AccountId, PoolId, ProviderPool, RookError}; /// Port for managing [`ProviderPool`] lifecycle. pub trait PoolService: Send + Sync { /// Return all pools. - fn list(&self) -> Vec; + fn list(&self) -> impl Future> + Send; /// Return a single pool by ID, or `None` if not found. - fn get(&self, id: PoolId) -> Option; + fn get(&self, id: PoolId) -> impl Future> + Send; /// Persist a new pool and return its assigned [`PoolId`]. - fn create(&self, pool: ProviderPool) -> Result; + fn create( + &self, + pool: ProviderPool, + ) -> impl Future> + Send; /// Overwrite an existing pool. /// /// Returns [`RookError::Registry`] if the ID is unknown. - fn update(&self, pool: ProviderPool) -> Result<(), RookError>; + fn update(&self, pool: ProviderPool) -> impl Future> + Send; /// Remove a pool by ID. /// /// Returns [`RookError::Registry`] if the ID is unknown. - fn delete(&self, id: PoolId) -> Result<(), RookError>; + fn delete(&self, id: PoolId) -> impl Future> + Send; /// Append `account_id` to `pool_id`'s member list (idempotent). /// /// Returns [`RookError::Registry`] if `pool_id` is unknown. - fn add_member(&self, pool_id: PoolId, account_id: AccountId) -> Result<(), RookError>; + fn add_member( + &self, + pool_id: PoolId, + account_id: AccountId, + ) -> impl Future> + Send; /// Remove `account_id` from `pool_id`'s member list. /// /// Returns [`RookError::Registry`] if `pool_id` is unknown or `account_id` /// is not a member. - fn remove_member(&self, pool_id: PoolId, account_id: AccountId) -> Result<(), RookError>; + fn remove_member( + &self, + pool_id: PoolId, + account_id: AccountId, + ) -> impl Future> + Send; } // ── In-memory implementation ────────────────────────────────────────────────── @@ -59,20 +71,21 @@ impl InMemoryPoolService { } impl PoolService for InMemoryPoolService { - fn list(&self) -> Vec { + async fn list(&self) -> Vec { self.store .lock() .map(|g| g.values().cloned().collect()) .unwrap_or_default() } - fn get(&self, id: PoolId) -> Option { + async fn get(&self, id: PoolId) -> Option { self.store.lock().ok()?.get(&id).cloned() } - fn create(&self, pool: ProviderPool) -> Result { + async fn create(&self, pool: ProviderPool) -> Result { let id = pool.id; - let mut guard = self.store + let mut guard = self + .store .lock() .map_err(|e| RookError::Registry(e.to_string()))?; @@ -84,7 +97,7 @@ impl PoolService for InMemoryPoolService { Ok(id) } - fn update(&self, pool: ProviderPool) -> Result<(), RookError> { + async fn update(&self, pool: ProviderPool) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; if !guard.contains_key(&pool.id) { @@ -94,7 +107,7 @@ impl PoolService for InMemoryPoolService { Ok(()) } - fn delete(&self, id: PoolId) -> Result<(), RookError> { + async fn delete(&self, id: PoolId) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; if guard.remove(&id).is_none() { @@ -103,7 +116,7 @@ impl PoolService for InMemoryPoolService { Ok(()) } - fn add_member(&self, pool_id: PoolId, account_id: AccountId) -> Result<(), RookError> { + async fn add_member(&self, pool_id: PoolId, account_id: AccountId) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; let pool = guard @@ -115,22 +128,93 @@ impl PoolService for InMemoryPoolService { Ok(()) } - fn remove_member(&self, pool_id: PoolId, account_id: AccountId) -> Result<(), RookError> { + async fn remove_member( + &self, + pool_id: PoolId, + account_id: AccountId, + ) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; let pool = guard .get_mut(&pool_id) .ok_or_else(|| RookError::Registry(format!("pool {pool_id} not found")))?; - let pos = pool.members.iter().position(|m| m == &account_id).ok_or_else(|| { - RookError::Registry(format!( - "account {account_id} is not a member of pool {pool_id}" - )) - })?; + let pos = + pool.members.iter().position(|m| m == &account_id).ok_or_else(|| { + RookError::Registry(format!( + "account {account_id} is not a member of pool {pool_id}" + )) + })?; pool.members.remove(pos); Ok(()) } } +// ── SQLite implementation ───────────────────────────────────────────────────── + +/// SQLite-backed [`PoolService`]. +/// +/// Delegates all storage to the Rook [`crate::db::SqliteDb`] connection pool. +#[derive(Clone, Debug)] +pub struct SqlitePoolService { + db: crate::db::SqliteDb, +} + +impl SqlitePoolService { + /// Wrap an existing [`crate::db::SqliteDb`]. + pub fn new(db: crate::db::SqliteDb) -> Self { + Self { db } + } +} + +impl PoolService for SqlitePoolService { + async fn list(&self) -> Vec { + self.db.list_pools().await.unwrap_or_default() + } + + async fn get(&self, id: PoolId) -> Option { + self.db.get_pool(&id).await.ok().flatten() + } + + async fn create(&self, pool: ProviderPool) -> Result { + let id = pool.id; + self.db.insert_pool(&pool).await?; + Ok(id) + } + + async fn update(&self, pool: ProviderPool) -> Result<(), RookError> { + // No update_pool in db layer — implement as delete + re-insert. + let pool_id_str = pool.id.to_string(); + sqlx::query("DELETE FROM provider_pools WHERE id = ?") + .bind(&pool_id_str) + .execute(self.db.pool()) + .await + .map_err(|e| RookError::Registry(format!("delete_pool failed: {e}")))?; + self.db.insert_pool(&pool).await + } + + async fn delete(&self, id: PoolId) -> Result<(), RookError> { + let id_str = id.to_string(); + sqlx::query("DELETE FROM provider_pools WHERE id = ?") + .bind(&id_str) + .execute(self.db.pool()) + .await + .map(|_| ()) + .map_err(|e| RookError::Registry(format!("delete_pool failed: {e}"))) + } + + async fn add_member(&self, pool_id: PoolId, account_id: AccountId) -> Result<(), RookError> { + self.db.add_pool_member(&pool_id, &account_id).await + } + + async fn remove_member( + &self, + pool_id: PoolId, + account_id: AccountId, + ) -> Result<(), RookError> { + self.db.remove_pool_member(&pool_id, &account_id).await + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -148,103 +232,104 @@ mod tests { } } - #[test] - fn crud_round_trip() { + #[tokio::test] + async fn crud_round_trip() { let svc = InMemoryPoolService::new(); let pool = make_pool("primary"); let id = pool.id; // Create - let returned_id = svc.create(pool.clone()).unwrap(); + let returned_id = svc.create(pool.clone()).await.unwrap(); assert_eq!(returned_id, id); // Read - let fetched = svc.get(id).unwrap(); + let fetched = svc.get(id).await.unwrap(); assert_eq!(fetched.name, "primary"); // List - assert_eq!(svc.list().len(), 1); + assert_eq!(svc.list().await.len(), 1); // Update let mut updated = fetched.clone(); updated.name = "updated".to_owned(); - svc.update(updated).unwrap(); - assert_eq!(svc.get(id).unwrap().name, "updated"); + svc.update(updated).await.unwrap(); + assert_eq!(svc.get(id).await.unwrap().name, "updated"); // Delete - svc.delete(id).unwrap(); - assert!(svc.get(id).is_none()); - assert!(svc.list().is_empty()); + svc.delete(id).await.unwrap(); + assert!(svc.get(id).await.is_none()); + assert!(svc.list().await.is_empty()); } - #[test] - fn get_nonexistent_returns_none() { + #[tokio::test] + async fn get_nonexistent_returns_none() { let svc = InMemoryPoolService::new(); - assert!(svc.get(PoolId::generate()).is_none()); + assert!(svc.get(PoolId::generate()).await.is_none()); } - #[test] - fn delete_nonexistent_returns_error() { + #[tokio::test] + async fn delete_nonexistent_returns_error() { let svc = InMemoryPoolService::new(); - let err = svc.delete(PoolId::generate()).unwrap_err(); + let err = svc.delete(PoolId::generate()).await.unwrap_err(); assert!(err.to_string().contains("not found")); } - #[test] - fn update_nonexistent_returns_error() { + #[tokio::test] + async fn update_nonexistent_returns_error() { let svc = InMemoryPoolService::new(); let pool = make_pool("ghost"); - let err = svc.update(pool).unwrap_err(); + let err = svc.update(pool).await.unwrap_err(); assert!(err.to_string().contains("not found")); } - #[test] - fn add_member_is_idempotent() { + #[tokio::test] + async fn add_member_is_idempotent() { let svc = InMemoryPoolService::new(); let pool = make_pool("p"); let pool_id = pool.id; - svc.create(pool).unwrap(); + svc.create(pool).await.unwrap(); let acct_id = AccountId::generate(); - svc.add_member(pool_id, acct_id).unwrap(); - svc.add_member(pool_id, acct_id).unwrap(); // second call must not duplicate + svc.add_member(pool_id, acct_id).await.unwrap(); + svc.add_member(pool_id, acct_id).await.unwrap(); // second call must not duplicate - let fetched = svc.get(pool_id).unwrap(); + let fetched = svc.get(pool_id).await.unwrap(); assert_eq!(fetched.members.len(), 1); } - #[test] - fn remove_member_succeeds_and_remove_nonmember_errors() { + #[tokio::test] + async fn remove_member_succeeds_and_remove_nonmember_errors() { let svc = InMemoryPoolService::new(); let pool = make_pool("p"); let pool_id = pool.id; - svc.create(pool).unwrap(); + svc.create(pool).await.unwrap(); let acct_id = AccountId::generate(); - svc.add_member(pool_id, acct_id).unwrap(); - svc.remove_member(pool_id, acct_id).unwrap(); + svc.add_member(pool_id, acct_id).await.unwrap(); + svc.remove_member(pool_id, acct_id).await.unwrap(); - let err = svc.remove_member(pool_id, acct_id).unwrap_err(); + let err = svc.remove_member(pool_id, acct_id).await.unwrap_err(); assert!(err.to_string().contains("not a member")); } - #[test] - fn add_member_to_nonexistent_pool_errors() { + #[tokio::test] + async fn add_member_to_nonexistent_pool_errors() { let svc = InMemoryPoolService::new(); - let err = svc.add_member(PoolId::generate(), AccountId::generate()).unwrap_err(); + let err = + svc.add_member(PoolId::generate(), AccountId::generate()).await.unwrap_err(); assert!(err.to_string().contains("not found")); } - #[test] - fn create_duplicate_pool_returns_error() { + #[tokio::test] + async fn create_duplicate_pool_returns_error() { let svc = InMemoryPoolService::new(); let pool = make_pool("test"); // First create should succeed - svc.create(pool.clone()).unwrap(); + svc.create(pool.clone()).await.unwrap(); // Second create with same ID should fail - let err = svc.create(pool).unwrap_err(); + let err = svc.create(pool).await.unwrap_err(); assert!(err.to_string().contains("duplicate")); } -} \ No newline at end of file +} diff --git a/clients/rook/src/services/route.rs b/clients/rook/src/services/route.rs index 54d3ec4f4..132b86c04 100644 --- a/clients/rook/src/services/route.rs +++ b/clients/rook/src/services/route.rs @@ -2,6 +2,7 @@ //! lifecycle management and logical-model resolution. use std::collections::HashMap; +use std::future::Future; use std::sync::{Arc, Mutex}; use crate::domain::{ModelRoute, RookError, RouteId}; @@ -11,27 +12,33 @@ use crate::domain::{ModelRoute, RookError, RouteId}; /// Port for managing [`ModelRoute`] lifecycle and resolution. pub trait RouteService: Send + Sync { /// Return all routes. - fn list(&self) -> Vec; + fn list(&self) -> impl Future> + Send; /// Return a single route by ID, or `None` if not found. - fn get(&self, id: RouteId) -> Option; + fn get(&self, id: RouteId) -> impl Future> + Send; /// Resolve a logical model name to its active route, or `None` if no route /// is configured for that model. - fn resolve(&self, logical_model: &str) -> Option; + fn resolve( + &self, + logical_model: &str, + ) -> impl Future> + Send; /// Persist a new route and return its assigned [`RouteId`]. - fn create(&self, route: ModelRoute) -> Result; + fn create( + &self, + route: ModelRoute, + ) -> impl Future> + Send; /// Overwrite an existing route. /// /// Returns [`RookError::Registry`] if the ID is unknown. - fn update(&self, route: ModelRoute) -> Result<(), RookError>; + fn update(&self, route: ModelRoute) -> impl Future> + Send; /// Remove a route by ID. /// /// Returns [`RookError::Registry`] if the ID is unknown. - fn delete(&self, id: RouteId) -> Result<(), RookError>; + fn delete(&self, id: RouteId) -> impl Future> + Send; } // ── In-memory implementation ────────────────────────────────────────────────── @@ -41,7 +48,7 @@ pub trait RouteService: Send + Sync { /// No persistence — used for tests and bootstrap scenarios. #[derive(Debug, Default)] pub struct InMemoryRouteService { - store: Arc>>, + pub(crate) store: Arc>>, } impl InMemoryRouteService { @@ -52,18 +59,18 @@ impl InMemoryRouteService { } impl RouteService for InMemoryRouteService { - fn list(&self) -> Vec { + async fn list(&self) -> Vec { self.store .lock() .map(|g| g.values().cloned().collect()) .unwrap_or_default() } - fn get(&self, id: RouteId) -> Option { + async fn get(&self, id: RouteId) -> Option { self.store.lock().ok()?.get(&id).cloned() } - fn resolve(&self, logical_model: &str) -> Option { + async fn resolve(&self, logical_model: &str) -> Option { let guard = self.store.lock().ok()?; let matches: Vec = guard .values() @@ -72,16 +79,13 @@ impl RouteService for InMemoryRouteService { .collect(); // Only return a route if exactly one is found - if matches.len() == 1 { - Some(matches[0].clone()) - } else { - None - } + if matches.len() == 1 { Some(matches[0].clone()) } else { None } } - fn create(&self, route: ModelRoute) -> Result { + async fn create(&self, route: ModelRoute) -> Result { let id = route.id; - let mut guard = self.store + let mut guard = self + .store .lock() .map_err(|e| RookError::Registry(e.to_string()))?; @@ -104,7 +108,7 @@ impl RouteService for InMemoryRouteService { Ok(id) } - fn update(&self, route: ModelRoute) -> Result<(), RookError> { + async fn update(&self, route: ModelRoute) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; @@ -127,7 +131,7 @@ impl RouteService for InMemoryRouteService { Ok(()) } - fn delete(&self, id: RouteId) -> Result<(), RookError> { + async fn delete(&self, id: RouteId) -> Result<(), RookError> { let mut guard = self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; if guard.remove(&id).is_none() { @@ -137,6 +141,53 @@ impl RouteService for InMemoryRouteService { } } +// ── SQLite implementation ───────────────────────────────────────────────────── + +/// SQLite-backed [`RouteService`]. +/// +/// Delegates all storage to the Rook [`crate::db::SqliteDb`] connection pool. +#[derive(Clone, Debug)] +pub struct SqliteRouteService { + db: crate::db::SqliteDb, +} + +impl SqliteRouteService { + /// Wrap an existing [`crate::db::SqliteDb`]. + pub fn new(db: crate::db::SqliteDb) -> Self { + Self { db } + } +} + +impl RouteService for SqliteRouteService { + async fn list(&self) -> Vec { + self.db.list_routes().await.unwrap_or_default() + } + + async fn get(&self, id: RouteId) -> Option { + self.db.get_route(&id).await.ok().flatten() + } + + async fn resolve(&self, logical_model: &str) -> Option { + self.db.find_route_by_model(logical_model).await.ok().flatten() + } + + async fn create(&self, route: ModelRoute) -> Result { + let id = route.id; + self.db.insert_route(&route).await?; + Ok(id) + } + + async fn update(&self, route: ModelRoute) -> Result<(), RookError> { + // No update_route in db layer — implement as delete + re-insert. + self.db.delete_route(&route.id).await.map(|_| ())?; + self.db.insert_route(&route).await + } + + async fn delete(&self, id: RouteId) -> Result<(), RookError> { + self.db.delete_route(&id).await.map(|_| ()) + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -154,120 +205,120 @@ mod tests { } } - #[test] - fn crud_round_trip() { + #[tokio::test] + async fn crud_round_trip() { let svc = InMemoryRouteService::new(); let route = make_route("gpt-4o"); let id = route.id; // Create - let returned_id = svc.create(route.clone()).unwrap(); + let returned_id = svc.create(route.clone()).await.unwrap(); assert_eq!(returned_id, id); // Read - let fetched = svc.get(id).unwrap(); + let fetched = svc.get(id).await.unwrap(); assert_eq!(fetched.logical_model, "gpt-4o"); // List - assert_eq!(svc.list().len(), 1); + assert_eq!(svc.list().await.len(), 1); // Update let mut updated = fetched.clone(); updated.logical_model = "gpt-4o-mini".to_owned(); - svc.update(updated).unwrap(); - assert_eq!(svc.get(id).unwrap().logical_model, "gpt-4o-mini"); + svc.update(updated).await.unwrap(); + assert_eq!(svc.get(id).await.unwrap().logical_model, "gpt-4o-mini"); // Delete - svc.delete(id).unwrap(); - assert!(svc.get(id).is_none()); - assert!(svc.list().is_empty()); + svc.delete(id).await.unwrap(); + assert!(svc.get(id).await.is_none()); + assert!(svc.list().await.is_empty()); } - #[test] - fn get_nonexistent_returns_none() { + #[tokio::test] + async fn get_nonexistent_returns_none() { let svc = InMemoryRouteService::new(); - assert!(svc.get(RouteId::generate()).is_none()); + assert!(svc.get(RouteId::generate()).await.is_none()); } - #[test] - fn delete_nonexistent_returns_error() { + #[tokio::test] + async fn delete_nonexistent_returns_error() { let svc = InMemoryRouteService::new(); - let err = svc.delete(RouteId::generate()).unwrap_err(); + let err = svc.delete(RouteId::generate()).await.unwrap_err(); assert!(err.to_string().contains("not found")); } - #[test] - fn update_nonexistent_returns_error() { + #[tokio::test] + async fn update_nonexistent_returns_error() { let svc = InMemoryRouteService::new(); let route = make_route("claude-3-opus"); - let err = svc.update(route).unwrap_err(); + let err = svc.update(route).await.unwrap_err(); assert!(err.to_string().contains("not found")); } - #[test] - fn resolve_finds_route_by_logical_model() { + #[tokio::test] + async fn resolve_finds_route_by_logical_model() { let svc = InMemoryRouteService::new(); let route = make_route("claude-3-5-sonnet"); - svc.create(route).unwrap(); + svc.create(route).await.unwrap(); - let found = svc.resolve("claude-3-5-sonnet"); + let found = svc.resolve("claude-3-5-sonnet").await; assert!(found.is_some()); assert_eq!(found.unwrap().logical_model, "claude-3-5-sonnet"); } - #[test] - fn resolve_returns_none_for_unknown_model() { + #[tokio::test] + async fn resolve_returns_none_for_unknown_model() { let svc = InMemoryRouteService::new(); - assert!(svc.resolve("no-such-model").is_none()); + assert!(svc.resolve("no-such-model").await.is_none()); } - #[test] - fn create_duplicate_route_id_returns_error() { + #[tokio::test] + async fn create_duplicate_route_id_returns_error() { let svc = InMemoryRouteService::new(); let route = make_route("gpt-4o"); // First create should succeed - svc.create(route.clone()).unwrap(); + svc.create(route.clone()).await.unwrap(); // Second create with same ID should fail - let err = svc.create(route).unwrap_err(); + let err = svc.create(route).await.unwrap_err(); assert!(err.to_string().contains("duplicate")); } - #[test] - fn create_duplicate_logical_model_returns_error() { + #[tokio::test] + async fn create_duplicate_logical_model_returns_error() { let svc = InMemoryRouteService::new(); let route1 = make_route("gpt-4o"); let mut route2 = make_route("gpt-4o"); route2.id = RouteId::generate(); // different ID, same logical_model // First create should succeed - svc.create(route1).unwrap(); + svc.create(route1).await.unwrap(); // Second create with same logical_model should fail - let err = svc.create(route2).unwrap_err(); + let err = svc.create(route2).await.unwrap_err(); assert!(err.to_string().contains("already exists")); } - #[test] - fn update_with_duplicate_logical_model_returns_error() { + #[tokio::test] + async fn update_with_duplicate_logical_model_returns_error() { let svc = InMemoryRouteService::new(); let route1 = make_route("gpt-4o"); let route2 = make_route("claude-3-opus"); - svc.create(route1.clone()).unwrap(); - svc.create(route2.clone()).unwrap(); + svc.create(route1.clone()).await.unwrap(); + svc.create(route2.clone()).await.unwrap(); // Try to update route2 to have the same logical_model as route1 let mut updated_route2 = route2.clone(); updated_route2.logical_model = "gpt-4o".to_string(); - let err = svc.update(updated_route2).unwrap_err(); + let err = svc.update(updated_route2).await.unwrap_err(); assert!(err.to_string().contains("already exists")); } - #[test] - fn resolve_with_multiple_matches_returns_none() { + #[tokio::test] + async fn resolve_with_multiple_matches_returns_none() { let svc = InMemoryRouteService::new(); // This shouldn't happen in practice due to create() validation, @@ -284,6 +335,6 @@ mod tests { } // resolve should return None when multiple routes match - assert!(svc.resolve("gpt-4o").is_none()); + assert!(svc.resolve("gpt-4o").await.is_none()); } -} \ No newline at end of file +} diff --git a/clients/rook/src/services/settings.rs b/clients/rook/src/services/settings.rs new file mode 100644 index 000000000..f283240b7 --- /dev/null +++ b/clients/rook/src/services/settings.rs @@ -0,0 +1,128 @@ +//! Settings service — port and in-memory implementation for [`RookSettings`] +//! singleton management. + +use std::future::Future; +use std::sync::{Arc, Mutex}; + +use crate::domain::{RookError, RookSettings}; + +// ── Port ───────────────────────────────────────────────────────────────────── + +/// Port for reading and persisting the global [`RookSettings`] singleton. +pub trait SettingsService: Send + Sync { + /// Load the current settings. + /// + /// Returns [`RookSettings::default`] if no settings have been persisted yet. + fn load(&self) -> impl Future + Send; + + /// Persist updated settings, overwriting any previous value. + fn save(&self, settings: RookSettings) -> impl Future> + Send; +} + +// ── In-memory implementation ────────────────────────────────────────────────── + +/// In-memory [`SettingsService`] backed by a `Mutex>`. +/// +/// No persistence — used for tests and bootstrap scenarios. +#[derive(Debug, Default)] +pub struct InMemorySettingsService { + store: Arc>>, +} + +impl InMemorySettingsService { + /// Create a service with no persisted settings (will return defaults on load). + pub fn new() -> Self { + Self::default() + } +} + +impl SettingsService for InMemorySettingsService { + async fn load(&self) -> RookSettings { + self.store + .lock() + .map(|g| g.clone().unwrap_or_default()) + .unwrap_or_default() + } + + async fn save(&self, settings: RookSettings) -> Result<(), RookError> { + let mut guard = + self.store.lock().map_err(|e| RookError::Registry(e.to_string()))?; + *guard = Some(settings); + Ok(()) + } +} + +// ── SQLite implementation ───────────────────────────────────────────────────── + +/// SQLite-backed [`SettingsService`]. +/// +/// Delegates all storage to the Rook [`crate::db::SqliteDb`] connection pool. +#[derive(Clone, Debug)] +pub struct SqliteSettingsService { + db: crate::db::SqliteDb, +} + +impl SqliteSettingsService { + /// Wrap an existing [`crate::db::SqliteDb`]. + pub fn new(db: crate::db::SqliteDb) -> Self { + Self { db } + } +} + +impl SettingsService for SqliteSettingsService { + async fn load(&self) -> RookSettings { + self.db.load_settings().await.unwrap_or_default() + } + + async fn save(&self, settings: RookSettings) -> Result<(), RookError> { + self.db.save_settings(settings).await + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn load_returns_defaults_when_empty() { + let svc = InMemorySettingsService::new(); + let settings = svc.load().await; + let defaults = RookSettings::default(); + assert_eq!(settings.gateway_port, defaults.gateway_port); + assert_eq!(settings.log_level, defaults.log_level); + assert_eq!(settings.log_json, defaults.log_json); + } + + #[tokio::test] + async fn save_and_load_round_trip() { + let svc = InMemorySettingsService::new(); + let mut s = RookSettings::default(); + s.gateway_port = 9090; + s.log_json = true; + s.log_level = "debug".to_owned(); + + svc.save(s.clone()).await.unwrap(); + + let loaded = svc.load().await; + assert_eq!(loaded.gateway_port, 9090); + assert!(loaded.log_json); + assert_eq!(loaded.log_level, "debug"); + } + + #[tokio::test] + async fn save_overwrites_previous() { + let svc = InMemorySettingsService::new(); + + let mut s1 = RookSettings::default(); + s1.gateway_port = 8080; + svc.save(s1).await.unwrap(); + + let mut s2 = RookSettings::default(); + s2.gateway_port = 9999; + svc.save(s2).await.unwrap(); + + assert_eq!(svc.load().await.gateway_port, 9999); + } +}