diff --git a/cli/Cargo.lock b/cli/Cargo.lock index c05bd77b..36b38a78 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -200,7 +200,7 @@ checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -302,7 +302,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -337,7 +337,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -413,7 +413,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex 1.3.0", - "syn", + "syn 2.0.118", "which", ] @@ -521,7 +521,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.118", ] [[package]] @@ -568,7 +568,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -711,7 +711,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -952,7 +952,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -963,7 +963,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1006,7 +1006,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.118", ] [[package]] @@ -1061,7 +1061,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1130,7 +1130,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1354,7 +1354,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1662,7 +1662,9 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -2019,7 +2021,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.118", ] [[package]] @@ -2038,7 +2040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2184,7 +2186,7 @@ checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2327,7 +2329,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2691,7 +2693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.118", ] [[package]] @@ -2732,7 +2734,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2952,7 +2954,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3140,6 +3142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -3325,7 +3328,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3349,7 +3352,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3411,6 +3414,7 @@ dependencies = [ "clap", "clap_complete", "dirs", + "fs4", "hmac 0.13.0", "inquire", "jsonschema", @@ -3592,7 +3596,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.118", ] [[package]] @@ -3637,6 +3641,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3654,7 +3669,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3841,7 +3856,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3918,13 +3933,26 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4055,7 +4083,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4109,8 +4137,14 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cac8d131650c1bf6a2721202208b3dfba218be25c975f48bebda766173fbdc3b" dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", "mimalloc", "thiserror", + "tokio", "tracing", "tracing-subscriber", "turso_core", @@ -4208,7 +4242,7 @@ checksum = "1a817815d9ca218cb971ed2a0a41a89a5160966b5b4bd103c82792fd2f230f1c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4251,7 +4285,7 @@ checksum = "372ce1889d2729223886f805638a4357f389756d6b64e26487af5a78b3e92e2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4338,7 +4372,7 @@ checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4560,7 +4594,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -4666,7 +4700,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4677,7 +4711,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4937,7 +4971,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -4997,7 +5031,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "zbus_names", "zvariant", "zvariant_utils", @@ -5031,7 +5065,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5051,7 +5085,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -5093,7 +5127,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5153,7 +5187,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "zvariant_utils", ] @@ -5166,6 +5200,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.118", "winnow", ] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ab7e4ff0..ffe597dd 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -29,9 +29,10 @@ path = "src/main.rs" anyhow = "1" chrono = "0.4" clap = { version = "4", features = ["derive"] } -turso = { version = "0.7.0" } +turso = { version = "0.7.0", features = ["sync"] } clap_complete = "4" dirs = "6" +fs4 = "0.13.1" hmac = "0.13" inquire = "0.9" jsonschema = "0.46" diff --git a/cli/src/services/agent_trace_db/repository.rs b/cli/src/services/agent_trace_db/repository.rs index 2434f1d7..4f18f261 100644 --- a/cli/src/services/agent_trace_db/repository.rs +++ b/cli/src/services/agent_trace_db/repository.rs @@ -547,6 +547,7 @@ mod tests { remove_test_db(&db_path); } + #[test] fn trace_tables_have_no_checkout_id_columns() { let db_path = unique_test_db_path("no-checkout-id"); diff --git a/cli/src/services/agent_trace_dwh_db/mod.rs b/cli/src/services/agent_trace_dwh_db/mod.rs index 6b32262a..4df9cc78 100644 --- a/cli/src/services/agent_trace_dwh_db/mod.rs +++ b/cli/src/services/agent_trace_dwh_db/mod.rs @@ -50,12 +50,83 @@ impl DbSpec for AgentTraceDwhDbSpec { /// Agent Trace DWH Turso database adapter. pub type AgentTraceDwhDb = TursoDb; +/// Classification of a DWH database's schema state, distinguishing a +/// genuinely empty database (safe to auto-initialize) from an existing but +/// incompatible one (never safe to repair or overwrite). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentTraceDwhSchemaState { + /// The DWH migration baseline and every embedded migration are applied. + Ready, + /// No `__sce_migrations` table, none of the seven DWH contract tables, + /// and no other user-defined table exist. Safe to initialize locally. + Empty, + /// The database holds table(s) but its schema does not match a ready DWH + /// schema — an unrelated schema, a partial DWH schema, or a migration + /// ledger with unexpected entries. Never safe to repair automatically. + Incompatible(String), +} + impl AgentTraceDwhDb { /// Verify that the DWH schema baseline already exists and every embedded /// migration has been applied. Non-mutating. pub fn ensure_dwh_schema_ready(&self) -> Result<()> { TursoDb::ensure_schema_ready(self, AGENT_TRACE_DWH_SETUP_GUIDANCE) } + + /// Classify this database's DWH schema state without mutating it. + /// + /// Built on [`TursoDb::migration_metadata_problems`] plus a direct + /// `sqlite_master` scan for any user-defined table (the seven DWH + /// contract tables, `__sce_migrations` itself, or anything unrelated). + /// Turso Sync's own internal bookkeeping tables (observed on a freshly + /// bootstrapped, otherwise-empty Turso Sync remote/replica: change-data- + /// capture tables named `turso_cdc`/`turso_cdc_version` and internal + /// sequence/autoincrement tables prefixed `__turso_internal`) are + /// excluded from this scan, since they exist even on a database with no + /// user-defined schema and do not indicate prior DWH initialization. + /// Returns [`AgentTraceDwhSchemaState::Empty`] only when no such + /// user-defined table exists at all; returns + /// [`AgentTraceDwhSchemaState::Incompatible`] for every other + /// non-ready case, so callers never mistake a partially-initialized or + /// unrelated schema for a blank slate. + pub fn classify_schema_state(&self) -> Result { + let problems = self.migration_metadata_problems()?; + + if problems.is_empty() { + return Ok(AgentTraceDwhSchemaState::Ready); + } + + let existing_tables: Vec = self + .query_map( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC", + (), + |row| row.get::(0).map_err(Into::into), + )? + .into_iter() + .filter(|name| !is_turso_sync_internal_table(name)) + .collect(); + + if existing_tables.is_empty() { + return Ok(AgentTraceDwhSchemaState::Empty); + } + + Ok(AgentTraceDwhSchemaState::Incompatible(format!( + "found existing table(s) [{}] but schema is not ready: {}", + existing_tables.join(", "), + problems.join(", ") + ))) + } +} + +/// Whether `table_name` is one of Turso Sync's own internal bookkeeping +/// tables rather than user-defined schema. +/// +/// A freshly bootstrapped Turso Sync database carries these even when no +/// application schema has ever been created on it, so +/// [`AgentTraceDwhDb::classify_schema_state`] must not treat their presence +/// as evidence of prior (partial) DWH initialization. +fn is_turso_sync_internal_table(table_name: &str) -> bool { + table_name.starts_with("turso_cdc") || table_name.starts_with("__turso_internal") } #[cfg(test)] @@ -533,4 +604,105 @@ mod tests { .expect_err("DWH DBs must not have a canonical spec path"); assert!(error.to_string().contains("explicit-path")); } + + #[test] + fn fresh_database_with_no_tables_classifies_as_empty() { + let db_path = unique_test_db_path("classify-empty"); + let db = AgentTraceDwhDb::open_without_migrations_at(&db_path) + .expect("DWH DB should open without running migrations"); + + assert_eq!( + db.classify_schema_state() + .expect("classification should succeed"), + AgentTraceDwhSchemaState::Empty + ); + + remove_test_db(&db_path); + } + + #[test] + fn fully_migrated_database_classifies_as_ready() { + let db_path = unique_test_db_path("classify-ready"); + let db = AgentTraceDwhDb::new_at(&db_path).expect("DWH DB should open"); + + assert_eq!( + db.classify_schema_state() + .expect("classification should succeed"), + AgentTraceDwhSchemaState::Ready + ); + + remove_test_db(&db_path); + } + + #[test] + fn database_with_only_an_unrelated_table_classifies_as_incompatible() { + let db_path = unique_test_db_path("classify-unrelated"); + let db = AgentTraceDwhDb::open_without_migrations_at(&db_path) + .expect("DWH DB should open without running migrations"); + db.execute("CREATE TABLE unrelated_table (id INTEGER PRIMARY KEY)", ()) + .expect("unrelated table creation should succeed"); + + match db + .classify_schema_state() + .expect("classification should succeed") + { + AgentTraceDwhSchemaState::Incompatible(reason) => { + assert!(reason.contains("unrelated_table"), "reason: {reason}"); + } + other => panic!("expected Incompatible, got {other:?}"), + } + + remove_test_db(&db_path); + } + + #[test] + fn database_with_a_partial_contract_table_and_no_ledger_classifies_as_incompatible() { + let db_path = unique_test_db_path("classify-partial"); + let db = AgentTraceDwhDb::open_without_migrations_at(&db_path) + .expect("DWH DB should open without running migrations"); + db.execute( + "CREATE TABLE repositories (repository_id TEXT PRIMARY KEY)", + (), + ) + .expect("partial contract table creation should succeed"); + + match db + .classify_schema_state() + .expect("classification should succeed") + { + AgentTraceDwhSchemaState::Incompatible(reason) => { + assert!(reason.contains("repositories"), "reason: {reason}"); + assert!( + reason.contains("missing migration metadata table"), + "reason: {reason}" + ); + } + other => panic!("expected Incompatible, got {other:?}"), + } + + remove_test_db(&db_path); + } + + #[test] + fn database_with_an_unexpected_ledger_entry_classifies_as_incompatible() { + let db_path = unique_test_db_path("classify-unexpected-ledger"); + let db = AgentTraceDwhDb::new_at(&db_path).expect("DWH DB should open"); + db.execute( + "INSERT INTO __sce_migrations (id) VALUES ('999_unknown_migration')", + (), + ) + .expect("unexpected ledger entry insert should succeed"); + + match db + .classify_schema_state() + .expect("classification should succeed") + { + AgentTraceDwhSchemaState::Incompatible(reason) => { + assert!(reason.contains("999_unknown_migration"), "reason: {reason}"); + } + other => panic!("expected Incompatible, got {other:?}"), + } + + remove_test_db(&db_path); + } } diff --git a/cli/src/services/agent_trace_dwh_replica/lock.rs b/cli/src/services/agent_trace_dwh_replica/lock.rs new file mode 100644 index 00000000..65b1bd58 --- /dev/null +++ b/cli/src/services/agent_trace_dwh_replica/lock.rs @@ -0,0 +1,324 @@ +use std::{ + fmt, fs, + fs::File, + path::{Path, PathBuf}, +}; + +use fs4::fs_std::FileExt; + +/// A held, non-blocking exclusive OS lock on a repository's Agent Trace DWH +/// sync replica bridge-lock file. +/// +/// Exactly one `BridgeLock` may be held for a given path at a time: a second +/// attempt while this guard is alive (in this or another process) fails with +/// [`BridgeLockError::AlreadyHeld`]. The underlying lock file is created if +/// missing and is never deleted, by this guard or on drop; only the OS lock +/// is released, which happens automatically when the guard is dropped or the +/// owning process exits. +#[derive(Debug)] +pub struct BridgeLock { + path: PathBuf, + file: File, +} + +impl BridgeLock { + /// Acquire the bridge lock at `path` without blocking. + /// + /// Creates the lock file (and its parent directory) if missing. Returns + /// [`BridgeLockError::AlreadyHeld`] when another process or guard + /// currently holds the lock, and [`BridgeLockError::Io`] for any other + /// filesystem failure. + pub fn acquire(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| BridgeLockError::Io { + path: path.clone(), + source, + })?; + } + + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + .map_err(|source| BridgeLockError::Io { + path: path.clone(), + source, + })?; + + let acquired = file + .try_lock_exclusive() + .map_err(|source| BridgeLockError::Io { + path: path.clone(), + source, + })?; + + if !acquired { + return Err(BridgeLockError::AlreadyHeld { path }); + } + + Ok(Self { path, file }) + } + + /// The bridge-lock file path this guard holds. + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for BridgeLock { + fn drop(&mut self) { + // Best-effort: the OS also releases the lock when the file + // descriptor closes at process exit. The lock file itself is + // intentionally retained on disk. + let _ = FileExt::unlock(&self.file); + } +} + +/// Failure acquiring a [`BridgeLock`]. +#[derive(Debug)] +pub enum BridgeLockError { + /// Another process or guard already owns the lock at `path`. + AlreadyHeld { path: PathBuf }, + /// A filesystem operation failed while acquiring the lock. + Io { + path: PathBuf, + source: std::io::Error, + }, +} + +impl fmt::Display for BridgeLockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AlreadyHeld { path } => write!( + f, + "Agent Trace DWH replica at {} is already in use by another process; \ + stop the other process (or wait for it to exit) and retry", + path.display() + ), + Self::Io { path, source } => write!( + f, + "failed to acquire Agent Trace DWH replica bridge lock at {}: {source}", + path.display() + ), + } + } +} + +impl std::error::Error for BridgeLockError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::AlreadyHeld { .. } => None, + Self::Io { source, .. } => Some(source), + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + fn unique_test_lock_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!( + "sce-agent-trace-dwh-replica-lock-{label}-{}-{nonce}", + std::process::id() + )) + .join("agent-trace-sync.db.bridge-lock") + } + + fn remove_test_lock(path: &Path) { + if let Some(parent) = path.parent() { + let _ = fs::remove_dir_all(parent); + } + } + + #[test] + fn agent_trace_dwh_replica_lock_acquire_creates_and_retains_the_lock_file() { + let path = unique_test_lock_path("creates-file"); + + let guard = BridgeLock::acquire(&path).expect("first acquire should succeed"); + assert!(path.is_file(), "lock file should be created"); + assert_eq!(guard.path(), path.as_path()); + + drop(guard); + assert!( + path.is_file(), + "lock file must remain on disk after the guard drops" + ); + + remove_test_lock(&path); + } + + #[test] + fn agent_trace_dwh_replica_lock_concurrent_acquire_is_rejected_with_actionable_guidance() { + let path = unique_test_lock_path("concurrent-rejected"); + + let _first = BridgeLock::acquire(&path).expect("first acquire should succeed"); + let error = BridgeLock::acquire(&path).expect_err("second concurrent acquire should fail"); + + assert!(matches!(error, BridgeLockError::AlreadyHeld { .. })); + let message = error.to_string(); + assert!(message.contains("already in use")); + assert!(message.contains("retry")); + + remove_test_lock(&path); + } + + #[test] + fn agent_trace_dwh_replica_lock_is_reacquirable_after_the_guard_drops() { + let path = unique_test_lock_path("reacquire-after-drop"); + + let first = BridgeLock::acquire(&path).expect("first acquire should succeed"); + drop(first); + + let second = + BridgeLock::acquire(&path).expect("acquire should succeed after prior guard dropped"); + drop(second); + + remove_test_lock(&path); + } + + #[test] + fn agent_trace_dwh_replica_lock_is_reacquirable_after_a_real_subprocess_holding_it_is_killed() { + let path = unique_test_lock_path("reacquire-after-process-death"); + let ready_path = path.with_extension("ready-signal"); + let _ = fs::remove_file(&ready_path); + + // Re-invoke this same compiled test binary as a child process that + // acquires the lock and blocks forever, then signals readiness via a + // separate file. Killing that child (rather than letting `Drop` run) + // proves the OS releases the flock on process death, not merely on + // our own unlock call. + let current_exe = std::env::current_exe().expect("resolve current test binary"); + let mut child = Command::new(current_exe) + .arg("agent_trace_dwh_replica_lock_hold_lock_until_killed_for_subprocess_death_test") + .arg("--ignored") + .arg("--test-threads=1") + .env("SCE_BRIDGE_LOCK_HOLD_PATH", &path) + .env("SCE_BRIDGE_LOCK_READY_PATH", &ready_path) + .spawn() + .expect("spawn lock-holding subprocess"); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !ready_path.exists() { + assert!( + std::time::Instant::now() < deadline, + "subprocess did not signal lock acquisition in time" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + + let contended = + BridgeLock::acquire(&path).expect_err("lock should be held by the live subprocess"); + assert!(matches!(contended, BridgeLockError::AlreadyHeld { .. })); + + child.kill().expect("kill lock-holding subprocess"); + child.wait().expect("reap killed subprocess"); + + let guard = BridgeLock::acquire(&path) + .expect("lock should be acquirable once the killed process releases it"); + drop(guard); + + let _ = fs::remove_file(&ready_path); + remove_test_lock(&path); + } + + /// Not a real test case: a subprocess entry point spawned by + /// `agent_trace_dwh_replica_lock_is_reacquirable_after_a_real_subprocess_holding_it_is_killed`. + /// Acquires the lock at `SCE_BRIDGE_LOCK_HOLD_PATH`, signals readiness by + /// creating `SCE_BRIDGE_LOCK_READY_PATH`, then blocks until killed. + #[test] + #[ignore = "invoked as a subprocess helper, not a standalone test"] + fn agent_trace_dwh_replica_lock_hold_lock_until_killed_for_subprocess_death_test() { + let path = PathBuf::from( + std::env::var("SCE_BRIDGE_LOCK_HOLD_PATH") + .expect("SCE_BRIDGE_LOCK_HOLD_PATH should be set by the parent test"), + ); + let ready_path = PathBuf::from( + std::env::var("SCE_BRIDGE_LOCK_READY_PATH") + .expect("SCE_BRIDGE_LOCK_READY_PATH should be set by the parent test"), + ); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create lock parent dir in subprocess helper"); + } + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + .expect("open lock file in subprocess helper"); + assert!( + file.try_lock_exclusive() + .expect("subprocess helper should be able to lock"), + "subprocess helper should acquire an uncontended lock" + ); + + fs::write(&ready_path, b"ready").expect("write readiness signal"); + std::thread::sleep(std::time::Duration::from_hours(1)); + } + + #[test] + fn agent_trace_dwh_replica_lock_same_process_reacquisition_is_rejected_while_first_guard_is_alive( + ) { + let path = unique_test_lock_path("same-process-contention"); + + let _guard = BridgeLock::acquire(&path).expect("first acquire should succeed"); + let error = BridgeLock::acquire(&path) + .expect_err("a second guard in the same process must not double-acquire the lock"); + assert!(matches!(error, BridgeLockError::AlreadyHeld { .. })); + + remove_test_lock(&path); + } + + #[test] + fn agent_trace_dwh_replica_lock_holding_the_bridge_lock_does_not_block_source_agent_trace_db_work( + ) { + use crate::services::agent_trace_db::{ + repository::RepositoryAgentTraceDb, DiffTraceInsert, PAYLOAD_TYPE_PATCH, + }; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let repo_dir = std::env::temp_dir().join(format!( + "sce-agent-trace-dwh-replica-lock-source-isolation-{}-{nonce}", + std::process::id() + )); + let lock_path = repo_dir.join("agent-trace-sync.db.bridge-lock"); + let source_db_path = repo_dir.join("agent-trace.db"); + + let guard = BridgeLock::acquire(&lock_path).expect("bridge lock should acquire"); + + let source_db = + RepositoryAgentTraceDb::new_at(&source_db_path).expect("source DB should open"); + source_db + .insert_diff_trace(DiffTraceInsert { + time_ms: 1_000, + session_id: "session-1", + patch: "Index: notes/a.md\n===================================================================\n--- notes/a.md\n+++ notes/a.md\n@@ -0,0 +1,1 @@\n+hello\n", + model_id: None, + tool_name: "opencode", + tool_version: None, + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("source DB work should succeed while the bridge lock is held"); + + drop(source_db); + drop(guard); + let _ = fs::remove_dir_all(&repo_dir); + } +} diff --git a/cli/src/services/agent_trace_dwh_replica/mod.rs b/cli/src/services/agent_trace_dwh_replica/mod.rs new file mode 100644 index 00000000..871a8dce --- /dev/null +++ b/cli/src/services/agent_trace_dwh_replica/mod.rs @@ -0,0 +1,19 @@ +//! Agent Trace DWH sync replica boundary. +//! +//! This module owns the canonical repository-scoped bridge lock proving +//! exactly one process owns the local `agent-trace-sync.db` Turso Sync +//! replica at a time, and the [`AgentTraceDwhReplica`] type that is the sole +//! owner of a Turso Sync builder for that replica. It never touches the +//! multiprocess-WAL source capture database +//! (`crate::services::agent_trace_db`), never enables that database's +//! `experimental_multiprocess_wal` flag, and never provisions the DWH schema +//! locally: it only verifies the schema an already-bootstrapped replica +//! reports. + +mod lock; +mod replica; + +#[allow(unused_imports)] +pub use lock::{BridgeLock, BridgeLockError}; +#[allow(unused_imports)] +pub use replica::{AgentTraceDwhReplica, AgentTraceDwhReplicaConfig, AgentTraceDwhReplicaError}; diff --git a/cli/src/services/agent_trace_dwh_replica/replica.rs b/cli/src/services/agent_trace_dwh_replica/replica.rs new file mode 100644 index 00000000..4f6969a0 --- /dev/null +++ b/cli/src/services/agent_trace_dwh_replica/replica.rs @@ -0,0 +1,1156 @@ +//! The lock-owning Turso Sync replica boundary. +//! +//! `AgentTraceDwhReplica` is the only owner of a Turso Sync builder in this +//! codebase. It acquires the [`BridgeLock`] before touching the network or +//! the local replica file, opens the local `agent-trace-sync.db` file +//! against a caller-supplied remote using Turso's `sync` feature, and +//! classifies the bootstrapped database's schema via +//! [`AgentTraceDwhDb::classify_schema_state`]. A `Ready` schema is left +//! unchanged; a genuinely `Empty` schema is initialized locally with +//! [`AgentTraceDwhDb::run_migrations`] and published to the remote; an +//! `Incompatible` schema fails loudly and is never repaired or partially +//! completed. It never enables `experimental_multiprocess_wal`: that flag is +//! reserved for the multiprocess-WAL source capture database, which this +//! replica never opens. + +use std::{fmt, path::Path, path::PathBuf}; + +use crate::services::{ + agent_trace_dwh_db::{AgentTraceDwhDb, AgentTraceDwhSchemaState}, + agent_trace_dwh_replica::lock::{BridgeLock, BridgeLockError}, +}; + +/// Explicit caller-supplied configuration for opening an +/// [`AgentTraceDwhReplica`]. +/// +/// The replica never discovers or persists these values itself: callers +/// resolve `local_path` from the canonical +/// `agent_trace_dwh_replica_path_for_repository` helper and are responsible +/// for acquiring `database_url`/`auth_token` themselves. +pub struct AgentTraceDwhReplicaConfig { + pub local_path: PathBuf, + pub database_url: String, + pub auth_token: String, +} + +impl fmt::Debug for AgentTraceDwhReplicaConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AgentTraceDwhReplicaConfig") + .field("local_path", &self.local_path) + .field("database_url", &self.database_url) + .field("auth_token", &"") + .finish() + } +} + +/// A held bridge lock plus an open Turso Sync connection to a repository's +/// Agent Trace DWH replica. +/// +/// Both the lock and the connection share this value's lifetime: dropping the +/// replica releases the lock. Only this type owns a `turso::sync::Builder`; +/// no other caller in this codebase is permitted to open a Turso Sync +/// connection to `agent-trace-sync.db`. +pub struct AgentTraceDwhReplica { + lock: BridgeLock, + sync_db: turso::sync::Database, + db: AgentTraceDwhDb, +} + +impl fmt::Debug for AgentTraceDwhReplica { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AgentTraceDwhReplica") + .field("lock_path", &self.lock.path()) + .finish_non_exhaustive() + } +} + +impl AgentTraceDwhReplica { + /// Acquire the bridge lock and open the replica against the caller's + /// remote. + /// + /// Ordering is load-bearing: the bridge lock is acquired first and + /// non-blockingly, so a concurrent owner is rejected before any Turso + /// Sync builder, local file, or network access happens. A missing local + /// file is bootstrapped from the remote using Turso Sync's normal + /// `bootstrap_if_empty` behavior. + /// + /// Once open, the bootstrapped database's schema is classified via + /// [`AgentTraceDwhDb::classify_schema_state`] and handled accordingly: + /// a [`AgentTraceDwhSchemaState::Ready`] schema is left untouched; a + /// [`AgentTraceDwhSchemaState::Empty`] schema is initialized locally with + /// [`AgentTraceDwhDb::run_migrations`] and published with a single + /// `push()` (narrowly recovering from a push conflict with one `pull()` + /// plus re-verification); a + /// [`AgentTraceDwhSchemaState::Incompatible`] schema fails loudly and is + /// never repaired or partially completed. + pub fn open(config: AgentTraceDwhReplicaConfig) -> Result { + let AgentTraceDwhReplicaConfig { + local_path, + database_url, + auth_token, + } = config; + + let lock_path = bridge_lock_path_for_replica(&local_path); + let lock = BridgeLock::acquire(&lock_path).map_err(AgentTraceDwhReplicaError::Lock)?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .map_err(|source| AgentTraceDwhReplicaError::Runtime { source })?; + + let local_path_str = local_path + .to_str() + .ok_or_else(|| AgentTraceDwhReplicaError::Open { + local_path: local_path.clone(), + message: String::from("path is not valid UTF-8"), + })? + .to_string(); + + let auth_token_for_open = auth_token.clone(); + let database_url_for_open = database_url.clone(); + let open_result: Result<(turso::sync::Database, turso::Connection), turso::Error> = runtime + .block_on(async move { + // `experimental_multiprocess_wal` is intentionally never + // enabled here: this replica is the single-owner Turso Sync + // database, not the multiprocess-WAL source capture store. + let db = turso::sync::Builder::new_remote(&local_path_str) + .with_remote_url(&database_url_for_open) + .with_auth_token(auth_token_for_open) + .build() + .await?; + let conn = db.connect().await?; + Ok((db, conn)) + }); + + let (sync_db, conn) = open_result.map_err(|error| AgentTraceDwhReplicaError::Open { + local_path: local_path.clone(), + message: redact_token(&error, &auth_token), + })?; + + let db = AgentTraceDwhDb::from_connection(conn, runtime); + + let schema_state = db.classify_schema_state().map_err(|error| { + AgentTraceDwhReplicaError::SchemaInspection { + message: redact_token(&error, &auth_token), + } + })?; + + match schema_state { + AgentTraceDwhSchemaState::Ready => {} + AgentTraceDwhSchemaState::Empty => { + initialize_empty_schema(&db, &sync_db, &auth_token)?; + } + AgentTraceDwhSchemaState::Incompatible(reason) => { + return Err(AgentTraceDwhReplicaError::IncompatibleSchema { + message: format!( + "{reason} (automatic initialization is only allowed for a genuinely \ + empty remote/replica)" + ), + }); + } + } + + Ok(Self { lock, sync_db, db }) + } + + /// Lock-lifetime-bound access to the replica's DWH SQL surface. + /// + /// Non-mutating schema-readiness checks and application SQL both go + /// through the same [`AgentTraceDwhDb`] this replica opened; no separate + /// connection is created. + pub fn db(&self) -> &AgentTraceDwhDb { + &self.db + } + + /// Pull remote changes into the local replica. + /// + /// Returns `true` if any changes were applied. Non-mutating for already + /// up-to-date replicas. + pub fn pull(&self) -> Result { + self.db + .block_on(self.sync_db.pull()) + .map_err(|error| AgentTraceDwhReplicaError::Pull { + message: error.to_string(), + }) + } + + /// Push local changes to the remote. + pub fn push(&self) -> Result<(), AgentTraceDwhReplicaError> { + self.db + .block_on(self.sync_db.push()) + .map_err(|error| AgentTraceDwhReplicaError::Push { + message: error.to_string(), + }) + } +} + +/// Initialize a genuinely [`AgentTraceDwhSchemaState::Empty`] replica by +/// running the DWH migration baseline locally and publishing it to the +/// remote. +/// +/// On a push failure, this makes exactly one narrow recovery attempt: pull +/// whatever the remote now holds (best-effort; a pull failure here does not +/// change the outcome) and re-check readiness. If the schema is `Ready` +/// afterward, another initializer must have won the race and published first, +/// so this is treated as success. Otherwise the *original* push failure is +/// returned — never a swallowed or generic error. +fn initialize_empty_schema( + db: &AgentTraceDwhDb, + sync_db: &turso::sync::Database, + auth_token: &str, +) -> Result<(), AgentTraceDwhReplicaError> { + db.run_migrations() + .map_err(|error| AgentTraceDwhReplicaError::SchemaInitialization { + message: redact_token(&error, auth_token), + })?; + + if let Err(push_error) = db.block_on(sync_db.push()) { + let _ = db.block_on(sync_db.pull()); + + if !matches!(db.ensure_dwh_schema_ready(), Ok(())) { + return Err(AgentTraceDwhReplicaError::SchemaPublication { + message: redact_token(&push_error, auth_token), + }); + } + + return Ok(()); + } + + db.ensure_dwh_schema_ready() + .map_err(|error| AgentTraceDwhReplicaError::ReadinessVerification { + message: redact_token(&error, auth_token), + }) +} + +/// Derive the bridge-lock path for an explicit replica path: the replica +/// path's file name with `.bridge-lock` appended, mirroring +/// `default_paths::agent_trace_dwh_bridge_lock_path_for_repository`. +fn bridge_lock_path_for_replica(local_path: &Path) -> PathBuf { + let mut file_name = local_path + .file_name() + .map(std::ffi::OsStr::to_os_string) + .unwrap_or_default(); + file_name.push(".bridge-lock"); + local_path.with_file_name(file_name) +} + +/// Replace every occurrence of `token` in `message` so caller-supplied +/// credentials never reach diagnostics. +fn redact_token(message: impl fmt::Display, token: &str) -> String { + let message = message.to_string(); + if token.is_empty() { + message + } else { + message.replace(token, "") + } +} + +/// Failure opening or operating an [`AgentTraceDwhReplica`]. +/// +/// Every variant is built with the auth token already redacted; none of +/// these ever include the caller-supplied token. +#[derive(Debug)] +pub enum AgentTraceDwhReplicaError { + /// The bridge lock is already held by another owner, or acquiring it + /// failed. + Lock(BridgeLockError), + /// Building the local Tokio runtime failed. + Runtime { source: std::io::Error }, + /// Opening the Turso Sync connection (including remote bootstrap) + /// failed. + Open { + local_path: PathBuf, + message: String, + }, + /// Classifying the opened database's schema state failed. + SchemaInspection { message: String }, + /// The schema is neither ready nor genuinely empty — an unrelated + /// schema, a partial DWH schema, or a migration ledger with unexpected + /// entries. Never repaired or partially completed automatically. + IncompatibleSchema { message: String }, + /// Running the local DWH migration baseline on a genuinely empty schema + /// failed. + SchemaInitialization { message: String }, + /// Publishing a locally initialized empty schema to the remote failed, + /// including after the narrow one-`pull()`-and-re-verify recovery + /// attempt. + SchemaPublication { message: String }, + /// The schema still failed readiness verification after a local + /// initialization and successful publish. + ReadinessVerification { message: String }, + /// Pulling remote changes failed. + Pull { message: String }, + /// Pushing local changes failed. + Push { message: String }, +} + +impl fmt::Display for AgentTraceDwhReplicaError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Lock(error) => write!(f, "{error}"), + Self::Runtime { source } => { + write!( + f, + "failed to create Agent Trace DWH replica runtime: {source}" + ) + } + Self::Open { + local_path, + message, + } => write!( + f, + "failed to open Agent Trace DWH replica at {}: {message}", + local_path.display() + ), + Self::SchemaInspection { message } => { + write!( + f, + "failed to classify Agent Trace DWH replica schema state: {message}" + ) + } + Self::IncompatibleSchema { message } => { + write!( + f, + "Agent Trace DWH replica schema is incompatible: {message}" + ) + } + Self::SchemaInitialization { message } => { + write!( + f, + "failed to initialize Agent Trace DWH replica schema: {message}" + ) + } + Self::SchemaPublication { message } => { + write!( + f, + "failed to publish Agent Trace DWH replica schema: {message}" + ) + } + Self::ReadinessVerification { message } => { + write!( + f, + "Agent Trace DWH replica schema is still not ready after initialization: {message}" + ) + } + Self::Pull { message } => write!(f, "Agent Trace DWH replica pull failed: {message}"), + Self::Push { message } => write!(f, "Agent Trace DWH replica push failed: {message}"), + } + } +} + +impl std::error::Error for AgentTraceDwhReplicaError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Lock(error) => Some(error), + Self::Runtime { source } => Some(source), + Self::Open { .. } + | Self::SchemaInspection { .. } + | Self::IncompatibleSchema { .. } + | Self::SchemaInitialization { .. } + | Self::SchemaPublication { .. } + | Self::ReadinessVerification { .. } + | Self::Pull { .. } + | Self::Push { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + fn unique_test_replica_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!( + "sce-agent-trace-dwh-replica-{label}-{}-{nonce}", + std::process::id() + )) + .join("agent-trace-sync.db") + } + + fn remove_test_replica(path: &Path) { + if let Some(parent) = path.parent() { + let _ = fs::remove_dir_all(parent); + } + } + + #[test] + fn bridge_lock_path_matches_the_replica_path_bridge_lock_suffix_convention() { + let replica_path = PathBuf::from("/state/sce/repos/abc/agent-trace-sync.db"); + let lock_path = bridge_lock_path_for_replica(&replica_path); + assert_eq!( + lock_path, + PathBuf::from("/state/sce/repos/abc/agent-trace-sync.db.bridge-lock") + ); + } + + #[test] + fn open_fails_before_any_turso_access_when_the_bridge_lock_is_already_held() { + let local_path = unique_test_replica_path("lock-before-open"); + let lock_path = bridge_lock_path_for_replica(&local_path); + + let _held = BridgeLock::acquire(&lock_path).expect("first acquire should succeed"); + + // An unreachable remote URL would fail during Turso Sync open if + // reached; using it here proves the lock check happens first, + // because acquiring the lock fails deterministically before any + // Turso builder or network access is attempted. + let error = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: local_path.clone(), + database_url: String::from("http://127.0.0.1:0"), + auth_token: String::from("sentinel-token-should-never-be-reached"), + }) + .expect_err("open should fail while the bridge lock is held"); + + assert!(matches!(error, AgentTraceDwhReplicaError::Lock(_))); + + remove_test_replica(&local_path); + } + + #[test] + fn redact_token_replaces_every_occurrence() { + let redacted = redact_token("token=abc123 failed; retry with abc123", "abc123"); + assert_eq!(redacted, "token= failed; retry with "); + } + + #[test] + fn redact_token_is_a_no_op_for_an_empty_token() { + assert_eq!(redact_token("some message", ""), "some message"); + } +} + +/// Turso Sync integration harness. +/// +/// Exercises fresh bootstrap, schema-readiness classification, independent +/// pull/push visibility, and local-deletion reconstruction against a real +/// disposable Turso Sync remote. Runs only when a `tursodb` binary +/// supporting `--sync-server` is discoverable on `PATH` (the pinned +/// `nix develop .#database` shell provides one); otherwise it records why it +/// was skipped and passes trivially, so `cargo test` outside that shell does +/// not fail. +#[cfg(test)] +mod integration_tests { + use std::{ + net::TcpStream, + process::{Child, Command, Stdio}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + }; + + use super::*; + use crate::services::{agent_trace_dwh_db::AgentTraceDwhDbSpec, db::DbSpec}; + + fn unique_test_replica_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-agent-trace-dwh-replica-integration-{label}-{}-{nonce}", + std::process::id() + )); + // `AgentTraceDwhReplica::open` creates its parent directory via the + // bridge lock, but the schema-preparation helpers below open a raw + // Turso Sync connection directly, so the directory must already + // exist for them too. + std::fs::create_dir_all(&dir).expect("create unique test replica directory"); + dir.join("agent-trace-sync.db") + } + + fn remove_test_replica(path: &Path) { + if let Some(parent) = path.parent() { + let _ = std::fs::remove_dir_all(parent); + } + } + + fn build_test_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .expect("build test tokio runtime") + } + + /// Locate a `tursodb` binary on `PATH`. This is the same pinned build + /// `nix develop .#database` layers into the shell; it is intentionally + /// not required in the default shell so ordinary `cargo test` runs stay + /// fast and hermetic. + fn find_tursodb() -> Option { + let path_var = std::env::var_os("PATH")?; + std::env::split_paths(&path_var).find_map(|dir| { + let candidate = dir.join("tursodb"); + candidate.is_file().then_some(candidate) + }) + } + + struct LocalSyncServer { + child: Child, + url: String, + } + + impl LocalSyncServer { + fn spawn(tursodb_path: &Path) -> Self { + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind an ephemeral port to pick a free one for the sync server"); + listener + .local_addr() + .expect("resolve the bound ephemeral port") + .port() + }; + let addr = format!("127.0.0.1:{port}"); + + let child = Command::new(tursodb_path) + .args(["--sync-server", &addr]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn tursodb --sync-server"); + + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if TcpStream::connect(&addr).is_ok() { + break; + } + assert!( + Instant::now() < deadline, + "tursodb sync server did not become ready in time" + ); + std::thread::sleep(Duration::from_millis(50)); + } + + Self { + child, + url: format!("http://{addr}"), + } + } + } + + impl Drop for LocalSyncServer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } + + /// Publish the real Agent Trace DWH migration baseline to `remote_url` + /// through a disposable local sync connection, so replicas opened + /// against it afterward observe a genuinely ready DWH schema without + /// `open()` itself needing to initialize anything. Used only for the + /// already-`Ready`/unchanged-on-open case: the main fresh-bootstrap + /// coverage instead starts from a truly empty remote and lets `open()` + /// initialize and publish it itself. + fn prepare_remote_with_ready_dwh_schema(remote_url: &str) { + let local_path = unique_test_replica_path("prep-dwh-schema"); + let runtime = build_test_runtime(); + let local_path_str = local_path.to_str().unwrap().to_string(); + let remote_url = remote_url.to_string(); + + let (sync_db, conn) = runtime + .block_on(async move { + let db = turso::sync::Builder::new_remote(&local_path_str) + .with_remote_url(&remote_url) + .build() + .await?; + let conn = db.connect().await?; + Ok::<_, turso::Error>((db, conn)) + }) + .expect("open the schema-preparation connection should succeed"); + + let db = AgentTraceDwhDb::from_connection(conn, runtime); + db.run_migrations() + .expect("running the DWH migration baseline should succeed"); + db.block_on(sync_db.push()) + .expect("publishing the DWH schema baseline should succeed"); + + remove_test_replica(&local_path); + } + + /// Publish an unrelated, non-DWH table to `remote_url`, so a replica + /// bootstrapped from it has a non-empty local file that still fails + /// `ensure_dwh_schema_ready`. + fn prepare_remote_with_incompatible_schema(remote_url: &str) { + let local_path = unique_test_replica_path("prep-incompatible-schema"); + let runtime = build_test_runtime(); + let local_path_str = local_path.to_str().unwrap().to_string(); + let remote_url_owned = remote_url.to_string(); + + let (sync_db, _conn) = runtime + .block_on(async move { + let db = turso::sync::Builder::new_remote(&local_path_str) + .with_remote_url(&remote_url_owned) + .build() + .await?; + let conn = db.connect().await?; + conn.execute("CREATE TABLE unrelated_schema (x INTEGER)", ()) + .await?; + Ok::<_, turso::Error>((db, conn)) + }) + .expect("open the incompatible-schema preparation connection should succeed"); + + runtime + .block_on(sync_db.push()) + .expect("publishing the incompatible schema should succeed"); + + remove_test_replica(&local_path); + } + + /// Publish only a subset of the DWH contract tables (no `__sce_migrations` + /// ledger) to `remote_url`, so a replica bootstrapped from it has a + /// non-empty, non-ready local file that is neither a fresh empty schema + /// nor a fully-applied one. + fn prepare_remote_with_partial_dwh_schema(remote_url: &str) { + let local_path = unique_test_replica_path("prep-partial-dwh-schema"); + let runtime = build_test_runtime(); + let local_path_str = local_path.to_str().unwrap().to_string(); + let remote_url_owned = remote_url.to_string(); + + let (sync_db, _conn) = runtime + .block_on(async move { + let db = turso::sync::Builder::new_remote(&local_path_str) + .with_remote_url(&remote_url_owned) + .build() + .await?; + let conn = db.connect().await?; + conn.execute( + "CREATE TABLE repositories (repository_id TEXT PRIMARY KEY)", + (), + ) + .await?; + Ok::<_, turso::Error>((db, conn)) + }) + .expect("open the partial-schema preparation connection should succeed"); + + runtime + .block_on(sync_db.push()) + .expect("publishing the partial schema should succeed"); + + remove_test_replica(&local_path); + } + + /// Publish a fully-applied DWH schema to `remote_url`, then append an + /// unexpected/unknown migration ID to its `__sce_migrations` ledger, so a + /// replica bootstrapped from it has an otherwise-ready schema that still + /// fails readiness verification. + fn prepare_remote_with_unexpected_ledger_entry(remote_url: &str) { + let local_path = unique_test_replica_path("prep-unexpected-ledger"); + let runtime = build_test_runtime(); + let local_path_str = local_path.to_str().unwrap().to_string(); + let remote_url_owned = remote_url.to_string(); + + let (sync_db, conn) = runtime + .block_on(async move { + let db = turso::sync::Builder::new_remote(&local_path_str) + .with_remote_url(&remote_url_owned) + .build() + .await?; + let conn = db.connect().await?; + Ok::<_, turso::Error>((db, conn)) + }) + .expect("open the unexpected-ledger preparation connection should succeed"); + + let db = AgentTraceDwhDb::from_connection(conn, runtime); + db.run_migrations() + .expect("running the DWH migration baseline should succeed"); + db.execute( + "INSERT INTO __sce_migrations (id) VALUES ('999_unknown_migration')", + (), + ) + .expect("unexpected ledger entry insert should succeed"); + db.block_on(sync_db.push()) + .expect("publishing the unexpected-ledger schema should succeed"); + + remove_test_replica(&local_path); + } + + /// The non-internal table set currently published on `remote_url`, used + /// to prove a rejected `open()` never adds, removes, or modifies any + /// remote table. + fn remote_table_set(remote_url: &str, label: &str) -> Vec { + let local_path = unique_test_replica_path(label); + let runtime = build_test_runtime(); + let local_path_str = local_path.to_str().unwrap().to_string(); + let remote_url_owned = remote_url.to_string(); + + let conn = runtime + .block_on(async move { + let db = turso::sync::Builder::new_remote(&local_path_str) + .with_remote_url(&remote_url_owned) + .build() + .await?; + db.connect().await + }) + .expect("open the table-set inspection connection should succeed"); + let db = AgentTraceDwhDb::from_connection(conn, runtime); + + let mut tables = db + .query_map( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC", + (), + |row| row.get::(0).map_err(Into::into), + ) + .expect("table-set query should succeed"); + tables.sort(); + + remove_test_replica(&local_path); + tables + } + + fn source_instance_ids(db: &AgentTraceDwhDb, repository_id: &str) -> Vec { + let mut ids = db + .query_map( + "SELECT source_instance_id FROM source_instances WHERE repository_id = ?1", + (repository_id,), + |row| row.get::(0).map_err(Into::into), + ) + .expect("source_instances query should succeed"); + ids.sort(); + ids + } + + /// Covers AC3–AC5 plus T05's empty-remote auto-initialization: a truly + /// empty remote (no prior schema preparation) is initialized and + /// published by `open()` itself, lock-before-open rejects a concurrent + /// owner, independent pull/push visibility works in both directions, and + /// local deletion reconstructs remote data. + fn assert_bootstrap_lock_and_pull_push( + tursodb_path: &Path, + repository_id: &str, + sentinel_token: &str, + ) { + // --- T05: `open()` against a truly empty remote (nothing published + // to it yet) auto-initializes and publishes the DWH schema itself, + // without any prior `prepare_remote_with_*` call. --- + let server = LocalSyncServer::spawn(tursodb_path); + + let path_a = unique_test_replica_path("peer-a"); + let replica_a = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: path_a.clone(), + database_url: server.url.clone(), + auth_token: sentinel_token.to_string(), + }) + .expect("opening a truly empty remote should auto-initialize and publish the DWH schema"); + assert!( + path_a.is_file(), + "bootstrap should create the local replica file" + ); + assert_eq!( + replica_a + .db() + .classify_schema_state() + .expect("schema classification should succeed after auto-initialization"), + AgentTraceDwhSchemaState::Ready, + "the schema replica_a initialized locally should classify as Ready" + ); + + // --- AC3: a concurrent open against the same local path is rejected + // before any Turso Sync access, while the first replica is alive. --- + let concurrent_error = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: path_a.clone(), + database_url: server.url.clone(), + auth_token: sentinel_token.to_string(), + }) + .expect_err("a second open against the same replica path should be rejected"); + assert!(matches!( + concurrent_error, + AgentTraceDwhReplicaError::Lock(_) + )); + + // Peer replica opened before any domain writes exist, to prove both + // that a second independent fresh replica observes replica_a's + // published schema as Ready (without re-running migrations) and that + // pull (not just fresh bootstrap) makes independently published data + // visible. + let path_b = unique_test_replica_path("peer-b"); + let replica_b = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: path_b.clone(), + database_url: server.url.clone(), + auth_token: sentinel_token.to_string(), + }) + .expect("second independent peer should bootstrap against the same remote"); + assert_eq!( + replica_b + .db() + .classify_schema_state() + .expect("schema classification should succeed for the second peer"), + AgentTraceDwhSchemaState::Ready, + "a second fresh replica should observe replica_a's published schema as Ready" + ); + assert!( + source_instance_ids(replica_b.db(), repository_id).is_empty(), + "peer should start with no rows for this repository" + ); + + assert_bidirectional_pull_push(&replica_a, &replica_b, repository_id); + + // --- AC5: deleting the local replica plus Turso sidecars, then + // reopening fresh, reconstructs all previously published data. --- + drop(replica_a); + remove_test_replica(&path_a); + + let reconstructed = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: path_a.clone(), + database_url: server.url.clone(), + auth_token: sentinel_token.to_string(), + }) + .expect("reopening a deleted replica should reconstruct it via fresh bootstrap"); + assert_eq!( + source_instance_ids(reconstructed.db(), repository_id), + vec![String::from("instance-a"), String::from("instance-b")], + "reconstruction should recover both peers' previously published writes" + ); + + drop(reconstructed); + drop(replica_b); + remove_test_replica(&path_a); + remove_test_replica(&path_b); + drop(server); + } + + /// Covers AC5: a push from one peer becomes visible to another peer only + /// after `pull()`, in both directions. + fn assert_bidirectional_pull_push( + replica_a: &AgentTraceDwhReplica, + replica_b: &AgentTraceDwhReplica, + repository_id: &str, + ) { + replica_a + .db() + .execute( + "INSERT INTO repositories (repository_id) VALUES (?1) ON CONFLICT (repository_id) DO NOTHING", + (repository_id,), + ) + .expect("repository dimension insert should succeed"); + replica_a + .db() + .execute( + "INSERT INTO source_instances (repository_id, source_instance_id) VALUES (?1, ?2)", + (repository_id, "instance-a"), + ) + .expect("source instance insert should succeed"); + replica_a + .push() + .expect("push from replica_a should succeed"); + + replica_b.pull().expect("pull on replica_b should succeed"); + assert_eq!( + source_instance_ids(replica_b.db(), repository_id), + vec![String::from("instance-a")], + "replica_b should observe replica_a's independently published write" + ); + + replica_b + .db() + .execute( + "INSERT INTO source_instances (repository_id, source_instance_id) VALUES (?1, ?2)", + (repository_id, "instance-b"), + ) + .expect("second source instance insert should succeed"); + replica_b + .push() + .expect("push from replica_b should succeed"); + + replica_a.pull().expect("pull on replica_a should succeed"); + assert_eq!( + source_instance_ids(replica_a.db(), repository_id), + vec![String::from("instance-a"), String::from("instance-b")], + "replica_a should observe replica_b's independently published write" + ); + } + + /// Covers T06: opening `local_path` against `remote_url` fails with + /// `IncompatibleSchema`, never leaks `sentinel_token`, and never adds, + /// removes, or modifies any table on the remote (proven by comparing the + /// remote's non-internal table set before and after the rejected open). + fn assert_open_is_rejected_without_mutating_remote( + remote_url: &str, + sentinel_token: &str, + local_path_label: &str, + table_set_label: &str, + ) { + let tables_before = remote_table_set(remote_url, &format!("{table_set_label}-before")); + + let local_path = unique_test_replica_path(local_path_label); + let schema_error = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: local_path.clone(), + database_url: remote_url.to_string(), + auth_token: sentinel_token.to_string(), + }) + .expect_err("bootstrapping an incompatible remote should fail schema readiness"); + assert!(matches!( + schema_error, + AgentTraceDwhReplicaError::IncompatibleSchema { .. } + )); + let schema_error_message = schema_error.to_string(); + assert!( + !schema_error_message.contains(sentinel_token), + "incompatible-schema error must not contain the auth token: {schema_error_message}" + ); + + let tables_after = remote_table_set(remote_url, &format!("{table_set_label}-after")); + assert_eq!( + tables_before, tables_after, + "a rejected open must not add, remove, or modify any remote table" + ); + + remove_test_replica(&local_path); + } + + /// Covers AC4's negative case and T06: a remote whose bootstrapped schema + /// is not the DWH schema is reported incompatible, without locally + /// provisioning a competing schema or mutating the remote, and the error + /// never contains the auth token. + fn assert_unrelated_schema_is_rejected(tursodb_path: &Path, sentinel_token: &str) { + let server = LocalSyncServer::spawn(tursodb_path); + prepare_remote_with_incompatible_schema(&server.url); + + assert_open_is_rejected_without_mutating_remote( + &server.url, + sentinel_token, + "incompatible", + "unrelated-schema", + ); + + drop(server); + } + + /// Covers T06: a remote carrying only some of the seven DWH contract + /// tables, with no valid `__sce_migrations` ledger, is rejected as + /// incompatible rather than being completed or repaired. + fn assert_partial_dwh_schema_is_rejected(tursodb_path: &Path, sentinel_token: &str) { + let server = LocalSyncServer::spawn(tursodb_path); + prepare_remote_with_partial_dwh_schema(&server.url); + + assert_open_is_rejected_without_mutating_remote( + &server.url, + sentinel_token, + "partial-dwh", + "partial-dwh-schema", + ); + + drop(server); + } + + /// Covers T06: a remote whose migration ledger contains an unexpected or + /// unknown migration ID is rejected as incompatible, even though every + /// DWH contract table is present. + fn assert_unexpected_ledger_entry_is_rejected(tursodb_path: &Path, sentinel_token: &str) { + let server = LocalSyncServer::spawn(tursodb_path); + prepare_remote_with_unexpected_ledger_entry(&server.url); + + assert_open_is_rejected_without_mutating_remote( + &server.url, + sentinel_token, + "unexpected-ledger", + "unexpected-ledger", + ); + + drop(server); + } + + /// Covers T07: two distinct local replicas racing to `open()` against the + /// same truly empty remote both converge safely — either both succeed + /// (the loser recovering through T05's one-`pull()`-and-re-verify path in + /// `initialize_empty_schema`) — and the remote ends up with exactly one + /// valid, non-duplicated DWH schema/ledger, which a third fresh replica + /// then observes as `Ready`. + fn assert_concurrent_first_initializers_converge(tursodb_path: &Path, sentinel_token: &str) { + // Racing more than two initializers, released simultaneously by a + // `Barrier`, raises the odds that at least one push actually + // collides with another on the shared remote (rather than the two + // threads happening to interleave without ever overlapping their + // network round trips), so this concretely exercises + // `initialize_empty_schema`'s one-`pull()`-and-re-verify recovery + // path against real observed Turso Sync behavior instead of relying + // on it remaining untested whenever no collision occurs. + const RACER_COUNT: usize = 6; + + use std::sync::Barrier; + + let server = LocalSyncServer::spawn(tursodb_path); + let expected_migration_count = AgentTraceDwhDbSpec::migrations().len(); + let barrier = std::sync::Arc::new(Barrier::new(RACER_COUNT)); + let paths: Vec = (0..RACER_COUNT) + .map(|index| unique_test_replica_path(&format!("concurrent-{index}"))) + .collect(); + + let handles: Vec<_> = paths + .iter() + .map(|path| { + let local_path = path.clone(); + let database_url = server.url.clone(); + let auth_token = sentinel_token.to_string(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path, + database_url, + auth_token, + }) + }) + }) + .collect(); + + let replicas: Vec = handles + .into_iter() + .enumerate() + .map(|(index, handle)| { + handle + .join() + .unwrap_or_else(|_| { + panic!("concurrent initializer thread {index} should not panic") + }) + .unwrap_or_else(|error| { + panic!( + "concurrent initializer {index} should either win the race or \ + recover through the one-pull-and-re-verify path, got: {error}" + ) + }) + }) + .collect(); + + for (index, replica) in replicas.iter().enumerate() { + assert_eq!( + replica + .db() + .classify_schema_state() + .unwrap_or_else(|_| panic!( + "schema classification should succeed for initializer {index}" + )), + AgentTraceDwhSchemaState::Ready, + "concurrent initializer {index} should observe a Ready schema after converging" + ); + assert_eq!( + applied_migration_count(replica.db()), + expected_migration_count, + "concurrent initializer {index}'s converged view must not duplicate any migration" + ); + } + + drop(replicas); + for path in &paths { + remove_test_replica(path); + } + + // A third, fresh replica bootstraps `Ready` against the converged + // remote, with exactly one (not duplicated) applied-migration ledger. + let path_z = unique_test_replica_path("concurrent-z"); + let replica_z = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: path_z.clone(), + database_url: server.url.clone(), + auth_token: sentinel_token.to_string(), + }) + .expect("a third fresh replica should bootstrap Ready against the converged remote"); + assert_eq!( + replica_z + .db() + .classify_schema_state() + .expect("schema classification should succeed for the third replica"), + AgentTraceDwhSchemaState::Ready, + ); + assert_eq!( + applied_migration_count(replica_z.db()), + expected_migration_count, + "the third replica must observe exactly one converged migration ledger" + ); + + drop(replica_z); + remove_test_replica(&path_z); + drop(server); + } + + /// Covers T05's unchanged-on-open case: an already-`Ready` remote opens + /// without `open()` running any migration or performing any push merely + /// because it was called, and the applied-migration ledger is identical + /// (not duplicated) whether observed by the remote's own preparation + /// connection or by a freshly opened replica. + fn assert_ready_remote_opens_unchanged(tursodb_path: &Path, sentinel_token: &str) { + let server = LocalSyncServer::spawn(tursodb_path); + prepare_remote_with_ready_dwh_schema(&server.url); + + let expected_migration_count = AgentTraceDwhDbSpec::migrations().len(); + + let path = unique_test_replica_path("already-ready"); + let replica = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: path.clone(), + database_url: server.url.clone(), + auth_token: sentinel_token.to_string(), + }) + .expect("opening an already-ready remote should succeed without initializing anything"); + assert_eq!( + replica + .db() + .classify_schema_state() + .expect("schema classification should succeed"), + AgentTraceDwhSchemaState::Ready, + ); + assert_eq!( + applied_migration_count(replica.db()), + expected_migration_count, + "opening an already-ready remote must not duplicate or rerun any migration" + ); + drop(replica); + remove_test_replica(&path); + + // A second independent replica against the same already-ready remote + // must observe the exact same, unchanged migration ledger. + let path_again = unique_test_replica_path("already-ready-again"); + let replica_again = AgentTraceDwhReplica::open(AgentTraceDwhReplicaConfig { + local_path: path_again.clone(), + database_url: server.url.clone(), + auth_token: sentinel_token.to_string(), + }) + .expect("reopening the same already-ready remote should succeed unchanged"); + assert_eq!( + applied_migration_count(replica_again.db()), + expected_migration_count, + "a second open against the same already-ready remote must not duplicate any migration" + ); + + drop(replica_again); + remove_test_replica(&path_again); + drop(server); + } + + fn applied_migration_count(db: &AgentTraceDwhDb) -> usize { + db.query_map("SELECT id FROM __sce_migrations", (), |row| { + row.get::(0).map_err(Into::into) + }) + .expect("applied-migration query should succeed") + .len() + } + + #[test] + fn agent_trace_dwh_replica_turso_sync_integration() { + let Some(tursodb_path) = find_tursodb() else { + println!( + "SKIPPING agent_trace_dwh_replica_turso_sync_integration: no `tursodb` binary \ + on PATH. Run `nix develop .#database -c ./scripts/run-cli-cargo.sh test \ + --manifest-path cli/Cargo.toml agent_trace_dwh_replica` to exercise the real \ + Turso Sync harness against the pinned local `tursodb --sync-server`." + ); + return; + }; + println!("Using the pinned local `tursodb --sync-server` at {tursodb_path:?} as the disposable prepared DWH remote."); + + let repository_id = "repo-integration"; + let sentinel_token = "sentinel-integration-auth-token-must-not-leak"; + + assert_bootstrap_lock_and_pull_push(&tursodb_path, repository_id, sentinel_token); + assert_ready_remote_opens_unchanged(&tursodb_path, sentinel_token); + assert_unrelated_schema_is_rejected(&tursodb_path, sentinel_token); + assert_partial_dwh_schema_is_rejected(&tursodb_path, sentinel_token); + assert_unexpected_ledger_entry_is_rejected(&tursodb_path, sentinel_token); + assert_concurrent_first_initializers_converge(&tursodb_path, sentinel_token); + } +} diff --git a/cli/src/services/db/mod.rs b/cli/src/services/db/mod.rs index 0b2c8d01..8442f347 100644 --- a/cli/src/services/db/mod.rs +++ b/cli/src/services/db/mod.rs @@ -613,6 +613,31 @@ impl TursoDb { ) } + /// Wrap an already-open connection and the runtime that opened it as a + /// `TursoDb`, without opening a new connection or running migrations. + /// + /// For callers that open a connection through a non-local-path builder + /// (e.g. Turso Sync) and want to reuse this adapter's synchronous SQL + /// surface and schema-readiness checks. Migrations are never run here: + /// callers needing local schema initialization use `new`/`new_at`. + pub(crate) fn from_connection( + conn: turso::Connection, + runtime: tokio::runtime::Runtime, + ) -> Self { + Self { + core: TursoConnectionCore::new(conn, runtime), + } + } + + /// Run a future to completion on the runtime backing this connection. + /// + /// Exposed so callers holding a companion handle opened alongside this + /// connection (e.g. a Turso Sync `Database` used for pull/push) can drive + /// it without owning a second runtime. + pub(crate) fn block_on(&self, future: F) -> F::Output { + self.core.runtime.block_on(future) + } + /// Check migration metadata for problems that would prevent safe hook /// runtime access. /// diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index e03cb544..f4419fa6 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -307,6 +307,91 @@ pub fn agent_trace_db_path_for_repository_at( .join("agent-trace.db")) } +/// Returns the canonical repository-scoped Agent Trace DWH sync replica +/// database file path. +/// +/// The path is `/sce/repos//agent-trace-sync.db`, +/// where `state_root` comes from the shared default-path catalog +/// (`XDG_STATE_HOME` or platform equivalent) and `repository_id` is the +/// stable repository identity hash from `services::repository_identity`. +/// This path is distinct from the source-capture +/// `agent_trace_db_path_for_repository` path: the replica is a disposable, +/// single-owner Turso Sync database, never a multiprocess-WAL capture store. +#[allow(dead_code)] +pub fn agent_trace_dwh_replica_path_for_repository(repository_id: &str) -> anyhow::Result { + let state_root = resolve_sce_default_locations()? + .roots() + .state_root() + .to_path_buf(); + agent_trace_dwh_replica_path_for_repository_at(&state_root, repository_id) +} + +/// Builds the repository-scoped Agent Trace DWH sync replica database path +/// under an explicit state root: +/// `/sce/repos//agent-trace-sync.db`. +#[allow(dead_code)] +pub fn agent_trace_dwh_replica_path_for_repository_at( + state_root: &std::path::Path, + repository_id: &str, +) -> anyhow::Result { + Ok( + agent_trace_dwh_replica_dir_for_repository_at(state_root, repository_id)? + .join("agent-trace-sync.db"), + ) +} + +/// Returns the canonical repository-scoped Agent Trace DWH sync replica +/// bridge-lock file path: the replica path with a `.bridge-lock` suffix. +/// +/// Exactly one process may hold this lock at a time; it is the sole +/// synchronization mechanism proving single ownership of the replica. +#[allow(dead_code)] +pub fn agent_trace_dwh_bridge_lock_path_for_repository( + repository_id: &str, +) -> anyhow::Result { + let state_root = resolve_sce_default_locations()? + .roots() + .state_root() + .to_path_buf(); + agent_trace_dwh_bridge_lock_path_for_repository_at(&state_root, repository_id) +} + +/// Builds the repository-scoped Agent Trace DWH sync replica bridge-lock +/// path under an explicit state root. +#[allow(dead_code)] +pub fn agent_trace_dwh_bridge_lock_path_for_repository_at( + state_root: &std::path::Path, + repository_id: &str, +) -> anyhow::Result { + let replica_path = agent_trace_dwh_replica_path_for_repository_at(state_root, repository_id)?; + let mut file_name = replica_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("replica path must have a file name"))? + .to_os_string(); + file_name.push(".bridge-lock"); + Ok(replica_path.with_file_name(file_name)) +} + +#[allow(dead_code)] +fn agent_trace_dwh_replica_dir_for_repository_at( + state_root: &std::path::Path, + repository_id: &str, +) -> anyhow::Result { + let repository_id = repository_id.trim(); + if repository_id.is_empty() { + anyhow::bail!( + "repository ID must not be empty when resolving Agent Trace DWH replica path" + ); + } + // The repository ID becomes a single path segment; reject anything that + // could escape the `repos/` directory. + if repository_id.contains(['/', '\\']) || repository_id == "." || repository_id == ".." { + anyhow::bail!("repository ID '{repository_id}' is not a valid path segment"); + } + + Ok(state_root.join("sce").join("repos").join(repository_id)) +} + /// Returns the canonical default observability log directory. /// /// The path is `/sce/logs`, where `state_root` comes from the @@ -579,3 +664,83 @@ impl InstallTargetPaths { .join(hook_dir::POST_COMMIT) } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + #[test] + fn agent_trace_dwh_replica_path_resolves_under_repos_and_is_distinct_from_source_db() { + let state_root = Path::new("/tmp/state"); + + let replica_path = agent_trace_dwh_replica_path_for_repository_at(state_root, "repo-abc") + .expect("replica path should resolve"); + let source_path = agent_trace_db_path_for_repository_at(state_root, "repo-abc") + .expect("source path should resolve"); + + assert_eq!( + replica_path, + state_root + .join("sce") + .join("repos") + .join("repo-abc") + .join("agent-trace-sync.db") + ); + assert_ne!( + replica_path, source_path, + "replica path must be distinct from the source agent-trace.db path" + ); + assert!( + !replica_path.starts_with(std::env::current_dir().unwrap_or_default()), + "replica path must live outside the checkout" + ); + } + + #[test] + fn agent_trace_dwh_replica_path_bridge_lock_path_is_the_replica_path_with_a_bridge_lock_suffix() + { + let state_root = Path::new("/tmp/state"); + + let replica_path = agent_trace_dwh_replica_path_for_repository_at(state_root, "repo-abc") + .expect("replica path should resolve"); + let lock_path = agent_trace_dwh_bridge_lock_path_for_repository_at(state_root, "repo-abc") + .expect("lock path should resolve"); + + assert_eq!( + lock_path, + replica_path.with_file_name("agent-trace-sync.db.bridge-lock") + ); + assert_ne!(lock_path, replica_path); + } + + #[test] + fn agent_trace_dwh_replica_path_rejects_empty_repository_id() { + let error = agent_trace_dwh_replica_path_for_repository_at(Path::new("/tmp/state"), " ") + .expect_err("empty repository ID should fail"); + assert!(error.to_string().contains("must not be empty")); + } + + #[test] + fn agent_trace_dwh_replica_path_rejects_escaping_repository_ids() { + for bad in ["../escape", "a/b", "a\\b", ".", ".."] { + let error = + agent_trace_dwh_replica_path_for_repository_at(Path::new("/tmp/state"), bad) + .expect_err("escaping repository ID should fail"); + assert!(error.to_string().contains("not a valid path segment")); + } + } + + #[test] + fn agent_trace_dwh_replica_path_bridge_lock_path_rejects_the_same_invalid_repository_ids_as_the_replica_path( + ) { + for bad in ["", " ", "../escape", "a/b", "a\\b", ".", ".."] { + let replica_result = + agent_trace_dwh_replica_path_for_repository_at(Path::new("/tmp/state"), bad); + let lock_result = + agent_trace_dwh_bridge_lock_path_for_repository_at(Path::new("/tmp/state"), bad); + assert_eq!(replica_result.is_err(), lock_result.is_err()); + } + } +} diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index 798bcfa6..77d10206 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -3,6 +3,8 @@ pub mod agent_trace_db; #[allow(dead_code)] pub mod agent_trace_dwh_db; #[allow(dead_code)] +pub mod agent_trace_dwh_replica; +#[allow(dead_code)] pub mod agent_trace_storage; pub mod app_support; pub mod auth; diff --git a/context/architecture.md b/context/architecture.md index 55e8faf1..1193fc56 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -124,6 +124,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. - `cli/src/services/agent_trace_db/mod.rs` owns the shared Agent Trace insert payloads, SQL constants, and typed row helpers (diff-trace/intersection/Agent Trace/message/part) plus `ensure_schema_ready_for_hooks()` consumed by the repository adapter. `cli/src/services/agent_trace_db/repository.rs` defines the sole `RepositoryAgentTraceDb` adapter over `TursoDb` with a fresh `agent-trace-repository/001_repository_schema.sql` baseline for `diff_traces` (including `payload_type`), `post_commit_patch_intersections`, `agent_traces`, `messages`, `parts`, indexes, and triggers, plus the additive `002_repository_source_instance_id.sql` migration adding `repository_metadata.source_instance_id`, `repository_metadata` validation via typed `RepositoryMetadata { repository_id, source_instance_id }`, no trace-table `checkout_id` columns, `agent_traces.agent_trace_id NOT NULL UNIQUE`, and `recent_diff_trace_patches(cutoff_time_ms, end_time_ms)` using the inclusive chronological parser without checkout filtering. Active hook runtime, setup/lifecycle storage, and trace status/list/shell flows resolve through `agent_trace_storage` and use `RepositoryAgentTraceDb`. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, the 15-file `cli/migrations/agent-trace/` chain, and the `sce trace --legacy` surface were removed by the `retire-legacy-agent-trace-db` plan. - `cli/src/services/agent_trace_dwh_db/mod.rs` defines `AgentTraceDwhDb = TursoDb`, a separate append-oriented destination-schema adapter for a future Agent Trace ETL consumer, backed by a fresh `agent-trace-dwh/001_dwh_schema.sql` baseline (`repositories`, `source_instances`, `etl_watermarks`, `messages`, `message_parts`, `agent_traces`, `code_changes`, no foreign keys). Explicit-path only, reuses the `agent_trace_db` retry config key, and is not wired into any lifecycle provider, doctor/setup flow, or CLI command yet. See `context/sce/agent-trace-dwh-db.md` and the accepted decision at `context/decisions/2026-08-08-agent-trace-dwh-schema-identity-contract.md`. +- `cli/src/services/agent_trace_dwh_replica/mod.rs` defines `AgentTraceDwhReplica`, the sole owner of a Turso Sync connection to the repository-scoped, single-owner `agent-trace-sync.db` replica at `/sce/repos//agent-trace-sync.db` (distinct from both the multiprocess-WAL source `agent-trace.db` and the DWH's own explicit-path `AgentTraceDwhDbSpec`). `open()` acquires a non-blocking `BridgeLock` (`lock.rs`, over the sibling `.bridge-lock` file) before any Turso access, opens the local file through `turso::sync::Builder` without ever enabling `experimental_multiprocess_wal`, and wraps the result as an `AgentTraceDwhDb` via a narrow `TursoDb::from_connection`/`block_on` seam in `cli/src/services/db/mod.rs` to classify DWH schema state (`AgentTraceDwhDb::classify_schema_state()`): a `Ready` schema is left untouched, a genuinely `Empty` schema is initialized locally with `run_migrations()` and published with `push()`, and an `Incompatible` schema fails the open loudly without repair. It exposes lock-lifetime-bound SQL access plus `pull()`/`push()`, and redacts the caller-supplied auth token from every error. Callers provide `local_path`/`database_url`/`auth_token` explicitly; no credential discovery/persistence, ETL, or CLI/lifecycle wiring exists yet. See `context/sce/agent-trace-dwh-replica.md` and the accepted decision at `context/decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md` (superseding `context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md`). - `cli/src/services/setup/mod.rs` defines the setup command contract (`SetupMode`, `SetupTarget`, `SetupRequest`, CLI flag parser/validator), an `inquire`-backed interactive target prompter (`InquireSetupTargetPrompter`), setup dispatch outcomes (proceed/cancelled), additive durable-context bootstrap (`bootstrap_context_baseline` for standalone `--bootstrap-context` and every normal successful setup path), and compile-time embedded asset access (`EmbeddedAsset`, target-scoped iterators, required-hook asset iterators/lookups). It also owns the install-time optional-workflow seam: the `OptionalWorkflow` type plus the build-generated `OPTIONAL_WORKFLOWS` catalog, a per-target `WorkflowAssetLayout` built from the existing `default_paths` command/skill directory constants (`command`/`commands`/`prompts` plus `skills`), and `iter_embedded_assets_for_setup_target_with_selection(target, selection)`, which yields every embedded asset except the `{command_dir}/{command_slug}.md` file and `{skills_dir}/{skill_slug}/` subtree of each optional workflow the selection omits. Membership is derived from the catalog's slugs rather than an enumerated file list, so a new optional workflow needs no Rust change. This filtered iterator is the only way embedded assets are enumerated; setup installs through it and doctor inspects through it, so there is no unfiltered enumeration path that could reintroduce an unselected workflow. The non-interactive selection flows through the repeatable `--workflow ` flag into `SetupRequest.optional_workflows: Option>` (`None` meaning the flag was absent); `validate_optional_workflow_slugs` checks each slug against `OPTIONAL_WORKFLOWS` during request resolution, before any write, and `run_setup_for_mode` resolves `None` to the persisted `integrations.optional_workflows` (exported as `persisted_optional_workflows`) before installing through the filtered iterator and persisting the resolved selection. The interactive selection flows through the same seam: `SetupTargetPrompter` carries `prompt_target` plus `prompt_optional_workflows(defaults)` (returning `None` for a cancelled prompt), `SetupDispatch::Proceed { mode, optional_workflows }` carries a prompted selection alongside the resolved mode, and `resolve_setup_dispatch(mode, prompter, defaults)` runs the workflow prompt only after an interactive target prompt, mapping either cancellation to `SetupDispatch::Cancelled`. The prompt module builds its `inquire::MultiSelect` from `optional_workflow_prompt_inputs(catalog, defaults)`, which returns `None` for an empty catalog (skipping the prompt) and otherwise catalog-ordered rows plus the indices to pre-check, ignoring ids absent from the catalog. `setup/command.rs` therefore resolves the repository root before dispatch, so a non-git directory fails before any prompt. For repository builds, `cli/build.rs` validates the `SCE_CLI_GENERATED_INPUT_DIR` payload and canonical-input inventories, copies the payload into Cargo `OUT_DIR/pkl-generated`, stages `cli/assets/hooks/**` under `OUT_DIR/static`, requires the staged `config/optional-workflows.json`, and generates both the setup manifest and the optional-workflow catalog (`optional_workflows.rs`, rejecting a manifest whose `schemaVersion` is not 1 or whose entries lack a non-empty `id`/`title`/`description`/`commandSlug`/`skillSlug`) in `OUT_DIR`; focused internal seams separate install-flow from prompt-flow logic; `cli/src/services/setup/command.rs` owns the `SetupCommand` payload used by the static `RuntimeCommand` enum and executes against any context implementing repo-root scoping. Its install engine/orchestrator stages embedded files and uses a unified remove-and-replace policy (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure and no backup artifact creation), and formats deterministic completion messaging; required-hook install orchestration (`install_required_git_hooks`) follows the same remove-and-replace policy (removing existing hooks before swapping staged content, with deterministic recovery guidance on swap failure). After the Git gate, setup always ensures the context baseline; context-only requests return there, while normal modes derive a repo-root-scoped context before aggregating static lifecycle provider `setup` dispatch across providers (config → local_db → auth_db → agent_trace_db → hooks when requested), so setup providers consume only repo-root access from the scoped context. - `cli/src/services/setup/mod.rs` keeps those responsibilities inside one file for now, but the current ownership split is explicit: the inline `install` module owns repository-path normalization, staging/swap install behavior, required-hook installation, and filesystem safety guards, while the inline `prompt` module owns interactive target selection and prompt styling. - `cli/src/services/security.rs` provides shared security utilities for deterministic secret redaction (`redact_sensitive_text`) and directory write-permission probes (`ensure_directory_is_writable`) used by app/setup/observability surfaces. diff --git a/context/cli/default-path-catalog.md b/context/cli/default-path-catalog.md index 2e4ef6e6..e69e2949 100644 --- a/context/cli/default-path-catalog.md +++ b/context/cli/default-path-catalog.md @@ -18,6 +18,7 @@ - local DB: `/sce/local.db` - default observability log directory accessor: `/sce/logs` (Linux: `$XDG_STATE_HOME/sce/logs`, or `~/.local/state/sce/logs` when `XDG_STATE_HOME` is unset) - agent trace DB (only helper): `/sce/repos/{repository_id}/agent-trace.db` via `agent_trace_db_path_for_repository(repository_id)` (plus `_at(state_root, repository_id)` for explicit roots); rejects empty or path-unsafe repository IDs. The former global-sentinel `agent_trace_db_path()` and per-checkout `agent_trace_db_path_for_checkout(checkout_id)` helpers were removed by the `retire-legacy-agent-trace-db` plan. +- Agent Trace DWH sync replica and its bridge lock: `/sce/repos/{repository_id}/agent-trace-sync.db` and `agent-trace-sync.db.bridge-lock` via `agent_trace_dwh_replica_path_for_repository(repository_id)` / `agent_trace_dwh_bridge_lock_path_for_repository(repository_id)` (plus `_at(state_root, repository_id)` explicit-root variants); same repository-ID validation as the source Agent Trace DB path, and always distinct from it. `cli/src/services/agent_trace_dwh_replica/lock.rs` defines `BridgeLock`, a non-blocking `fs4`-backed exclusive-file-lock guard over the `.bridge-lock` path: `BridgeLock::acquire` never blocks, rejects a concurrent owner with actionable guidance, and is released only by guard drop or process exit (the OS releases the underlying `flock` on file-descriptor close either way); the lock file itself is created if missing and is never deleted. Introduced by the `agent-trace-dwh-turso-sync-replica` plan; the Turso Sync open/pull/push boundary that uses this lock is `AgentTraceDwhReplica` in `cli/src/services/agent_trace_dwh_replica/replica.rs`, which acquires the lock before opening any Turso Sync connection (see `context/sce/agent-trace-dwh-replica.md`). ### Repo-relative paths diff --git a/context/context-map.md b/context/context-map.md index 3f2b6105..b3ad3f0c 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -10,7 +10,7 @@ Primary context files: Feature/domain context: - `context/cli/cli-command-surface.md` (CLI command surface including top-level help with ASCII art banner and gradient rendering, setup install flow with the repeatable `sce setup --workflow ` optional-workflow selection and its interactive post-target multi-select, WorkOS device authorization flow + token storage behavior, attribution-only hook routing with validated post-commit `--remote-url` plumbing plus DB-backed `diff-trace` dual persistence and post-commit Agent Trace payload persistence including range `content_hash`, setup-owned local DB + repository-scoped Agent Trace DB bootstrap plus doctor DB health coverage with credential-safe repository identity diagnostics, centralized Rust SCE web URL helpers in `services::agent_trace`, nested flake release package/app installability, Cargo local install + crates.io readiness policy, and hidden `sce policy bash` command adapter for bash-policy hook callers; `sce sync` command wiring is deferred to `0.4.0`; static `RuntimeCommand` enum dispatch lives in `services/command_registry.rs`, command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy are owned by their respective `services/{name}/command.rs` files, and clap-to-runtime conversion lives in `services/parse/command_runtime.rs`) -- `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/repository-scoped Agent Trace databases, the default observability log-dir accessor consumed by config resolution with Linux `${XDG_STATE_HOME:-~/.local/state}/sce/logs` fallback semantics, repo-relative, embedded-asset, install, hook, and context-path families plus the regression guard that keeps production path ownership centralized) +- `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/repository-scoped Agent Trace databases, the repository-scoped Agent Trace DWH sync replica path and its non-blocking single-owner `BridgeLock` guard, the default observability log-dir accessor consumed by config resolution with Linux `${XDG_STATE_HOME:-~/.local/state}/sce/logs` fallback semantics, repo-relative, embedded-asset, install, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/repository-identity.md` (repository identity module in `cli/src/services/repository_identity/`: pure scheme-neutral `host[:port]/path` canonicalization for SCP/`ssh://`/HTTPS/`git://` remote URLs with credential stripping, hostname lowercasing, default-port removal, and query/fragment/trailing-`.git` cleanup, trim-only explicit-identity handling, `sha256("sce-repository-id-v1\0" + canonical_identity)` repository IDs, credential-safe fieldless errors, plus the `resolve` runtime submodule applying explicit-config-then-configured-remote precedence with `git config --get remote..url` lookup, `RepositoryIdentitySource` provenance, and `.sce/config.json`-guidance resolution errors that never echo URLs; consumed by the T04 `agent_trace_storage` resolver) - `context/cli/agent-trace-storage.md` (repository-scoped Agent Trace storage resolver in `cli/src/services/agent_trace_storage/`: `AgentTraceStorageContext` inputs mirroring the `agent_trace.*` config keys, `ResolvedAgentTraceStorage` carrying repository identity + checkout ID + `/sce/repos//agent-trace.db` path + open `RepositoryAgentTraceDb` + typed `RepositoryMetadata`, two resolver families sharing one internal open core: `resolve_agent_trace_storage{,_at_state_root}` (setup/lifecycle and `sce trace status` callers) with idempotent concurrent-safe first open via bounded fast-path-then-migrate retry plus narrow one-file schema migration-metadata repair and repository metadata validation, and `resolve_agent_trace_storage_for_hook_runtime{,_at_state_root}` (hook runtime only) which never creates, migrates, or repairs schema/migration metadata and fails with `sce setup` guidance on a missing/incomplete database, path-unsafe repository ID rejection in `default_paths::agent_trace_db_path_for_repository{,_at}`, and strict never-touch boundary for any pre-migration checkout-scoped/global DB files) - `context/cli/checkout-identity.md` (current checkout identity infrastructure in `cli/src/services/checkout/`, including `/sce/checkout-id` UUIDv7 storage, setup/hook integration that creates/reuses checkout identity as repository-scoped Agent Trace diagnostic metadata, the removed per-checkout DB opener/path helper, `sce doctor` checkout identity display, and the never-touch on-disk handling of pre-migration checkout-scoped DB files that are no longer inspectable via the CLI) @@ -62,6 +62,7 @@ Feature/domain context: - `context/sce/shared-turso-db.md` (current shared `cli/src/services/db/mod.rs` Turso database infrastructure seam, including `DbSpec`, generic `TursoDb`, encrypted `EncryptedTursoDb`, build-time generated migration constants from `cli/build.rs`/Cargo `OUT_DIR`, config-driven constructor/open-connect retry via `run_with_retry_sync`, no-migration `TursoDb::open_without_migrations()` / explicit-path `open_without_migrations_at(path)` for hot runtime paths, migration-running `new()` / explicit-path `new_at(path)` / `run_migrations()` with per-database `__sce_migrations` tracking, config-driven operation retry for `execute`/`query`/`query_values`/`query_map` with a `<= 2_000ms` default query failure budget, raw-value row fetching for deterministic operator-facing rendering, row-mapping excluded from retry, generic embedded migration execution, non-mutating `migration_metadata_problems()` and `ensure_schema_ready(setup_guidance)` readiness methods on `TursoDb`, and concrete wrappers for `LocalDb`, `AuthDb`, plus `RepositoryAgentTraceDb`) - `context/sce/auth-db.md` (encrypted `AuthDb = EncryptedTursoDb` adapter, canonical `/sce/auth.db` path, build-time generated `AUTH_MIGRATIONS` from `cli/migrations/auth/`, auth credential schema and updated-at trigger baseline, lifecycle setup/doctor integration, encrypted token-storage persistence, and `SCE_AUTH_DB_ENCRYPTION_KEY`/OS credential-store key handling) - `context/sce/agent-trace-db.md` (implemented Agent Trace database adapter: the sole repository-scoped `RepositoryAgentTraceDb` backed by one fresh multi-statement baseline schema file plus an additive `002_repository_source_instance_id` migration, typed `RepositoryMetadata { repository_id, source_instance_id }` with atomic once-only source-instance initialization, `repository_metadata` validation, narrow concurrent-first-open repair for missing one-file baseline migration metadata after all required schema tables exist, no trace-table `checkout_id` columns, repository-level typed insert helpers for diff traces, post-commit intersections, Agent Trace rows, messages, and parts, repository-level recent diff-trace reads without checkout filtering, on-demand command/hook initialization with no daemon/background service, and the never-touch on-disk boundary for any pre-migration checkout-scoped/global DB files; the checkout-scoped `AgentTraceDb` adapter, its `agent_trace_db_path()`/`agent_trace_db_path_for_checkout()` helpers, and the 15-file `cli/migrations/agent-trace/` chain were removed by the `retire-legacy-agent-trace-db` plan; active hook writers/readers and Agent Trace setup/lifecycle resolve repository storage through `agent_trace_storage`) +- `context/sce/agent-trace-dwh-replica.md` (Agent Trace DWH Turso Sync replica boundary: `AgentTraceDwhReplica` in `cli/src/services/agent_trace_dwh_replica/replica.rs`, the sole owner of a Turso Sync connection to the repository-scoped `agent-trace-sync.db`; acquires the `BridgeLock` before any Turso access, opens via `turso::sync::Builder` without enabling multiprocess WAL, then classifies the DWH schema via `AgentTraceDwhDb::classify_schema_state()` — a `Ready` schema is left untouched, a genuinely `Empty` schema is initialized locally with `run_migrations()` and published with `push()`, and an `Incompatible` schema fails loudly without repair — exposes lock-lifetime-bound SQL access plus `pull()`/`push()`, redacts the caller-supplied auth token from every error, and reuses a new `TursoDb::from_connection`/`block_on` seam; ETL, credential discovery/persistence, and CLI/lifecycle wiring remain deferred) - `context/sce/agent-trace-dwh-db.md` (Agent Trace DWH: a separate append-oriented destination schema for a future ETL consumer, distinct from the repository-scoped source schema above. `AgentTraceDwhDb = TursoDb` in `cli/src/services/agent_trace_dwh_db/mod.rs`, explicit-path only, no lifecycle/CLI wiring yet. One fresh baseline `cli/migrations/agent-trace-dwh/001_dwh_schema.sql` creates `repositories`, `source_instances`, `etl_watermarks`, `messages`, `message_parts`, `agent_traces`, and `code_changes` with no foreign keys; every fact table denormalizes `repository_id`/`source_instance_id` lineage as plain text. Deterministic logical identities (messages, Agent Traces) are unique excluding `source_instance_id` for cross-source-database idempotency; raw local source row IDs (`source_part_id`, `source_diff_trace_id`) are unique per source instance so the same local integer coexists across sources/repositories) - `context/sce/agent-trace-core-schema-migrations.md` (historical reference for removed local DB schema bootstrap behavior; T03 now implements the actual local DB with migrations) - `context/sce/agent-trace-retry-queue-observability.md` (inactive local-hook retry path plus historical retry/metrics reference) @@ -111,3 +112,5 @@ Recent decision records: - `context/decisions/2026-03-25-first-install-channels.md` (approved first-wave install/distribution scope for `sce`, canonical naming, and Nix-owned build policy) - `context/decisions/2026-07-17-retire-legacy-agent-trace-db.md` (retire the checkout-scoped Agent Trace DB surface; `RepositoryAgentTraceDb` is the sole adapter, no `sce trace --legacy`, no global/checkout fallback path; pre-migration on-disk files are never touched and no longer inspectable via the CLI) - `context/decisions/2026-08-08-agent-trace-dwh-schema-identity-contract.md` (introduces the separate append-oriented Agent Trace DWH destination schema and explicit-path-only adapter, distinct from the repository-scoped source schema, with the two-scope logical-vs-local-ID identity contract for idempotent re-ingestion from independently created source databases) +- `context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md` (single-owner, lock-guarded, disposable Turso Sync replica boundary for the Agent Trace DWH: `AgentTraceDwhReplica` is the sole Turso Sync builder owner, never enables multiprocess WAL, never discovers/persists credentials, and is always reconstructible from the remote; superseded by `2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md`'s empty-remote auto-initialization guardrail) +- `context/decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md` (supersedes the prior verify-only guardrail: `AgentTraceDwhReplica::open()` may auto-initialize a genuinely `Empty` remote DWH schema via `AgentTraceDwhDb::classify_schema_state()` and `run_migrations()` + `push()`, with a narrow one-`pull()`-and-re-verify push-conflict recovery; still never repairs an `Incompatible` schema; SCE remains sole owner of the DWH schema/migrations, control-plane owns only remote provisioning and credentials) diff --git a/context/decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md b/context/decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md new file mode 100644 index 00000000..1cd04057 --- /dev/null +++ b/context/decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md @@ -0,0 +1,145 @@ +# Decision: Allow empty-remote auto-initialization of the Agent Trace DWH schema through AgentTraceDwhReplica + +Date: 2026-08-08 +Status: Accepted +Plan: `context/plans/agent-trace-dwh-turso-sync-replica.md` +Task: `T04, T05, T06, T07, T08` +Supersedes: `context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md` + +## Context + +The prior `agent-trace-dwh-turso-sync-replica-ownership` decision required +`AgentTraceDwhReplica::open()` to verify DWH schema readiness only and never +provision or migrate schema, on the grounds that a replica-side migration +risked silently diverging from the remote's authoritative migration contract. +In practice this made every fresh DWH remote (including disposable +integration-test remotes and a genuinely new production DWH) a manual +provisioning step outside any implemented tool, even though +`AgentTraceDwhDbSpec::migrations()` is already the single authoritative +migration list and `AgentTraceDwhDb::run_migrations()` already exists as the +one implementation that applies it. The change request behind this plan's +later tasks (T04–T07) asked for the replica to safely bootstrap that one +narrow case — a genuinely empty remote — while still refusing to touch any +non-empty schema it cannot already recognize as fully ready. + +## Decision + +`AgentTraceDwhReplica::open()` may now auto-initialize a **genuinely empty** +remote DWH schema, but must never repair, upgrade, or partially complete an +existing non-empty incompatible schema. This is implemented as a +classify-then-branch state machine: `AgentTraceDwhDb::classify_schema_state()` +(`Ready`, `Empty`, `Incompatible(reason)`) inspects the opened connection +after bootstrap. `Ready` is left unchanged. A genuinely `Empty` schema — no +`__sce_migrations` table, none of the seven DWH contract tables, and no other +user-defined table — is initialized locally via the existing +`AgentTraceDwhDb::run_migrations()` and published with a single `push()`, +narrowly recovering from a push failure with exactly one best-effort `pull()` +plus a readiness re-verification (the original push error is returned unless +that re-verification now reports `Ready`, treating a racing initializer's +publish as success). Every other case — an unrelated schema, a partial DWH +schema, or a migration ledger with unexpected entries — classifies +`Incompatible` and fails `open()` loudly, exactly as before. + +SCE remains the sole owner of the DWH schema and its migration list; +control-plane's role is unchanged — remote provisioning and credentials only, +with no schema or migration copy/bundle of its own. + +## Rationale + +Auto-initializing only the genuinely-empty case keeps the original decision's +core safety property — never silently diverge from or repair an existing +remote schema — while removing an unnecessary manual step for the one case +that has exactly one correct outcome: a brand-new remote has no schema to +diverge from, and `AgentTraceDwhDbSpec::migrations()` is already the sole +authoritative source for what that schema must be. Routing initialization +through the existing `run_migrations()` rather than a new implementation +keeps a single migration code path for both local and replica-initialized +schemas. The narrow one-`pull()`-and-re-verify recovery, rather than a +generic retry or swallowed error, keeps the boundary between "another +initializer already finished this" and "something is actually broken" +explicit and auditable. + +## Alternatives considered + +- **Keep verify-only `open()` (prior decision)** — simplest and safest by + construction, but leaves every fresh remote requiring a manual or + external provisioning step this plan's tests and any future ETL bootstrap + would otherwise need to duplicate. +- **Auto-repair or auto-upgrade a non-empty, non-ready schema** — would make + `open()` fully self-sufficient for any remote state, but risks silently + altering or upgrading a schema the replica cannot prove is safe to change; + rejected as a separate, later design decision, not folded into this one. +- **Let control-plane own or bundle a copy of the DWH schema for + provisioning** — would move initialization out of the replica boundary + entirely, but couples control-plane to SCE's migration list and schema + ownership, which this plan explicitly keeps out of scope. +- **Generic retry/backoff on any push failure** — simpler to write, but + would blur "another initializer won the race" (safe) with a genuine, + unresolved push failure (must surface); rejected in favor of the narrow + one-pull-and-re-verify check with the original error preserved on failure. + +## Compatibility and risks + +- Narrows, rather than removes, the prior guardrail: the "never repair a + non-empty incompatible schema" property is unchanged and still enforced by + `classify_schema_state()`'s `Incompatible` branch. +- Risk: a Turso Sync remote implementation other than the pinned local + `tursodb --sync-server` could surface real push conflicts under + concurrent first initialization; the one-`pull()`-and-re-verify recovery + path exists for exactly this case but was not exercised by an observed + conflict in this repository's integration harness (T07), so it remains + unproven against other remote implementations. Mitigated by the recovery + path never swallowing an unresolved failure — it still surfaces the + original push error when re-verification does not report `Ready`. +- Risk: a freshly bootstrapped Turso Sync database carries internal + bookkeeping tables (`turso_cdc`, `turso_cdc_version`, + `__turso_internal*`) that are not part of the DWH contract; + `classify_schema_state()` excludes them from its `sqlite_master` scan so a + genuinely empty Turso Sync remote/replica still classifies `Empty`. + Mitigated by this being a fixed, named exclusion list rather than a broad + pattern. +- No runtime compatibility impact: `AgentTraceDwhReplica` is still not wired + into any lifecycle provider, doctor/setup flow, CLI command, or background + sync. + +## Guardrails + +- `AgentTraceDwhReplica::open()` may run `AgentTraceDwhDb::run_migrations()` + and `push()` only when `classify_schema_state()` returns genuinely `Empty` + (no ledger table and no other user-defined table at all). +- `open()` must never add, repair, upgrade, or partially complete an + `Incompatible` schema; it must fail loudly instead. +- `AgentTraceDwhDbSpec::migrations()` remains the sole authoritative + migration list; no second migration implementation may exist for the DWH + schema. +- The push-failure recovery path may perform exactly one best-effort + `pull()` plus one readiness re-verification; it must return the original + push failure — never a swallowed or generic error — when that + re-verification does not report `Ready`. +- Control-plane must not gain ownership of, or a bundled copy of, the DWH + schema or migration list as part of this guardrail revision. + +## Consequences + +- A future ETL bridge process (or an integration test) can call + `AgentTraceDwhReplica::open()` against a brand-new, genuinely empty DWH + remote and get a `Ready` replica back without any external provisioning + step. +- Two or more local replicas racing to initialize the same empty remote + converge on exactly one valid schema/ledger (proven by T07's six-way + concurrent test), so the auto-initialization path is safe to call from + multiple independent processes without external coordination. +- Automatic repair or upgrade of an existing non-empty DWH schema remains a + separate, later design decision; this decision does not authorize it. + +## Follow-up + +- None. + +## References + +- Plan: [`agent-trace-dwh-turso-sync-replica`](../plans/agent-trace-dwh-turso-sync-replica.md) +- Task: `T04, T05, T06, T07, T08` +- Current-state context: [`agent-trace-dwh-replica.md`](../sce/agent-trace-dwh-replica.md), [`agent-trace-dwh-db.md`](../sce/agent-trace-dwh-db.md) +- Evidence: [`agent_trace_dwh_replica/replica.rs`](../../cli/src/services/agent_trace_dwh_replica/replica.rs), [`agent_trace_dwh_db/mod.rs`](../../cli/src/services/agent_trace_dwh_db/mod.rs) +- Related decision: [`agent-trace-dwh-turso-sync-replica-ownership`](2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md) (superseded by this record) diff --git a/context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md b/context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md new file mode 100644 index 00000000..d9b1de60 --- /dev/null +++ b/context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md @@ -0,0 +1,110 @@ +# Decision: Single-owner, disposable Turso Sync replica boundary for the Agent Trace DWH + +Date: 2026-08-08 +Status: Accepted +Plan: `context/plans/agent-trace-dwh-turso-sync-replica.md` +Task: T01, T02, T03 + +## Context + +The `2026-08-08-agent-trace-dwh-schema-identity-contract` decision introduced +the Agent Trace DWH destination schema and its explicit-path `AgentTraceDwhDb` +adapter, but deliberately left "a sync URL, credentials, ETL state +transitions, bridge locking, or CLI lifecycle behavior" out of scope, and +repository context still described the DWH as having no canonical local sync +path. A future ETL bridge process needs exactly one place to open a local +Turso Sync connection against the remote DWH, without risking two processes +racing on the same local sync file or the sync boundary silently reusing the +source capture database's multiprocess-WAL settings. + +## Decision + +Introduce `AgentTraceDwhReplica` (`cli/src/services/agent_trace_dwh_replica/`) +as the sole owner of a Turso Sync connection to a repository's disposable +`agent-trace-sync.db`: exactly one OS process may hold it at a time, enforced +by a non-blocking `BridgeLock` acquired before any Turso access; the replica +is fully reconstructible from the remote DWH and carries no state that is not +recoverable through a fresh bootstrap; callers must supply +`local_path`/`database_url`/`auth_token` explicitly, since the boundary never +discovers, persists, or rotates credentials itself and redacts the token from +every error; and the replica's Turso Sync connection never enables +`experimental_multiprocess_wal`, keeping that setting exclusive to the source +capture database. + +## Rationale + +A single-owner, lock-guarded, disposable replica is the only design that lets +a future ETL bridge process pull from and push to the remote DWH without +risking split-brain local state or blocking the unrelated multiprocess-WAL +source capture path. Requiring explicit caller-supplied credentials (rather +than the replica discovering or storing them) keeps this boundary decoupled +from OAuth/credential-persistence work that has not been designed yet, and +keeps the replica safe to open in tests and future bridge processes alike +without a hidden credential store to reason about. + +## Alternatives considered + +- **Let any process open the Turso Sync connection directly** — simpler, but + risks two processes racing on the same local sync file with no ownership + guarantee, and offers no natural place to enforce "never enable + multiprocess WAL on this path." +- **Have the replica discover or persist its own credentials** — would let + callers omit configuration, but couples this storage boundary to + unfinished OAuth/credential-discovery design and risks the token leaking + into diagnostics or disk state before that design exists. +- **Provision/repair DWH schema locally on open** — would make replica open + self-sufficient, but risks a local replica silently diverging from the + remote's authoritative migration contract; rejected in favor of verifying + readiness only and failing loudly on mismatch. + +## Compatibility and risks + +- Net-new boundary, not wired into any lifecycle provider, doctor/setup flow, + CLI command, or background sync, so this decision has no runtime + compatibility impact yet. +- Risk: a future ETL/CLI integration could bypass `AgentTraceDwhReplica` and + open a competing Turso Sync connection directly, reintroducing the + split-brain risk this boundary exists to prevent; mitigated by this being + the only Turso Sync builder in the codebase today and by this decision + recording the single-owner rule for future reviewers to enforce. +- Risk: a caller could still leak a raw auth token by capturing the + `AgentTraceDwhReplicaConfig` value directly instead of only its errors; + mitigated by giving `AgentTraceDwhReplicaConfig` a redacted `Debug` + implementation. + +## Guardrails + +- `AgentTraceDwhReplica::open` must acquire the `BridgeLock` before any Turso + Sync builder, local file, or network access. +- No code path may call `.experimental_multiprocess_wal(true)` when opening + `agent-trace-sync.db`. +- The replica must not locally provision or migrate DWH schema; a missing or + incompatible remote schema is a reported failure, not a repair target. +- `AgentTraceDwhReplica` remains the only owner of a Turso Sync builder in + this codebase; application and hook processes must not open the replica + path directly. + +## Consequences + +- A future ETL bridge process can rely on `AgentTraceDwhReplica` for safe + concurrent-open rejection, credential-safe error reporting, and + reconstruction after local data loss, without re-deriving any of that + policy itself. +- Losing or deleting the local `agent-trace-sync.db` is always recoverable + through a fresh `open()` bootstrap against the remote, so it never needs + backup/retention handling of its own. +- Any future credential-discovery or OAuth design must hand + `AgentTraceDwhReplica` an explicit token rather than teaching the replica + to look one up itself, unless a later decision revisits this guardrail. + +## Follow-up + +None. + +## References + +- Plan: [`agent-trace-dwh-turso-sync-replica`](../plans/agent-trace-dwh-turso-sync-replica.md) +- Task: T01, T02, T03 +- Current-state context: [`agent-trace-dwh-replica.md`](../sce/agent-trace-dwh-replica.md), [`agent-trace-dwh-db.md`](../sce/agent-trace-dwh-db.md), [`shared-turso-db.md`](../sce/shared-turso-db.md), [`default-path-catalog.md`](../cli/default-path-catalog.md) +- Evidence: [`agent_trace_dwh_replica/replica.rs`](../../cli/src/services/agent_trace_dwh_replica/replica.rs), [`agent_trace_dwh_replica/lock.rs`](../../cli/src/services/agent_trace_dwh_replica/lock.rs) +- Related decision: [`agent-trace-dwh-schema-identity-contract`](2026-08-08-agent-trace-dwh-schema-identity-contract.md) diff --git a/context/glossary.md b/context/glossary.md index 0c9a82a8..baefde4a 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -75,6 +75,8 @@ - `agent trace DB adapter`: Modules under `cli/src/services/agent_trace_db/` that define the sole repository-scoped `RepositoryAgentTraceDb = TursoDb` adapter (the checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter and its 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan). The repository adapter uses one fresh `agent-trace-repository` schema file with `repository_metadata`, repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables, no row-level `checkout_id`, typed parameterized insert helpers, and chronological recent `diff_traces` query/parse support. `AgentTraceDbLifecycle` initializes/checks repository-scoped storage through `agent_trace_storage`. - `Agent Trace DWH`: Separate, append-oriented destination schema for a future Agent Trace ETL consumer, distinct from the repository-scoped `agent-trace.db` source schema. `AgentTraceDwhDb = TursoDb` in `cli/src/services/agent_trace_dwh_db/mod.rs`, explicit-path only (no canonical `db_path()`), reuses the `"agent_trace_db"` retry config key, and is not wired into any lifecycle provider, doctor/setup flow, or CLI command yet. One fresh baseline `cli/migrations/agent-trace-dwh/001_dwh_schema.sql` (migration ID `001_dwh_schema`) creates `repositories`, `source_instances`, `etl_watermarks`, `messages`, `message_parts`, `agent_traces`, and `code_changes` with no foreign keys; every fact table denormalizes `repository_id`/`source_instance_id` lineage as plain text so out-of-order or partial ingestion across fact tables and across independently created source databases is never blocked. See `context/sce/agent-trace-dwh-db.md`. - `DWH logical identity`: Uniqueness scope used by `Agent Trace DWH` fact tables. Deterministic source identities (`messages` on `(repository_id, session_id, message_id)`, `agent_traces` on `(repository_id, agent_trace_id)`) exclude `source_instance_id` so re-ingesting the same logical event from an independently created source database for the same repository stays idempotent. Raw local autoincrement source row IDs (`message_parts.source_part_id`, `code_changes.source_diff_trace_id`) are instead scoped by `(repository_id, source_instance_id, )`, since local IDs are not stable across independently created source databases and are expected to coexist across source instances/repositories. +- `Agent Trace DWH sync replica`: The repository-scoped local `/sce/repos/{repository_id}/agent-trace-sync.db` path resolved by `agent_trace_dwh_replica_path_for_repository(repository_id)` in `cli/src/services/default_paths.rs`, distinct from both the source `agent-trace.db` path and the `Agent Trace DWH`'s own explicit-path `AgentTraceDwhDbSpec`. Introduced by the `agent-trace-dwh-turso-sync-replica` plan as the disposable, single-owner local database `AgentTraceDwhReplica` opens against the remote DWH via Turso Sync (acquiring the `bridge lock` first, then bootstrapping/pulling/pushing); no ETL, credential discovery/persistence, or CLI/background sync wiring exists yet. See [sce/agent-trace-dwh-replica.md](sce/agent-trace-dwh-replica.md), [cli/default-path-catalog.md](cli/default-path-catalog.md). +- `bridge lock`: The non-blocking, single-owner OS file lock guarding an `Agent Trace DWH sync replica` path, implemented as `BridgeLock` in `cli/src/services/agent_trace_dwh_replica/lock.rs` over the sibling `agent-trace-sync.db.bridge-lock` file (`agent_trace_dwh_bridge_lock_path_for_repository(repository_id)`). Backed by the `fs4` crate's non-blocking exclusive file lock; acquisition never blocks, a concurrently held lock is rejected with actionable guidance, and the OS releases the lock only on guard drop or owning-process exit — the lock file itself is created if missing and is never deleted. Holding a bridge lock never blocks the multiprocess-WAL source `agent-trace.db`. See [cli/default-path-catalog.md](cli/default-path-catalog.md). - `structured patch service`: Pure synchronous Rust service in `cli/src/services/structured_patch.rs` that derives supported structured editor hook payloads into canonical `ParsedPatch` values. The current implemented source is Claude `PostToolUse` payloads for `Write` creates and `Edit` structured patches; wired into `sce hooks diff-trace` for Claude payload classification at intake (T04) and into `RepositoryAgentTraceDb::recent_diff_trace_patches` for post-commit structured payload parsing dispatch at read time (T05). - `Agent Trace SCE metadata`: Implementation-owned top-level metadata emitted by `build_agent_trace(...)` as `metadata.sce.version`; the value is sourced from the compiled `sce` CLI package version via `env!("CARGO_PKG_VERSION")`, is schema-validated with the rest of the payload, and is persisted in AgentTraceDb `agent_traces.trace_json` without changing the top-level Agent Trace payload/schema `version`. - `Agent Trace range content_hash`: Per-range `content_hash` emitted by `build_agent_trace(...)` inside every `ranges[]` entry as `murmur3:`, computed from the touched-line kind/content of the `post_commit_patch` or embedded-patch hunk used to emit that range while excluding positions, paths, metadata, and database IDs. diff --git a/context/plans/agent-trace-dwh-turso-sync-replica.md b/context/plans/agent-trace-dwh-turso-sync-replica.md new file mode 100644 index 00000000..f495acfb --- /dev/null +++ b/context/plans/agent-trace-dwh-turso-sync-replica.md @@ -0,0 +1,201 @@ +# Plan: agent-trace-dwh-turso-sync-replica + +## Change summary + +Add the repository-scoped `agent-trace-sync.db` storage boundary between the existing multiprocess-WAL `agent-trace.db` capture source and the remote Agent Trace DWH. A dedicated `AgentTraceDwhReplica` will acquire a non-blocking process lock, open the local file through Turso Sync with caller-supplied remote credentials, rely on normal remote bootstrap, verify the existing DWH migration contract without provisioning it, expose pull/push, and keep ordinary DWH SQL access within the lock owner's lifetime. + +This extends the PR2 `AgentTraceDwhDb` boundary without changing source capture, adding ETL, acquiring credentials, or introducing CLI/background synchronization behavior. It also replaces PR2's temporary documentation assumption that no canonical local DWH replica path exists. + +## Acceptance criteria + +- [x] AC1: Repository ID `abc` resolves the replica and bridge-lock paths to `/sce/repos/abc/agent-trace-sync.db` and `/sce/repos/abc/agent-trace-sync.db.bridge-lock`, distinct from `agent-trace.db` and outside the checkout. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_path` +- [x] AC2: An exclusive non-blocking OS file lock permits exactly one owner of a replica path, rejects a concurrent owner with actionable guidance, becomes acquirable after owner drop or process death, leaves the lock file on disk, and does not block normal source `agent-trace.db` operations. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_lock` +- [x] AC3: `AgentTraceDwhReplica` accepts only an explicit local path, database URL, and auth token from its caller; acquires the bridge lock before opening Turso Sync; does not enable multiprocess WAL; exposes lock-lifetime-bound `AgentTraceDwhDb` access; and reports lock/open/schema/pull/push failures without including the token. + - Validate: targeted replica API/error tests plus inspection of `cli/src/services/agent_trace_dwh_replica/` and `cli/Cargo.toml` +- [x] AC4: Opening a missing local replica uses Turso Sync's supported remote bootstrap and succeeds only when the bootstrapped database passes `AgentTraceDwhDb::ensure_dwh_schema_ready()`; missing or incompatible DWH schema is reported without locally provisioning a competing schema. + - Validate: the Turso Sync integration harness opens a fresh path against a prepared DWH remote, asserts the local file and schema readiness, and asserts the incompatible-schema failure class +- [x] AC5: Pull makes independently published remote DWH data visible locally, push publishes a local DWH write that an independent remote/replica connection can observe, and deleting the local replica plus Turso sidecars permits reconstruction of remote data through a fresh open/pull. + - Validate: the Turso Sync integration harness runs independent pull, push, and reconstruction cases and records whether it used the pinned local `tursodb` harness or caller-provided test-remote environment +- [x] AC6: Existing local Turso adapters continue opening with multiprocess WAL where they do today, while no ETL, credential discovery/persistence, control-plane, lifecycle, doctor, setup, hook, `sce trace sync`, or background-sync behavior is introduced. + - Validate: `nix flake check` and inspection of command/lifecycle registrations +- [x] AC7: Durable context distinguishes the multiprocess local capture source, single-owner reconstructible Turso Sync replica, and remote DWH; records caller-owned credentials and the exact ownership rule; and captures observed bootstrap behavior and SDK constraints relevant to the next ETL PR. + - Validate: inspect the updated focused Agent Trace DWH/DB/Turso/path context and root architecture/context-map/glossary entries against the implemented API and tests + +- [x] AC8: A truly empty remote — no `__sce_migrations` ledger, none of the seven Agent Trace DWH contract tables, and no unrelated user-defined tables — is classified `Empty`; `AgentTraceDwhReplica::open()` against it applies the current `AgentTraceDwhDbSpec::migrations()` locally via `AgentTraceDwhDb::run_migrations()`, publishes them with `push()`, and returns a `Ready` replica; a second fresh replica opened against the same remote afterward observes the published schema as `Ready` without re-running migrations. + - Validate: extended `agent_trace_dwh_replica_turso_sync_integration` — fresh-empty-remote auto-initialization, followed by a second independent replica proving remote publication +- [x] AC9: An already-ready DWH remote continues to open unchanged: no migrations rerun, no push performed merely because `open()` was called, and existing data intact. + - Validate: integration assertion that opening a prepared-ready remote leaves its migration ledger and row counts unchanged +- [x] AC10: A remote containing an unrelated schema, a partially-initialized DWH (some but not all contract tables, no valid ledger), or a migration ledger with unexpected/unknown entries is classified `Incompatible`; `open()` fails loudly for each case without adding, repairing, or upgrading any schema. + - Validate: three targeted integration rejection cases (unrelated schema, partial DWH tables, unexpected ledger entries) +- [x] AC11: Two distinct local replicas racing to initialize against the same empty remote converge on exactly one valid DWH schema and migration ledger, with no duplicate or corrupt schema state; a third fresh replica bootstraps `Ready` afterward. + - Validate: a concurrent-first-initializers integration test using two distinct local replica paths against one empty remote, plus a third verifying replica +- [x] AC12: `AgentTraceDwhReplicaError` distinguishes lock, open, schema-inspection, incompatible-schema, schema-initialization, schema-publication, and readiness-verification failures; no variant ever includes the caller-supplied auth token; the incompatible-schema variant explains that automatic initialization is allowed only for a genuinely empty remote/replica. + - Validate: targeted unit tests per new error variant, including token-redaction assertions +- [x] AC13: Durable context describes SCE as sole owner of the Agent Trace DWH schema and migrations, control-plane as owner of remote provisioning and credentials only (no schema dependency or bundle), and `AgentTraceDwhReplica` as the boundary that publishes SCE's schema only into a genuinely empty remote; the architecture decision record reflects the revised guardrail. + - Validate: inspect updated `context/sce/agent-trace-dwh-replica.md`, `context/sce/agent-trace-dwh-db.md`, and the new superseding decision record against the implemented API/tests; confirm no control-plane schema/migration surface was introduced + +### Full validation + +- `nix flake check` +- `nix run .#pkl-check-generated` + +### Context sync + +- Add focused durable context for the `AgentTraceDwhReplica` boundary and register it in `context/context-map.md`. +- Update `context/sce/agent-trace-dwh-db.md`, `context/sce/agent-trace-db.md`, `context/sce/shared-turso-db.md`, and `context/cli/default-path-catalog.md` for the three-database architecture, canonical paths, ownership, bootstrap/readiness behavior, and SDK constraints. +- Update `context/architecture.md` and `context/glossary.md` with the single-owner replica boundary and repair the temporary PR2 statements that the DWH has no canonical local sync path or sync owner. +- Update `context/overview.md` only where its high-level database-boundary description needs the new current state. +- Update `context/sce/agent-trace-dwh-replica.md` and `context/sce/agent-trace-dwh-db.md` with the empty-remote auto-initialization state machine, schema-state classification, crash/recovery semantics, and concurrent-initialization behavior. +- Record the revised guardrail (empty-remote auto-initialization is now allowed; non-empty incompatible schemas are still never repaired) as a new dated decision superseding `context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md`, per this repository's immutable-accepted-ADR convention (see Open questions). + +## Constraints and non-goals + +- **In scope:** Turso's current `sync` crate feature, a minimal direct OS-lock dependency, canonical replica/lock path helpers, a dedicated replica module and safe DWH connection seam, focused ownership/path tests, practical Turso Sync integration coverage, durable architecture documentation, an explicit `AgentTraceDwhSchemaState` (or equivalent) classification on the DWH adapter, and empty-remote auto-initialization (migrate locally via the existing `AgentTraceDwhDb::run_migrations()`, then `push()`) inside `AgentTraceDwhReplica::open()`. +- **Out of scope:** Source extraction or transformation, hashing, watermark reads/advancement, source busy retry, control-plane/provisioning calls (including control-plane owning or bundling a copy of the DWH schema), OAuth, credential discovery or persistence, token rotation, CLI commands (including `sce trace sync`), lifecycle/setup/doctor/hook wiring, automatic/background sync, archive/retention behavior, partial sync, automatic schema upgrades for an existing non-empty DWH that is behind the current migration set, schema downgrade, schema repair, and remote database deletion/recreation. +- **Constraints:** Keep Turso at `0.7.0` unless compilation proves a version change necessary; enable its supported `sync` feature without changing existing local-only behavior; never call `experimental_multiprocess_wal(true)` on the replica; acquire `.bridge-lock` non-blockingly before any sync open; retain the lock file and hold its OS lock for the complete replica lifetime; never expose the auth token in diagnostics; run local DWH migrations during replica open only when the schema classifies as genuinely `Empty`, using the existing `AgentTraceDwhDb::run_migrations()` rather than a new migration implementation; treat `AgentTraceDwhDbSpec::migrations()` as the sole authoritative migration list. +- **Non-goal:** Generalize every `TursoDb` into a sync-capable adapter or let application/hook processes open the replica. The new abstraction is the only Turso Sync builder owner and is designed for the future ETL bridge process. Automatic repair or upgrade of an existing non-empty DWH schema remains a separate, later design decision. + +## Assumptions + +- `fs4` `0.13.1`, already present transitively in `cli/Cargo.lock`, is the minimal project-compatible direct dependency for non-blocking advisory whole-file locks on Linux/macOS; the implementation will hold the opened `File` rather than deleting the lock path. +- Turso `0.7.0` exposes sync through the `sync` feature and `turso::sync::Builder`; its builder defaults `bootstrap_if_empty` to true, so reconstruction uses normal SDK bootstrap rather than physical copying. +- The central/test DWH is provisioned before a replica opens and carries the same `__sce_migrations` contract as `AgentTraceDwhDb`; this PR detects missing/incompatible schema but does not create it remotely. +- The replica may use synchronous public wrappers around its owned current-thread Tokio runtime if that is required to preserve the CLI's existing blocking `AgentTraceDwhDb` SQL API; exact async spelling is subordinate to lock-safe ownership and thin Turso Sync pull/push semantics. +- Remote integration tests may use the pinned local `tursodb` package when it supports the required sync protocol, otherwise a caller-provided disposable test URL/token environment; credentials and remote state are never checked in or printed. +- This change request revises the same `AgentTraceDwhReplica` boundary (PR #189) that T01–T03 built, so it continues this plan by appending tasks rather than starting a new plan file; completed tasks/evidence above are preserved unchanged. +- The push-conflict recovery path (definition of done: "concurrent first initializers converge safely or fail/recover deterministically") is implemented narrowly: on a push failure during the `Empty` branch only, attempt exactly one `pull()` followed by `ensure_dwh_schema_ready()`; if that reports `Ready`, treat initialization as successful; otherwise return the original push failure, not a swallowed/generic error. +- The literal request to edit `context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md` in place is superseded by this repository's `2026-07-30-synchronization-scoped-decision-writing` convention (never edit an accepted ADR; write a new dated superseding record instead). T08 follows that convention. See Open questions. + +## Task stack + +- [x] T01: `Add replica paths and the single-owner bridge lock` (status:done) + - Task ID: T01 + - Goal: Establish the canonical repository-scoped replica/lock locations and a reusable non-blocking lock guard that proves exactly-one-process ownership without touching source storage. + - Boundaries (in/out of scope): In — `default_paths` replica and lock helpers with repository-ID validation parity, direct minimal file-lock dependency, bridge-lock guard/acquisition errors, lock lifetime/drop behavior, same-process contention/reacquisition, subprocess-death, source-isolation, and path tests in the repository's filesystem-test-appropriate harness. Out — Turso Sync open, credentials, schema readiness, pull, and push. + - Dependencies: none + - Done when: Paths resolve under the repository state directory without colliding with `agent-trace.db`; lock acquisition is non-blocking and actionable; the lock remains held by the guard and is released by drop/process exit; the on-disk lock file is retained; source DB work succeeds while the bridge lock is held; targeted ownership/path tests pass on supported Unix CI platforms. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_path`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_lock`; inspect that only the `.bridge-lock` file is passed to the locking API. + - Implementation evidence: Added `agent_trace_dwh_replica_path_for_repository[_at]` and `agent_trace_dwh_bridge_lock_path_for_repository[_at]` to `cli/src/services/default_paths.rs`, mirroring the existing `agent_trace_db_path_for_repository_at` repository-ID validation and resolving `/sce/repos//agent-trace-sync.db[.bridge-lock]`. Added `cli/src/services/agent_trace_dwh_replica/{mod.rs,lock.rs}` with a `BridgeLock` guard built on `fs4`'s non-blocking `try_lock_exclusive`/`unlock` (`fs4 = "0.13.1"` promoted to a direct `cli/Cargo.toml` dependency; only the `.bridge-lock` `File` is ever passed to the locking API). The guard creates and retains the lock file, never deletes it, and releases only the OS lock on `Drop`. Registered the new module in `cli/src/services/mod.rs`. + - Verification evidence: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_path` (5 passed); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_lock` (6 passed, 1 ignored subprocess-helper test invoked by the real-subprocess-death test); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (213 passed, 1 ignored, 0 failed — full workspace regression); `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` (clean). Lock tests cover: file creation/retention, non-blocking contention rejection with actionable guidance, reacquisition after drop, reacquisition after a real killed subprocess (proving OS-level release on process death, not just `Drop`), same-process double-acquisition rejection, and that source `agent-trace.db` writes (via `RepositoryAgentTraceDb`) succeed while the bridge lock is held. Inspected call sites: `try_lock_exclusive`/`unlock` in `lock.rs` are only ever invoked on the `File` opened at the caller-supplied bridge-lock path. + +- [x] T02: `Implement and prove the Turso Sync replica boundary` (status:done) + - Task ID: T02 + - Goal: Add the lock-owning `AgentTraceDwhReplica` API with caller-provided credentials, remote bootstrap, DWH readiness, local SQL access, pull, and push, backed by integration evidence. + - Boundaries (in/out of scope): In — Turso `sync` feature enablement, typed config, dedicated sync builder ownership, any narrow shared-DB connection/runtime seam needed to reuse `AgentTraceDwhDb`, lock-before-open ordering, schema-readiness classification, credential-safe errors, pull/push wrappers, concurrent replica rejection, fresh bootstrap, independent pull/push verification, and reconstruction tests. Out — local schema migration/provisioning, ETL/domain writes, retries beyond SDK behavior, token acquisition/storage, lifecycle/CLI/background wiring, partial sync, and changes to source DB open semantics. + - Dependencies: T01 + - Done when: A prepared DWH remote can bootstrap a missing `agent-trace-sync.db`; open fails before Turso access when the lock is owned; the resulting object owns both lock and DWH access; schema readiness is non-mutating; pull/push work through independent peers; local deletion reconstructs remote data; token-bearing failure cases are redacted; no sync builder enables multiprocess WAL; targeted local and remote-harness tests pass and record observed SDK/bootstrap constraints for context sync. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica`; run the repository-owned Turso Sync integration harness against a disposable prepared DWH remote (using `nix develop .#database` when a local server is supported), then independently inspect pull, push, reconstruction, and incompatible-schema outcomes; inspect error assertions with a sentinel auth token. + - Implementation evidence: Enabled `turso`'s `sync` feature in `cli/Cargo.toml`. Added a narrow `pub(crate)` seam to `cli/src/services/db/mod.rs` — `TursoDb::from_connection(conn, runtime)` builds a `TursoDb` from an already-open connection/runtime without opening a new local-path builder or running migrations, and `TursoDb::block_on(future)` lets a companion handle opened alongside it (a Turso Sync `Database`) drive async operations on the same runtime. Added `cli/src/services/agent_trace_dwh_replica/replica.rs` with `AgentTraceDwhReplicaConfig` (explicit `local_path`/`database_url`/`auth_token`, redacted `Debug`) and `AgentTraceDwhReplica`: `open()` acquires the `BridgeLock` first (non-blockingly, before any Turso access), then opens via `turso::sync::Builder::new_remote(local_path).with_remote_url(..).with_auth_token(..)` (never `.experimental_multiprocess_wal(true)`), wraps the resulting connection into `AgentTraceDwhDb` via `from_connection`, and calls `ensure_dwh_schema_ready()` (non-mutating; no local migrations run). `db()` exposes lock-lifetime-bound SQL access; `pull()`/`push()` wrap `turso::sync::Database::pull`/`push` via `block_on`. All error variants (`Lock`, `Runtime`, `Open`, `SchemaNotReady`, `Pull`, `Push`) are built through a `redact_token` helper so the auth token never reaches a `Display`/error string. Registered the module in `cli/src/services/agent_trace_dwh_replica/mod.rs`. + - Verification evidence: `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica` (17 passed, 1 ignored subprocess helper) — includes a real Turso Sync integration test (`agent_trace_dwh_replica_turso_sync_integration`) run against the pinned local `tursodb --sync-server` (`nix develop .#database`), which prepares a genuine DWH-schema remote via `AgentTraceDwhDb::run_migrations` + push, then independently verifies: fresh bootstrap of a missing replica against a prepared remote succeeds; a concurrent open against the same local path is rejected with `AgentTraceDwhReplicaError::Lock` before any Turso access; a peer opened before any writes starts empty; a push from one peer becomes visible to another peer only after `pull()` (both directions); deleting the local replica directory and reopening at the same path reconstructs all previously published data via fresh bootstrap; and a remote bootstrapped with an unrelated (non-DWH) schema is rejected as `SchemaNotReady` without the sentinel auth token appearing in the error. The same harness run outside `nix develop .#database` (default shell) deterministically skips with a printed reason instead of failing, confirmed via `-- --nocapture`. Unit tests (no server needed) cover: bridge-lock-path derivation from an explicit replica path, lock-before-open ordering using an unreachable remote (proves the lock check runs before any Turso builder/network access), auth-token redaction on an open failure against an unreachable remote, and `redact_token` behavior (multi-occurrence replacement, empty-token no-op). Also ran: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (219 passed, 1 ignored, 0 failed — full workspace regression; one unrelated pre-existing test, `agent_trace_db::repository::tests::concurrent_missing_source_instance_id_initialization_converges_on_one_persisted_winner`, flaked once under full-suite parallel load and passed on isolated rerun, confirmed unrelated to this change); `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` (clean). + - Observed SDK/bootstrap constraints for context sync (T03): `turso::sync::Builder::new_remote(path)` takes the *local* file path (the name is not about the remote); the local file's parent directory must already exist before `build()` — `AgentTraceDwhReplica::open` relies on `BridgeLock::acquire` to create it, since the sync builder does not. `bootstrap_if_empty` defaults to `true`, so a missing/empty local file is bootstrapped from the remote automatically inside `build()`/`create()` — no separate bootstrap call is needed or available. `turso::sync::Database` exposes async `pull()`/`push()`/`connect()`/`stats()`/`checkpoint()`; there is no synchronous wrapper, so callers driving it from sync code need their own current-thread runtime (reused here via the new `TursoDb::block_on` seam). The `sync` Cargo feature is additive (`dep:hyper`, `dep:tokio`, etc.) and required no Turso version change from the pinned `0.7.0`. The local `tursodb` binary from `nix develop .#database` supports `--sync-server ` and speaks the same `/v2/pipeline` HTTP protocol as Turso Cloud, making it a viable disposable local remote for integration tests without any external network dependency. + +- [x] T03: `Document the source-replica-DWH operating model` (status:done) + - Task ID: T03 + - Goal: Make the three-database architecture, single-owner rule, reconstructibility, credential boundary, and observed Turso SDK behavior durable and discoverable for the next ETL PR. + - Boundaries (in/out of scope): In — focused replica context, context-map registration, source/DWH/shared-Turso/default-path updates, root architecture/glossary updates where important, and concrete bootstrap/pull/push test observations plus SDK constraints from T02. Out — ETL/control-plane/CLI design beyond explicitly naming those deferred boundaries, speculative retry or synchronization policy, and implementation changes. + - Dependencies: T02 + - Done when: Documentation states exactly one OS process may own a repository's `agent-trace-sync.db`; application/hooks never open it; its lock cannot block `agent-trace.db`; the replica is disposable/reconstructible; callers provide URL/token; remote provisioning, ETL, and CLI sync remain deferred; current code no longer conflicts with PR2's temporary no-sync-path wording; and T02's bootstrap/integration results and next-PR SDK constraints are recorded. + - Verification notes (commands or checks): inspect all context-sync paths against `cli/src/services/agent_trace_dwh_replica/`, `default_paths`, dependency features, and the integration evidence; verify every new context file is linked from `context/context-map.md` and root terminology is consistent. + - Implementation evidence: Added `context/sce/agent-trace-dwh-replica.md` covering `AgentTraceDwhReplica`'s ownership/lock-before-open sequencing, pull/push and reconstruction semantics, credential-safe error redaction, the observed Turso Sync SDK constraints recorded by T02, and the explicitly deferred (not-yet-implemented) ETL/control-plane/CLI/lifecycle boundaries. Registered it in `context/context-map.md`. Updated `context/cli/default-path-catalog.md` and `context/sce/agent-trace-dwh-db.md` to point at the now-implemented `AgentTraceDwhReplica`/`replica.rs` boundary in place of PR2's temporary "not yet implemented" wording. Updated `context/sce/shared-turso-db.md` to document the new `TursoDb::from_connection`/`block_on` seam and register `cli/src/services/agent_trace_dwh_replica/mod.rs` in its module list. Updated `context/glossary.md`'s `Agent Trace DWH sync replica` entry to describe the implemented Turso Sync boundary (previously "no code opens a Turso Sync connection at this path yet") and cross-linked it to the new focused context file. Inspected `context/architecture.md` and `context/overview.md`: neither contains PR2's temporary no-canonical-sync-path wording, and `overview.md`'s high-level database list does not mention the DWH/replica at all yet, so no change was needed there under the plan's conditional context-sync instruction. + - Verification evidence: Inspected every context-sync file's prose against `cli/src/services/agent_trace_dwh_replica/{mod.rs,lock.rs,replica.rs}`, `default_paths.rs`, `cli/Cargo.toml`'s `sync` feature, and T02's recorded integration evidence — no discrepancies found. Confirmed `context/sce/agent-trace-dwh-replica.md` is linked from `context/context-map.md`, `context/glossary.md`, `context/sce/agent-trace-dwh-db.md`, and `context/sce/shared-turso-db.md`, and that it links back to all four plus `context/cli/default-path-catalog.md`. `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica` (17 passed, 1 ignored subprocess helper); `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` (clean). + +- [x] T04: `Add explicit DWH schema-state classification` (status:done) + - Task ID: T04 + - Goal: Give `AgentTraceDwhDb` a reusable, non-mutating classification distinguishing a genuinely empty schema from an existing incompatible one, replacing the unsafe "readiness check failed -> assume empty" shortcut. + - Boundaries (in/out of scope): In — an `AgentTraceDwhSchemaState` enum (or equivalent) with at least `Ready`, `Empty`, and `Incompatible(String)` variants; a classification method on `AgentTraceDwhDb` built on the existing `migration_metadata_problems()`/`ensure_dwh_schema_ready()` machinery plus a check for the seven contract tables (`repositories`, `source_instances`, `etl_watermarks`, `messages`, `message_parts`, `agent_traces`, `code_changes`) and any unrelated user-defined table via `sqlite_master`; unit tests against locally opened (non-sync) databases covering fresh-empty, ready-after-`run_migrations`, unrelated-table-only, partial-contract-table, and unexpected-ledger-entry fixtures. Out — wiring this into `AgentTraceDwhReplica::open()` (T05), and any Turso-Sync-specific internal-object exclusion list not yet proven by a real sync bootstrap (T05's integration test proves that). + - Dependencies: none + - Done when: The classification method returns `Ready` only when `ensure_dwh_schema_ready()` would succeed; returns `Empty` only when there is no `__sce_migrations` table, none of the seven contract tables, and no other user-defined table; returns `Incompatible` with an explanatory message for every other case (unrelated schema, partial DWH tables without a valid ledger, and a ledger with unexpected/unknown migration IDs); unit tests pass. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_db` + - Implementation evidence: Added `AgentTraceDwhSchemaState` (`Ready`, `Empty`, `Incompatible(String)`) and `AgentTraceDwhDb::classify_schema_state()` to `cli/src/services/agent_trace_dwh_db/mod.rs`. The method first calls the existing `migration_metadata_problems()`: an empty result classifies `Ready`. Otherwise it queries `sqlite_master` for every non-`sqlite_%` table (covering `__sce_migrations` itself, all seven DWH contract tables, and any unrelated user-defined table in one scan); no matching table classifies `Empty`, and any matching table classifies `Incompatible` with a message naming the found table(s) and the underlying migration-metadata problem(s) (missing ledger table, wrong applied count, or unexpected/missing migration IDs) — this also correctly rejects the ledger-present-but-no-contract-tables case, since `__sce_migrations` itself counts as a found table. Added five unit tests covering fresh-empty (`open_without_migrations_at`, no migrations run), ready (`new_at`), unrelated-table-only (raw `CREATE TABLE unrelated_table`), partial-contract-table-without-ledger (raw `CREATE TABLE repositories` only, no `__sce_migrations`), and unexpected-ledger-entry (`new_at` then a manual `INSERT INTO __sce_migrations` with an unknown ID). + - Verification evidence: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_db` (14 passed, 0 failed — all pre-existing DWH DB tests plus the 5 new classification tests); `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` (clean, after applying one auto-format pass to the new test bodies). + +- [x] T05: `Auto-initialize a genuinely empty DWH remote in AgentTraceDwhReplica::open()` (status:done) + - Task ID: T05 + - Goal: Replace the current verify-only `ensure_dwh_schema_ready()` open path with a classify-then-branch state machine: `Ready` continues unchanged, `Empty` runs `AgentTraceDwhDb::run_migrations()` locally, publishes with `push()`, verifies `Ready`, and recovers narrowly from a push conflict via one `pull()` + re-verify; `Incompatible` fails loudly. + - Boundaries (in/out of scope): In — rewiring `AgentTraceDwhReplica::open()` around T04's classification; extending `AgentTraceDwhReplicaError` with schema-inspection, incompatible-schema, schema-initialization, schema-publication, and readiness-verification variants (all token-redacted); the narrow push-conflict recovery path; refactoring the existing `agent_trace_dwh_replica_turso_sync_integration` harness so its main fresh-bootstrap case starts from a truly empty remote and proves `open()` initializes and publishes it itself (verified via a second independent fresh replica), removing the requirement that tests call `prepare_remote_with_dwh_schema()` before a normal `open()`; keeping a renamed/retained helper for constructing an already-ready remote (for the unchanged-on-open case) and one for an incompatible remote. Out — the three additional rejection scenarios beyond the existing unrelated-schema case (T06), the concurrent-initializer test (T07), and documentation (T08). + - Dependencies: T04 + - Done when: A truly empty remote initializes automatically through `open()` and a second fresh replica observes the published schema as `Ready`; an already-`Ready` remote opens without any migration run or push; an `Incompatible` remote fails `open()` with an actionable error that never contains the auth token and explains that automatic initialization only applies to an empty remote/replica; targeted and integration tests pass. + - Verification notes (commands or checks): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica`; `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_turso_sync_integration` + - Implementation evidence: Rewired `AgentTraceDwhReplica::open()` (`cli/src/services/agent_trace_dwh_replica/replica.rs`) to call `AgentTraceDwhDb::classify_schema_state()` after connecting and branch on `AgentTraceDwhSchemaState`: `Ready` is left unchanged; `Empty` calls the new `initialize_empty_schema()` helper, which runs `AgentTraceDwhDb::run_migrations()` locally, then `push()`s — on a push failure it makes exactly one best-effort `pull()` followed by `ensure_dwh_schema_ready()` re-verification, returning the *original* push error (never the pull error or a swallowed one) unless that re-verification now reports ready, in which case another initializer is treated as having won the race; `Incompatible(reason)` returns immediately with an error explaining automatic initialization only applies to a genuinely empty remote/replica. Extended `AgentTraceDwhReplicaError` with `SchemaInspection`, `IncompatibleSchema`, `SchemaInitialization`, `SchemaPublication`, and `ReadinessVerification` variants (replacing the old blanket `SchemaNotReady`), each built through the existing `redact_token` helper so the auth token never appears in any of them. While proving this against the real Turso Sync harness, discovered that a freshly bootstrapped Turso Sync database is never truly table-empty at the SQLite level — it carries Turso's own internal change-data-capture and autoincrement bookkeeping tables (`turso_cdc`, `turso_cdc_version`, and tables prefixed `__turso_internal`) even before any user schema exists. T04's `classify_schema_state()` (`cli/src/services/agent_trace_dwh_db/mod.rs`) did not know about these and misclassified a genuinely empty Turso Sync remote as `Incompatible`; added `is_turso_sync_internal_table()` and filtered these tables out of the `sqlite_master` scan so a genuinely empty remote/replica still classifies as `Empty`. Refactored the `agent_trace_dwh_replica_turso_sync_integration` harness (`cli/src/services/agent_trace_dwh_replica/replica.rs`): the main fresh-bootstrap case (`assert_bootstrap_lock_and_pull_push`) no longer calls `prepare_remote_with_dwh_schema()` before opening `replica_a` — it now opens directly against a freshly spawned, untouched `tursodb --sync-server` remote, proving `open()` itself initializes and publishes the DWH schema (asserted via `classify_schema_state() == Ready` on both `replica_a` and the independently bootstrapped `replica_b` peer); extracted the AC5 bidirectional pull/push exchange into `assert_bidirectional_pull_push()` to keep the caller under Clippy's line-count limit. Renamed the schema-preparation helper to `prepare_remote_with_ready_dwh_schema()` and added a new `assert_ready_remote_opens_unchanged()` case proving the already-`Ready` remote opens without any migration or push: it opens two independent fresh replicas against the same prepared-ready remote and asserts both observe exactly `AgentTraceDwhDbSpec::migrations().len()` applied-migration rows (no duplication from a hypothetical rerun). Updated `assert_incompatible_schema_is_rejected()` to assert `AgentTraceDwhReplicaError::IncompatibleSchema` (renamed from `SchemaNotReady`) and that its message never contains the sentinel auth token. + - Verification evidence: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica` (16 passed, 1 ignored subprocess helper); `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica` (16 passed, 1 ignored — includes the real Turso Sync integration test, now exercising a truly empty remote for the fresh-bootstrap case, the already-ready unchanged-on-open case, and the incompatible-schema rejection case, all against the pinned local `tursodb --sync-server`); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_db` (14 passed — T04's classification tests still pass with the new internal-table filter, since none of their fixtures use those table names); `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` (clean); `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (223 passed, 1 ignored, 0 failed — full workspace regression, no flake this run). + +- [x] T06: `Reject unrelated, partial, and ledger-mismatched DWH schemas` (status:done) + - Task ID: T06 + - Goal: Prove, with real Turso Sync integration coverage, that `open()` rejects every non-empty incompatible remote shape without repairing or partially completing it. + - Boundaries (in/out of scope): In — three integration cases against the harness from T05: an unrelated non-DWH schema (already covered, extend assertions to confirm no DWH tables are added), a partially-initialized DWH (one or several but not all contract tables, no valid ledger), and a non-empty database whose migration ledger contains unexpected/unknown migration IDs. Out — concurrent initialization (T07) and documentation (T08). + - Dependencies: T05 + - Done when: All three cases fail `open()` with `AgentTraceDwhReplicaError`'s incompatible-schema variant; none add, remove, or modify any table on the remote; tests pass under `nix develop .#database`. + - Verification notes (commands or checks): `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_turso_sync_integration` + - Implementation evidence: Extended `cli/src/services/agent_trace_dwh_replica/replica.rs`'s `integration_tests` module with `prepare_remote_with_partial_dwh_schema` (publishes only the `repositories` contract table, no `__sce_migrations` ledger) and `prepare_remote_with_unexpected_ledger_entry` (runs the real DWH migration baseline, then inserts and publishes an unknown `999_unknown_migration` ledger row). Added `remote_table_set()`, which opens a disposable raw Turso Sync connection and lists the remote's non-`sqlite_%` tables, and a shared `assert_open_is_rejected_without_mutating_remote()` that snapshots the remote's table set, asserts `open()` fails with `AgentTraceDwhReplicaError::IncompatibleSchema` and never leaks the sentinel auth token, then re-snapshots the table set and asserts it is byte-for-byte unchanged. Renamed the existing unrelated-schema case to `assert_unrelated_schema_is_rejected` and rebuilt it on the shared helper (now also proving no table mutation, which the prior version did not check); added `assert_partial_dwh_schema_is_rejected` and `assert_unexpected_ledger_entry_is_rejected` following the same pattern. Wired all three into `agent_trace_dwh_replica_turso_sync_integration`, each against its own freshly spawned `LocalSyncServer`. No production code in `cli/src/services/agent_trace_dwh_replica/replica.rs` or `cli/src/services/agent_trace_dwh_db/mod.rs` changed — T04/T05's `classify_schema_state`/`open()` state machine already handled all three shapes; this task adds integration proof only. + - Verification evidence: `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_turso_sync_integration` (1 passed, exercising the fresh-bootstrap/lock/pull-push case, the already-ready-unchanged case, and now three incompatible-schema rejection cases — unrelated schema, partial contract tables, unexpected ledger entry — each asserting `IncompatibleSchema`, no token leak, and an unmutated remote table set); `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica` (16 passed, 1 ignored subprocess helper — full module regression); `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` (clean). + +- [x] T07: `Prove concurrent first initializers converge safely` (status:done) + - Task ID: T07 + - Goal: Prove two independent local replicas racing to initialize the same empty remote converge on exactly one valid DWH schema, and exercise T05's push-conflict recovery path against real observed Turso Sync behavior. + - Boundaries (in/out of scope): In — an integration test opening two distinct local replica paths against one empty remote as close to concurrently as the harness can create (e.g. two threads), asserting the remote ends with one valid schema/ledger, no duplicate/corrupt state, and that a third fresh replica bootstraps `Ready`; recording the actually observed Turso Sync push-conflict/error behavior; adjusting T05's recovery path only if the observed behavior requires it, without loosening it into generic error-swallowing. Out — any CLI/lifecycle wiring or cross-machine credential handling. + - Dependencies: T05, T06 + - Done when: The concurrent test passes deterministically (or with a documented bounded retry appropriate to the harness) and shows a single converged, valid remote schema; a third fresh replica opens `Ready`; the recovery path's behavior is backed by an integration assertion, not string-matched SDK error text where a structured signal is available. + - Verification notes (commands or checks): `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_turso_sync_integration` + - Implementation evidence: Added `assert_concurrent_first_initializers_converge` to `cli/src/services/agent_trace_dwh_replica/replica.rs`'s `integration_tests` module: six distinct local replica paths (`RACER_COUNT = 6`, more than the plan's minimum two, to raise collision odds) race to `open()` against one freshly spawned, untouched `tursodb --sync-server` remote, released simultaneously via a `std::sync::Barrier` so their `open()` calls start as close to concurrently as the harness can create. Every racer is asserted to succeed (each concurrent initializer must either win the race or recover through `initialize_empty_schema`'s existing one-`pull()`-and-re-verify path — no swallowed/generic error is tolerated, matching T05's narrow recovery contract), and every racer's converged view is asserted to classify `Ready` with exactly `AgentTraceDwhDbSpec::migrations().len()` applied-migration rows (no duplication). After dropping all six, a third, entirely fresh replica opens against the same remote and is asserted to observe the same single converged `Ready` schema with the identical non-duplicated migration count. Wired into `agent_trace_dwh_replica_turso_sync_integration`. Observed real Turso Sync behavior (recorded for T08): repeated runs of this test — both the original two-thread version and the six-racer barrier-released version, verified with temporary instrumentation on `initialize_empty_schema`'s push-failure branch — never observed an actual push conflict/error from the local `tursodb --sync-server` even at six-way concurrency; every racer's `run_migrations()` + `push()` succeeded on its own first attempt against the shared empty remote. The one-`pull()`-and-re-verify recovery path therefore remains defensively in place (unexercised by this harness) rather than being provably dead: the local `tursodb --sync-server` appears to serialize/accept concurrent first-time schema pushes to an empty remote without surfacing a conflict, so no adjustment to T05's recovery path was warranted by observed behavior. + - Verification evidence: `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_turso_sync_integration` run 8 times total across the two implementation iterations (5 runs of the original 2-racer version, 3 of the final 6-racer barrier version) -> exit 0 every time (1 passed each run), confirming deterministic convergence; `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica` (16 passed, 1 ignored subprocess helper — full module regression); `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` (clean); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` (clean); `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` (223 passed, 1 ignored, 0 failed on a clean rerun — one earlier parallel run flaked 3 tests, `concurrent_missing_source_instance_id_initialization_converges_on_one_persisted_winner` plus two `agent_trace_dwh_db` classification tests, all under `database is locked`/timeout errors consistent with the pre-existing SQLite lock-contention flake already documented in T02's evidence and the plan's Validation Report residual risk; not a regression from this task's changes, confirmed by an isolated rerun of `agent_trace_dwh_db` alone passing 14/14 and a subsequent full-suite rerun passing 223/223). + +- [x] T08: `Document the empty-DWH auto-initialization ownership model` (status:done) + - Task ID: T08 + - Goal: Make the revised `open()` state machine, schema-state classification, crash/recovery semantics, and concurrent-initialization behavior durable, and record the ownership-model revision as a new decision superseding the prior guardrail. + - Boundaries (in/out of scope): In — updating `context/sce/agent-trace-dwh-replica.md` (state machine, `Empty`/`Ready`/`Incompatible` classification, crash-before-migrations/after-migrations/after-push semantics, concurrent-initializer behavior, and the T07-observed SDK conflict behavior) and `context/sce/agent-trace-dwh-db.md` (the new classification method); writing a new dated decision record that supersedes `context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md`'s "must not locally provision or migrate DWH schema" guardrail with the revised rule, per this repository's immutable-accepted-ADR convention, and documenting the SCE/control-plane/`AgentTraceDwhReplica` ownership split explicitly stating control-plane needs no DWH schema copy. Out — editing the accepted 2026-08-08 record's body directly, and any ETL/control-plane/CLI design beyond naming those boundaries as still deferred. + - Dependencies: T07 + - Done when: Documentation states the revised guardrail and the classification/state-machine behavior precisely enough to match the implemented code; the new decision record references and supersedes the prior one; no stale "must not locally provision or migrate DWH schema" wording remains uncontradicted in current-state context; `context/context-map.md` reflects any changed file descriptions. + - Verification notes (commands or checks): inspect all touched context files against `cli/src/services/agent_trace_dwh_replica/replica.rs` and `cli/src/services/agent_trace_dwh_db/mod.rs`; confirm the new decision file exists under `context/decisions/` and is linked from the plan/glossary/context-map as applicable. + - Implementation evidence: Added `## Crash and recovery semantics` and `## Concurrent-initializer behavior` sections to `context/sce/agent-trace-dwh-replica.md`, covering the three interruption points of the `Empty` branch (before `run_migrations()`, after `run_migrations()` but before `push()`, and during/after an ambiguous `push()`), and T07's six-racer barrier-released convergence test plus the observed real-SDK behavior that the local `tursodb --sync-server` never surfaced an actual push conflict across repeated runs. `context/sce/agent-trace-dwh-db.md` already documented `classify_schema_state()` precisely (from a prior task's context synchronization) and needed no further edit for this task's scope. The superseding decision record, its links into current-state context (including this file's "See also" line and `context/context-map.md`), and the mandatory root-context pass are produced by this workflow's required context-synchronization phase, per this skill's rule that decision writing is channeled exclusively through that gate. + - Verification evidence: Inspected the new `context/sce/agent-trace-dwh-replica.md` sections against `initialize_empty_schema` and `assert_concurrent_first_initializers_converge` in `cli/src/services/agent_trace_dwh_replica/replica.rs` — no discrepancies. No code changed by this task, so no build/test commands apply; `context/sce/agent-trace-dwh-db.md` re-inspected against `classify_schema_state()` in `cli/src/services/agent_trace_dwh_db/mod.rs` and found already accurate. + +## Open questions + +- Requirement 17 of the change request asks to edit `context/decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md` directly. This repository's accepted `2026-07-30-synchronization-scoped-decision-writing` decision states accepted ADRs are never edited — corrections create a new dated superseding record instead. T08 follows that convention (a new superseding decision) rather than the literal instruction. If a direct edit to the accepted record is actually wanted instead, say so and T08's scope changes. + +## Validation Report + +**Status:** validated +**Date:** 2026-08-08 + +### Commands run + +- `nix flake check` -> flaky under the sandboxed parallel test build: attempts 1–4 each failed only on the pre-existing `services::agent_trace_db::repository::tests::concurrent_missing_source_instance_id_initialization_converges_on_one_persisted_winner` lock-contention flake (attempt 1 additionally flaked two `agent_trace_dwh_db` classification tests under the same contention); attempt 5 -> exit 0, "all checks passed!" +- `nix run .#pkl-check-generated` -> exit 0 (Ephemeral Pkl generation passed: 101 files, inventory sha256 766f5111af2434d6c345d07ae0aeb8b276aeeb94e7ecb7d39688c3f1267c8971) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_db` -> exit 0 (14 passed, isolated rerun confirming the flake above was load-induced, not a regression) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml` -> exit 0 (223 passed, 1 ignored, 0 failed, isolated full-workspace rerun) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica` -> exit 0 (16 passed, 1 ignored subprocess helper; includes `agent_trace_dwh_replica_turso_sync_integration`) +- `nix develop .#database -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml agent_trace_dwh_replica_turso_sync_integration -- --nocapture` -> exit 0 (1 passed; confirmed run against the pinned local `tursodb --sync-server`) +- `nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets -- -D warnings` -> exit 0 (clean) +- `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` -> exit 0 (clean) + +### Scaffolding removed + +- None. + +### Success-criteria verification + +- [x] AC1: Replica/bridge-lock paths resolve under `/sce/repos/{id}/agent-trace-sync.db[.bridge-lock]`, distinct from `agent-trace.db` -> `agent_trace_dwh_replica_path` suite passing within the `agent_trace_dwh_replica` module run +- [x] AC2: Non-blocking exclusive lock semantics (single owner, actionable rejection, reacquire after drop/process death, lock file retained, no interference with source DB) -> `agent_trace_dwh_replica_lock` suite passing within the same module run +- [x] AC3: Explicit-credential-only config, lock-before-open ordering, no multiprocess WAL, lock-lifetime-bound DB access, token redaction -> `open_fails_before_any_turso_access_when_the_bridge_lock_is_already_held`, `redact_token_*` unit tests passed; inspected `cli/src/services/agent_trace_dwh_replica/replica.rs` — `experimental_multiprocess_wal` never called +- [x] AC4: Remote bootstrap on a missing local replica gated by DWH schema readiness, incompatible-schema rejection -> `agent_trace_dwh_replica_turso_sync_integration` passed under `nix develop .#database` +- [x] AC5: Independent pull/push visibility and reconstruction after local deletion -> same integration test's `assert_bidirectional_pull_push` and reconstruction assertions +- [x] AC6: No multiprocess-WAL regression on existing adapters; no ETL/credential/control-plane/lifecycle/doctor/setup/hook/`sce trace sync`/background-sync introduced -> `nix flake check` passed on retry; `grep` across `cli/src` found no references to `agent_trace_dwh_replica`/`AgentTraceDwhReplica` outside `default_paths.rs` and its own module registration +- [x] AC7: Durable context distinguishes source/replica/DWH, records caller-owned credentials and the ownership rule, and captures SDK/bootstrap constraints -> inspected `context/sce/agent-trace-dwh-replica.md`, its registration in `context/context-map.md`, and cross-links +- [x] AC8: Fresh-empty-remote auto-initialization via `run_migrations()` + `push()`, second replica observes `Ready` -> `assert_bootstrap_lock_and_pull_push` in `agent_trace_dwh_replica_turso_sync_integration` opens directly against an untouched remote and asserts `classify_schema_state() == Ready` on both `replica_a` and independently bootstrapped `replica_b` +- [x] AC9: Already-ready remote opens unchanged, no migration/push rerun -> `assert_ready_remote_opens_unchanged` opens two independent fresh replicas against a prepared-ready remote and asserts identical, non-duplicated applied-migration counts +- [x] AC10: Unrelated schema, partial DWH schema, and unexpected-ledger-entry remotes classify `Incompatible` and fail `open()` without mutating the remote -> `assert_unrelated_schema_is_rejected`, `assert_partial_dwh_schema_is_rejected`, `assert_unexpected_ledger_entry_is_rejected`, each via the shared `assert_open_is_rejected_without_mutating_remote` helper that snapshots and re-asserts the remote's table set is unchanged +- [x] AC11: Concurrent first initializers converge on one valid schema; a third fresh replica bootstraps `Ready` -> `assert_concurrent_first_initializers_converge` races six barrier-released replicas against one empty remote, then opens a third fresh replica and asserts a single non-duplicated converged schema +- [x] AC12: `AgentTraceDwhReplicaError` distinguishes `Lock`, `Runtime`, `Open`, `SchemaInspection`, `IncompatibleSchema`, `SchemaInitialization`, `SchemaPublication`, `ReadinessVerification`, `Pull`, `Push`, none token-leaking, `IncompatibleSchema` explains empty-only auto-init -> inspected the enum in `cli/src/services/agent_trace_dwh_replica/replica.rs`; token-redaction unit tests and the integration harness's no-token-leak assertions all passed +- [x] AC13: Durable context describes SCE's sole schema/migration ownership, control-plane's credential/provisioning-only role, and the revised guardrail -> inspected `context/sce/agent-trace-dwh-replica.md`, `context/sce/agent-trace-dwh-db.md`, and `context/decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md` (states SCE/control-plane split and supersedes `2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md`); `grep` for `control_plane`/`control-plane` in `cli/src/services/agent_trace_dwh_replica/` and `cli/src/services/agent_trace_dwh_db/` found no matches + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- `services::agent_trace_db::repository::tests::concurrent_missing_source_instance_id_initialization_converges_on_one_persisted_winner` remains a pre-existing flaky test (SQLite lock contention under parallel full-suite/sandboxed-build load) unrelated to this plan's `agent_trace_dwh_replica` changes; this session observed it (and, once, two unrelated `agent_trace_dwh_db` tests under the same contention) flake across four consecutive `nix flake check` attempts before a clean pass on the fifth, and confirmed via isolated reruns that no `agent_trace_dwh_replica`/`agent_trace_dwh_db` test fails outside that contention. Not a blocker for this plan; worth a future fix (e.g. relaxed retry budget or serialized sandbox test execution) outside this scope. +- T07's push-conflict recovery path (`initialize_empty_schema`'s one-`pull()`-and-re-verify branch) remains unexercised by an observed real push conflict against the pinned local `tursodb --sync-server`, even at six-way concurrency; it is defensively in place and returns the original failure when re-verification does not report `Ready`, but is unproven against other Turso Sync remote implementations (recorded in the decision record's Compatibility and risks section). diff --git a/context/sce/agent-trace-dwh-db.md b/context/sce/agent-trace-dwh-db.md index ad567d33..343158ac 100644 --- a/context/sce/agent-trace-dwh-db.md +++ b/context/sce/agent-trace-dwh-db.md @@ -6,9 +6,10 @@ The Agent Trace DWH is a separate, append-oriented destination schema for a futu `cli/src/services/agent_trace_dwh_db/mod.rs` defines: -- `AgentTraceDwhDbSpec: DbSpec` — like `RepositoryAgentTraceDbSpec`, `db_path()` bails; the DWH has no canonical spec path yet because this schema explicitly excludes a local sync database and provisioning. Callers use the explicit-path `TursoDb` constructors. `db_config_key()` reuses `"agent_trace_db"` rather than adding new retry configuration surface. `migrations()` returns the build-time generated `generated_migrations::AGENT_TRACE_DWH_MIGRATIONS`, discovered from `cli/migrations/agent-trace-dwh/` the same way as every other `DbSpec` (see [shared-turso-db.md](shared-turso-db.md)). +- `AgentTraceDwhDbSpec: DbSpec` — like `RepositoryAgentTraceDbSpec`, `db_path()` bails: this adapter still has no canonical spec path and callers must use the explicit-path `TursoDb` constructors. A canonical local sync replica now exists as a *separate* boundary — `AgentTraceDwhReplica` (see [agent-trace-dwh-replica.md](agent-trace-dwh-replica.md)) — which opens a `AgentTraceDwhDb` over a Turso Sync connection rather than through this spec's own constructors. `db_config_key()` reuses `"agent_trace_db"` rather than adding new retry configuration surface. `migrations()` returns the build-time generated `generated_migrations::AGENT_TRACE_DWH_MIGRATIONS`, discovered from `cli/migrations/agent-trace-dwh/` the same way as every other `DbSpec` (see [shared-turso-db.md](shared-turso-db.md)). - `pub type AgentTraceDwhDb = TursoDb` — a fourth concrete `TursoDb` wrapper alongside `LocalDb`, `AuthDb`, and `RepositoryAgentTraceDb`. - `AgentTraceDwhDb::ensure_dwh_schema_ready()` — non-mutating readiness check delegating to the shared `TursoDb::ensure_schema_ready()`. +- `AgentTraceDwhDb::classify_schema_state()` — non-mutating `AgentTraceDwhSchemaState` classification (`Ready`, `Empty`, `Incompatible(String)`) built on `TursoDb::migration_metadata_problems()` plus a `sqlite_master` scan for any user-defined table. `Empty` requires no `__sce_migrations` table and no other user-defined table at all (including the seven DWH contract tables); every other non-ready case — an unrelated schema, a partial DWH schema, or a migration ledger with unexpected entries — classifies `Incompatible`. The `sqlite_master` scan excludes Turso Sync's own internal bookkeeping tables (`turso_cdc`, `turso_cdc_version`, and any table prefixed `__turso_internal`), which a freshly bootstrapped Turso Sync database carries even before any user-defined schema exists; without this exclusion a genuinely empty Turso Sync remote/replica would misclassify as `Incompatible`. `AgentTraceDwhReplica::open()` drives its empty-remote auto-initialization state machine directly off this classification (see [agent-trace-dwh-replica.md](agent-trace-dwh-replica.md)). The module is not registered with any lifecycle provider, doctor/setup flow, or CLI command; it is `#[allow(dead_code)]` at the `cli/src/services/mod.rs` registration until an ETL consumer exists. @@ -33,4 +34,4 @@ Two different uniqueness scopes are used, chosen by whether the source identity `message_parts.text` and `agent_traces.trace_json` store complete source text/JSON verbatim, with no truncation or normalization columns. Source event timestamps (`generated_at_unix_ms`, `commit_time_ms`, `time_ms`) are preserved as integer milliseconds, matching the source schema; only DWH-local metadata timestamps (`first_seen_at`, `updated_at`, `ingested_at`) use the shared UTC text default `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`. Integrity hash columns (`message_parts.text_sha256`, `agent_traces.trace_json_sha256`, `code_changes.patch_sha256`) exist as storage for a future hashing implementation; this schema does not compute or populate them. -See also: [agent-trace-db.md](agent-trace-db.md), [shared-turso-db.md](shared-turso-db.md), [../context-map.md](../context-map.md) +See also: [agent-trace-db.md](agent-trace-db.md), [agent-trace-dwh-replica.md](agent-trace-dwh-replica.md), [shared-turso-db.md](shared-turso-db.md), [../context-map.md](../context-map.md) diff --git a/context/sce/agent-trace-dwh-replica.md b/context/sce/agent-trace-dwh-replica.md new file mode 100644 index 00000000..c9c165f4 --- /dev/null +++ b/context/sce/agent-trace-dwh-replica.md @@ -0,0 +1,50 @@ +# Agent Trace DWH Turso Sync Replica + +`AgentTraceDwhReplica` is the sole owner of a Turso Sync connection to a repository's `agent-trace-sync.db` — a disposable, single-owner local database distinct from both the multiprocess-WAL source `agent-trace.db` (see [agent-trace-db.md](agent-trace-db.md)) and the `Agent Trace DWH`'s own explicit-path adapter (see [agent-trace-dwh-db.md](agent-trace-dwh-db.md)). It is the boundary a future ETL bridge process will use to pull from and push to the remote Agent Trace DWH; this repository does not yet run ETL, credential discovery/persistence, or any background sync against it. + +## Ownership and lock-before-open + +`cli/src/services/agent_trace_dwh_replica/replica.rs` defines `AgentTraceDwhReplica::open(config: AgentTraceDwhReplicaConfig)`, where `AgentTraceDwhReplicaConfig { local_path, database_url, auth_token }` are all caller-supplied explicit values — the replica never discovers, stores, or persists credentials itself. `open()`: + +1. Derives the sibling `.bridge-lock` path from `local_path` (the same suffix convention as `agent_trace_dwh_bridge_lock_path_for_repository`, see [../cli/default-path-catalog.md](../cli/default-path-catalog.md)) and acquires a `BridgeLock` **before** any Turso access. A concurrently held lock fails the whole call with `AgentTraceDwhReplicaError::Lock` before a Turso Sync builder, the local file, or the network is ever touched. +2. Opens the local file through `turso::sync::Builder::new_remote(local_path).with_remote_url(..).with_auth_token(..)`, never calling `.experimental_multiprocess_wal(true)` — that flag is reserved for the source capture database this replica never opens. +3. Wraps the resulting connection into `AgentTraceDwhDb` via a narrow `TursoDb::from_connection(conn, runtime)` seam (see [shared-turso-db.md](shared-turso-db.md)) and classifies its schema state via `AgentTraceDwhDb::classify_schema_state()` (see [agent-trace-dwh-db.md](agent-trace-dwh-db.md)). A `Ready` schema is left untouched. A genuinely `Empty` schema is initialized locally with `AgentTraceDwhDb::run_migrations()` and published with a single `push()`, narrowly recovering from a push conflict with one best-effort `pull()` plus a readiness re-verification (the *original* push failure is returned unless that re-verification now reports ready, in which case another initializer is treated as having won the race). An `Incompatible` schema — an unrelated schema, a partial DWH schema, or a migration ledger with unexpected entries — fails the whole call as `AgentTraceDwhReplicaError::IncompatibleSchema`, without repairing or partially completing it. + +The returned `AgentTraceDwhReplica` owns both the `BridgeLock` and the `AgentTraceDwhDb` connection for its lifetime; dropping it releases the lock. `AgentTraceDwhReplica::db()` exposes lock-lifetime-bound SQL access through the same `AgentTraceDwhDb` the replica opened — no second connection is created. + +## Pull and push + +`pull()` and `push()` wrap `turso::sync::Database::pull`/`push`, driven through `TursoDb::block_on(future)` — a seam that lets a companion async handle (the Turso Sync `Database`) run on the same current-thread runtime that owns the SQL connection, without the replica owning a second runtime. `pull()` returns whether any remote changes were applied; it is a no-op for an already up-to-date replica. Deleting the local replica file and its Turso sidecars, then reopening at the same path, reconstructs all previously published remote data via `open()`'s normal bootstrap path — the replica carries no state that isn't recoverable from the remote. + +## Crash and recovery semantics + +The `Empty` branch of `open()` has three distinct interruption points, because the local schema and the remote publication are not committed atomically: + +- **Interrupted before `run_migrations()` completes.** Nothing has been written locally or remotely. Losing the process also releases the `BridgeLock` at the OS level, so a fresh `open()` against the same `local_path` reclassifies the (still-untouched) local file as `Empty` again and restarts initialization from scratch. +- **Interrupted after `run_migrations()` but before `push()` starts or completes.** The local file now carries a fully-applied local schema that was never published. A fresh `open()` against the *same* `local_path` reopens that existing file directly rather than bootstrapping, so `classify_schema_state()` observes the already-applied local schema and classifies it `Ready` — `open()` takes the unchanged-on-open branch and does not retry the publish, leaving the remote `Empty` until some other opener publishes to it. Deleting the local file (the replica's normal disposability guarantee) forces the next `open()` at that path to bootstrap fresh from the still-`Empty` remote and re-run initialization. +- **Interrupted during or immediately after `push()`, with an ambiguous outcome.** This is the case `initialize_empty_schema`'s narrow recovery path exists for: a `push()` error (crash-adjacent or genuine SDK/network failure) triggers exactly one best-effort `pull()` plus `ensure_dwh_schema_ready()`. If that reports `Ready`, the push must have landed (by this opener or a racing one) and initialization is treated as successful; otherwise the *original* `push()` error is returned unchanged — never a swallowed or generic error. + +## Concurrent-initializer behavior + +Multiple local replicas may independently observe the same remote as `Empty` and race to initialize it — there is no external coordination beyond the recovery path above. `assert_concurrent_first_initializers_converge` in `replica.rs`'s integration harness races six distinct local replica paths against one freshly spawned, untouched remote, released simultaneously via a `Barrier`. Every racer is required to either win the race outright or recover through the one-`pull()`-and-re-verify path; the remote converges on exactly one valid schema and migration ledger (no duplicated `__sce_migrations` rows), and a third, entirely fresh replica opened afterward observes that same converged schema as `Ready`. + +Observed real-SDK behavior (recorded across repeated runs, including with temporary instrumentation on the push-failure branch): the pinned local `tursodb --sync-server` never surfaced an actual push conflict/error even at six-way simultaneous concurrency — every racer's `run_migrations()` + `push()` succeeded on its own first attempt. The one-`pull()`-and-re-verify recovery path therefore remains defensively in place but has not been exercised by an observed real conflict against this harness; it is not proven dead, only unexercised. This is a `tursodb --sync-server`-specific observation and should not be assumed to hold against Turso Cloud or another remote implementation. + +## Credential-safe errors + +`AgentTraceDwhReplicaError` (`Lock`, `Runtime`, `Open`, `SchemaInspection`, `IncompatibleSchema`, `SchemaInitialization`, `SchemaPublication`, `ReadinessVerification`, `Pull`, `Push`) never includes the caller-supplied auth token. Every message that could echo SDK/network error text is passed through a `redact_token` helper that replaces every occurrence of the token with `` before the error is constructed. + +## Observed Turso Sync SDK behavior + +- `turso::sync::Builder::new_remote(path)`'s `path` argument is the *local* file path, not a remote identifier — the name is unrelated to `with_remote_url`. +- The local file's parent directory must exist before `build()`; the sync builder does not create it. `AgentTraceDwhReplica::open` relies on `BridgeLock::acquire`'s existing directory-creation behavior for this. +- `bootstrap_if_empty` defaults to `true`, so a missing/empty local file is bootstrapped from the remote automatically inside `build()`— there is no separate explicit bootstrap call. +- `turso::sync::Database` exposes only async `pull()`/`push()`/`connect()`/`stats()`/`checkpoint()`; there is no synchronous wrapper in the SDK itself. +- The `sync` Cargo feature (`turso = { version = "0.7.0", features = ["sync"] }`) is additive and required no Turso version change from the pinned `0.7.0`. +- The local `tursodb` binary available via `nix develop .#database` supports `--sync-server ` and speaks the same `/v2/pipeline` HTTP protocol as Turso Cloud, so it serves as a disposable local remote for integration tests without any external network dependency. + +## Not yet implemented + +ETL extraction/transformation/hashing, watermark reads or advancement, source-busy retry, control-plane/provisioning calls, OAuth or credential discovery/persistence, token rotation, any CLI command, lifecycle/setup/doctor/hook wiring, automatic/background sync, archive/retention behavior, and partial sync all remain out of scope for this boundary. + +See also: [agent-trace-dwh-db.md](agent-trace-dwh-db.md), [agent-trace-db.md](agent-trace-db.md), [shared-turso-db.md](shared-turso-db.md), [../cli/default-path-catalog.md](../cli/default-path-catalog.md), [../glossary.md](../glossary.md), [../context-map.md](../context-map.md), and the accepted decision at [../decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md](../decisions/2026-08-08-agent-trace-dwh-empty-remote-auto-initialization.md) (superseding [../decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md](../decisions/2026-08-08-agent-trace-dwh-turso-sync-replica-ownership.md)) diff --git a/context/sce/shared-turso-db.md b/context/sce/shared-turso-db.md index 23b0c355..5dcc6563 100644 --- a/context/sce/shared-turso-db.md +++ b/context/sce/shared-turso-db.md @@ -22,6 +22,8 @@ - explicit-path no-migration opening through `open_without_migrations_at(path)` for path-resolved hot runtime callers - `migration_metadata_problems(&self) -> Result>`: non-mutating readiness check that queries `__sce_migrations` metadata and compares applied migration IDs against `M::migrations()`; returns a list of problems (missing metadata table, incomplete applied migrations, unexpected extra migrations) or an empty list when the schema is ready - `ensure_schema_ready(&self, setup_guidance: &str) -> Result<()>`: non-mutating hook-readiness gate that calls `migration_metadata_problems()` and bails with a formatted error including `M::db_name()` and the caller-provided guidance string when problems are found; returns `Ok(())` when the schema is ready + - `pub(crate) fn from_connection(conn: turso::Connection, runtime: tokio::runtime::Runtime) -> Self`: wraps an already-open connection and the runtime that opened it as a `TursoDb`, without opening a new connection, creating `__sce_migrations`, or running migrations. For callers that open a connection through a non-local-path builder — currently only `AgentTraceDwhReplica`'s Turso Sync open (see [agent-trace-dwh-replica.md](agent-trace-dwh-replica.md)) — and want to reuse this adapter's synchronous SQL surface and schema-readiness checks instead of duplicating them. + - `pub(crate) fn block_on(&self, future: F) -> F::Output`: runs a future to completion on the runtime backing this connection, so a companion async handle opened alongside it (e.g. a Turso Sync `Database` used for `pull`/`push`) can be driven without owning a second runtime. - `EncryptedTursoDb`: encrypted-adapter seam parallel to `TursoDb` with the same structural shape (connection, runtime bridge, and spec marker). `EncryptedTursoDb::new()` resolves the encryption key via `encryption_key::get_or_create_encryption_key()` (environment variable `SCE_AUTH_DB_ENCRYPTION_KEY` with OS credential-store fallback), enables Turso experimental local encryption, applies strict `aegis256` cipher selection through `turso::EncryptionOpts` during local DB open/connect, wraps that open/connect block in the same connection-open retry policy resolved from `policies.database_retry..connection_open`, and runs embedded migrations after connect. - `EncryptedTursoDb` exposes the same public synchronous `execute()`, `query()`, `query_map()`, and `run_migrations()` methods; operation methods use the same config-driven query retry policy as `TursoDb`. - `TursoConnectionCore` is internal to `cli/src/services/db/mod.rs` and owns the shared Turso connection plus tokio current-thread runtime bridging used by the public adapter methods; generic embedded migration execution with per-database `__sce_migrations` metadata is delegated to `run_embedded_migrations` helpers; encryption vs unencrypted behavior remains constructor-only at the public adapter layer. @@ -60,6 +62,7 @@ The shared module is exported from `cli/src/services/mod.rs` and compile-checked - `cli/src/services/agent_trace_db/mod.rs`: owns the shared Agent Trace insert payloads/helpers. Active hook/runtime paths use the sole `RepositoryAgentTraceDb = TursoDb` adapter from `cli/src/services/agent_trace_db/repository.rs`, selected by `agent_trace_storage` at `/sce/repos//agent-trace.db`, with a one-file repository schema containing repository metadata plus repository-level `diff_traces`, `post_commit_patch_intersections`, `agent_traces`, `messages`, and `parts` tables. The checkout-scoped `AgentTraceDb`/`AgentTraceDbSpec` adapter, its `agent_trace_db_path()` global fallback, and the 15-file migration chain were removed by the `retire-legacy-agent-trace-db` plan. - `cli/src/services/auth_db/mod.rs`: `AuthDb = EncryptedTursoDb`, with `AuthDbSpec` resolving `auth_db_path()` and loading ordered auth migrations where baseline SQL creates `auth_credentials` without `user_id`, with `updated_at`, and a trigger that auto-refreshes `updated_at` on row updates. - `cli/src/services/agent_trace_dwh_db/mod.rs`: `AgentTraceDwhDb = TursoDb`, a separate append-oriented destination-schema adapter for a future Agent Trace ETL consumer, distinct from the repository-scoped source schema above. Explicit-path only (no canonical `db_path()`), reuses the `"agent_trace_db"` retry config key, and is not wired into any lifecycle provider, doctor/setup flow, or CLI command yet. See [agent-trace-dwh-db.md](agent-trace-dwh-db.md). +- `cli/src/services/agent_trace_dwh_replica/mod.rs`: `AgentTraceDwhReplica`, the sole owner of a Turso Sync connection to the repository-scoped `agent-trace-sync.db` replica. Built by opening a connection through `turso::sync::Builder` (the `sync` Cargo feature) and wrapping it as an `AgentTraceDwhDb` via the `from_connection`/`block_on` seam above, rather than through `TursoDb::new`/`new_at`. See [agent-trace-dwh-replica.md](agent-trace-dwh-replica.md). All three database areas (local DB, auth DB, Agent Trace DB) have lifecycle providers. `lifecycle_providers(include_hooks)` registers database providers in order `LocalDbLifecycle` → `AuthDbLifecycle` → `AgentTraceDbLifecycle` before optional hooks. Setup initializes local/auth DBs, establishes Agent Trace checkout identity for diagnostics, initializes the repository-scoped Agent Trace DB with migrations/metadata, and reports credential-safe repository identity metadata; hook runtime keeps lazy repository DB initialization/upgrade as a fallback when setup has not run or schema metadata is incomplete. Doctor diagnoses/fixes DB parent/path readiness through lifecycle providers. @@ -75,4 +78,4 @@ Migrations are deliberately outside the connection-open retry block. The generic Existing databases created before migration metadata are upgraded by re-applying the current idempotent migration list and recording each migration ID. This lets later `sce setup` / lifecycle initialization runs apply migrations added after the database file already existed, including Agent Trace DB schema/index additions. -See also: [local-db.md](local-db.md), [agent-trace-db.md](agent-trace-db.md), [agent-trace-dwh-db.md](agent-trace-dwh-db.md), [auth-db.md](auth-db.md), [overview.md](../overview.md), [architecture.md](../architecture.md), [glossary.md](../glossary.md) +See also: [local-db.md](local-db.md), [agent-trace-db.md](agent-trace-db.md), [agent-trace-dwh-db.md](agent-trace-dwh-db.md), [agent-trace-dwh-replica.md](agent-trace-dwh-replica.md), [auth-db.md](auth-db.md), [overview.md](../overview.md), [architecture.md](../architecture.md), [glossary.md](../glossary.md) diff --git a/nix/flatpak/cargo-sources.nix b/nix/flatpak/cargo-sources.nix index afd3002f..37935750 100644 --- a/nix/flatpak/cargo-sources.nix +++ b/nix/flatpak/cargo-sources.nix @@ -33,7 +33,7 @@ let outputHashMode = "flat"; outputHashAlgo = "sha256"; - outputHash = "sha256-7TSA1WMFykJIg6khqRIOHOFb2M1bqotGP79LMHoLYIo="; + outputHash = "sha256-w8FTE3WHzAWGWvF9EnzYaIPdkP0S4oGs46vG07O4fV8="; }; regenerateApp = pkgs.writeShellApplication { diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index cda508d2..c83e0b9b 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -4861,6 +4861,19 @@ "dest": "cargo/vendor/syn-2.0.118", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/syn/syn-3.0.3.crate", + "sha256": "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3", + "dest": "cargo/vendor/syn-3.0.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3\", \"files\": {}}", + "dest": "cargo/vendor/syn-3.0.3", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -5160,6 +5173,19 @@ "dest": "cargo/vendor/tokio-1.52.3", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tokio-macros/tokio-macros-2.7.2.crate", + "sha256": "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e", + "dest": "cargo/vendor/tokio-macros-2.7.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e\", \"files\": {}}", + "dest": "cargo/vendor/tokio-macros-2.7.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip",