use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; use std::sync::{Arc, Mutex, mpsc}; use std::thread::JoinHandle; use std::time::Duration; use anyhow::{Result, anyhow}; use bds_core::db::Database; use bds_core::engine::domain_events::EventSubscription; use bds_core::engine::task::{TaskManager, TaskSnapshot, TaskStatus}; use bds_core::engine::{domain_events, project, settings}; use bds_core::scripting::{CoreHost, HostApi}; use serde_json::{Value, json}; use crate::protocol::{Command, PROTOCOL_VERSION, RemoteTask, Request, ServerMessage}; pub fn run_local_terminal(host: ApplicationHost) -> Result<()> { crate::tui::run_local(host) } #[derive(Clone)] pub struct ApplicationHost { inner: Arc, } struct HostInner { database_path: PathBuf, data_root: PathBuf, tasks: Arc, sync_watcher: SyncWatcher, completed_requests: Mutex>, execution_lock: Mutex<()>, } struct SyncWatcher { shutdown: mpsc::Sender<()>, thread: Option>, errors: Arc>, } #[derive(Default)] struct WatcherErrors { next_id: u64, entries: VecDeque<(u64, String)>, } impl SyncWatcher { fn start(database_path: PathBuf) -> Result { let (shutdown, shutdown_rx) = mpsc::channel(); let (started_tx, started_rx) = mpsc::sync_channel(1); let errors = Arc::new(Mutex::new(WatcherErrors::default())); let thread_errors = Arc::clone(&errors); let thread = std::thread::Builder::new() .name("bds-cli-sync-watcher".into()) .spawn(move || { let database = match Database::open(&database_path) { Ok(database) => database, Err(error) => { let _ = started_tx.send(Err(error.to_string())); return; } }; let _ = started_tx.send(Ok(())); loop { match shutdown_rx.recv_timeout(Duration::from_millis(100)) { Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break, Err(mpsc::RecvTimeoutError::Timeout) => { if let Err(error) = bds_core::engine::cli_sync::poll_notifications(database.conn()) { let mut errors = lock(&thread_errors); let message = error.to_string(); if errors .entries .back() .is_some_and(|(_, last)| last == &message) { continue; } errors.next_id += 1; let id = errors.next_id; errors.entries.push_back((id, message)); if errors.entries.len() > 128 { errors.entries.pop_front(); } } } } } })?; match started_rx.recv() { Ok(Ok(())) => Ok(Self { shutdown, thread: Some(thread), errors, }), Ok(Err(error)) => { let _ = thread.join(); Err(anyhow!("could not start the CLI sync watcher: {error}")) } Err(_) => { let _ = thread.join(); Err(anyhow!("the CLI sync watcher stopped during startup")) } } } fn errors_since(&self, id: u64) -> Vec<(u64, String)> { lock(&self.errors) .entries .iter() .filter(|(entry_id, _)| *entry_id > id) .cloned() .collect() } } impl Drop for SyncWatcher { fn drop(&mut self) { let _ = self.shutdown.send(()); if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } impl ApplicationHost { pub fn start(database_path: PathBuf, data_root: PathBuf) -> Result { if let Some(parent) = database_path.parent() { std::fs::create_dir_all(parent)?; } let db = Database::open(&database_path)?; db.migrate() .map_err(|error| anyhow!("could not migrate the application database: {error}"))?; bds_core::engine::search::prepare_search_index(db.conn())?; let sync_watcher = SyncWatcher::start(database_path.clone())?; Ok(Self { inner: Arc::new(HostInner { database_path, data_root, tasks: Arc::new(TaskManager::default()), sync_watcher, completed_requests: Mutex::new(HashMap::new()), execution_lock: Mutex::new(()), }), }) } pub fn tasks(&self) -> Arc { Arc::clone(&self.inner.tasks) } pub fn session(&self) -> Result { let db = self.database()?; let locale = settings::ui_language(db.conn())? .map(|value| bds_core::i18n::normalize_language(&value)) .unwrap_or_else(bds_core::i18n::detect_os_locale) .code() .to_owned(); Ok(ApplicationSession { host: self.clone(), selected_project: None, negotiated: false, locale, sequence: 0, events: domain_events::subscribe(), last_tasks: Vec::new(), last_watcher_error: 0, }) } pub(crate) fn database(&self) -> Result { Database::open(&self.inner.database_path).map_err(Into::into) } pub(crate) fn database_path(&self) -> &std::path::Path { &self.inner.database_path } pub(crate) fn project_data_dir(&self, project: &bds_core::model::Project) -> PathBuf { project .data_path .as_deref() .map(PathBuf::from) .unwrap_or_else(|| self.inner.data_root.join("projects").join(&project.id)) } fn cached(&self, id: &str) -> Option { lock(&self.inner.completed_requests).get(id).cloned() } fn remember(&self, id: String, response: ServerMessage) { let mut completed = lock(&self.inner.completed_requests); if completed.len() >= 4_096 && let Some(oldest) = completed.keys().next().cloned() { completed.remove(&oldest); } completed.insert(id, response); } } pub struct ApplicationSession { host: ApplicationHost, selected_project: Option, negotiated: bool, locale: String, sequence: u64, events: EventSubscription, last_tasks: Vec, last_watcher_error: u64, } #[derive(Clone)] struct SelectedProject { id: String, data_dir: PathBuf, } impl ApplicationSession { pub fn locale(&self) -> &str { &self.locale } pub fn handle(&mut self, request: Request) -> ServerMessage { let idempotent = matches!(request.command, Command::Call { .. }); if idempotent { let inner = Arc::clone(&self.host.inner); let _execution = lock(&inner.execution_lock); if let Some(response) = self.host.cached(&request.id) { return response; } let id = request.id; let response = self.response(id.clone(), &request.command); self.host.remember(id, response.clone()); return response; } let id = request.id; self.response(id, &request.command) } fn response(&mut self, id: String, command: &Command) -> ServerMessage { match self.execute(command) { Ok(result) => ServerMessage::Response { id, result }, Err(error) => ServerMessage::Error { id, code: error.code.to_owned(), message: error.message, }, } } pub fn pending(&mut self) -> Vec { let mut messages = self .events .drain() .into_iter() .filter(|event| { event.project_id().is_none() || self .selected_project .as_ref() .is_some_and(|project| event.project_id() == Some(project.id.as_str())) }) .map(|event| { self.sequence += 1; ServerMessage::Event { sequence: self.sequence, event, } }) .collect::>(); let tasks = self .host .inner .tasks .snapshots() .into_iter() .map(remote_task) .collect::>(); if tasks != self.last_tasks { self.last_tasks.clone_from(&tasks); self.sequence += 1; messages.push(ServerMessage::Tasks { sequence: self.sequence, tasks, }); } for (id, message) in self .host .inner .sync_watcher .errors_since(self.last_watcher_error) { self.last_watcher_error = id; messages.push(ServerMessage::Error { id: String::new(), code: "sync_watcher_error".into(), message, }); } messages } fn execute(&mut self, command: &Command) -> Result { if !matches!(command, Command::Hello { .. }) && !self.negotiated { return Err(ProtocolError::new( "protocol_required", "hello must be the first request", )); } match command { Command::Hello { protocol_version } => { if *protocol_version != PROTOCOL_VERSION { return Err(ProtocolError::new( "unsupported_protocol", format!( "unsupported remote protocol {protocol_version}; server requires {PROTOCOL_VERSION}" ), )); } self.negotiated = true; Ok(json!({ "protocol_version": PROTOCOL_VERSION, "server_name": "Blogging Desktop Server", "locale": self.locale, })) } Command::ListProjects => { let db = self.host.database().map_err(ProtocolError::engine)?; let projects = project::list_projects(db.conn()).map_err(ProtocolError::engine)?; serde_json::to_value(projects).map_err(ProtocolError::engine) } Command::OpenProject { project_id } => { let db = self.host.database().map_err(ProtocolError::engine)?; let value = bds_core::db::queries::project::get_project_by_id(db.conn(), project_id) .map_err(|_| { ProtocolError::new( "project_not_found", format!("project '{project_id}' was not found on the server"), ) })?; let data_dir = value .data_path .as_deref() .map(PathBuf::from) .unwrap_or_else(|| self.host.inner.data_root.join("projects").join(&value.id)); if !data_dir.join("meta/project.json").is_file() { return Err(ProtocolError::new( "project_unavailable", format!("project '{}' data is unavailable", value.name), )); } self.selected_project = Some(SelectedProject { id: value.id.clone(), data_dir, }); serde_json::to_value(value).map_err(ProtocolError::engine) } Command::Call { namespace, method, arguments, } => { let selected = self.selected_project.as_ref().ok_or_else(|| { ProtocolError::new("project_required", "open a remote project first") })?; CoreHost::new( &self.host.inner.database_path, &selected.id, &selected.data_dir, ) .with_task_manager(Arc::clone(&self.host.inner.tasks)) .call(namespace, method, arguments.clone()) .map_err(|message| ProtocolError::new("engine_error", message)) } Command::Ping => Ok(json!({"ok": true})), } } } struct ProtocolError { code: &'static str, message: String, } impl ProtocolError { fn new(code: &'static str, message: impl Into) -> Self { Self { code, message: message.into(), } } fn engine(error: impl std::fmt::Display) -> Self { Self::new("engine_error", error.to_string()) } } fn remote_task(snapshot: TaskSnapshot) -> RemoteTask { let (status, failure) = match snapshot.status { TaskStatus::Pending => ("pending", None), TaskStatus::Running => ("running", None), TaskStatus::Completed => ("completed", None), TaskStatus::Failed(message) => ("failed", Some(message)), TaskStatus::Cancelled => ("cancelled", None), }; RemoteTask { id: snapshot.id, label: snapshot.label, status: status.to_owned(), progress: snapshot.progress, message: snapshot.message.or(failure), } } fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { mutex .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } #[cfg(test)] mod tests { use super::*; use crate::protocol::{Command, Request}; struct Fixture { _root: tempfile::TempDir, host: ApplicationHost, project_id: String, } impl Fixture { fn new() -> Self { let root = tempfile::tempdir().unwrap(); let database_path = root.path().join("bds.db"); let data_root = root.path().join("data"); let host = ApplicationHost::start(database_path.clone(), data_root.clone()).unwrap(); let db = Database::open(&database_path).unwrap(); let project_dir = root.path().join("blog"); let value = project::create_project( db.conn(), "Remote Blog", Some(project_dir.to_str().unwrap()), ) .unwrap(); settings::set(db.conn(), settings::UI_LANGUAGE_KEY, "de").unwrap(); Self { _root: root, host, project_id: value.id, } } fn session(&self) -> ApplicationSession { let mut session = self.host.session().unwrap(); assert!(matches!( session.handle(request( "hello", Command::Hello { protocol_version: PROTOCOL_VERSION, } )), ServerMessage::Response { .. } )); session } } fn request(id: &str, command: Command) -> Request { Request { id: id.to_owned(), command, } } #[test] fn session_negotiates_server_locale_and_selects_a_project() { let fixture = Fixture::new(); let mut session = fixture.host.session().unwrap(); assert_eq!(session.locale(), "de"); assert!(matches!( session.handle(request("early", Command::ListProjects)), ServerMessage::Error { ref code, .. } if code == "protocol_required" )); let mut session = fixture.session(); let listed = session.handle(request("list", Command::ListProjects)); assert!(matches!(listed, ServerMessage::Response { .. })); let opened = session.handle(request( "open", Command::OpenProject { project_id: fixture.project_id.clone(), }, )); assert!(matches!(opened, ServerMessage::Response { .. })); } #[test] fn two_clients_observe_one_ordered_mutation_and_request_replay_is_idempotent() { let fixture = Fixture::new(); let mut first = fixture.session(); let mut second = fixture.session(); for (index, session) in [&mut first, &mut second].into_iter().enumerate() { session.handle(request( &format!("open-{index}"), Command::OpenProject { project_id: fixture.project_id.clone(), }, )); let _ = session.pending(); } let create = request( "globally-unique-create", Command::Call { namespace: "posts".into(), method: "create".into(), arguments: vec![json!({"title":"Exactly Once","content":"body"})], }, ); assert!(matches!( first.handle(create.clone()), ServerMessage::Response { .. } )); assert_eq!(second.handle(create.clone()), first.handle(create)); let first_events = first.pending(); let second_events = second.pending(); assert_eq!(event_count(&first_events, &fixture.project_id), 1); assert_eq!(event_count(&second_events, &fixture.project_id), 1); assert!(strictly_ordered(&first_events)); assert!(strictly_ordered(&second_events)); let db = fixture.host.database().unwrap(); assert_eq!( bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id) .unwrap() .len(), 1 ); } #[test] fn simultaneous_replay_from_two_clients_executes_the_write_once() { let fixture = Fixture::new(); let mut first = fixture.session(); let mut second = fixture.session(); for (index, session) in [&mut first, &mut second].into_iter().enumerate() { session.handle(request( &format!("parallel-open-{index}"), Command::OpenProject { project_id: fixture.project_id.clone(), }, )); } let barrier = Arc::new(std::sync::Barrier::new(2)); let command = request( "same-concurrent-id", Command::Call { namespace: "posts".into(), method: "create".into(), arguments: vec![json!({"title":"Concurrent","content":"body"})], }, ); let first_barrier = Arc::clone(&barrier); let first_command = command.clone(); let first = std::thread::spawn(move || { first_barrier.wait(); first.handle(first_command) }); let second = std::thread::spawn(move || { barrier.wait(); second.handle(command) }); assert_eq!(first.join().unwrap(), second.join().unwrap()); let db = fixture.host.database().unwrap(); assert_eq!( bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id) .unwrap() .len(), 1 ); } #[test] fn task_progress_is_shared_without_repeating_unchanged_snapshots() { let fixture = Fixture::new(); let mut session = fixture.session(); let _ = session.pending(); let task = fixture.host.tasks().submit("Generate site"); fixture .host .tasks() .report_progress(task, Some(0.5), Some("Writing".into())); let update = session.pending(); assert!(matches!( update.as_slice(), [ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5) )); assert!(session.pending().is_empty()); } #[test] fn headless_sync_watcher_republishes_external_cli_notifications() { let fixture = Fixture::new(); let mut session = fixture.session(); session.handle(request( "open-for-sync", Command::OpenProject { project_id: fixture.project_id.clone(), }, )); let _ = session.pending(); let db = fixture.host.database().unwrap(); bds_core::engine::cli_sync::record_cli_event( db.conn(), &bds_core::model::DomainEvent::EntityChanged { project_id: fixture.project_id.clone(), entity: bds_core::model::DomainEntity::Post, entity_id: "external-post".into(), action: bds_core::model::NotificationAction::Created, }, ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); let mut updates = Vec::new(); while std::time::Instant::now() < deadline { updates.extend(session.pending()); if event_count(&updates, &fixture.project_id) == 1 { break; } std::thread::sleep(std::time::Duration::from_millis(20)); } assert_eq!(event_count(&updates, &fixture.project_id), 1); let notifications = bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap(); assert_eq!(notifications.len(), 1); assert!(notifications[0].seen_at.is_some()); } fn event_count(messages: &[ServerMessage], project_id: &str) -> usize { messages .iter() .filter(|message| { matches!( message, ServerMessage::Event { event, .. } if event.project_id() == Some(project_id) ) }) .count() } fn strictly_ordered(messages: &[ServerMessage]) -> bool { let sequences = messages .iter() .filter_map(|message| match message { ServerMessage::Event { sequence, .. } | ServerMessage::Tasks { sequence, .. } => { Some(*sequence) } _ => None, }) .collect::>(); sequences.windows(2).all(|pair| pair[0] < pair[1]) } }