From 342e5aa4c81314046da92a6b9ec2c9027f8c971c Mon Sep 17 00:00:00 2001 From: Tryanks Date: Mon, 5 Oct 2026 03:09:39 +0800 Subject: [PATCH] perf: keep the project and thread index in redb tcode.redb replaces sessions.json. Each index change writes the rows it touches in one transaction on the blocking pool, and the store writer commits back-to-back index writes together, instead of re-reading and rewriting the whole file for every change. The host opens the database as redb's single writer under experimental-multiprocess: another process can read it while the host runs, and a second host on the same data directory fails with an explicit error instead of silently overwriting. A relaunch waits for the outgoing instance to close it. sessions.json is imported once into a temporary database, read back and compared, then kept as sessions.json.migrated. A row this build cannot decode is skipped and left in place rather than failing the whole index. Closes #523 --- Cargo.lock | 1 + crates/app/src/main.rs | 84 ++- crates/core/src/project.rs | 18 +- crates/runtime/src/app/lifecycle.rs | 5 +- crates/runtime/src/app/mod.rs | 67 +- crates/runtime/src/app/sessions.rs | 8 +- crates/runtime/src/app/snapshots.rs | 2 +- crates/runtime/src/app/store_write.rs | 181 +++++- crates/runtime/src/app/test_support.rs | 38 ++ crates/runtime/src/app/tests.rs | 139 ++-- crates/runtime/src/pipe.rs | 55 +- crates/services/Cargo.toml | 1 + crates/services/src/relaunch.rs | 6 + crates/services/src/store.rs | 286 +------- crates/services/src/store/compaction.rs | 8 +- crates/services/src/store/index.rs | 832 ++++++++++++++++++++++++ crates/ui/src/store/mod.rs | 95 ++- locales/en.yml | 4 + locales/zh-CN.yml | 4 + 19 files changed, 1349 insertions(+), 485 deletions(-) create mode 100644 crates/services/src/store/index.rs diff --git a/Cargo.lock b/Cargo.lock index 7664f492b..1e72bf65b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10331,6 +10331,7 @@ dependencies = [ "flate2", "image", "log", + "redb", "serde", "serde_json", "smol", diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index d0c101ee0..abf48838d 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -183,7 +183,7 @@ fn pair_command(args: &[String], client_host: &NativeClientHost) -> Result (SpawnedHost, HostMux) { +fn start_local(store: SessionStore) -> std::io::Result<(SpawnedHost, HostMux)> { let mut host_services = HostServices { background_startup_probes: true, ai_title_generation: true, @@ -204,9 +204,71 @@ fn start_local(store: SessionStore) -> (SpawnedHost, HostMux) { } Err(error) => log::warn!("MCP host failed to bind: {error}"), } - let host = spawn_host(store, host_services).expect("failed to start tcode host thread"); + let host = spawn_host(store, host_services)?; let mux = HostMux::new(host.to_host.clone(), host.from_host.clone()); - (host, mux) + Ok((host, mux)) +} + +/// The window-less stand-in for a host that could not start: an alert that +/// says why, after which the app quits. +struct StartupFailure; + +impl gpui::Render for StartupFailure { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + gpui::Empty + } +} + +fn report_startup_failure(error: &std::io::Error, data_dir: &std::path::Path) { + eprintln!("tcode: {error}"); + let (title, detail) = if error.kind() == std::io::ErrorKind::ResourceBusy { + ( + tcode_ui::tr!("startup.index_in_use_title").into_owned(), + tcode_ui::tr!("startup.index_in_use", dir = data_dir.display()).into_owned(), + ) + } else { + ( + tcode_ui::tr!("startup.failed_title").into_owned(), + error.to_string(), + ) + }; + let quit = tcode_ui::tr!("quit.confirm").into_owned(); + gpui_platform::application().run(move |cx| { + // On macOS `cx.quit()` ends in `-[NSApplication terminate:]`, which + // calls `exit(0)` right after gpui's shutdown has run its quit + // handlers, so the failure status has to be set from one of them. + cx.on_app_quit(|_| async { std::process::exit(1) }).detach(); + cx.activate(true); + let window = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::centered(size(px(420.), px(160.)), cx)), + ..Default::default() + }, + |_, cx| gpui::AppContext::new(cx, |_| StartupFailure), + ); + let Ok(window) = window else { + cx.quit(); + return; + }; + let _ = window.update(cx, |_, window, cx| { + let answer = window.prompt( + gpui::PromptLevel::Critical, + &title, + Some(&detail), + &[quit.as_str()], + cx, + ); + cx.spawn(async move |_, cx| { + let _ = answer.await; + cx.update(|cx| cx.quit()); + }) + .detach(); + }); + }); } struct LocalKernel { @@ -217,18 +279,18 @@ struct LocalKernel { } impl LocalKernel { - fn start(store: SessionStore) -> Self { - let (host, mux) = start_local(store); + fn start(store: SessionStore) -> std::io::Result { + let (host, mux) = start_local(store)?; let connection = mux.attach(); let control_link = HostLink::new(connection.to_host, connection.from_host); let pump_link = control_link.clone(); let control_pump = smol::spawn(async move { pump_link.pump().await }); - Self { + Ok(Self { host, mux, control_link, _control_pump: control_pump, - } + }) } /// A window's link to the local kernel. The mux keeps the kernel alive @@ -360,7 +422,13 @@ fn main() { }; // Kernel ownership is process composition, not a property of whichever // host the window currently views. - let kernel = Rc::new(LocalKernel::start(store)); + let kernel = match LocalKernel::start(store) { + Ok(kernel) => Rc::new(kernel), + Err(error) => { + report_startup_failure(&error, &data_dir); + std::process::exit(1); + } + }; let local_settings = kernel.settings(); gpui_platform::application() diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index e6b4ad2a8..84623e4f3 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -470,8 +470,8 @@ pub fn order_sessions_with_children(sessions: Vec) -> Vec`; the store loader tolerates both. +/// Shape of the legacy `sessions.json` index (its last schema; older files +/// were a bare `Vec`), and of an index being migrated. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct IndexFile { #[serde(default)] @@ -481,9 +481,11 @@ pub struct IndexFile { } /// Ensure every session belongs to a project, deriving implicit projects from -/// each orphan session's cwd (deduped by root). Idempotent. +/// each orphan session's cwd (deduped by root). A session whose project is in +/// `unreadable_projects` (stored, but not readable by this build) is not an +/// orphan. Idempotent. #[cfg(feature = "process")] -pub fn migrate_index(mut file: IndexFile) -> IndexFile { +pub fn migrate_index(mut file: IndexFile, unreadable_projects: &HashSet) -> IndexFile { // Map existing project roots to their ids so derived projects dedupe. let mut root_to_id: std::collections::HashMap = file .projects @@ -492,11 +494,9 @@ pub fn migrate_index(mut file: IndexFile) -> IndexFile { .collect(); for session in &mut file.sessions { - if session - .project_id - .as_ref() - .is_some_and(|id| file.projects.iter().any(|p| &p.id == id)) - { + if session.project_id.as_ref().is_some_and(|id| { + unreadable_projects.contains(id) || file.projects.iter().any(|p| &p.id == id) + }) { continue; } let root = session.cwd.clone(); diff --git a/crates/runtime/src/app/lifecycle.rs b/crates/runtime/src/app/lifecycle.rs index 68970a87c..6d2181b05 100644 --- a/crates/runtime/src/app/lifecycle.rs +++ b/crates/runtime/src/app/lifecycle.rs @@ -333,9 +333,8 @@ impl AppState { }, cx, ); - // Reflect the upsert in memory instead of reloading the whole index - // from disk: `persist_metas` runs on every turn, - // where re-reading and re-parsing a large sessions.json stalls the UI. + // The host's lists are the index while it runs; the file is read + // only at startup. self.upsert_sessions_in_memory(metas); } diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index 1d0bc4ed4..154b729e6 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -72,7 +72,7 @@ use tcode_services::import::{ use tcode_services::provider_probe::{default_program, probe_provider, run_capture_env}; use tcode_services::session_search::SessionSearch; use tcode_services::settings::SettingsStore; -use tcode_services::store::{SessionStore, now_millis, now_secs}; +use tcode_services::store::{LoadedIndex, SessionIndex, SessionStore, now_millis, now_secs}; use tcode_services::user_files; use tcode_services::version_check::provider_updates::{ self, CheckInput as ProviderCheckInput, Installation, @@ -245,7 +245,7 @@ mod subagents; mod terminals; #[cfg(test)] -mod test_support; +pub(crate) mod test_support; #[cfg(test)] mod tests; @@ -309,9 +309,14 @@ impl Default for TcodeUpdateState { pub struct AppState { store: SessionStore, + /// The project and session index this host owns while it runs; written + /// only by the store writer once startup is over. + index: SessionIndex, settings_store: SettingsStore, store_writes: smol::channel::Sender, store_write_receiver: Option>, + /// The running store writer, which ends once every queued write is done. + store_writer: Option>, store_write_failures: smol::channel::Sender>, store_write_failure_receiver: Option>>, pub sessions: Vec, @@ -438,20 +443,34 @@ pub(crate) fn startup_collapsed_threads(sessions: &[SessionMeta]) -> Vec .collect() } +/// How long a host started by a relaunch waits for the instance it replaces +/// to quit and release the index. That instance may first ask whether to stop +/// its working threads. +const RELAUNCH_INDEX_WAIT: Duration = Duration::from_secs(15); + impl AppState { - pub fn new(store: SessionStore) -> Self { - Self::with_ai_titles(store, false) + #[cfg(test)] + pub(crate) fn new(store: SessionStore) -> Self { + Self::start(store, false).expect("open the session index") } - pub(crate) fn with_ai_titles(store: SessionStore, ai_title_generation_enabled: bool) -> Self { - // Load + migrate once and persist so derived project ids stay stable. - let file = store.read_file(); - if let Err(err) = store.persist_index(&file) { - log::warn!("failed to persist migrated session index: {err}"); - } - let mut sessions = file.sessions; + /// Open the data dir's index and load the host's state from it. Fails + /// when another host has the index open. + pub(crate) fn start( + store: SessionStore, + ai_title_generation_enabled: bool, + ) -> std::io::Result { + let index_wait = if tcode_services::relaunch::pending(store.root()) { + RELAUNCH_INDEX_WAIT + } else { + Duration::ZERO + }; + let index = store.open_index(index_wait)?; + let LoadedIndex { + projects, + mut sessions, + } = index.load()?; sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at)); - let projects = file.projects; let settings_store = SettingsStore::new(store.root().clone()); let mut settings = settings_store.load(); settings.collapsed_threads = startup_collapsed_threads(&sessions); @@ -491,11 +510,13 @@ impl AppState { let (store_writes, store_write_receiver) = smol::channel::unbounded(); let (store_write_failures, store_write_failure_receiver) = smol::channel::unbounded(); let session_search = Arc::new(std::sync::Mutex::new(SessionSearch::new(store.clone()))); - Self { + Ok(Self { store, + index, settings_store, store_writes, store_write_receiver: Some(store_write_receiver), + store_writer: None, store_write_failures, store_write_failure_receiver: Some(store_write_failure_receiver), sessions, @@ -544,7 +565,7 @@ impl AppState { external_imports: HashMap::new(), next_import_run_id: 1, session_search, - } + }) } #[cfg(any(test, feature = "test-support"))] @@ -554,20 +575,15 @@ impl AppState { fn start_store_writer(&mut self, cx: &mut HostCx) { if let Some(writes) = self.store_write_receiver.take() { - let mut writer = StoreWriter::new( + let writer = StoreWriter::new( self.store.clone(), + self.index.clone(), self.settings_store.clone(), self.terminal_preferences_path.clone(), ); let failures = self.store_write_failures.clone(); let host_cx = cx.clone(); - HostCx::spawn_detached(cx, async move { - while let Ok(write) = writes.recv().await { - if let Some(failure) = writer.run(write, &host_cx).await { - let _ = failures.send(failure).await; - } - } - }); + self.store_writer = Some(cx.spawn_background(writer.serve(writes, failures, host_cx))); } if let Some(failures) = self.store_write_failure_receiver.take() { let host_cx = cx.clone(); @@ -582,6 +598,13 @@ impl AppState { } } + /// Stop accepting writes and hand back the writer, which finishes the + /// queued ones and then closes the index. + pub(crate) fn close_store(self) -> Option> { + let Self { store_writer, .. } = self; + store_writer + } + fn enqueue_store_write(&mut self, write: StoreWrite, cx: &mut HostCx) { self.start_store_writer(cx); if self.store_writes.try_send(write).is_err() { diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 01c9ae102..729231973 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -1139,10 +1139,14 @@ impl AppState { .terminal_preferences .remove(&draft_destination.preference_key()) .is_some(); - if self.forget_sessions(&session_ids, false, cx) || draft_preferences_changed { + // Queued right behind its sessions' removal, so both leave the index + // in one commit. + let preferences_changed = + self.forget_sessions(&session_ids, false, cx) || draft_preferences_changed; + self.enqueue_store_write(StoreWrite::RemoveProject(project_id.to_string()), cx); + if preferences_changed { self.write_terminal_preferences(cx); } - self.enqueue_store_write(StoreWrite::RemoveProject(project_id.to_string()), cx); self.settings .collapsed_projects .retain(|id| id != project_id); diff --git a/crates/runtime/src/app/snapshots.rs b/crates/runtime/src/app/snapshots.rs index ac5576f94..f8b419621 100644 --- a/crates/runtime/src/app/snapshots.rs +++ b/crates/runtime/src/app/snapshots.rs @@ -445,7 +445,7 @@ impl AppState { statuses } - /// `sessions` stays newest-first, matching `load_index`'s order. + /// `sessions` stays newest-first, the order the host loads it in. pub(super) fn upsert_sessions_in_memory( &mut self, metas: impl IntoIterator, diff --git a/crates/runtime/src/app/store_write.rs b/crates/runtime/src/app/store_write.rs index 90bd1acc2..5b2969622 100644 --- a/crates/runtime/src/app/store_write.rs +++ b/crates/runtime/src/app/store_write.rs @@ -2,7 +2,7 @@ use std::sync::atomic::Ordering; use super::compaction::{Compacted, CompactionRequest}; use super::*; -use tcode_services::store::{CompactOutcome, CompactRejection}; +use tcode_services::store::{CompactOutcome, CompactRejection, IndexWrite}; pub(super) enum StoreWrite { AppendEvent { @@ -51,9 +51,45 @@ pub(super) enum StoreWrite { Flush(smol::channel::Sender<()>), } -/// Runs every queued write, one at a time in queue order. +impl StoreWrite { + fn changes_index(&self) -> bool { + matches!( + self, + StoreWrite::UpsertMetas { .. } + | StoreWrite::UpsertProject(_) + | StoreWrite::RemoveSessions(_) + | StoreWrite::RemoveProject(_) + ) + } +} + +/// The error a failed index write reports, by what it was for. +#[derive(Clone, Copy, PartialEq)] +enum IndexFailure { + CreateSession, + UpdateSession, + PersistProject, + DeleteSession, + DeleteProject, +} + +impl IndexFailure { + fn error(self, error: String) -> RuntimeError { + match self { + Self::CreateSession => RuntimeError::PersistSession { error }, + Self::UpdateSession => RuntimeError::PersistSessionIndex { error }, + Self::PersistProject => RuntimeError::PersistProject { error }, + Self::DeleteSession => RuntimeError::DeleteSession { error }, + Self::DeleteProject => RuntimeError::DeleteProject { error }, + } + } +} + +/// Runs every queued write in queue order. Index writes queued back to back +/// are committed together, since every index commit is synced to disk. pub(super) struct StoreWriter { store: SessionStore, + index: SessionIndex, settings_store: SettingsStore, terminal_preferences_path: PathBuf, /// Logs compacted in this run with nothing written to them since, which @@ -64,21 +100,125 @@ pub(super) struct StoreWriter { impl StoreWriter { pub(super) fn new( store: SessionStore, + index: SessionIndex, settings_store: SettingsStore, terminal_preferences_path: PathBuf, ) -> Self { Self { store, + index, settings_store, terminal_preferences_path, compacted: HashSet::new(), } } + /// Run queued writes until every sender is gone and the queue is empty. + pub(super) async fn serve( + mut self, + writes: smol::channel::Receiver, + failures: smol::channel::Sender>, + host_cx: HostCx, + ) { + let mut next = None; + loop { + let write = match next.take() { + Some(write) => write, + None => match writes.recv().await { + Ok(write) => write, + Err(_) => break, + }, + }; + let reported = if write.changes_index() { + let mut batch = vec![write]; + while let Ok(write) = writes.try_recv() { + if write.changes_index() { + batch.push(write); + } else { + next = Some(write); + break; + } + } + self.commit_index(batch, &host_cx).await + } else { + self.run(write, &host_cx).await.into_iter().collect() + }; + for failure in reported { + let _ = failures.send(failure).await; + } + } + } + + /// Commit index writes in one transaction on the blocking pool, then + /// delete the logs of the sessions they removed. + async fn commit_index( + &mut self, + batch: Vec, + host_cx: &HostCx, + ) -> Vec> { + let mut writes = Vec::with_capacity(batch.len()); + let mut failures = Vec::with_capacity(batch.len()); + let mut removed = Vec::new(); + for write in batch { + let (write, failure) = match write { + StoreWrite::UpsertMetas { metas, initial } => ( + IndexWrite::UpsertSessions(metas), + if initial { + IndexFailure::CreateSession + } else { + IndexFailure::UpdateSession + }, + ), + StoreWrite::UpsertProject(project) => ( + IndexWrite::UpsertProject(project), + IndexFailure::PersistProject, + ), + StoreWrite::RemoveSessions(ids) => { + for id in &ids { + self.compacted.remove(id); + } + removed.push(ids.clone()); + (IndexWrite::RemoveSessions(ids), IndexFailure::DeleteSession) + } + StoreWrite::RemoveProject(id) => { + (IndexWrite::RemoveProject(id), IndexFailure::DeleteProject) + } + _ => unreachable!("only index writes are batched"), + }; + writes.push(write); + if !failures.contains(&failure) { + failures.push(failure); + } + } + let index = self.index.clone(); + let store = self.store.clone(); + let committed = host_cx + .unblock(move || { + index.commit(&writes)?; + Ok::<_, std::io::Error>( + removed + .iter() + .filter_map(|ids| store.remove_session_logs(ids).err()) + .collect::>(), + ) + }) + .await; + match committed { + Ok(log_errors) => log_errors + .into_iter() + .map(|error| Ok(IndexFailure::DeleteSession.error(error.to_string()))) + .collect(), + Err(error) => failures + .into_iter() + .map(|failure| Ok(failure.error(error.to_string()))) + .collect(), + } + } + /// Run one write. Compaction's reading, folding and syncing happen on the /// blocking pool and are awaited here, so the writes queued behind it /// still wait for it while the executor thread stays free. - pub(super) async fn run( + async fn run( &mut self, write: StoreWrite, host_cx: &HostCx, @@ -104,11 +244,6 @@ impl StoreWriter { StoreWrite::CloneEvents { dst, .. } => { self.compacted.remove(dst); } - StoreWrite::RemoveSessions(ids) => { - for id in ids { - self.compacted.remove(id); - } - } _ => {} } run_store_write( @@ -175,32 +310,6 @@ fn run_store_write( }) }) } - StoreWrite::UpsertMetas { metas, initial } => store.upsert_metas(&metas).err().map(|err| { - if initial { - Ok(RuntimeError::PersistSession { - error: err.to_string(), - }) - } else { - Ok(RuntimeError::PersistSessionIndex { - error: err.to_string(), - }) - } - }), - StoreWrite::UpsertProject(project) => store.upsert_project(&project).err().map(|err| { - Ok(RuntimeError::PersistProject { - error: err.to_string(), - }) - }), - StoreWrite::RemoveSessions(ids) => store.remove_sessions(&ids).err().map(|err| { - Ok(RuntimeError::DeleteSession { - error: err.to_string(), - }) - }), - StoreWrite::RemoveProject(id) => store.remove_project(&id).err().map(|err| { - Ok(RuntimeError::DeleteProject { - error: err.to_string(), - }) - }), StoreWrite::CloneEvents { src, dst, @@ -274,5 +383,9 @@ fn run_store_write( StoreWrite::CompactLog { .. } | StoreWrite::DiscardCompactionLeftovers => { unreachable!("run by StoreWriter::run") } + StoreWrite::UpsertMetas { .. } + | StoreWrite::UpsertProject(_) + | StoreWrite::RemoveSessions(_) + | StoreWrite::RemoveProject(_) => unreachable!("run by StoreWriter::commit_index"), } } diff --git a/crates/runtime/src/app/test_support.rs b/crates/runtime/src/app/test_support.rs index ea504f22b..3db88416c 100644 --- a/crates/runtime/src/app/test_support.rs +++ b/crates/runtime/src/app/test_support.rs @@ -5,6 +5,7 @@ use std::rc::{Rc, Weak}; use crate::host::HostFn; use tcode_protocol::{ClientMessage, ClientPayload, Command, HostMessage, decode_host_line}; +use tcode_services::store::IndexWrite; pub(super) struct TestStore(SessionStore); @@ -29,6 +30,31 @@ impl Drop for TestStore { } } +/// Write the index a host started on `store` will load, the way an earlier +/// host would have left it. +pub(crate) fn seed_index(store: &SessionStore, projects: &[Project], sessions: &[SessionMeta]) { + let mut writes: Vec = projects + .iter() + .cloned() + .map(IndexWrite::UpsertProject) + .collect(); + writes.push(IndexWrite::UpsertSessions(sessions.to_vec())); + store + .open_index(Duration::ZERO) + .expect("open the index") + .commit(&writes) + .expect("seed the index"); +} + +/// What a host that stopped left in `store`'s index. +pub(crate) fn stored_index(store: &SessionStore) -> LoadedIndex { + store + .open_index(Duration::ZERO) + .expect("open the index") + .load() + .expect("load the index") +} + /// Plain smol/mailbox replacement for the former gpui test context. /// /// Tests still exercise the exact production [`HostCx`] seam: background @@ -231,6 +257,18 @@ impl TestEntity { pub(super) fn read(&self, read: impl FnOnce(&TestClientState) -> R) -> R { read(&self.0.borrow()) } + + /// Stop this host as `spawn_host` does: once its queued writes are done + /// and the index is closed, another host may start on the data dir. + pub(super) fn stop(self) { + let state = Rc::try_unwrap(self.0) + .ok() + .expect("nothing else holds the test host") + .into_inner(); + if let Some(writer) = state.host.close_store() { + smol::block_on(writer); + } + } } /// Single-client fixture: selection belongs to this test client, never AppState. diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 03b9ec40f..3e7ae2c50 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -4,6 +4,7 @@ use super::{active_session::*, events::*, orchestrate::*, providers::*}; use tcode_core::settings::{SettingsPatch, ThemeMode}; use tcode_protocol::{Command, CommandResponse, HostMessage, Subscription}; +use tcode_services::store::IndexWrite; #[test] fn permission_relaunch_marker_requires_screen_access_only_for_computer_use() { for screen_recording in [false, true] { @@ -172,8 +173,10 @@ fn provider_native_subagent_events_create_and_feed_read_only_mirror_session() { // A completion without model/effort must not erase the last observation, // and reopening the persisted mirror must retain it. let persisted = state - .store - .load_index() + .index + .load() + .unwrap() + .sessions .into_iter() .find(|meta| meta.id == mirror.id) .unwrap(); @@ -710,8 +713,8 @@ fn loading_a_mirror_with_an_open_turn_and_no_live_parent_ends_it() { mirror.title = "subagent".into(); mirror.parent_session_id = Some("parent".into()); mirror.native_subagent = Some("toolu_zombie".into()); - test_store.upsert_metas([&parent]).unwrap(); - test_store.upsert_metas([&mirror]).unwrap(); + seed_index(&test_store, &[], &[parent.clone()]); + seed_index(&test_store, &[], &[mirror.clone()]); let stored = [ AgentEvent::TurnStarted { turn_id: "toolu_zombie".into(), @@ -764,6 +767,7 @@ fn loading_a_mirror_with_an_open_turn_and_no_live_parent_ends_it() { }); // Reloading the repaired mirror leaves the closed turn alone. + state.stop(); let state = cx.new_entity(TestClientState::new((*test_store).clone())); state.update(cx, |state, cx| state.select_session("mirror", cx)); cx.run_until(|state| { @@ -1033,7 +1037,7 @@ fn scripted_provider_connects_command_launch_and_agent_event_paths() { let cwd = test_store.root().join("project"); std::fs::create_dir_all(&cwd).unwrap(); let project = Project::from_root(cwd.clone()); - test_store.upsert_project(&project).unwrap(); + seed_index(&test_store, std::slice::from_ref(&project), &[]); let scripted = scripted_provider(ProviderKind::ClaudeCode); let commands = scripted.commands.clone(); let events = scripted.events.clone(); @@ -1073,8 +1077,10 @@ fn scripted_provider_connects_command_launch_and_agent_event_paths() { assert!(!state.sessions.iter().any(|meta| meta.id == session_id)); assert!( !state - .store - .load_index() + .index + .load() + .unwrap() + .sessions .iter() .any(|meta| meta.id == session_id) ); @@ -1144,8 +1150,10 @@ fn scripted_provider_connects_command_launch_and_agent_event_paths() { .unwrap() .clone(), state - .store - .load_index() + .index + .load() + .unwrap() + .sessions .into_iter() .find(|meta| meta.id == session_id) .unwrap(), @@ -1179,7 +1187,7 @@ fn thread_fold_is_host_state_shared_over_the_pipe_and_pruned_with_the_thread() { if id == "archived-child" { meta.archived_at = Some(1); } - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); } let state = cx.new_entity(TestClientState::new(store.clone())); state.update(cx, |state, _| { @@ -1265,8 +1273,8 @@ fn lifecycle_batches_apply_exact_timestamp_cascades_and_persist() { let store = (*test_store).clone(); let kept = Project::from_root(root.join("kept")); let doomed = Project::from_root(root.join("doomed")); - store.upsert_project(&kept).unwrap(); - store.upsert_project(&doomed).unwrap(); + seed_index(&store, std::slice::from_ref(&kept), &[]); + seed_index(&store, std::slice::from_ref(&doomed), &[]); let mut metas = Vec::new(); for (id, parent, project) in [ ("parent", None, &kept), @@ -1284,7 +1292,7 @@ fn lifecycle_batches_apply_exact_timestamp_cascades_and_persist() { meta.project_id = Some(project.id.clone()); metas.push(meta); } - store.upsert_metas(&metas).unwrap(); + seed_index(&store, &[], &metas); let state = cx.new_entity(TestClientState::new(store.clone())); let archived_at = |state: &TestClientState, id: &str| { state @@ -1359,7 +1367,7 @@ fn lifecycle_batches_apply_exact_timestamp_cascades_and_persist() { "every batch is accepted" ); - let reopened = SessionStore::open_at(root).unwrap().read_file(); + let reopened = state.read(|state| state.index.load().unwrap()); assert_eq!(reopened.projects, [kept]); let mut persisted: Vec<_> = reopened .sessions @@ -1538,7 +1546,7 @@ fn title_regeneration_uses_stored_history_and_preserves_intervening_changes() { meta.title = "Old title".into(); meta.updated_at = 123; let id = meta.id.clone(); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); let image = store.root().join("qr.png"); fs::write(&image, [1, 2, 3]).unwrap(); for (item_id, content) in [ @@ -1703,7 +1711,13 @@ fn title_regeneration_uses_stored_history_and_preserves_intervening_changes() { .find(|meta| meta.id == id) .map(|meta| meta.title.as_str()); assert_eq!(title, expected, "{outcome}"); - let persisted = store.load_index().into_iter().find(|meta| meta.id == id); + let persisted = state + .index + .load() + .unwrap() + .sessions + .into_iter() + .find(|meta| meta.id == id); assert_eq!(persisted.as_ref().map(|meta| meta.title.as_str()), expected); if !matches!(outcome, "renamed" | "deleted") { assert_eq!(state.sessions[0].updated_at, 123, "{outcome}"); @@ -1730,7 +1744,7 @@ fn title_regeneration_rejects_empty_history_without_calling_the_provider() { let store = TestStore::new("tcode-empty-title"); let meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); let id = meta.id.clone(); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], std::slice::from_ref(&meta)); let scripted = scripted_provider(ProviderKind::Codex); let state = cx.new_entity({ let mut state = TestClientState::new((*store).clone()); @@ -2788,8 +2802,8 @@ fn updates_on_the_viewed_thread_do_not_mark_it_unread() { let mut first = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/a"), None); first.updated_at = 100; let second = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/b"), None); - store.upsert_metas([&first]).unwrap(); - store.upsert_metas([&second]).unwrap(); + seed_index(&store, &[], &[first.clone()]); + seed_index(&store, &[], std::slice::from_ref(&second)); let first_id = first.id.clone(); let second_id = second.id.clone(); let state = cx.new_entity(TestClientState::new(store)); @@ -2944,7 +2958,7 @@ fn draft_defaults_follow_project_history_then_global_history_without_persisting_ vec![] }; assert_eq!(draft.meta.option_selections, expected, "{name}"); - assert!(state.store.load_index().is_empty(), "{name}"); + assert!(state.index.load().unwrap().sessions.is_empty(), "{name}"); }); } } @@ -2989,7 +3003,7 @@ fn draft_model_selection_switches_to_the_rows_explicit_provider() { assert_eq!(draft.meta.model.as_deref(), Some("claude-fable-5-1")); assert!(draft.meta.acp_agent_id.is_none()); assert!(draft.meta.option_selections.is_empty()); - assert!(state.store.load_index().is_empty()); + assert!(state.index.load().unwrap().sessions.is_empty()); }); } @@ -5149,7 +5163,7 @@ fn startup_generation_rejects_stale_same_session_attempt() { let store = TestStore::new("tcode-startup-generation"); let mut meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); meta.id = "same-session".into(); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); let (starts, started) = smol::channel::unbounded(); let launcher = ProviderLauncher(Arc::new(move |_, _| { let (complete, completed) = smol::channel::bounded(1); @@ -5428,9 +5442,12 @@ fn select_session_readopts_idle_resident_without_changing_recency_or_shutdown() let updated_at = now_secs() - 3600; session.meta.updated_at = updated_at; let meta = session.meta.clone(); - test_store.upsert_metas([&meta]).unwrap(); state.update(cx, |state, cx| { + state + .index + .commit(&[IndexWrite::UpsertSessions(vec![meta.clone()])]) + .unwrap(); state.sessions.push(meta); state.install_selected(session); state.park_active(cx); @@ -5446,7 +5463,8 @@ fn select_session_readopts_idle_resident_without_changing_recency_or_shutdown() assert!(actor.try_recv().is_err(), "re-adoption sent Shutdown"); }); cx.run_until_parked(); - assert_eq!(test_store.load_index()[0].updated_at, updated_at); + let persisted = state.read(|state| state.index.load().unwrap().sessions); + assert_eq!(persisted[0].updated_at, updated_at); } #[test] @@ -5644,7 +5662,7 @@ fn fork_thread_clones_timeline_and_provider_cursor() { base: "main".into(), branch: "tcode/source".into(), }); - store.upsert_metas([&source]).unwrap(); + seed_index(&store, &[], &[source.clone()]); store .append_event( &source.id, @@ -5663,7 +5681,7 @@ fn fork_thread_clones_timeline_and_provider_cursor() { empty_source.resume_cursor = Some(agent::ResumeCursor( serde_json::json!({"thread_id": "native-empty-source"}), )); - store.upsert_metas([&empty_source]).unwrap(); + seed_index(&store, &[], &[empty_source.clone()]); assert!(!root.join(format!("{}.jsonl", empty_source.id)).exists()); let state = cx.new_entity(TestClientState::new(store)); @@ -5714,8 +5732,10 @@ fn fork_thread_clones_timeline_and_provider_cursor() { assert!(active.timeline.turns.is_empty()); assert!( state - .store - .load_index() + .index + .load() + .unwrap() + .sessions .iter() .any(|meta| meta.id == fork.id) ); @@ -5742,8 +5762,8 @@ fn store_writer_flush_persists_ordered_events_metadata_and_secrets_for_reopening let fresh = SessionStore::open_at(test_store.root().clone()).unwrap(); assert_eq!( - fresh - .load_index() + state + .read(|state| state.index.load().unwrap().sessions) .iter() .find(|meta| meta.id == id) .unwrap() @@ -5906,7 +5926,7 @@ fn cold_select_installs_immediately_then_loads_persisted_timeline() { let test_store = TestStore::new("tcode-cold-select-async-test"); let store = (*test_store).clone(); let meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/cold"), None); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], std::slice::from_ref(&meta)); store .append_event( &meta.id, @@ -5947,7 +5967,7 @@ fn parked_readopt_refolds_events_appended_while_parked() { let test_store = TestStore::new("tcode-parked-readopt-async-test"); let store = (*test_store).clone(); let meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/parked"), None); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], std::slice::from_ref(&meta)); store .append_event(&meta.id, 1, &persisted_assistant_event("before parking")) .unwrap(); @@ -5997,8 +6017,8 @@ fn stale_timeline_completion_cannot_land_on_another_session() { let store = (*test_store).clone(); let a = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/a"), None); let b = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/b"), None); - store.upsert_metas([&a]).unwrap(); - store.upsert_metas([&b]).unwrap(); + seed_index(&store, &[], std::slice::from_ref(&a)); + seed_index(&store, &[], std::slice::from_ref(&b)); store .append_event(&a.id, 1, &persisted_assistant_event("only session A")) .unwrap(); @@ -6042,7 +6062,7 @@ fn timeline_load_keeps_records_appended_during_the_fold() { let test_store = TestStore::new("tcode-timeline-watermark-test"); let store = (*test_store).clone(); let meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/watermark"), None); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], std::slice::from_ref(&meta)); store .append_event(&meta.id, 1, &persisted_assistant_event("before load")) .unwrap(); @@ -6229,8 +6249,11 @@ fn submitted_queue_head_cannot_leak_delivery_after_turn_completion() { let id = session.meta.id.clone(); state.update(cx, |state, cx| { - state.store.upsert_metas([&session.meta]).unwrap(); - state.sessions = state.store.load_index(); + state + .index + .commit(&[IndexWrite::UpsertSessions(vec![session.meta.clone()])]) + .unwrap(); + state.sessions = state.index.load().unwrap().sessions; state.install_selected(session); state.send_turn(&id, "finish this task".into(), Vec::new(), cx); let delivery_id = match commands.try_recv() { @@ -6290,7 +6313,10 @@ fn event_stream_end_closes_only_its_own_live_provider_in_any_residency() { if !live { session.runtime = Runtime::Idle; } - state.store.upsert_metas([&session.meta]).unwrap(); + state + .index + .commit(&[IndexWrite::UpsertSessions(vec![session.meta.clone()])]) + .unwrap(); state.sessions.push(session.meta.clone()); if parked { state.residents.parked.insert("session".into(), session); @@ -6428,8 +6454,11 @@ fn switching_threads_parks_a_working_session_instead_of_killing_it() { .binary_path = Some("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/nonexistent/tcode-test-claude".into()); // A live session with a running turn (the overnight workflow). - state.store.upsert_metas([&session.meta]).unwrap(); - state.sessions = state.store.load_index(); + state + .index + .commit(&[IndexWrite::UpsertSessions(vec![session.meta.clone()])]) + .unwrap(); + state.sessions = state.index.load().unwrap().sessions; state.install_selected(session); state.send_turn(&id_a, "run the long migration".into(), Vec::new(), cx); state.send_turn(&id_a, "queued follow-up".into(), Vec::new(), cx); @@ -6556,8 +6585,11 @@ fn drained_parked_session_stays_resident() { let id = session.meta.id.clone(); state.update(cx, |state, cx| { - state.store.upsert_metas([&session.meta]).unwrap(); - state.sessions = state.store.load_index(); + state + .index + .commit(&[IndexWrite::UpsertSessions(vec![session.meta.clone()])]) + .unwrap(); + state.sessions = state.index.load().unwrap().sessions; state.install_selected(session); state.send_turn(&id, "one last thing".into(), Vec::new(), cx); let delivery_id = match commands.try_recv() { @@ -7432,7 +7464,7 @@ fn history_pages_of_an_opened_session_parse_the_log_once() { let store = TestStore::new("history-single-parse"); let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, store.root().clone(), None); meta.id = "paged".into(); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); let records = persist_streamed_turns(&store, "paged", 5); let state = cx.new_entity(TestClientState::new((*store).clone())); let reads_before_open = store.event_reads(); @@ -7509,7 +7541,7 @@ fn session_log_follows_residency_and_flushes_before_release() { let store = TestStore::new("history-log-residency"); let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, store.root().clone(), None); meta.id = "resident".into(); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); let persisted = persist_streamed_turns(&store, "resident", 2).len() as u64; let state = cx.new_entity(TestClientState::new((*store).clone())); let reads_before_open = store.event_reads(); @@ -7688,7 +7720,7 @@ fn live_records(messages: &[HostMessage]) -> Vec<(u64, u64, SessionEventRecord)> fn open_stored_session(store: &SessionStore, id: &str) -> SessionMeta { let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, store.root().clone(), None); meta.id = id.into(); - store.upsert_metas([&meta]).unwrap(); + seed_index(store, &[], &[meta.clone()]); meta } @@ -8315,7 +8347,7 @@ fn settled_commands_persist_without_closing_the_selected_conversation() { let mut meta = SessionMeta::new(ProviderKind::Codex, store.root().clone(), None); meta.id = id.into(); meta.parent_session_id = parent.map(str::to_string); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); } let state = cx.new_entity(TestClientState::new(store.clone())); state.update(cx, |state, cx| state.select_session("parent", cx)); @@ -8338,10 +8370,9 @@ fn settled_commands_persist_without_closing_the_selected_conversation() { .all(|meta| meta.settled_at.is_some() && meta.archived_at.is_none()) ); }); - let restarted = AppState::new(store.clone()); assert!( - restarted - .sessions + state + .read(|state| state.index.load().unwrap().sessions) .iter() .all(|meta| meta.settled_at.is_some()) ); @@ -8380,8 +8411,8 @@ fn settled_commands_persist_without_closing_the_selected_conversation() { ); cx.run_until_parked(); assert!( - store - .load_index() + state + .read(|state| state.index.load().unwrap().sessions) .iter() .all(|meta| meta.settled_at.is_none()) ); @@ -8607,7 +8638,7 @@ fn history_paging_bench() { let store = TestStore::new("tcode-history-paging-bench"); let mut meta = SessionMeta::new(ProviderKind::ClaudeCode, store.root().clone(), None); meta.id = "bench".into(); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); fs::copy(&source, store.root().join("bench.jsonl")).unwrap(); let state = cx.new_entity(TestClientState::new((*store).clone())); let started = Instant::now(); @@ -8672,12 +8703,12 @@ fn index_and_visit_changes_cross_the_wire_one_thread_at_a_time() { let cx = &mut TestAppContext::default(); let store = TestStore::new("index-deltas"); let project = Project::from_root(store.root().join("project")); - store.upsert_project(&project).unwrap(); + seed_index(&store, std::slice::from_ref(&project), &[]); for id in ["kept", "archived"] { let mut meta = SessionMeta::new(ProviderKind::Codex, project.root.clone(), None); meta.id = id.into(); meta.project_id = Some(project.id.clone()); - store.upsert_metas([&meta]).unwrap(); + seed_index(&store, &[], &[meta.clone()]); } let state = cx.new_entity(TestClientState::new((*store).clone())); cx.run_until_parked(); diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index d7c121d91..ba759f275 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -123,7 +123,13 @@ pub fn spawn_host(store: SessionStore, mut services: HostServices) -> std::io::R std::thread::Builder::new() .name("tcode-host".into()) .spawn(move || { - let mut state = AppState::with_ai_titles(store, services.ai_title_generation); + let mut state = match AppState::start(store, services.ai_title_generation) { + Ok(state) => state, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return; + } + }; if let Some((url, tokens)) = preview_registration { state.attach_preview_mcp(url, tokens); } @@ -149,8 +155,13 @@ pub fn spawn_host(store: SessionStore, mut services: HostServices) -> std::io::R state.start_log_compaction(&mut cx); } state.sync_terminal_handles(); - let _ = ready_tx.send(()); - smol::block_on(host_loop(state, cx, client_rx, mailbox_rx)); + let _ = ready_tx.send(Ok(())); + let state = smol::block_on(host_loop(state, cx, client_rx, mailbox_rx)); + // Stopped means the queued writes are on disk and the index is + // closed for the next host. + if let Some(writer) = state.close_store() { + smol::block_on(writer); + } let _ = stopped_tx.send_blocking(()); })?; ready_rx.recv().map_err(|error| { @@ -158,7 +169,7 @@ pub fn spawn_host(store: SessionStore, mut services: HostServices) -> std::io::R std::io::ErrorKind::BrokenPipe, format!("host failed during startup: {error}"), ) - })?; + })??; Ok(SpawnedHost { to_host: client_tx, @@ -175,7 +186,7 @@ async fn host_loop( mut cx: HostCx, client: smol::channel::Receiver, mailbox: smol::channel::Receiver, -) { +) -> AppState { let mut domain_diff = DomainDiff::new(&state); loop { enum Input { @@ -206,6 +217,7 @@ async fn host_loop( state.reap_terminal_projections(); domain_diff.emit_changes(&state, &mut cx); } + state } fn malformed_message_id(line: &str) -> Option { @@ -766,6 +778,7 @@ fn io_protocol_error(error: std::io::Error) -> ProtocolError { #[cfg(test)] mod tests { use super::*; + use crate::app::test_support::{seed_index, stored_index}; pub(super) fn next_event( events: &async_channel::Receiver, @@ -1021,7 +1034,7 @@ mod tests { }, ) .expect("append event"); - store.upsert_metas([&meta]).expect("write meta"); + seed_index(&store, &[], std::slice::from_ref(&meta)); let event_log = store.read_event_log(&meta.id).expect("read event log"); let host = spawn_host(store, HostServices::default()).expect("spawn host"); @@ -1130,7 +1143,7 @@ mod tests { }), ) .expect("append empty command output"); - store.upsert_metas([&meta]).expect("write meta"); + seed_index(&store, &[], std::slice::from_ref(&meta)); let host = spawn_host(store, HostServices::default()).expect("spawn host"); let link = host.link(); @@ -1257,16 +1270,18 @@ mod tests { ) .unwrap(); let store = SessionStore::open_at(root.clone()).unwrap(); - // An older project record must still load with automatic artwork. - std::fs::write( - root.join("sessions.json"), - serde_json::json!({ - "projects": [{"id":"p", "name":"Project", "root": project_root, "created_at":1}], - "sessions": [] - }) - .to_string(), - ) - .unwrap(); + // A project without a chosen image loads with automatic artwork. + seed_index( + &store, + &[tcode_core::project::Project { + id: "p".into(), + name: "Project".into(), + root: project_root.clone(), + icon_path: None, + created_at: 1, + }], + &[], + ); let host = spawn_host(store.clone(), HostServices::default()).unwrap(); let link = host.link(); link.subscribe(Subscription { @@ -1326,7 +1341,7 @@ mod tests { assert!(first.starts_with(root.join("project-icons"))); host.shutdown_blocking().unwrap(); assert_eq!( - store.read_file().projects[0].icon_path.as_ref(), + stored_index(&store).projects[0].icon_path.as_ref(), Some(&first) ); // Removing the source must not break the saved custom copy. @@ -1347,7 +1362,7 @@ mod tests { }) .unwrap(); host.shutdown_blocking().unwrap(); - let second = store.read_file().projects[0].icon_path.clone().unwrap(); + let second = stored_index(&store).projects[0].icon_path.clone().unwrap(); assert_ne!(first, second); assert!(!first.exists()); std::fs::write(&logo, include_bytes!("../../../assets/icons/app/tcode.png")).unwrap(); @@ -1367,7 +1382,7 @@ mod tests { QueryResponse::FileBytes(_) )); host.shutdown_blocking().unwrap(); - assert!(store.read_file().projects[0].icon_path.is_none()); + assert!(stored_index(&store).projects[0].icon_path.is_none()); assert!(!second.exists()); assert!(logo.exists()); std::fs::remove_dir_all(root).unwrap(); diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 28b627235..6abaeb144 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -15,6 +15,7 @@ chrono = "0.4" dirs = "7" flate2 = "1" log = "0.4" +redb = { version = "4", features = ["experimental-multiprocess"] } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/services/src/relaunch.rs b/crates/services/src/relaunch.rs index cce8a3420..25a438fc1 100644 --- a/crates/services/src/relaunch.rs +++ b/crates/services/src/relaunch.rs @@ -72,6 +72,12 @@ pub fn clear(data_dir: &Path) -> std::io::Result<()> { } } +/// Whether a marker is waiting for this launch: the instance that wrote it +/// may still be quitting. +pub fn pending(data_dir: &Path) -> bool { + marker_path(data_dir).exists() +} + /// Read the marker and delete it (consume-once). Returns `None` when absent or /// unparsable; the file is removed either way so a corrupt marker can't wedge /// every future launch into a relaunch loop. diff --git a/crates/services/src/store.rs b/crates/services/src/store.rs index 9ac62fb9a..a0d2ca520 100644 --- a/crates/services/src/store.rs +++ b/crates/services/src/store.rs @@ -1,7 +1,9 @@ //! On-disk persistence for tcode sessions. //! //! Layout (under the platform data dir, e.g. `~/Library/Application Support/tcode/`): -//! * `sessions.json` — an [`IndexFile`] containing projects and sessions. +//! * `tcode.redb` — the [`SessionIndex`] of projects and sessions, opened by +//! the host alone. It replaced `sessions.json`, which its first open +//! imports and renames to `sessions.json.migrated`. //! * `.jsonl` — append-only `{ ts, event }` records. A log that //! [`SessionStore::compact_log`] rewrote begins with a layout header line; //! a log without one is layout epoch 0. @@ -10,7 +12,7 @@ //! Replay accepts timestamped records and legacy bare [`AgentEvent`] lines, //! then folds [`StoredEvent`]s into a [`tcode_core::session::Timeline`]. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::path::PathBuf; @@ -18,12 +20,13 @@ use std::path::PathBuf; use agent::{AgentEvent, ModelSpec, ProviderCommand, ProviderKind}; use serde::{Deserialize, Serialize}; -use tcode_core::project::{IndexFile, Project, SessionMeta, migrate_index}; use tcode_core::session::StoredEvent; mod compaction; +mod index; pub use compaction::{CompactOutcome, CompactRejection, MalformedLine}; +pub use index::{IndexWrite, LoadedIndex, SessionIndex}; /// The first line of a rewritten log. It lives in the log itself so the /// records and the epoch they belong to are replaced in one rename and can @@ -140,10 +143,6 @@ impl SessionStore { &self.root } - fn index_path(&self) -> PathBuf { - self.root.join("sessions.json") - } - fn events_path(&self, id: &str) -> PathBuf { self.root.join(format!("{id}.jsonl")) } @@ -271,107 +270,10 @@ impl SessionStore { fs::rename(&tmp, path) } - /// Load the whole index file (projects + sessions), tolerating the old - /// bare-array schema and deriving implicit projects for orphan sessions. - pub fn read_file(&self) -> IndexFile { - let path = self.index_path(); - let Ok(bytes) = fs::read(&path) else { - return IndexFile::default(); - }; - // Current schema is an object; the legacy schema was a bare array. - let parsed = serde_json::from_slice::(&bytes).or_else(|_| { - serde_json::from_slice::>(&bytes).map(|sessions| IndexFile { - projects: Vec::new(), - sessions, - }) - }); - match parsed { - Ok(file) => migrate_index(file), - Err(err) => { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - let corrupt_path = self.root.join(format!("sessions.json.corrupt-{timestamp}")); - match fs::rename(&path, &corrupt_path) { - Ok(()) => log::warn!( - "failed to parse sessions.json: {err}; preserved it as {}", - corrupt_path.display() - ), - Err(rename_err) => log::warn!( - "failed to parse sessions.json: {err}; failed to preserve corrupt index: {rename_err}" - ), - } - IndexFile::default() - } - } - } - - /// Load the session index (newest first). Empty if missing / unreadable. - pub fn load_index(&self) -> Vec { - let mut metas = self.read_file().sessions; - metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at)); - metas - } - - /// Insert or replace metas in the index (by id) with one index write. - pub fn upsert_metas<'a>( - &self, - metas: impl IntoIterator, - ) -> std::io::Result<()> { - let mut file = self.read_file(); - let mut positions: HashMap = file - .sessions - .iter() - .enumerate() - .map(|(position, meta)| (meta.id.clone(), position)) - .collect(); - for meta in metas { - match positions.get(&meta.id) { - Some(&position) => file.sessions[position] = meta.clone(), - None => { - positions.insert(meta.id.clone(), file.sessions.len()); - file.sessions.push(meta.clone()); - } - } - } - self.persist_index(&file) - } - - /// Insert or replace a project, then remove its previous managed icon. - pub fn upsert_project(&self, project: &Project) -> std::io::Result<()> { - let mut file = self.read_file(); - let mut previous = None; - if let Some(existing) = file.projects.iter_mut().find(|p| p.id == project.id) { - if existing.icon_path != project.icon_path { - previous = existing.icon_path.clone(); - } - *existing = project.clone(); - } else { - file.projects.push(project.clone()); - } - self.persist_index(&file)?; - self.remove_project_icon(previous); - Ok(()) - } - - /// Remove a project and its managed icon. Sessions are removed separately. - pub fn remove_project(&self, id: &str) -> std::io::Result<()> { - let mut file = self.read_file(); - let icon = file - .projects - .iter() - .find(|p| p.id == id) - .and_then(|p| p.icon_path.clone()); - file.projects.retain(|project| project.id != id); - self.persist_index(&file)?; - self.remove_project_icon(icon); - Ok(()) - } - - fn remove_project_icon(&self, path: Option) { - if let Some(path) = path - && path.parent() == Some(self.root.join("project-icons").as_path()) + /// Delete a project icon the host copied into the data dir; an icon + /// elsewhere is the user's own file. + fn remove_project_icon(&self, path: PathBuf) { + if path.parent() == Some(self.root.join("project-icons").as_path()) && let Err(error) = fs::remove_file(&path) && error.kind() != std::io::ErrorKind::NotFound { @@ -379,15 +281,6 @@ impl SessionStore { } } - /// Persist a whole index file atomically (also flushes migration on startup). - pub fn persist_index(&self, file: &IndexFile) -> std::io::Result<()> { - let tmp = self.index_path().with_extension("json.tmp"); - let data = serde_json::to_vec_pretty(file) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - fs::write(&tmp, data)?; - fs::rename(&tmp, self.index_path()) - } - /// Append one event to the session's JSONL log, wrapped in a timestamped /// envelope (`{"ts": , "event": {…}}`). pub fn append_event(&self, id: &str, ts: u64, event: &AgentEvent) -> std::io::Result<()> { @@ -467,15 +360,10 @@ impl SessionStore { fs::rename(tmp, dst) } - /// Remove sessions from the index with one index write, then delete their - /// event logs and the originals kept from before a rewrite. Every file is - /// attempted; the first failure is returned. - pub fn remove_sessions(&self, ids: &[String]) -> std::io::Result<()> { + /// Delete removed sessions' event logs and the originals kept from before + /// a rewrite. Every file is attempted; the first failure is returned. + pub fn remove_session_logs(&self, ids: &[String]) -> std::io::Result<()> { let removed: HashSet<&str> = ids.iter().map(String::as_str).collect(); - let mut file = self.read_file(); - file.sessions - .retain(|meta| !removed.contains(meta.id.as_str())); - self.persist_index(&file)?; let mut result = Ok(()); for id in removed { for path in [self.events_path(id), self.original_events_path(id)] { @@ -537,89 +425,6 @@ mod tests { p } - #[test] - fn removing_project_cleans_only_managed_icons_after_persisting() { - let root = - std::env::temp_dir().join(format!("tcode-icon-cleanup-{}", uuid::Uuid::new_v4())); - let store = SessionStore::open_at(root.clone()).unwrap(); - fs::create_dir(root.join("project-icons")).unwrap(); - let mut project = Project::from_root(root.join("project")); - for (path, managed) in [ - (root.join("project-icons/icon.png"), true), - (root.join("original.png"), false), - ] { - fs::write(&path, b"stored image").unwrap(); - project.icon_path = Some(path.clone()); - store.upsert_project(&project).unwrap(); - // A failed index replacement must retain the image still referenced on disk. - fs::create_dir(root.join("sessions.json.tmp")).unwrap(); - assert!(store.remove_project(&project.id).is_err()); - assert!(path.exists()); - assert_eq!( - store.read_file().projects[0].icon_path.as_ref(), - Some(&path) - ); - fs::remove_dir(root.join("sessions.json.tmp")).unwrap(); - store.remove_project(&project.id).unwrap(); - assert!(store.read_file().projects.is_empty()); - assert_eq!(path.exists(), !managed); - } - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn session_index_upserts_orders_and_removes_only_the_selected_session() { - let store = SessionStore::open_at(temp_root()).unwrap(); - let mut a = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/a"), None); - a.updated_at = 100; - let mut b = SessionMeta::new(ProviderKind::ClaudeCode, PathBuf::from("/b"), None); - b.updated_at = 200; - store.upsert_metas([&a, &b]).unwrap(); - - let index = store.load_index(); - assert_eq!(index.len(), 2); - // newest first - assert_eq!(index[0].id, b.id); - assert_eq!(index[1].id, a.id); - - // upsert replaces - let mut a2 = a.clone(); - a2.title = "renamed".into(); - store.upsert_metas([&a2]).unwrap(); - let index = store.load_index(); - assert_eq!(index.len(), 2); - assert_eq!( - index.iter().find(|m| m.id == a.id).unwrap().title, - "renamed" - ); - store - .append_event( - &a.id, - 1, - &AgentEvent::TurnStarted { - turn_id: "turn-a".into(), - }, - ) - .unwrap(); - store - .append_event( - &b.id, - 2, - &AgentEvent::TurnStarted { - turn_id: "turn-b".into(), - }, - ) - .unwrap(); - store.remove_sessions(std::slice::from_ref(&a.id)).unwrap(); - store.remove_sessions(std::slice::from_ref(&a.id)).unwrap(); - let remaining = store.load_index(); - assert_eq!(remaining.len(), 1); - assert_eq!(remaining[0].id, b.id); - assert!(!store.events_path(&a.id).exists()); - assert!(store.events_path(&b.id).exists()); - let _ = fs::remove_dir_all(store.root()); - } - #[test] fn command_cache_roundtrips_per_provider_and_acp_agent() { let root = temp_root(); @@ -705,69 +510,4 @@ mod tests { ); fs::remove_dir_all(store.root()).unwrap(); } - - #[test] - fn corrupt_index_is_preserved_before_returning_empty() { - let store = SessionStore::open_at(temp_root()).unwrap(); - let corrupt_bytes = b"not valid session json"; - fs::write(store.index_path(), corrupt_bytes).unwrap(); - - assert!(store.load_index().is_empty()); - assert!(!store.index_path().exists()); - let backups: Vec<_> = fs::read_dir(store.root()) - .unwrap() - .filter_map(Result::ok) - .filter(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with("sessions.json.corrupt-") - }) - .collect(); - assert_eq!(backups.len(), 1); - assert_eq!(fs::read(backups[0].path()).unwrap(), corrupt_bytes); - let _ = fs::remove_dir_all(store.root()); - } - - #[test] - fn legacy_bare_array_index_loads_and_derives_projects() { - let store = SessionStore::open_at(temp_root()).unwrap(); - // Old-format file: a bare JSON array with no project_id fields. - let legacy = serde_json::json!([ - { - "id": "s1", "title": "One", "provider": "claude_code", - "cwd": "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/work/alpha", "created_at": 1, "updated_at": 10 - }, - { - "id": "s2", "title": "Two", "provider": "codex", - "cwd": "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/work/alpha", "created_at": 2, "updated_at": 20 - }, - { - "id": "s3", "title": "Three", "provider": "codex", - "cwd": "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/work/beta", "created_at": 3, "updated_at": 30 - } - ]); - fs::write(store.index_path(), legacy.to_string()).unwrap(); - - let file = store.read_file(); - // Two distinct roots -> two derived projects, deduped by root. - assert_eq!(file.projects.len(), 2); - let alpha = file - .projects - .iter() - .find(|p| p.root == std::path::Path::new("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/work/alpha")) - .unwrap(); - assert_eq!(alpha.name, "alpha"); - // Both alpha sessions share the same derived project. - let s1 = file.sessions.iter().find(|s| s.id == "s1").unwrap(); - let s2 = file.sessions.iter().find(|s| s.id == "s2").unwrap(); - assert_eq!(s1.project_id, Some(alpha.id.clone())); - assert_eq!(s2.project_id, s1.project_id); - let s3 = file.sessions.iter().find(|s| s.id == "s3").unwrap(); - assert_ne!(s3.project_id, s1.project_id); - let migrated_again = migrate_index(file.clone()); - assert_eq!(migrated_again.projects, file.projects); - assert_eq!(migrated_again.sessions, file.sessions); - let _ = fs::remove_dir_all(store.root()); - } } diff --git a/crates/services/src/store/compaction.rs b/crates/services/src/store/compaction.rs index 5b816eb3b..0d4dee5b7 100644 --- a/crates/services/src/store/compaction.rs +++ b/crates/services/src/store/compaction.rs @@ -459,12 +459,8 @@ mod tests { assert_eq!(fs::read(store.events_path("s")).unwrap(), compacted); assert_eq!(fs::read(store.original_events_path("s")).unwrap(), original); - store.remove_sessions(&["s".to_string()]).unwrap(); - assert_eq!( - fs::read_dir(store.root()).unwrap().count(), - 1, - "only the index" - ); + store.remove_session_logs(&["s".to_string()]).unwrap(); + assert_eq!(fs::read_dir(store.root()).unwrap().count(), 0); fs::remove_dir_all(store.root()).unwrap(); } } diff --git a/crates/services/src/store/index.rs b/crates/services/src/store/index.rs new file mode 100644 index 000000000..1016251c5 --- /dev/null +++ b/crates/services/src/store/index.rs @@ -0,0 +1,832 @@ +//! The host's index of projects and sessions: `tcode.redb` in the data dir. +//! +//! One row per project and per session, keyed by id. A value is the row's +//! JSON, so a row written by another build decodes with the same defaults and +//! unknown-field tolerance the types have on the wire; a row this build cannot +//! decode is skipped and left as it is. +//! +//! Only a host opens the index, and only one at a time: it is opened in +//! redb's single-writer mode, where the writing process holds byte-range +//! locks for as long as it has the file open and other processes may open it +//! read-only meanwhile. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use redb::{ + Builder, ConcurrencyMode, Database, DatabaseError, ReadableDatabase, ReadableTable, + TableDefinition, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use tcode_core::project::{IndexFile, Project, SessionMeta, migrate_index}; + +use super::SessionStore; + +const PROJECTS: TableDefinition<&str, &[u8]> = TableDefinition::new("projects"); +const SESSIONS: TableDefinition<&str, &[u8]> = TableDefinition::new("sessions"); +/// The index's own bookkeeping, keyed by the names below. +const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta"); +/// The file's size when it was last compacted. +const COMPACTED_SIZE: &str = "compacted_size"; + +const DATABASE_FILE: &str = "tcode.redb"; +/// Where a new index is built. redb does not create a file atomically, so the +/// index only takes [`DATABASE_FILE`]'s name once it is complete. +const NEW_DATABASE_FILE: &str = "tcode.redb.tmp"; +const LEGACY_INDEX: &str = "sessions.json"; +const MIGRATED_INDEX: &str = "sessions.json.migrated"; + +/// How often a host waiting for the index tries again. +const OPEN_RETRY_INTERVAL: Duration = Duration::from_millis(100); + +/// One change to the index. +#[derive(Debug, Clone)] +pub enum IndexWrite { + UpsertSessions(Vec), + UpsertProject(Project), + RemoveSessions(Vec), + RemoveProject(String), +} + +/// Every row the index holds that this build can read. +#[derive(Debug)] +pub struct LoadedIndex { + pub projects: Vec, + pub sessions: Vec, +} + +/// The host's handle to the index. Clones share one open database, which +/// closes when the last clone is dropped. +#[derive(Clone)] +pub struct SessionIndex { + db: Arc, + store: SessionStore, +} + +fn other(error: impl Into>) -> io::Error { + io::Error::other(error) +} + +fn encode(value: &T) -> io::Result> { + serde_json::to_vec(value).map_err(other) +} + +fn decode(bytes: &[u8]) -> serde_json::Result { + serde_json::from_slice(bytes) +} + +fn builder() -> Builder { + let mut builder = Builder::new(); + builder.set_concurrency_mode(ConcurrencyMode::SingleWriter); + builder +} + +impl SessionStore { + fn database_path(&self) -> PathBuf { + self.root().join(DATABASE_FILE) + } + + /// Open the index for this host, importing `sessions.json` the first + /// time. While another process has it open, try again until `wait` has + /// passed, then fail with [`io::ErrorKind::ResourceBusy`]. + pub fn open_index(&self, wait: Duration) -> io::Result { + let path = self.database_path(); + let deadline = Instant::now() + wait; + let (mut db, size) = loop { + let size = fs::metadata(&path).map_or(0, |metadata| metadata.len()); + let opened = if size == 0 && !path.exists() { + self.import().and_then(|()| builder().open(&path)) + } else { + builder().open(&path) + }; + match opened { + Ok(db) => break (db, size), + Err(DatabaseError::DatabaseAlreadyOpen) if Instant::now() < deadline => { + std::thread::sleep(OPEN_RETRY_INTERVAL); + } + Err(DatabaseError::DatabaseAlreadyOpen) => { + return Err(io::Error::new( + io::ErrorKind::ResourceBusy, + format!( + "{} is open in another Tcode process; quit it or start this one with another data directory", + path.display() + ), + )); + } + Err(error) => { + return Err(io::Error::other(format!( + "could not open {}: {error}", + path.display() + ))); + } + } + }; + let legacy = self.root().join(LEGACY_INDEX); + if legacy.exists() { + log::warn!( + "ignoring {}: the index in {DATABASE_FILE} is the one in use", + legacy.display() + ); + } + if let Err(error) = compact_if_grown(&mut db, &path, size) { + log::warn!("could not compact {}: {error}", path.display()); + } + Ok(SessionIndex { + db: Arc::new(db), + store: self.clone(), + }) + } + + /// Build the index from `sessions.json`, or from `sessions.json.migrated` + /// when only that is left, and put it in place. It is written in one + /// transaction under a temporary name and read back; then the JSON is + /// renamed away, and only then does the index take its name. An + /// interruption before the first rename leaves `sessions.json` to import + /// again, and one before the second leaves `sessions.json.migrated`, + /// which holds the same index. + fn import(&self) -> Result<(), DatabaseError> { + let building = self.root().join(NEW_DATABASE_FILE); + // What an interrupted import left is rebuilt; a file that is not yet + // a database at all is started over. + let db = match builder().create(&building) { + Err(DatabaseError::DatabaseAlreadyOpen) => { + return Err(DatabaseError::DatabaseAlreadyOpen); + } + Err(_) => { + fs::remove_file(&building)?; + builder().create(&building)? + } + Ok(db) => db, + }; + let legacy = self.root().join(LEGACY_INDEX); + let migrated = self.root().join(MIGRATED_INDEX); + let (file, source) = match read_legacy_index(&legacy)? { + Legacy::Parsed(file) => (file, Some(&legacy)), + Legacy::Corrupt => (IndexFile::default(), None), + Legacy::Missing => match read_legacy_index(&migrated)? { + Legacy::Parsed(file) => (file, None), + Legacy::Missing | Legacy::Corrupt => (IndexFile::default(), None), + }, + }; + let started = Instant::now(); + write_index(&db, &file)?; + drop(db); + if let Some(legacy) = source { + fs::rename(legacy, &migrated)?; + } + fs::rename(&building, self.database_path())?; + log::info!( + "imported {} project(s) and {} session(s) into {DATABASE_FILE} in {:?}", + file.projects.len(), + file.sessions.len(), + started.elapsed() + ); + Ok(()) + } +} + +fn read_meta(db: &Database, key: &str) -> io::Result> { + let txn = db.begin_read().map_err(other)?; + let table = match txn.open_table(META) { + Ok(table) => table, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None), + Err(error) => return Err(other(error)), + }; + let Some(value) = table.get(key).map_err(other)? else { + return Ok(None); + }; + decode(value.value()).map(Some).map_err(other) +} + +fn write_meta(db: &Database, key: &str, value: &T) -> io::Result<()> { + let txn = db.begin_write().map_err(other)?; + txn.open_table(META) + .map_err(other)? + .insert(key, encode(value)?.as_slice()) + .map_err(other)?; + txn.commit().map_err(other) +} + +/// What a legacy JSON index file held. +enum Legacy { + Missing, + /// It did not parse and was kept beside itself as `.corrupt-`. + Corrupt, + Parsed(IndexFile), +} + +/// The legacy index, parsed as the JSON store parsed it: the current object +/// or the older bare array, projects derived for orphan sessions. +fn read_legacy_index(path: &Path) -> io::Result { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Legacy::Missing), + Err(error) => return Err(error), + }; + let parsed = serde_json::from_slice::(&bytes).or_else(|_| { + serde_json::from_slice::>(&bytes).map(|sessions| IndexFile { + projects: Vec::new(), + sessions, + }) + }); + match parsed { + Ok(file) => Ok(Legacy::Parsed(migrate_index(file, &HashSet::new()))), + Err(err) => { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let mut corrupt = path.as_os_str().to_owned(); + corrupt.push(format!(".corrupt-{timestamp}")); + fs::rename(path, &corrupt)?; + log::warn!( + "failed to parse {}: {err}; preserved it as {}", + path.display(), + Path::new(&corrupt).display() + ); + Ok(Legacy::Corrupt) + } + } +} + +/// Replace every row with `file`'s in one transaction, then read them back. +fn write_index(db: &Database, file: &IndexFile) -> io::Result<()> { + let projects: HashMap<&str, &Project> = file + .projects + .iter() + .map(|project| (project.id.as_str(), project)) + .collect(); + let sessions: HashMap<&str, &SessionMeta> = file + .sessions + .iter() + .map(|meta| (meta.id.as_str(), meta)) + .collect(); + let txn = db.begin_write().map_err(other)?; + for table in [PROJECTS, SESSIONS] { + txn.delete_table(table).map_err(other)?; + } + { + let mut table = txn.open_table(PROJECTS).map_err(other)?; + for (id, project) in &projects { + table + .insert(*id, encode(project)?.as_slice()) + .map_err(other)?; + } + let mut table = txn.open_table(SESSIONS).map_err(other)?; + for (id, meta) in &sessions { + table.insert(*id, encode(meta)?.as_slice()).map_err(other)?; + } + } + txn.commit().map_err(other)?; + + let txn = db.begin_read().map_err(other)?; + let stored_projects = read_rows::(&txn, PROJECTS)?; + let stored_sessions = read_rows::(&txn, SESSIONS)?; + let matches = stored_projects.undecodable.is_empty() + && stored_sessions.undecodable.is_empty() + && stored_projects.rows.len() == projects.len() + && stored_sessions.rows.len() == sessions.len() + && stored_projects + .rows + .iter() + .all(|row| projects.get(row.id.as_str()) == Some(&row)) + && stored_sessions + .rows + .iter() + .all(|row| sessions.get(row.id.as_str()) == Some(&row)); + if matches { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "the imported index does not read back as it was written", + )) + } +} + +struct Rows { + rows: Vec, + /// Ids of the rows that did not decode. + undecodable: Vec, +} + +trait Row: DeserializeOwned { + fn id(&self) -> &str; +} + +impl Row for Project { + fn id(&self) -> &str { + &self.id + } +} + +impl Row for SessionMeta { + fn id(&self) -> &str { + &self.id + } +} + +fn read_rows( + txn: &redb::ReadTransaction, + definition: TableDefinition<&str, &[u8]>, +) -> io::Result> { + let mut rows = Rows { + rows: Vec::new(), + undecodable: Vec::new(), + }; + let table = match txn.open_table(definition) { + Ok(table) => table, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(rows), + Err(error) => return Err(other(error)), + }; + for entry in table.iter().map_err(other)? { + let (key, value) = entry.map_err(other)?; + let id = key.value(); + match decode::(value.value()) { + Ok(row) if row.id() == id => rows.rows.push(row), + Ok(_) => { + log::warn!("skipping {definition} row {id}: its value names another id"); + rows.undecodable.push(id.to_owned()); + } + Err(error) => { + log::warn!("skipping {definition} row {id}: {error}"); + rows.undecodable.push(id.to_owned()); + } + } + } + Ok(rows) +} + +/// Compact the file once it has grown past twice its size after the last +/// compaction. redb reuses freed pages and trims free space off the end of +/// the file when it closes, but only `compact` moves pages to return free +/// space in the middle, and it cannot tell cheaply when that is worth doing. +/// Sizes are taken as a close left the file, before it is opened again, so a +/// compaction's size is recorded by the open after it. +fn compact_if_grown(db: &mut Database, path: &Path, size: u64) -> io::Result<()> { + match read_meta::(db, COMPACTED_SIZE)? { + Some(compacted) if size <= compacted.saturating_mul(2) => Ok(()), + Some(_) => { + let started = Instant::now(); + db.compact().map_err(other)?; + log::info!( + "compacted {} of {size} bytes in {:?}", + path.display(), + started.elapsed() + ); + let txn = db.begin_write().map_err(other)?; + txn.open_table(META) + .map_err(other)? + .remove(COMPACTED_SIZE) + .map_err(other)?; + txn.commit().map_err(other) + } + // A new file has no size a close left yet. + None if size == 0 => Ok(()), + None => write_meta(db, COMPACTED_SIZE, &size), + } +} + +impl SessionIndex { + /// Every row this build can decode, with a project derived for each + /// session that has none; the derived projects and reassigned sessions + /// are written back so their ids stay stable. A row that does not decode + /// is logged and left in the index as it is. + pub fn load(&self) -> io::Result { + let txn = self.db.begin_read().map_err(other)?; + let projects = read_rows::(&txn, PROJECTS)?; + let sessions = read_rows::(&txn, SESSIONS)?; + drop(txn); + let before: HashMap> = sessions + .rows + .iter() + .map(|meta| (meta.id.clone(), meta.project_id.clone())) + .collect(); + let known_projects = projects.rows.len(); + let foreign = projects.undecodable.into_iter().collect(); + let file = migrate_index( + IndexFile { + projects: projects.rows, + sessions: sessions.rows, + }, + &foreign, + ); + let reassigned: Vec = file + .sessions + .iter() + .filter(|meta| before.get(&meta.id) != Some(&meta.project_id)) + .cloned() + .collect(); + if !reassigned.is_empty() { + let mut writes: Vec = file.projects[known_projects..] + .iter() + .cloned() + .map(IndexWrite::UpsertProject) + .collect(); + writes.push(IndexWrite::UpsertSessions(reassigned)); + self.commit(&writes)?; + } + Ok(LoadedIndex { + projects: file.projects, + sessions: file.sessions, + }) + } + + /// Apply `writes` in one durable transaction, then delete the managed + /// icons of projects it removed or gave another icon. + pub fn commit(&self, writes: &[IndexWrite]) -> io::Result<()> { + let txn = self.db.begin_write().map_err(other)?; + let mut replaced_icons = Vec::new(); + { + let mut projects = txn.open_table(PROJECTS).map_err(other)?; + let mut sessions = txn.open_table(SESSIONS).map_err(other)?; + let previous_icon = |bytes: Option<&[u8]>| { + bytes + .and_then(|bytes| decode::(bytes).ok()) + .and_then(|project| project.icon_path) + }; + for write in writes { + match write { + IndexWrite::UpsertSessions(metas) => { + for meta in metas { + sessions + .insert(meta.id.as_str(), encode(meta)?.as_slice()) + .map_err(other)?; + } + } + IndexWrite::UpsertProject(project) => { + let old = projects + .insert(project.id.as_str(), encode(project)?.as_slice()) + .map_err(other)?; + if let Some(icon) = previous_icon(old.as_ref().map(|old| old.value())) + && project.icon_path.as_ref() != Some(&icon) + { + replaced_icons.push(icon); + } + } + IndexWrite::RemoveSessions(ids) => { + for id in ids { + sessions.remove(id.as_str()).map_err(other)?; + } + } + IndexWrite::RemoveProject(id) => { + let old = projects.remove(id.as_str()).map_err(other)?; + replaced_icons.extend(previous_icon(old.as_ref().map(|old| old.value()))); + } + } + } + } + txn.commit().map_err(other)?; + for icon in replaced_icons { + self.store.remove_project_icon(icon); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent::{ApprovalMode, InteractionMode, ProviderKind}; + + struct DataDir(SessionStore); + + impl DataDir { + fn new() -> Self { + let root = + std::env::temp_dir().join(format!("tcode-index-test-{}", uuid::Uuid::new_v4())); + Self(SessionStore::open_at(root).unwrap()) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.root().join(name) + } + + fn open(&self) -> SessionIndex { + self.0.open_index(Duration::ZERO).unwrap() + } + + /// Open the file as another build or a crash would have left it. + fn raw(&self) -> Database { + builder().create(self.path(DATABASE_FILE)).unwrap() + } + } + + impl Drop for DataDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(self.0.root()); + } + } + + fn raw_row(db: &Database, table: TableDefinition<&str, &[u8]>, id: &str) -> Option> { + let txn = db.begin_read().unwrap(); + let table = txn.open_table(table).unwrap(); + table.get(id).unwrap().map(|value| value.value().to_vec()) + } + + fn put_raw(db: &Database, table: TableDefinition<&str, &[u8]>, id: &str, value: &[u8]) { + let txn = db.begin_write().unwrap(); + txn.open_table(table).unwrap().insert(id, value).unwrap(); + txn.commit().unwrap(); + } + + fn sorted(mut loaded: LoadedIndex) -> LoadedIndex { + loaded.projects.sort_by(|a, b| a.id.cmp(&b.id)); + loaded.sessions.sort_by(|a, b| a.id.cmp(&b.id)); + loaded + } + + #[test] + fn legacy_index_files_import_once_and_the_database_stays_the_truth() { + let bare_array = r#"[ + {"id": "s1", "title": "One", "provider": "claude_code", + "cwd": "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/work/alpha", "created_at": 1, "updated_at": 10}, + {"id": "s2", "title": "Two", "provider": "codex", + "cwd": "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/work/alpha", "created_at": 2, "updated_at": 20}, + {"id": "s3", "title": "Three", "provider": "codex", + "cwd": "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/work/beta", "created_at": 3, "updated_at": 30} + ]"#; + let missing_defaults = r#"{"sessions": [ + {"id": "s1", "title": "One", "provider": "codex", "cwd": "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/work/alpha", + "project_id": "p", "created_at": 1, "updated_at": 10} + ], "projects": [ + {"id": "p", "name": "alpha", "root": "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/work/alpha", "created_at": 1} + ]}"#; + let removed_fields = r#"{"projects": [ + {"id": "p", "name": "alpha", "root": "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/work/alpha", "created_at": 1} + ], "sessions": [ + {"id": "s1", "title": "One", "provider": "codex", "cwd": "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/work/alpha", + "project_id": "p", "created_at": 1, "updated_at": 10, + "checkpoints": [{"turn_id": "t", "ref": "abc"}], "forked_from": "s0"} + ]}"#; + for (name, legacy) in [ + ("bare array", bare_array), + ("missing defaults", missing_defaults), + ("removed fields", removed_fields), + ] { + let dir = DataDir::new(); + fs::write(dir.path(LEGACY_INDEX), legacy).unwrap(); + let index = dir.open(); + let loaded = sorted(index.load().unwrap()); + drop(index); + + assert!(!dir.path(LEGACY_INDEX).exists(), "{name}"); + assert_eq!( + fs::read_to_string(dir.path(MIGRATED_INDEX)).unwrap(), + legacy, + "{name}" + ); + let titles: Vec<&str> = loaded.sessions.iter().map(|s| s.title.as_str()).collect(); + match name { + "bare array" => { + assert_eq!(titles, ["One", "Two", "Three"]); + // Two distinct roots -> two derived projects, deduped by root. + let alpha = loaded + .projects + .iter() + .find(|p| p.root == Path::new("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/work/alpha")) + .unwrap(); + assert_eq!(alpha.name, "alpha"); + assert_eq!(loaded.projects.len(), 2); + assert_eq!(loaded.sessions[0].project_id.as_ref(), Some(&alpha.id)); + assert_eq!(loaded.sessions[1].project_id.as_ref(), Some(&alpha.id)); + assert_ne!(loaded.sessions[2].project_id.as_ref(), Some(&alpha.id)); + } + _ => { + assert_eq!(titles, ["One"], "{name}"); + let session = &loaded.sessions[0]; + assert_eq!(session.project_id.as_deref(), Some("p"), "{name}"); + assert_eq!(session.approval_mode, ApprovalMode::default(), "{name}"); + assert_eq!(session.interaction_mode, InteractionMode::default()); + assert!(session.option_selections.is_empty(), "{name}"); + assert_eq!(loaded.projects.len(), 1, "{name}"); + } + } + + // An older build run in between writes a sessions.json of its own. + fs::write(dir.path(LEGACY_INDEX), "[]").unwrap(); + let reloaded = sorted(dir.open().load().unwrap()); + assert_eq!(reloaded.projects, loaded.projects, "{name}"); + assert_eq!(reloaded.sessions, loaded.sessions, "{name}"); + assert_eq!(fs::read(dir.path(LEGACY_INDEX)).unwrap(), b"[]", "{name}"); + } + } + + #[test] + fn an_interrupted_import_is_redone_from_the_file_still_holding_the_index() { + let legacy = r#"{"projects": [ + {"id": "p", "name": "alpha", "root": "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/work/alpha", "created_at": 1} + ], "sessions": [ + {"id": "s1", "title": "One", "provider": "codex", "cwd": "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/work/alpha", + "project_id": "p", "created_at": 1, "updated_at": 10}, + {"id": "s2", "title": "Two", "provider": "codex", "cwd": "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/work/alpha", + "project_id": "p", "created_at": 2, "updated_at": 20} + ]}"#; + let dir = DataDir::new(); + fs::write(dir.path(LEGACY_INDEX), legacy).unwrap(); + let imported = sorted(dir.open().load().unwrap()); + + let building = dir.path(NEW_DATABASE_FILE); + let partial = || { + let db = builder().create(&building).unwrap(); + put_raw(&db, SESSIONS, "stray", b"{}"); + }; + // Each state an interrupted import leaves: a file redb had begun to + // create, or one holding some rows, beside sessions.json; or the + // same once sessions.json was renamed away. + for (state, legacy_left) in [ + ("created", true), + ("partial", true), + ("created", false), + ("partial", false), + ] { + fs::remove_file(dir.path(DATABASE_FILE)).unwrap(); + match state { + "created" => fs::write(&building, b"redb").unwrap(), + _ => partial(), + } + if legacy_left { + fs::rename(dir.path(MIGRATED_INDEX), dir.path(LEGACY_INDEX)).unwrap(); + } + let redone = sorted(dir.open().load().unwrap()); + assert_eq!(redone.projects, imported.projects, "{state} {legacy_left}"); + assert_eq!(redone.sessions, imported.sessions, "{state} {legacy_left}"); + assert!(!building.exists()); + assert!(!dir.path(LEGACY_INDEX).exists()); + assert_eq!( + fs::read_to_string(dir.path(MIGRATED_INDEX)).unwrap(), + legacy + ); + } + } + + #[test] + fn an_unparseable_sessions_json_is_preserved_and_the_index_starts_empty() { + let dir = DataDir::new(); + let corrupt = b"not valid session json"; + fs::write(dir.path(LEGACY_INDEX), corrupt).unwrap(); + let loaded = dir.open().load().unwrap(); + assert!(loaded.projects.is_empty() && loaded.sessions.is_empty()); + assert!(!dir.path(LEGACY_INDEX).exists()); + let backups: Vec<_> = fs::read_dir(dir.0.root()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("sessions.json.corrupt-") + }) + .collect(); + assert_eq!(backups.len(), 1); + assert_eq!(fs::read(backups[0].path()).unwrap(), corrupt); + } + + #[test] + fn rows_this_build_cannot_decode_are_skipped_and_kept() { + let dir = DataDir::new(); + let project = Project::from_root(PathBuf::from("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/work/alpha")); + let mut meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("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/work/alpha"), None); + meta.project_id = Some(project.id.clone()); + let mut child = SessionMeta::new(ProviderKind::Codex, PathBuf::from("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/work/beta"), None); + child.project_id = Some("newer-project".into()); + let index = dir.open(); + index + .commit(&[ + IndexWrite::UpsertProject(project.clone()), + IndexWrite::UpsertSessions(vec![meta.clone(), child.clone()]), + ]) + .unwrap(); + drop(index); + + let newer_session = br#"{"id":"newer","title":"t","provider":"warp_drive","cwd":"/w","created_at":1,"updated_at":2}"#; + let newer_project = br#"{"id":"newer-project","name":{"localized":true},"root":"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/work/beta","created_at":1}"#; + let db = dir.raw(); + put_raw(&db, SESSIONS, "newer", newer_session); + put_raw(&db, PROJECTS, "newer-project", newer_project); + drop(db); + + let index = dir.open(); + let loaded = sorted(index.load().unwrap()); + assert_eq!(loaded.projects, vec![project]); + let mut expected = vec![meta.clone(), child]; + expected.sort_by(|a, b| a.id.cmp(&b.id)); + // The child of the unreadable project keeps it rather than being + // given a derived one. + assert_eq!(loaded.sessions, expected); + meta.title = "renamed".into(); + index + .commit(&[IndexWrite::UpsertSessions(vec![meta])]) + .unwrap(); + drop(index); + + let db = dir.raw(); + assert_eq!(raw_row(&db, SESSIONS, "newer").unwrap(), newer_session); + assert_eq!( + raw_row(&db, PROJECTS, "newer-project").unwrap(), + newer_project + ); + } + + #[test] + fn a_second_host_is_refused_until_the_first_closes_the_index() { + let dir = DataDir::new(); + let first = dir.open(); + let refused = dir.0.open_index(Duration::ZERO).err().unwrap(); + assert_eq!(refused.kind(), io::ErrorKind::ResourceBusy); + // A relaunch waits for the instance it replaces to quit. + let closing = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(first); + }); + dir.0.open_index(Duration::from_secs(10)).unwrap(); + closing.join().unwrap(); + } + + #[test] + fn replacing_or_removing_a_project_deletes_only_its_managed_icon() { + let dir = DataDir::new(); + let index = dir.open(); + fs::create_dir(dir.path("project-icons")).unwrap(); + let mut project = Project::from_root(dir.path("project")); + for (path, managed) in [ + (dir.path("project-icons/icon.png"), true), + (dir.path("original.png"), false), + ] { + fs::write(&path, b"stored image").unwrap(); + project.icon_path = Some(path.clone()); + index + .commit(&[IndexWrite::UpsertProject(project.clone())]) + .unwrap(); + project.icon_path = None; + index + .commit(&[IndexWrite::UpsertProject(project.clone())]) + .unwrap(); + assert_eq!(path.exists(), !managed); + + fs::write(&path, b"stored image").unwrap(); + project.icon_path = Some(path.clone()); + index + .commit(&[ + IndexWrite::UpsertProject(project.clone()), + IndexWrite::RemoveProject(project.id.clone()), + ]) + .unwrap(); + assert!(index.load().unwrap().projects.is_empty()); + assert_eq!(path.exists(), !managed); + } + } + + #[test] + fn the_file_is_compacted_at_open_once_it_has_doubled() { + let dir = DataDir::new(); + let size = || fs::metadata(dir.path(DATABASE_FILE)).unwrap().len(); + let project = Project::from_root(PathBuf::from("/work")); + let session = |id: String, title: String| { + let mut meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/work"), None); + meta.id = id; + meta.project_id = Some(project.id.clone()); + meta.title = title; + meta + }; + let index = dir.open(); + index + .commit(&[IndexWrite::UpsertProject(project.clone())]) + .unwrap(); + drop(index); + let index = dir.open(); + let compacted = size(); + // Kept rows written between large ones pin the pages the large ones + // leave free once they are removed. + let mut kept = Vec::new(); + let mut removed = Vec::new(); + for n in 0..32 { + let large = session(format!("{n:02}-large"), "x".repeat(64 * 1024)); + let small = session(format!("{n:02}-kept"), "kept".into()); + index + .commit(&[IndexWrite::UpsertSessions(vec![ + large.clone(), + small.clone(), + ])]) + .unwrap(); + removed.push(large.id); + kept.push(small); + } + index + .commit(&[IndexWrite::RemoveSessions(removed)]) + .unwrap(); + drop(index); + let grown = size(); + assert!(grown > compacted * 2); + + let index = dir.open(); + assert!(size() < grown / 2); + kept.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(sorted(index.load().unwrap()).sessions, kept); + } +} diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 236e7d2e0..68865f0c7 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -3199,9 +3199,8 @@ mod tests { fn deleted_thread_keeps_its_pending_and_rejected_send_visible(cx: &mut TestAppContext) { let root = scratch_root("tcode-deleted-pending"); let disk = SessionStore::open_at(root.clone()).unwrap(); - disk.upsert_project(&project_at("p", &root)).unwrap(); - disk.upsert_metas([&thread(&root, "deleted", "p", None)]) - .unwrap(); + seed_index(&disk, &[project_at("p", &root)], &[]); + seed_index(&disk, &[], &[thread(&root, "deleted", "p", None)]); let host = test_host(disk); let link = host.link(); let workspace = cx.new(|cx| WorkspaceStore::new(link.clone(), cx)); @@ -4262,6 +4261,23 @@ mod tests { spawn_host(store, HostServices::default()).expect("spawn test host") } + /// Write the index a host started on `store` will load. + fn seed_index(store: &SessionStore, projects: &[Project], sessions: &[SessionMeta]) { + let mut writes: Vec = projects + .iter() + .cloned() + .map(tcode_services::store::IndexWrite::UpsertProject) + .collect(); + writes.push(tcode_services::store::IndexWrite::UpsertSessions( + sessions.to_vec(), + )); + store + .open_index(std::time::Duration::ZERO) + .expect("open the index") + .commit(&writes) + .expect("seed the index"); + } + macro_rules! update_host { ($host:expr, $update:expr) => { smol::block_on($host.update_state_for_test($update)).expect("update test host") @@ -4302,9 +4318,8 @@ mod tests { use tcode_client::ConnectionState; let root = scratch_root("baseline-replay"); let disk = SessionStore::open_at(root.clone()).unwrap(); - disk.upsert_project(&project_at("p", &root)).unwrap(); - disk.upsert_metas([&thread(&root, "one", "p", None)]) - .unwrap(); + seed_index(&disk, &[project_at("p", &root)], &[]); + seed_index(&disk, &[], &[thread(&root, "one", "p", None)]); let host = test_host(disk); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); workspace.update(cx, |store, _| store.select_session("one".into())); @@ -4484,14 +4499,13 @@ mod tests { fn archiving_the_viewed_child_returns_to_its_parent(cx: &mut TestAppContext) { let root = scratch_root("archive-to-parent"); let disk = SessionStore::open_at(root.clone()).expect("open test store"); - disk.upsert_project(&project_at("p", &root)) - .expect("persist project"); + seed_index(&disk, &[project_at("p", &root)], &[]); for meta in [ thread(&root, "parent", "p", None), thread(&root, "child", "p", Some("parent")), thread(&root, "sibling", "p", None), ] { - disk.upsert_metas([&meta]).expect("persist session"); + seed_index(&disk, &[], std::slice::from_ref(&meta)); } let host = test_host(disk); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -4555,15 +4569,13 @@ mod tests { let disk = SessionStore::open_at(root.clone()).expect("open test store"); // "other" is listed first, so a draft for it proves nothing about the // remembered project; "p" is the one the user last worked in. - disk.upsert_project(&project_at("other", &root.join("other"))) - .expect("persist project"); - disk.upsert_project(&project_at("p", &root)) - .expect("persist project"); + seed_index(&disk, &[project_at("other", &root.join("other"))], &[]); + seed_index(&disk, &[project_at("p", &root)], &[]); for meta in [ thread(&root, "parent", "p", None), thread(&root, "child", "p", Some("parent")), ] { - disk.upsert_metas([&meta]).expect("persist session"); + seed_index(&disk, &[], std::slice::from_ref(&meta)); } let host = test_host(disk); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -4636,10 +4648,8 @@ mod tests { ) { let root = scratch_root("remembered-project"); let disk = SessionStore::open_at(root.clone()).expect("open test store"); - disk.upsert_project(&project_at("first", &root.join("first"))) - .expect("persist project"); - disk.upsert_project(&project_at("remembered", &root)) - .expect("persist project"); + seed_index(&disk, &[project_at("first", &root.join("first"))], &[]); + seed_index(&disk, &[project_at("remembered", &root)], &[]); let host = test_host(disk); command( &host, @@ -4691,7 +4701,7 @@ mod tests { let disk = SessionStore::open_at(root.clone()).unwrap(); let mut meta = SessionMeta::new(ProviderKind::Codex, root.clone(), None); meta.id = "stale".into(); - disk.upsert_metas([&meta]).unwrap(); + seed_index(&disk, &[], &[meta.clone()]); let delta = |item: &str, text: &str| AgentEvent::Delta { item_id: item.into(), kind: agent::DeltaKind::AssistantText, @@ -4951,9 +4961,7 @@ mod tests { let session_store = SessionStore::open_at(root.clone()).expect("open test store"); let meta = SessionMeta::new(ProviderKind::Codex, root.join("worktree"), None); let session_id = meta.id.clone(); - session_store - .upsert_metas([&meta]) - .expect("persist session"); + seed_index(&session_store, &[], std::slice::from_ref(&meta)); let events = [ AgentEvent::ItemCompleted(ThreadItem { id: "user-1".into(), @@ -5116,12 +5124,8 @@ mod tests { SessionMeta::new(ProviderKind::Codex, seed_project.root.clone(), None); seed_session.project_id = Some(seed_project.id.clone()); let seed_session_id = seed_session.id.clone(); - session_store - .upsert_project(&seed_project) - .expect("persist seed project"); - session_store - .upsert_metas([&seed_session]) - .expect("persist seed session"); + seed_index(&session_store, std::slice::from_ref(&seed_project), &[]); + seed_index(&session_store, &[], &[seed_session.clone()]); let host = test_host(session_store); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); wait_until(cx, &workspace, "initial session index", |cx| { @@ -5254,9 +5258,7 @@ mod tests { let session_store = SessionStore::open_at(root.clone()).expect("open test store"); let meta = SessionMeta::new(ProviderKind::Codex, root.join("worktree"), None); let session_id = meta.id.clone(); - session_store - .upsert_metas([&meta]) - .expect("persist session"); + seed_index(&session_store, &[], std::slice::from_ref(&meta)); let host = test_host(session_store); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -5340,12 +5342,8 @@ mod tests { let session_store = SessionStore::open_at(root.clone()).expect("open test store"); let first = SessionMeta::new(ProviderKind::Codex, root.join("first"), None); let second = SessionMeta::new(ProviderKind::Codex, root.join("second"), None); - session_store - .upsert_metas([&first]) - .expect("persist first session"); - session_store - .upsert_metas([&second]) - .expect("persist second session"); + seed_index(&session_store, &[], std::slice::from_ref(&first)); + seed_index(&session_store, &[], std::slice::from_ref(&second)); let host = test_host(session_store); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -5464,12 +5462,8 @@ mod tests { let session_store = SessionStore::open_at(root.clone()).expect("open test store"); let first = SessionMeta::new(ProviderKind::ClaudeCode, root.join("first"), None); let second = SessionMeta::new(ProviderKind::ClaudeCode, root.join("second"), None); - session_store - .upsert_metas([&first]) - .expect("persist first session"); - session_store - .upsert_metas([&second]) - .expect("persist second session"); + seed_index(&session_store, &[], std::slice::from_ref(&first)); + seed_index(&session_store, &[], std::slice::from_ref(&second)); let host = test_host(session_store); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -5541,9 +5535,7 @@ mod tests { let root = scratch_root("tcode-fallback-lifecycle-test"); let session_store = SessionStore::open_at(root.clone()).expect("open test store"); let meta = SessionMeta::new(ProviderKind::ClaudeCode, root.join("worktree"), None); - session_store - .upsert_metas([&meta]) - .expect("persist session"); + seed_index(&session_store, &[], std::slice::from_ref(&meta)); let host = test_host(session_store); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -5639,7 +5631,7 @@ mod tests { let session_store = SessionStore::open_at(root.clone()).expect("open test store"); let mut meta = SessionMeta::new(ProviderKind::Codex, root.join("worktree"), None); meta.id = "git-replica".into(); - session_store.upsert_metas([&meta]).unwrap(); + seed_index(&session_store, &[], &[meta.clone()]); let host = test_host(session_store); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -5739,11 +5731,9 @@ mod tests { let root = scratch_root("remove-project-draft-ui"); let disk = SessionStore::open_at(root.clone()).expect("open test store"); for project in ["doomed", "kept"] { - disk.upsert_project(&project_at(project, &root)) - .expect("persist project"); + seed_index(&disk, &[project_at(project, &root)], &[]); } - disk.upsert_metas([&thread(&root, "kept-thread", "kept", None)]) - .expect("persist session"); + seed_index(&disk, &[], &[thread(&root, "kept-thread", "kept", None)]); let host = test_host(disk); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); @@ -5803,8 +5793,7 @@ mod tests { fn committing_a_draft_carries_its_conversation_state_to_the_thread(cx: &mut TestAppContext) { let root = scratch_root("commit-draft-ui"); let disk = SessionStore::open_at(root.clone()).expect("open test store"); - disk.upsert_project(&project_at("p", &root)) - .expect("persist project"); + seed_index(&disk, &[project_at("p", &root)], &[]); let host = test_host(disk); let workspace = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); diff --git a/locales/en.yml b/locales/en.yml index 22e417633..fa28094bc 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -218,6 +218,10 @@ quit: title: "Quit Tcode?" description: "%{count} session(s) are still working. Quitting will stop them." confirm: "Quit" +startup: + index_in_use_title: "Tcode is already running" + index_in_use: "Another Tcode is using %{dir}. Quit it, then open Tcode again." + failed_title: "Tcode could not start" settings: title: "Settings" sections: "All settings" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 2354e0a1a..6f663d82f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -218,6 +218,10 @@ quit: title: "退出 Tcode?" description: "仍有 %{count} 个会话正在工作。退出将停止这些会话。" confirm: "退出" +startup: + index_in_use_title: "Tcode 已在运行" + index_in_use: "另一个 Tcode 正在使用 %{dir}。请先退出它,再重新打开 Tcode。" + failed_title: "Tcode 无法启动" settings: title: "设置" sections: "全部设置"