diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f7b51d9b2..512f4937c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -58,6 +67,78 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccb-jobs" +version = "7.7.0" +dependencies = [ + "camino", + "ccb-storage", + "chrono", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "ccb-project" +version = "7.7.0" +dependencies = [ + "camino", + "hex", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + [[package]] name = "ccb-runtime-accelerator" version = "7.7.0" @@ -68,6 +149,57 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ccb-storage" +version = "7.7.0" +dependencies = [ + "camino", + "ccb-project", + "ccb-storage-classification", + "chrono", + "filetime", + "fs2", + "hex", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "uuid", +] + +[[package]] +name = "ccb-storage-classification" +version = "7.7.0" +dependencies = [ + "camino", + "ccb-storage", + "chrono", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -114,12 +246,164 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -132,18 +416,68 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -162,6 +496,31 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "serde" version = "1.0.228" @@ -205,6 +564,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "strsim" version = "0.11.1" @@ -222,6 +604,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -234,12 +655,150 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ca411a49c..7d5a5a0f6 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,6 +2,10 @@ resolver = "2" members = [ "crates/ccb-runtime-accelerator", + "crates/ccb-project", + "crates/ccb-storage", + "crates/ccb-storage-classification", + "crates/ccb-jobs", ] [workspace.package] diff --git a/rust/crates/ccb-jobs/Cargo.toml b/rust/crates/ccb-jobs/Cargo.toml new file mode 100644 index 000000000..3352f4bb1 --- /dev/null +++ b/rust/crates/ccb-jobs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ccb-jobs" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +chrono = { version = "0.4", features = ["serde"] } +camino = { version = "1", features = ["serde1"] } +ccb-storage = { path = "../ccb-storage" } + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/ccb-jobs/src/lib.rs b/rust/crates/ccb-jobs/src/lib.rs new file mode 100644 index 000000000..8f1940a33 --- /dev/null +++ b/rust/crates/ccb-jobs/src/lib.rs @@ -0,0 +1,31 @@ +//! CCB job store abstraction. +//! +//! Canonical home for job records, submission records, job events, and the +//! persistent JSONL stores that back them. Mirrors `lib/jobs/` from Python +//! v7.5.2. + +pub mod models; +pub mod store; + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum JobsError { + #[error("storage error: {0}")] + Storage(#[from] ccb_storage::StorageError), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("not found: {0}")] + NotFound(String), +} + +pub type Result = std::result::Result; + +// Re-export the most commonly used types at crate root for ergonomics. +pub use models::{ + DeliveryScope, JobEvent, JobRecord, JobStatus, MessageEnvelope, ProjectViewJobSummary, + SubmissionRecord, TargetKind, +}; +pub use store::{JobEventStore, JobStore, SubmissionStore}; diff --git a/rust/crates/ccb-jobs/src/models.rs b/rust/crates/ccb-jobs/src/models.rs new file mode 100644 index 000000000..85b69d6cf --- /dev/null +++ b/rust/crates/ccb-jobs/src/models.rs @@ -0,0 +1,214 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Delivery scope for a message envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum DeliveryScope { + #[default] + Agent, + Group, + Broadcast, +} + +/// A message envelope submitted to the daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageEnvelope { + pub project_id: String, + pub to_agent: String, + pub from_actor: String, + pub body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + pub message_type: String, + pub delivery_scope: DeliveryScope, + #[serde(default)] + pub silence_on_success: bool, + #[serde(default)] + pub route_options: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body_artifact: Option, +} + +/// Job status enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum JobStatus { + #[default] + Accepted, + Running, + Completed, + Failed, + Incomplete, + Cancelled, +} + +/// Target kind for job routing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum TargetKind { + #[default] + Agent, + Group, +} + +/// A job record persisted per target. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobRecord { + pub job_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submission_id: Option, + #[serde(default)] + pub agent_name: String, + pub provider: String, + pub request: MessageEnvelope, + pub status: JobStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_decision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cancel_requested_at: Option, + pub created_at: String, + pub updated_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default)] + pub target_kind: TargetKind, + #[serde(default)] + pub target_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_instance: Option, + #[serde(default)] + pub provider_options: Value, +} + +/// A job event persisted per target. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobEvent { + pub event_id: String, + pub job_id: String, + #[serde(default)] + pub agent_name: String, + #[serde(default)] + pub target_kind: TargetKind, + #[serde(default)] + pub target_name: String, + #[serde(rename = "type")] + pub event_type: String, + #[serde(default)] + pub payload: Value, + pub timestamp: String, +} + +/// A submission record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmissionRecord { + pub submission_id: String, + pub project_id: String, + pub from_actor: String, + pub target_scope: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(default)] + pub job_ids: Vec, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub updated_at: String, +} + +/// Statuses that `list_project_view_recent_jobs` considers by default. +pub const PROJECT_VIEW_RECENT_JOB_STATUSES: &[JobStatus] = &[ + JobStatus::Completed, + JobStatus::Cancelled, + JobStatus::Failed, + JobStatus::Incomplete, +]; + +/// Summary projection of a message envelope used by project view. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectViewMessageSummary { + pub project_id: String, + pub to_agent: String, + pub from_actor: String, + pub body: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + pub message_type: String, + pub delivery_scope: String, + #[serde(default)] + pub silence_on_success: bool, + #[serde(default)] + pub route_options: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body_artifact: Option, +} + +impl From<&MessageEnvelope> for ProjectViewMessageSummary { + fn from(envelope: &MessageEnvelope) -> Self { + Self { + project_id: envelope.project_id.clone(), + to_agent: envelope.to_agent.clone(), + from_actor: envelope.from_actor.clone(), + body: envelope.body.clone(), + task_id: envelope.task_id.clone(), + reply_to: envelope.reply_to.clone(), + message_type: envelope.message_type.clone(), + delivery_scope: serde_json::to_value(envelope.delivery_scope) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| "agent".to_string()), + silence_on_success: envelope.silence_on_success, + route_options: envelope.route_options.clone(), + body_artifact: envelope.body_artifact.clone(), + } + } +} + +/// Summary projection of a job record used by project view. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectViewJobSummary { + pub job_id: String, + pub agent_name: String, + pub provider: String, + pub request: ProjectViewMessageSummary, + pub status: JobStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_decision: Option, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub target_kind: TargetKind, + #[serde(default)] + pub target_name: String, + #[serde(default)] + pub provider_options: Value, +} + +impl From<&JobRecord> for ProjectViewJobSummary { + fn from(job: &JobRecord) -> Self { + Self { + job_id: job.job_id.clone(), + agent_name: job.agent_name.clone(), + provider: job.provider.clone(), + request: ProjectViewMessageSummary::from(&job.request), + status: job.status, + terminal_decision: job.terminal_decision.clone(), + created_at: job.created_at.clone(), + updated_at: job.updated_at.clone(), + target_kind: job.target_kind, + target_name: if job.target_name.is_empty() { + job.agent_name.clone() + } else { + job.target_name.clone() + }, + provider_options: job.provider_options.clone(), + } + } +} diff --git a/rust/crates/ccb-jobs/src/store.rs b/rust/crates/ccb-jobs/src/store.rs new file mode 100644 index 000000000..500ed05c3 --- /dev/null +++ b/rust/crates/ccb-jobs/src/store.rs @@ -0,0 +1,318 @@ +use ccb_storage::{jsonl::JsonlStore, paths::PathLayout}; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use std::io::{BufRead, BufReader}; + +use crate::models::{ + JobEvent, JobRecord, JobStatus, ProjectViewJobSummary, SubmissionRecord, TargetKind, +}; +use crate::Result; + +const JOB_EVENT_RECORD_TYPE: &str = "job_event"; + +const SCHEMA_VERSION: i32 = 2; + +#[derive(Serialize)] +struct Record<'a, T: Serialize> { + schema_version: i32, + record_type: &'a str, + #[serde(flatten)] + payload: &'a T, +} + +impl<'a, T: Serialize> Record<'a, T> { + fn new(record_type: &'a str, payload: &'a T) -> Self { + Self { + schema_version: SCHEMA_VERSION, + record_type, + payload, + } + } +} + +/// Store for job records, persisted per target. +#[derive(Clone)] +pub struct JobStore { + layout: PathLayout, + jsonl: JsonlStore, +} + +impl JobStore { + pub fn new(layout: &PathLayout) -> Self { + Self { + layout: layout.clone(), + jsonl: JsonlStore::new(), + } + } + + pub fn append(&self, record: &JobRecord) -> Result<()> { + let path = self.layout.target_jobs_path( + &format!("{:?}", record.target_kind).to_lowercase(), + &record.target_name, + )?; + self.jsonl + .append(&path, &Record::new("job_record", record)) + .map_err(Into::into) + } + + pub fn list_agent(&self, agent_name: &str) -> Vec { + self.list_target(TargetKind::Agent, agent_name) + } + + pub fn list_target(&self, target_kind: TargetKind, target_name: &str) -> Vec { + let Ok(path) = self + .layout + .target_jobs_path(&format!("{:?}", target_kind).to_lowercase(), target_name) + else { + return Vec::new(); + }; + self.jsonl.read_all(&path).unwrap_or_default() + } + + pub fn list_agent_tail(&self, agent_name: &str, limit: usize) -> Vec { + self.list_target_tail(TargetKind::Agent, agent_name, limit) + } + + pub fn list_target_tail( + &self, + target_kind: TargetKind, + target_name: &str, + limit: usize, + ) -> Vec { + let Ok(path) = self + .layout + .target_jobs_path(&format!("{:?}", target_kind).to_lowercase(), target_name) + else { + return Vec::new(); + }; + self.jsonl.read_tail(&path, limit).unwrap_or_default() + } + + pub fn get_latest(&self, agent_name: &str, job_id: &str) -> Option { + self.get_latest_target(TargetKind::Agent, agent_name, job_id) + } + + pub fn get_latest_target( + &self, + target_kind: TargetKind, + target_name: &str, + job_id: &str, + ) -> Option { + let Ok(path) = self + .layout + .target_jobs_path(&format!("{:?}", target_kind).to_lowercase(), target_name) + else { + return None; + }; + self.jsonl + .find_last(&path, |payload: &JobRecord| payload.job_id == job_id) + .unwrap_or_default() + } + + /// Returns the last `limit` job records for each requested agent. + pub fn list_agent_tails_batch( + &self, + agent_names: &[String], + limit: usize, + ) -> HashMap> { + agent_names + .iter() + .map(|name| (name.clone(), self.list_agent_tail(name, limit))) + .collect() + } + + /// Returns the last `limit` job summaries for each requested agent. + pub fn list_agent_tail_summaries_batch( + &self, + agent_names: &[String], + limit: usize, + ) -> HashMap> { + self.list_agent_tails_batch(agent_names, limit) + .into_iter() + .map(|(name, records)| { + ( + name, + records + .iter() + .map(ProjectViewJobSummary::from) + .collect::>(), + ) + }) + .collect() + } + + /// Returns up to `result_limit` recent project-view job summaries across the + /// requested agents, using an adaptive scan bounded by `per_agent_limit`. + /// + /// Mirrors Python `JobStore.list_project_view_recent_jobs` from v8.0.4. + pub fn list_project_view_recent_jobs( + &self, + agent_names: &[String], + per_agent_limit: usize, + result_limit: usize, + statuses: Option<&[JobStatus]>, + per_agent_initial_limit: Option, + ) -> Vec { + let status_set: HashSet = statuses + .map(|s| s.iter().copied().collect()) + .unwrap_or_else(|| { + crate::models::PROJECT_VIEW_RECENT_JOB_STATUSES + .iter() + .copied() + .collect() + }); + let initial_limit = per_agent_initial_limit + .unwrap_or(per_agent_limit) + .clamp(1, per_agent_limit.max(1)); + + let mut collected: HashMap> = HashMap::new(); + let mut summaries: Vec = Vec::new(); + + let mut current_limit = initial_limit; + loop { + let mut added_this_round = 0usize; + for agent_name in agent_names { + let seen = collected.entry(agent_name.clone()).or_default(); + let tail = self.list_agent_tail(agent_name, current_limit); + for record in tail { + if !status_set.contains(&record.status) { + continue; + } + if seen.insert(record.job_id.clone()) { + summaries.push(ProjectViewJobSummary::from(&record)); + added_this_round += 1; + } + } + } + + // If we have enough results, or we already scanned the maximum, + // stop expanding. + if summaries.len() >= result_limit || current_limit >= per_agent_limit { + break; + } + // If this round added nothing, further expansion won't help. + if added_this_round == 0 { + break; + } + current_limit = (current_limit * 2).clamp(1, per_agent_limit); + } + + summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + summaries.truncate(result_limit); + summaries + } +} + +/// Store for job events. +#[derive(Clone)] +pub struct JobEventStore { + layout: PathLayout, + jsonl: JsonlStore, +} + +impl JobEventStore { + pub fn new(layout: &PathLayout) -> Self { + Self { + layout: layout.clone(), + jsonl: JsonlStore::new(), + } + } + + pub fn append(&self, event: &JobEvent) -> Result<()> { + let path = self.layout.target_events_path( + &format!("{:?}", event.target_kind).to_lowercase(), + &event.target_name, + )?; + self.jsonl + .append(&path, &Record::new("job_event", event)) + .map_err(Into::into) + } + + pub fn read_since(&self, agent_name: &str, start_line: usize) -> (usize, Vec) { + self.read_since_target(TargetKind::Agent, agent_name, start_line) + } + + pub fn read_since_target( + &self, + target_kind: TargetKind, + target_name: &str, + start_line: usize, + ) -> (usize, Vec) { + let Ok(path) = self + .layout + .target_events_path(&format!("{:?}", target_kind).to_lowercase(), target_name) + else { + return (0, Vec::new()); + }; + let Ok(file) = std::fs::File::open(&path) else { + return (0, Vec::new()); + }; + let reader = BufReader::new(file); + let mut events = Vec::new(); + let mut current = 0usize; + for line in reader.lines() { + let Ok(line) = line else { + continue; + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + current += 1; + if current <= start_line { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + let record_type = value + .get("record_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if record_type != JOB_EVENT_RECORD_TYPE { + continue; + } + if let Ok(event) = serde_json::from_value::(value) { + events.push(event); + } + } + (current, events) + } +} + +/// Store for submission records. +#[derive(Clone)] +pub struct SubmissionStore { + layout: PathLayout, + jsonl: JsonlStore, +} + +impl SubmissionStore { + pub fn new(layout: &PathLayout) -> Self { + Self { + layout: layout.clone(), + jsonl: JsonlStore::new(), + } + } + + pub fn append(&self, record: &SubmissionRecord) -> Result<()> { + let path = self.layout.ccbd_submissions_path(); + self.jsonl + .append(&path, &Record::new("submission_record", record)) + .map_err(Into::into) + } + + pub fn list_all(&self) -> Vec { + let path = self.layout.ccbd_submissions_path(); + self.jsonl.read_all(&path).unwrap_or_default() + } + + pub fn get_latest(&self, submission_id: &str) -> Option { + let path = self.layout.ccbd_submissions_path(); + self.jsonl + .find_last(&path, |payload: &SubmissionRecord| { + payload.submission_id == submission_id + }) + .unwrap_or_default() + } +} diff --git a/rust/crates/ccb-project/Cargo.toml b/rust/crates/ccb-project/Cargo.toml new file mode 100644 index 000000000..261cce83f --- /dev/null +++ b/rust/crates/ccb-project/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ccb-project" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +camino = { version = "1", features = ["serde1"] } +sha2 = "0.10" +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/ccb-project/src/discovery.rs b/rust/crates/ccb-project/src/discovery.rs new file mode 100644 index 000000000..33bf6607a --- /dev/null +++ b/rust/crates/ccb-project/src/discovery.rs @@ -0,0 +1,322 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; + +use crate::path_utils::{expand_user_path, resolve_utf8_path}; + +pub const CCB_DIRNAME: &str = ".ccb"; +pub const WORKSPACE_BINDING_FILENAME: &str = ".ccb-workspace.json"; + +#[derive(Debug, thiserror::Error)] +pub enum ProjectDiscoveryError { + #[error("cannot read workspace binding {path}: {source}")] + WorkspaceBindingRead { + path: String, + source: std::io::Error, + }, + #[error("cannot parse workspace binding {path}: {source}")] + WorkspaceBindingParse { + path: String, + source: serde_json::Error, + }, + #[error("workspace binding {0} must contain an object")] + InvalidWorkspaceBinding(String), + #[error("workspace binding {0} is missing target_project")] + MissingTargetProject(String), + #[error("invalid project anchor: {0}")] + InvalidAnchor(String), + #[error("cannot auto-create .ccb in {0}")] + NestedAnchor(String), + #[error("cannot resolve project for {0}; no .ccb anchor or workspace binding found")] + Unresolved(String), + #[error("refusing to auto-create .ccb in {0}; set CCB_INIT_PROJECT_DANGEROUS=1 to override")] + DangerousRoot(String), + #[error("{0}")] + AnchorNotFound(String), + #[error("{0}")] + WorkspaceAnchorMissing(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WorkspaceBinding { + pub target_project: String, +} + +/// Return the project-local `.ccb` directory. +pub fn project_ccb_dir(project_root: impl AsRef) -> Utf8PathBuf { + resolve_utf8_path(project_root.as_ref()).join(CCB_DIRNAME) +} + +/// Return the global `~/.ccb` directory. +pub fn global_ccb_dir() -> Utf8PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + Utf8PathBuf::from(expand_user_path(&home)).join(CCB_DIRNAME) +} + +/// Return `start_dir` if it directly contains a `.ccb` anchor. +pub fn find_current_project_anchor(start_dir: impl AsRef) -> Option { + let current = resolved_dir(start_dir.as_ref()); + if project_anchor_dir(¤t).is_some() { + Some(current) + } else { + None + } +} + +/// Find the nearest `.ccb` anchor starting at `start_dir` and walking upward. +pub fn find_nearest_project_anchor(start_dir: impl AsRef) -> Option { + let current = resolved_dir(start_dir.as_ref()); + for root in search_roots(¤t) { + if project_anchor_dir(&root).is_none() { + continue; + } + let (dangerous, _) = is_dangerous_project_root(&root); + if root != current && dangerous { + continue; + } + return Some(root); + } + None +} + +/// Find a parent project's `.ccb` directory, skipping dangerous roots. +pub fn find_parent_project_anchor_dir(start_dir: impl AsRef) -> Option { + let current = resolved_dir(start_dir.as_ref()); + for root in current.ancestors().skip(1) { + let candidate = project_anchor_dir(root); + if candidate.is_none() { + continue; + } + let (dangerous, _) = is_dangerous_project_root(root); + if dangerous { + continue; + } + return candidate; + } + None +} + +/// Return whether `start_dir` is a root where auto-creating `.ccb` would be +/// dangerous (home, temp root, filesystem root). +pub fn is_dangerous_project_root(start_dir: impl AsRef) -> (bool, String) { + let current = resolved_dir(start_dir.as_ref()); + if let Some(home) = resolved_home_dir() { + if home == current { + return (true, "$HOME".into()); + } + } + if let Some(temp) = resolved_temp_dir() { + if temp == current { + return (true, "temporary directory root".into()); + } + } + if let Some(anchor) = filesystem_anchor(¤t) { + if anchor == current { + return (true, "filesystem root".into()); + } + } + (false, String::new()) +} + +fn project_anchor_dir(root: &Utf8Path) -> Option { + let primary = root.join(CCB_DIRNAME); + if primary.as_std_path().is_dir() { + Some(primary) + } else { + None + } +} + +/// Find the nearest `.ccb-workspace.json` binding file. +pub fn find_workspace_binding(start_dir: impl AsRef) -> Option { + let current = resolved_dir(start_dir.as_ref()); + for root in search_roots(¤t) { + let candidate = root.join(WORKSPACE_BINDING_FILENAME); + if candidate.as_std_path().is_file() { + return Some(candidate); + } + } + None +} + +/// Load and validate a workspace binding file. +pub fn load_workspace_binding( + path: impl AsRef, +) -> Result { + let path = path.as_ref(); + let path_str = path.as_str().to_string(); + let text = std::fs::read_to_string(path.as_std_path()).map_err(|e| { + ProjectDiscoveryError::WorkspaceBindingRead { + path: path_str.clone(), + source: e, + } + })?; + let data: serde_json::Value = + serde_json::from_str(&text).map_err(|e| ProjectDiscoveryError::WorkspaceBindingParse { + path: path_str.clone(), + source: e, + })?; + let obj = data + .as_object() + .ok_or_else(|| ProjectDiscoveryError::InvalidWorkspaceBinding(path_str.clone()))?; + let target_project = obj + .get("target_project") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ProjectDiscoveryError::MissingTargetProject(path_str.clone()))?; + Ok(WorkspaceBinding { + target_project: target_project.to_string(), + }) +} + +fn resolved_dir(path: &Utf8Path) -> Utf8PathBuf { + resolve_utf8_path(path) +} + +fn resolved_home_dir() -> Option { + let home = std::env::var("HOME").ok()?; + if home.trim().is_empty() { + return None; + } + Some(resolve_utf8_path(Utf8Path::new(&expand_user_path(&home)))) +} + +fn filesystem_anchor(current: &Utf8Path) -> Option { + let mut components = current.as_std_path().components(); + let first = components.next()?; + let raw = first.as_os_str().to_string_lossy().to_string(); + if raw.is_empty() { + None + } else { + Some(Utf8PathBuf::from(raw)) + } +} + +fn resolved_temp_dir() -> Option { + let path = std::env::temp_dir(); + if let Ok(resolved) = std::fs::canonicalize(&path) { + if let Ok(utf8) = Utf8PathBuf::from_path_buf(resolved) { + return Some(utf8); + } + } + Utf8PathBuf::from_path_buf(crate::path_utils::absolute_path(&path)).ok() +} + +fn search_roots(current: &Utf8Path) -> Vec { + std::iter::once(current.to_path_buf()) + .chain(current.ancestors().skip(1).map(|p| p.to_path_buf())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8Path; + use std::io::Write; + + fn tmpdir() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + fn utf8(path: &std::path::Path) -> Utf8PathBuf { + Utf8PathBuf::from_path_buf(path.to_path_buf()).unwrap() + } + + #[test] + fn test_project_ccb_dir_appends_ccb() { + let dir = project_ccb_dir(Utf8Path::new("/home/user/project")); + assert!(dir.as_str().ends_with("/.ccb")); + } + + #[test] + fn test_find_current_project_anchor() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir(root.join(CCB_DIRNAME)).unwrap(); + assert_eq!(find_current_project_anchor(&root), Some(root.clone())); + assert!(find_current_project_anchor(Utf8Path::new("/nonexistent-no-anchor")).is_none()); + } + + #[test] + fn test_find_nearest_project_anchor_walks_up() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir(root.join(CCB_DIRNAME)).unwrap(); + let sub = root.join("src/nested"); + std::fs::create_dir_all(&sub).unwrap(); + assert_eq!(find_nearest_project_anchor(&sub), Some(root)); + } + + #[test] + fn test_find_parent_project_anchor_dir() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir(root.join(CCB_DIRNAME)).unwrap(); + let child = root.join("child"); + std::fs::create_dir_all(&child).unwrap(); + assert_eq!( + find_parent_project_anchor_dir(&child), + Some(root.join(CCB_DIRNAME)) + ); + } + + #[test] + fn test_is_dangerous_project_root() { + let (yes, reason) = is_dangerous_project_root(Utf8Path::new("/")); + assert!(yes); + assert_eq!(reason, "filesystem root"); + + let (yes, reason) = is_dangerous_project_root(Utf8Path::new("/tmp")); + assert!(yes); + assert_eq!(reason, "temporary directory root"); + + let tmp = tmpdir(); + let home = utf8(tmp.path()); + let old_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.as_str()); + let (yes, reason) = is_dangerous_project_root(&home); + assert!(yes); + assert_eq!(reason, "$HOME"); + if let Some(h) = old_home { + std::env::set_var("HOME", h); + } else { + std::env::remove_var("HOME"); + } + } + + #[test] + fn test_find_workspace_binding() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + let sub = root.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + let binding = root.join(WORKSPACE_BINDING_FILENAME); + std::fs::write(&binding, r#"{"target_project": "/other/project"}"#).unwrap(); + assert_eq!(find_workspace_binding(&sub), Some(binding)); + } + + #[test] + fn test_load_workspace_binding_ok() { + let tmp = tmpdir(); + let path = tmp.path().join(WORKSPACE_BINDING_FILENAME); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(r#"{"target_project": "/target"}"#.as_bytes()) + .unwrap(); + let binding = + load_workspace_binding(Utf8PathBuf::from_path_buf(path.to_path_buf()).unwrap()) + .unwrap(); + assert_eq!(binding.target_project, "/target"); + } + + #[test] + fn test_load_workspace_binding_rejects_missing_target() { + let tmp = tmpdir(); + let path = tmp.path().join(WORKSPACE_BINDING_FILENAME); + std::fs::write(&path, r#"{"other": "x"}"#).unwrap(); + let err = load_workspace_binding(Utf8PathBuf::from_path_buf(path.to_path_buf()).unwrap()) + .unwrap_err(); + assert!(err.to_string().contains("missing target_project")); + } +} diff --git a/rust/crates/ccb-project/src/identity.rs b/rust/crates/ccb-project/src/identity.rs new file mode 100644 index 000000000..0ca498f45 --- /dev/null +++ b/rust/crates/ccb-project/src/identity.rs @@ -0,0 +1,165 @@ +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::path_utils::{ + expand_user_path, is_win_drive_path, normalize_mnt_drive_mapping, normalize_msys_drive_mapping, + normalize_posix_path, resolve_utf8_path, +}; + +pub use crate::ids::{compute_project_id, normalize_project_path, project_slug}; + +fn sha256_hex(input: impl AsRef<[u8]>) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(input); + hex::encode(hasher.finalize()) +} + +/// True if `raw` looks like an absolute path on any supported platform. +fn is_absolute_preview(raw: &str) -> bool { + let preview = raw.replace('\\', "/"); + preview.starts_with('/') + || preview.starts_with("//") + || preview.starts_with("\\\\") + || is_win_drive_path(raw) +} + +/// Make a relative path absolute against the current working directory. +fn absolutize_relative_path(raw: &str) -> String { + if is_absolute_preview(raw) { + return raw.to_string(); + } + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + cwd.join(raw).to_string_lossy().to_string() +} + +fn normalize_path_slashes(raw: &str) -> String { + raw.replace('\\', "/") +} + +fn normalize_platform_drive_mapping(value: &str) -> String { + normalize_mnt_drive_mapping(value) + .or_else(|| normalize_msys_drive_mapping(value)) + .unwrap_or_else(|| value.to_string()) +} + +fn normalize_drive_letter_case(value: &str) -> String { + if is_win_drive_path(value) { + value[0..1].to_ascii_lowercase() + &value[1..] + } else { + value.to_string() + } +} + +/// Normalize a work_dir into a stable string for hashing and matching. +/// Mirrors Python `project.identity.normalize_work_dir`. +pub fn normalize_work_dir(value: impl AsRef) -> String { + let mut raw = value.as_ref().trim().to_string(); + if raw.is_empty() { + return String::new(); + } + raw = expand_user_path(&raw); + raw = absolutize_relative_path(&raw); + let mut normalized = normalize_path_slashes(&raw); + normalized = normalize_platform_drive_mapping(&normalized); + normalized = normalize_posix_path(&normalized); + normalize_drive_letter_case(&normalized) +} + +/// Compute a stable worktree/workspace scope id (first 12 hex chars of SHA256). +/// Mirrors Python `project.identity.compute_worktree_scope_id`. +pub fn compute_worktree_scope_id(work_dir: impl AsRef) -> String { + let norm = normalize_work_dir(work_dir); + if norm.is_empty() { + return String::new(); + } + sha256_hex(norm.as_bytes())[..12].to_string() +} + +fn _resolved_path(path: &str) -> Utf8PathBuf { + let expanded = expand_user_path(path); + resolve_utf8_path(Utf8Path::new(&expanded)) +} + +/// Try to resolve the project root from a work_dir. +pub(crate) fn try_resolve_project_root( + work_dir: impl AsRef, +) -> Result { + let current = _resolved_path(work_dir.as_ref()); + if let Some(binding_path) = crate::discovery::find_workspace_binding(¤t) { + let binding = crate::discovery::load_workspace_binding(&binding_path)?; + return Ok(_resolved_path(&expand_user_path(&binding.target_project))); + } + if let Some(anchor) = crate::discovery::find_nearest_project_anchor(¤t) { + return Ok(anchor); + } + Ok(current) +} + +/// Resolve the project root from a work_dir, considering workspace bindings and anchors. +/// Mirrors Python `project.identity.resolve_project_root`. +pub fn resolve_project_root(work_dir: impl AsRef) -> Utf8PathBuf { + try_resolve_project_root(work_dir).expect("failed to resolve project root") +} + +/// Compatibility wrapper for the v2 project id. +/// Mirrors Python `project.identity.compute_ccb_project_id`. +pub fn compute_ccb_project_id(work_dir: impl AsRef) -> String { + compute_project_id(resolve_project_root(work_dir).as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_id_deterministic_and_64_chars() { + let a = compute_project_id("/home/user/project"); + let b = compute_project_id("/home/user/project"); + assert_eq!(a, b); + assert_eq!(a.len(), 64); + } + + #[test] + fn test_project_slug_contains_digest() { + let slug = project_slug("/home/user/project"); + let digest = &compute_project_id("/home/user/project")[..8]; + assert!(slug.ends_with(digest)); + } + + #[test] + fn test_normalize_work_dir_mnt_drive() { + let normalized = normalize_work_dir("/mnt/C/Users/demo/repo"); + assert_eq!(normalized, "c:/Users/demo/repo"); + } + + #[test] + fn test_compute_worktree_scope_id_12_chars() { + let id = compute_worktree_scope_id("/home/user/project"); + assert_eq!(id.len(), 12); + } + + #[test] + fn test_resolve_project_root_prefers_binding() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + let target = root.join("target"); + std::fs::create_dir(&target).unwrap(); + let binding = root.join(crate::discovery::WORKSPACE_BINDING_FILENAME); + std::fs::write( + &binding, + format!(r#"{{"target_project": "{}"}}"#, target.as_str()), + ) + .unwrap(); + let resolved = resolve_project_root(root.as_str()); + assert_eq!(resolved, target); + } + + #[test] + fn test_resolve_project_root_falls_back_to_anchor() { + let tmp = tempfile::tempdir().unwrap(); + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); + std::fs::create_dir(root.join(crate::discovery::CCB_DIRNAME)).unwrap(); + let resolved = resolve_project_root(root.as_str()); + assert_eq!(resolved, root); + } +} diff --git a/rust/crates/ccb-project/src/ids.rs b/rust/crates/ccb-project/src/ids.rs new file mode 100644 index 000000000..1bef58bf9 --- /dev/null +++ b/rust/crates/ccb-project/src/ids.rs @@ -0,0 +1,147 @@ +use crate::path_utils::{ + absolute_path, expand_user_path, is_win_drive_path, normalize_mnt_drive_mapping, + normalize_posix_path, +}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; + +/// Compute SHA256 hex digest of input bytes. +pub(crate) fn sha256_hex(input: impl AsRef<[u8]>) -> String { + let mut hasher = Sha256::new(); + hasher.update(input); + hex::encode(hasher.finalize()) +} + +/// Normalize a project path the same way Python `project.ids.normalize_project_path` does. +pub fn normalize_project_path(value: impl AsRef) -> String { + let mut raw = value.as_ref().trim().to_string(); + if raw.is_empty() { + return String::new(); + } + if raw.starts_with('~') { + raw = expand_user_path(&raw); + } + + let path = PathBuf::from(&raw); + raw = if path.exists() { + std::fs::canonicalize(&path) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string_lossy().to_string()) + } else { + absolute_path(&path).to_string_lossy().to_string() + }; + + let mut normalized = raw.replace('\\', "/"); + if let Some(converted) = normalize_mnt_drive_mapping(&normalized) { + normalized = converted; + } + normalized = normalize_posix_path(&normalized); + + if is_win_drive_path(&normalized) { + normalized = format!( + "{}{}", + normalized[..1].to_ascii_lowercase(), + &normalized[1..] + ); + normalized = normalized.to_lowercase(); + } + normalized +} + +/// Compute the full CCB project id (64-char SHA256 hex). +pub fn compute_project_id(project_root: impl AsRef) -> String { + let normalized = normalize_project_path(project_root); + if normalized.is_empty() { + panic!("project_root cannot be empty"); + } + sha256_hex(normalized.as_bytes()) +} + +/// Compute the project slug used for display and tmux session names. +pub fn project_slug(project_root: impl AsRef) -> String { + let normalized = normalize_project_path(project_root); + let digest = &compute_project_id(&normalized)[..8]; + let base_name = std::path::Path::new(&normalized) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("project"); + let slug: String = base_name + .to_lowercase() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' { + ch + } else { + '-' + } + }) + .collect(); + let slug = slug.trim_matches('-').to_string(); + let slug = if slug.is_empty() { + "project".to_string() + } else { + slug + }; + format!("{slug}-{digest}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_id_deterministic_and_64_chars() { + let a = compute_project_id("/home/user/project"); + let b = compute_project_id("/home/user/project"); + assert_eq!(a, b); + assert_eq!(a.len(), 64); + } + + #[test] + fn test_project_slug_contains_digest() { + let slug = project_slug("/home/user/project"); + let digest = &compute_project_id("/home/user/project")[..8]; + assert!(slug.ends_with(digest)); + } + + #[test] + fn test_normalize_project_path_mnt_drive() { + assert_eq!(normalize_project_path("/mnt/D/code/repo"), "d:/code/repo"); + } + + #[test] + fn test_normalize_project_path_collapses_dotdot() { + assert_eq!( + normalize_project_path("/home/user/project/../other"), + "/home/user/other" + ); + } + + #[test] + #[should_panic(expected = "project_root cannot be empty")] + fn test_compute_project_id_rejects_empty() { + compute_project_id(""); + } + + #[test] + fn test_project_id_matches_python_reference() { + // Generated by lib/project/ids.py compute_project_id + let cases = [ + ( + "/home/user/demo-project", + "05824418a5aa8e25072756317c51326b193b00d4e97f126f09c8a978033d36d1", + ), + ( + "/tmp/test-repo", + "f34a73caaa3c6a872a20369f5e14cae997fcf4e7dd9b4ea9e036c37a9a4b3e8c", + ), + ( + "/mnt/C/code/ccb", + "dc4b2e73496d4e21a5a7a6a5675ee0c120b9eb15bfc454839f92e63f4d20379b", + ), + ]; + for (path, expected) in cases { + assert_eq!(compute_project_id(path), expected, "mismatch for {path}"); + } + } +} diff --git a/rust/crates/ccb-project/src/lib.rs b/rust/crates/ccb-project/src/lib.rs new file mode 100644 index 000000000..6ef97b8fd --- /dev/null +++ b/rust/crates/ccb-project/src/lib.rs @@ -0,0 +1,51 @@ +//! CCB project discovery, identity, resolver, and runtime paths. +//! +//! Mirrors the corresponding Python v7.5.2 package. This crate is the canonical +//! home for project id/slug computation and worktree scope resolution. + +pub mod discovery; +pub mod identity; +pub mod ids; +pub mod resolver; +pub mod runtime_paths; + +mod path_utils; + +// Re-export the most commonly used helpers at crate root, matching __init__.py. +pub use discovery::{ + find_current_project_anchor, find_nearest_project_anchor, find_parent_project_anchor_dir, + find_workspace_binding, global_ccb_dir, is_dangerous_project_root, load_workspace_binding, + project_ccb_dir, ProjectDiscoveryError, WorkspaceBinding, +}; +pub use identity::{ + compute_ccb_project_id, compute_project_id, compute_worktree_scope_id, normalize_project_path, + normalize_work_dir, project_slug, resolve_project_root, +}; +pub use resolver::{bootstrap_project, ProjectContext, ProjectResolver}; +pub use runtime_paths::{ + project_anchor_dir, project_anchor_exists, project_ccbd_dir, project_lock_dir, + project_registry_dir, +}; + +/// Crate version. +pub fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crate_smoke() { + assert_eq!(version(), env!("CARGO_PKG_VERSION")); + } + + #[test] + fn project_id_is_stable() { + let id1 = compute_project_id("/home/user/my-project"); + let id2 = compute_project_id("/home/user/my-project"); + assert_eq!(id1, id2); + assert_eq!(id1.len(), 64); + } +} diff --git a/rust/crates/ccb-project/src/path_utils.rs b/rust/crates/ccb-project/src/path_utils.rs new file mode 100644 index 000000000..f734bc2ff --- /dev/null +++ b/rust/crates/ccb-project/src/path_utils.rs @@ -0,0 +1,119 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use std::path::{Path, PathBuf}; + +/// Expand a leading `~` using the `HOME` environment variable. +pub(crate) fn expand_user_path(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix('~') { + if let Ok(home) = std::env::var("HOME") { + return home + rest; + } + } + raw.to_string() +} + +/// Return an absolute version of `path`. Relative paths are anchored to the +/// current working directory. +pub(crate) fn absolute_path(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + +/// Resolve a path like Python's `Path.resolve()` with non-strict fallback: +/// canonicalize if the path exists, otherwise make it absolute. +pub(crate) fn resolve_utf8_path(path: &Utf8Path) -> Utf8PathBuf { + let expanded = expand_user_path(path.as_str()); + let sys_path = PathBuf::from(&expanded); + + if let Ok(resolved) = std::fs::canonicalize(&sys_path) { + if let Ok(utf8) = Utf8PathBuf::from_path_buf(resolved) { + return utf8; + } + } + + let absolute = absolute_path(&sys_path); + Utf8PathBuf::from_path_buf(absolute).unwrap_or_else(|_| Utf8PathBuf::from(expanded)) +} + +/// Manual POSIX-style path normalization that matches Python's +/// `posixpath.normpath`. +pub(crate) fn normalize_posix_path(value: &str) -> String { + if let Some(rest) = value.strip_prefix("//") { + let norm = normalize_posix_path(rest); + return format!("//{}", norm.trim_start_matches('/')); + } + + let absolute = value.starts_with('/'); + let mut stack: Vec = Vec::new(); + for part in value.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + if let Some(top) = stack.last() { + if top != ".." { + stack.pop(); + continue; + } + } + if !absolute { + stack.push("..".to_string()); + } + continue; + } + stack.push(part.to_string()); + } + + let joined = stack.join("/"); + if absolute { + format!("/{joined}") + } else { + joined + } +} + +/// True when `value` looks like a Windows drive-letter path. +pub(crate) fn is_win_drive_path(value: &str) -> bool { + value.len() >= 2 + && value.as_bytes()[0].is_ascii_alphabetic() + && value.as_bytes()[1] == b':' + && (value.len() == 2 || value.as_bytes()[2] == b'/' || value.as_bytes()[2] == b'\\') +} + +/// Convert `/mnt/X/rest` to `x:/rest`. +pub(crate) fn normalize_mnt_drive_mapping(value: &str) -> Option { + let rest = value.strip_prefix("/mnt/")?; + let drive = rest.chars().next()?; + if !drive.is_ascii_alphabetic() { + return None; + } + let after = &rest[drive.len_utf8()..]; + if after.is_empty() { + return Some(format!("{}:/", drive.to_ascii_lowercase())); + } + if after.starts_with('/') { + return Some(format!("{}:{}", drive.to_ascii_lowercase(), after)); + } + None +} + +/// Convert `/X/rest` to `x:/rest` when running under MSYS/Windows. +pub(crate) fn normalize_msys_drive_mapping(value: &str) -> Option { + if value.len() < 2 || value.as_bytes()[0] != b'/' { + return None; + } + let drive = value.chars().nth(1)?; + if !drive.is_ascii_alphabetic() { + return None; + } + if std::env::var("MSYSTEM").is_ok() || std::env::consts::OS == "windows" { + let after = &value[1 + drive.len_utf8()..]; + if after.starts_with('/') { + return Some(format!("{}:{}", drive.to_ascii_lowercase(), after)); + } + } + None +} diff --git a/rust/crates/ccb-project/src/resolver.rs b/rust/crates/ccb-project/src/resolver.rs new file mode 100644 index 000000000..4fc34de46 --- /dev/null +++ b/rust/crates/ccb-project/src/resolver.rs @@ -0,0 +1,256 @@ +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::discovery::{ + find_current_project_anchor, find_nearest_project_anchor, find_parent_project_anchor_dir, + find_workspace_binding, is_dangerous_project_root, load_workspace_binding, project_ccb_dir, + ProjectDiscoveryError, +}; +use crate::ids::compute_project_id; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectContext { + pub cwd: Utf8PathBuf, + pub project_root: Utf8PathBuf, + pub config_dir: Utf8PathBuf, + pub project_id: String, + pub source: String, +} + +#[derive(Debug, Default, Clone)] +pub struct ProjectResolver; + +impl ProjectResolver { + pub fn new() -> Self { + Self + } + + pub fn resolve( + &self, + cwd: impl AsRef, + explicit_project: Option<&Utf8Path>, + allow_ancestor_anchor: bool, + ) -> Result { + let current = _resolved_path(cwd.as_ref()); + + if let Some(explicit) = explicit_project { + return _explicit_project_context(¤t, explicit); + } + + if let Some(binding_path) = find_workspace_binding(¤t) { + return _workspace_binding_context(¤t, &binding_path); + } + + let anchor = if allow_ancestor_anchor { + find_nearest_project_anchor(¤t) + } else { + find_current_project_anchor(¤t) + }; + if let Some(anchor) = anchor { + return _project_context(¤t, &anchor, "anchor"); + } + + Err(ProjectDiscoveryError::Unresolved(current.to_string())) + } +} + +/// Bootstrap a new project anchor at `project_root`. +pub fn bootstrap_project( + project_root: impl AsRef, +) -> Result { + let root = _resolved_path(project_root.as_ref()); + let config_dir = project_ccb_dir(&root); + if config_dir.as_std_path().exists() && !config_dir.as_std_path().is_dir() { + return Err(ProjectDiscoveryError::InvalidAnchor(format!( + "{} exists but is not a directory", + config_dir + ))); + } + if let Some(parent_anchor) = find_parent_project_anchor_dir(&root) { + return Err(ProjectDiscoveryError::NestedAnchor( + _nested_anchor_bootstrap_error(&root, parent_anchor.parent().unwrap_or(root.as_ref())), + )); + } + let (is_dangerous, danger_reason) = is_dangerous_project_root(&root); + if is_dangerous && !env_truthy("CCB_INIT_PROJECT_DANGEROUS") { + return Err(ProjectDiscoveryError::DangerousRoot(format!( + "{danger_reason}; set CCB_INIT_PROJECT_DANGEROUS=1 to override" + ))); + } + std::fs::create_dir_all(&config_dir)?; + ensure_bootstrap_project_config(&root)?; + _project_context(&root, &root, "bootstrapped") +} + +fn ensure_bootstrap_project_config( + project_root: &Utf8Path, +) -> Result { + let config_dir = project_ccb_dir(project_root); + std::fs::create_dir_all(&config_dir)?; + Ok(config_dir.join("ccb.config")) +} + +fn env_truthy(name: &str) -> bool { + let value = std::env::var(name).unwrap_or_default(); + matches!( + value.trim().to_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +fn _resolved_path(path: &Utf8Path) -> Utf8PathBuf { + crate::path_utils::resolve_utf8_path(path) +} + +fn _project_context( + cwd: &Utf8Path, + root: &Utf8Path, + source: &str, +) -> Result { + Ok(ProjectContext { + cwd: cwd.to_path_buf(), + project_root: root.to_path_buf(), + config_dir: project_ccb_dir(root), + project_id: compute_project_id(root.as_str()), + source: source.to_string(), + }) +} + +fn _explicit_project_context( + cwd: &Utf8Path, + explicit_project: &Utf8Path, +) -> Result { + let root = _resolved_path(explicit_project); + _require_anchor_dir(&root, "project anchor not found")?; + _project_context(cwd, &root, "explicit") +} + +fn _workspace_binding_context( + cwd: &Utf8Path, + binding_path: &Utf8Path, +) -> Result { + let binding = load_workspace_binding(binding_path)?; + let root = _resolved_path(Utf8Path::new(&crate::path_utils::expand_user_path( + &binding.target_project, + ))); + _require_anchor_dir(&root, "workspace binding points to missing project anchor")?; + _project_context(cwd, &root, "workspace-binding") +} + +fn _require_anchor_dir(root: &Utf8Path, reason: &str) -> Result<(), ProjectDiscoveryError> { + let config_dir = project_ccb_dir(root); + if !config_dir.as_std_path().is_dir() { + return Err(ProjectDiscoveryError::AnchorNotFound(format!( + "{reason}: {config_dir}" + ))); + } + Ok(()) +} + +fn _nested_anchor_bootstrap_error(project_root: &Utf8Path, parent_root: &Utf8Path) -> String { + format!( + "cannot auto-create .ccb in {project_root}: \ + parent project anchor already exists at {parent_anchor}; \ + .ccb is the unique project anchor for a project tree. \ + If you intentionally want {project_root} to be a separate project, \ + create {new_anchor} manually and rerun", + parent_anchor = project_ccb_dir(parent_root), + new_anchor = project_ccb_dir(project_root) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8Path; + + fn tmpdir() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + fn utf8(path: &std::path::Path) -> Utf8PathBuf { + Utf8PathBuf::from_path_buf(path.to_path_buf()).unwrap() + } + + #[test] + fn test_resolve_explicit_project() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir(root.join(crate::discovery::CCB_DIRNAME)).unwrap(); + let resolver = ProjectResolver::new(); + let ctx = resolver + .resolve(Utf8Path::new("/cwd"), Some(root.as_ref()), true) + .unwrap(); + assert_eq!(ctx.project_root, root); + assert_eq!(ctx.source, "explicit"); + } + + #[test] + fn test_resolve_workspace_binding() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + let target = root.join("target"); + std::fs::create_dir(&target).unwrap(); + std::fs::create_dir(target.join(crate::discovery::CCB_DIRNAME)).unwrap(); + let binding = root.join(crate::discovery::WORKSPACE_BINDING_FILENAME); + std::fs::write( + &binding, + format!(r#"{{"target_project": "{}"}}"#, target.as_str()), + ) + .unwrap(); + let resolver = ProjectResolver::new(); + let ctx = resolver.resolve(root.as_path(), None, true).unwrap(); + assert_eq!(ctx.project_root, target); + assert_eq!(ctx.source, "workspace-binding"); + } + + #[test] + fn test_resolve_anchor_ancestor() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir(root.join(crate::discovery::CCB_DIRNAME)).unwrap(); + let sub = root.join("sub"); + std::fs::create_dir(&sub).unwrap(); + let resolver = ProjectResolver::new(); + let ctx = resolver.resolve(sub.as_path(), None, true).unwrap(); + assert_eq!(ctx.project_root, root); + assert_eq!(ctx.source, "anchor"); + } + + #[test] + fn test_resolve_no_anchor_fails() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + let resolver = ProjectResolver::new(); + let err = resolver.resolve(root.as_path(), None, true).unwrap_err(); + assert!(err + .to_string() + .contains("no .ccb anchor or workspace binding found")); + } + + #[test] + fn test_bootstrap_project_creates_anchor() { + let tmp = tmpdir(); + let root = utf8(tmp.path()).join("new-project"); + std::fs::create_dir(&root).unwrap(); + let ctx = bootstrap_project(&root).unwrap(); + assert_eq!(ctx.project_root, root); + assert_eq!(ctx.source, "bootstrapped"); + assert!(root + .join(crate::discovery::CCB_DIRNAME) + .as_std_path() + .is_dir()); + } + + #[test] + fn test_bootstrap_rejects_nested_anchor() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir(root.join(crate::discovery::CCB_DIRNAME)).unwrap(); + let child = root.join("child"); + std::fs::create_dir(&child).unwrap(); + let err = bootstrap_project(&child).unwrap_err(); + assert!(err + .to_string() + .contains("parent project anchor already exists")); + } +} diff --git a/rust/crates/ccb-project/src/runtime_paths.rs b/rust/crates/ccb-project/src/runtime_paths.rs new file mode 100644 index 000000000..c7bad33a8 --- /dev/null +++ b/rust/crates/ccb-project/src/runtime_paths.rs @@ -0,0 +1,78 @@ +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::discovery::project_ccb_dir; +use crate::identity::try_resolve_project_root; + +/// Return the `.ccb` directory for the project containing `work_dir`. +pub fn project_anchor_dir(work_dir: impl AsRef) -> Utf8PathBuf { + project_ccb_dir(crate::identity::resolve_project_root( + work_dir.as_ref().as_str(), + )) +} + +/// Return whether a project anchor exists for `work_dir`. +pub fn project_anchor_exists(work_dir: impl AsRef) -> bool { + match try_resolve_project_root(work_dir.as_ref().as_str()) { + Ok(root) => project_ccb_dir(&root).as_std_path().is_dir(), + Err(_) => false, + } +} + +/// Return the `ccbd` runtime directory for the project containing `work_dir`. +pub fn project_ccbd_dir(work_dir: impl AsRef) -> Utf8PathBuf { + project_anchor_dir(work_dir).join("ccbd") +} + +/// Return the registry directory for the project containing `work_dir`. +pub fn project_registry_dir(work_dir: impl AsRef) -> Utf8PathBuf { + project_ccbd_dir(work_dir).join("registry") +} + +/// Return the lock directory for the project containing `work_dir`. +pub fn project_lock_dir(work_dir: impl AsRef) -> Utf8PathBuf { + project_ccbd_dir(work_dir).join("locks") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmpdir() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + fn utf8(path: &std::path::Path) -> Utf8PathBuf { + Utf8PathBuf::from_path_buf(path.to_path_buf()).unwrap() + } + + #[test] + fn test_project_anchor_dir_and_exists() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir(root.join(crate::discovery::CCB_DIRNAME)).unwrap(); + let anchor = project_anchor_dir(&root); + assert!(anchor.as_str().ends_with("/.ccb")); + assert!(project_anchor_exists(&root)); + } + + #[test] + fn test_project_ccbd_registry_lock_dirs() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + std::fs::create_dir_all(root.join(crate::discovery::CCB_DIRNAME)).unwrap(); + assert!(project_ccbd_dir(&root).as_str().ends_with("/.ccb/ccbd")); + assert!(project_registry_dir(&root) + .as_str() + .ends_with("/.ccb/ccbd/registry")); + assert!(project_lock_dir(&root) + .as_str() + .ends_with("/.ccb/ccbd/locks")); + } + + #[test] + fn test_project_anchor_exists_false_for_missing_anchor() { + let tmp = tmpdir(); + let root = utf8(tmp.path()); + assert!(!project_anchor_exists(&root)); + } +} diff --git a/rust/crates/ccb-storage-classification/Cargo.toml b/rust/crates/ccb-storage-classification/Cargo.toml new file mode 100644 index 000000000..6380b70f2 --- /dev/null +++ b/rust/crates/ccb-storage-classification/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ccb-storage-classification" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +camino = { version = "1", features = ["serde1"] } +ccb-storage = { path = "../ccb-storage" } + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/ccb-storage-classification/src/classification.rs b/rust/crates/ccb-storage-classification/src/classification.rs new file mode 100644 index 000000000..20a7addfe --- /dev/null +++ b/rust/crates/ccb-storage-classification/src/classification.rs @@ -0,0 +1,2047 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; + +use ccb_storage::path_helpers::RootKind; +use ccb_storage::paths::PathLayout; + +pub const SCHEMA_VERSION: i32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StorageClass { + Secret, + Session, + Authority, + StartupAuthorityBundle, + RuntimeEphemeral, + Workspace, + UserContent, + ProjectedConfig, + RebuildableCache, + Residue, + Unknown, +} + +impl StorageClass { + pub fn as_str(&self) -> &'static str { + match self { + StorageClass::Secret => "secret", + StorageClass::Session => "session", + StorageClass::Authority => "authority", + StorageClass::StartupAuthorityBundle => "startup_authority_bundle", + StorageClass::RuntimeEphemeral => "runtime_ephemeral", + StorageClass::Workspace => "workspace", + StorageClass::UserContent => "user_content", + StorageClass::ProjectedConfig => "projected_config", + StorageClass::RebuildableCache => "rebuildable_cache", + StorageClass::Residue => "residue", + StorageClass::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageEntry { + pub path: String, + pub relative_path: String, + pub storage_class: StorageClass, + pub size_bytes: i64, + pub provider: Option, + pub agent: Option, + pub active: Option, + pub is_active_version: Option, + pub reachable_from_current_symlink: Option, + pub reclaimable: Option, + pub reason: Option, + pub root_kind: String, +} + +impl StorageEntry { + pub fn to_record(&self) -> serde_json::Map { + serde_json::to_value(self) + .unwrap_or_default() + .as_object() + .cloned() + .unwrap_or_default() + } +} + +const CCBD_AUTHORITY_FILES: &[&str] = &[ + "keeper.json", + "lease.json", + "lifecycle.json", + "restore-report.json", + "shutdown-intent.json", + "shutdown-report.json", + "start-policy.json", + "startup-report.json", + "state.json", +]; + +const CCBD_RUNTIME_DIRS: &[&str] = &["heartbeats", "leases", "cursors"]; + +const AGENT_AUTHORITY_FILES: &[&str] = &[ + "agent.json", + "runtime.json", + "helper.json", + "restore.json", + "provider.json", +]; + +const SECRET_FILENAMES: &[&str] = &[ + ".credentials.json", + ".env", + "auth.json", + "google_accounts.json", + "oauth_creds.json", +]; + +const CLAUDE_PROJECTED_NAMES: &[&str] = &["settings.json", "CLAUDE.md"]; +const GEMINI_PROJECTED_NAMES: &[&str] = &["settings.json", "trustedFolders.json"]; +const CODEX_PROJECTED_NAMES: &[&str] = &["config.toml"]; +const OPENCODE_PROJECTED_NAMES: &[&str] = &["opencode.json"]; +const MIMO_PROJECTED_NAMES: &[&str] = &["mimocode.json"]; +const NATIVE_CLI_PROVIDERS: &[&str] = &["qwen", "cursor", "copilot", "crush", "kiro", "pi"]; +const NATIVE_CLI_PROJECTED_ROOTS: &[&str] = &["inherited-skills", "role-skills"]; +const NATIVE_CLI_CACHE_ROOTS: &[&str] = &[".cache", ".npm", ".tmp", "cache", "node_modules", "tmp"]; +const NATIVE_CLI_SESSION_ROOTS: &[&str] = &[ + ".config", ".crush", ".cursor", ".kiro", ".local", ".pi", ".qwen", "data", "logs", "session", + "sessions", "state", +]; +const CODEX_SESSION_NAMES: &[&str] = &[ + ".ccb-session-namespace.json", + "history.jsonl", + "logs_2.sqlite", + "logs_2.sqlite-shm", + "logs_2.sqlite-wal", + "state_5.sqlite", + "state_5.sqlite-shm", + "state_5.sqlite-wal", +]; + +pub fn summarize_storage( + layout: &PathLayout, +) -> ccb_storage::Result> { + let entries = scan_layout(layout)?; + Ok(summary_payload(layout, &entries)) +} + +fn scan_layout(layout: &PathLayout) -> ccb_storage::Result> { + let mut roots: Vec<(&str, Utf8PathBuf)> = vec![("project", layout.ccb_dir())]; + if layout.runtime_state_root() != layout.ccb_dir().as_path() { + roots.push(("runtime", layout.runtime_state_root().to_path_buf())); + } + + let mut entries = Vec::new(); + let mut seen = HashMap::<(u64, u64), ()>::new(); + for (root_kind, root) in roots { + if !root.exists() { + continue; + } + for path in walk_files(&root)? { + let identity = scan_identity(&path)?; + if seen.contains_key(&identity) { + continue; + } + seen.insert(identity, ()); + entries.push(classify_path(layout, &root, &path, root_kind)?); + } + } + Ok(entries) +} + +fn walk_files(root: &Utf8Path) -> std::io::Result> { + let mut results = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = entry.metadata()?; + if metadata.is_symlink() { + if let Ok(p) = Utf8PathBuf::from_path_buf(path) { + results.push(p); + } + continue; + } + if metadata.is_dir() { + if let Ok(p) = Utf8PathBuf::from_path_buf(path) { + stack.push(p); + } + } else if metadata.is_file() { + if let Ok(p) = Utf8PathBuf::from_path_buf(path) { + results.push(p); + } + } + } + } + Ok(results) +} + +fn classify_path( + layout: &PathLayout, + root: &Utf8Path, + path: &Utf8Path, + root_kind: &str, +) -> ccb_storage::Result { + let size = safe_size(path); + let relative_path = relative_display(layout, root, path, root_kind); + if is_allowed_provider_secret_symlink(path, layout) { + return classify_relative(layout, path, &relative_path, size, root_kind); + } + if let Some(reason) = unsafe_symlink_reason(path, layout) { + if !is_marked_projected_symlink(path) { + return Ok(entry( + path, + &relative_path, + StorageClass::Unknown, + size, + root_kind, + None, + None, + None, + None, + None, + None, + Some(reason), + )); + } + } + classify_relative(layout, path, &relative_path, size, root_kind) +} + +fn classify_relative( + _layout: &PathLayout, + path: &Utf8Path, + relative_path: &str, + size: i64, + root_kind: &str, +) -> ccb_storage::Result { + let parts: Vec<&str> = relative_path.split('/').collect(); + if parts.is_empty() { + return Ok(entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + )); + } + + if parts[0] == "ccb.config" { + return Ok(entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + )); + } + if parts[0] == "ccb_memory.md" { + return Ok(entry( + path, + relative_path, + StorageClass::UserContent, + size, + root_kind, + None, + None, + None, + None, + None, + None, + Some("project_shared_memory".into()), + )); + } + if parts[0] == "runtime-root.json" || parts[0] == "runtime-root-ref.json" { + return Ok(entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + )); + } + if parts[0].starts_with('.') && parts[0].ends_with("-session") { + let filename = parts[0] + .trim_start_matches('.') + .trim_end_matches("-session"); + let segments: Vec<&str> = filename.split('-').collect(); + let provider = segments.first().map(|s| s.to_string()); + let agent = if segments.len() >= 3 { + Some(segments[1].to_string()) + } else { + None + }; + return Ok(entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + provider, + agent, + None, + None, + None, + None, + None, + )); + } + if parts.len() >= 2 && parts[0] == "ccbd" { + return Ok(classify_ccbd(path, relative_path, &parts, size, root_kind)); + } + if parts.len() >= 3 && parts[0] == "agents" { + return Ok(classify_agent(path, relative_path, &parts, size, root_kind)); + } + if parts.len() >= 3 && parts[0] == "provider-profiles" { + let provider = parts[2]; + let agent = parts[1]; + let remainder: Vec<&str> = parts[3..].to_vec(); + return Ok(classify_provider_home( + path, + relative_path, + provider, + agent, + &remainder, + size, + root_kind, + )); + } + if parts == ["state", "memory.seed.json"] { + return Ok(entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + None, + None, + None, + None, + None, + Some("project_memory_seed".into()), + )); + } + if parts.len() >= 5 && parts[0] == "runtime" && parts[1] == "skills" { + return Ok(entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(parts[3].to_string()), + Some(parts[2].to_string()), + None, + None, + None, + None, + Some("provider_skill_instruction".into()), + )); + } + if parts.len() == 3 + && parts[0] == "runtime" + && parts[1] == "memory" + && parts[2].ends_with(".md") + { + let agent = parts[2][..parts[2].len() - 3].to_string(); + return Ok(entry( + path, + relative_path, + StorageClass::RuntimeEphemeral, + size, + root_kind, + None, + Some(agent), + None, + None, + None, + None, + Some("project_memory_bundle".into()), + )); + } + if parts.len() >= 2 && parts[0] == "shared-cache" { + return Ok(entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(parts[1].to_string()), + None, + None, + None, + None, + Some(false), + Some("shared_cache".into()), + )); + } + if parts[0] == "workspaces" { + return Ok(entry( + path, + relative_path, + StorageClass::Workspace, + size, + root_kind, + None, + None, + None, + None, + None, + None, + Some("agent_workspace".into()), + )); + } + if parts[0] == "history" { + return Ok(entry( + path, + relative_path, + StorageClass::UserContent, + size, + root_kind, + None, + None, + None, + None, + None, + None, + Some("project_history".into()), + )); + } + Ok(entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + )) +} + +fn classify_ccbd( + path: &Utf8Path, + relative_path: &str, + parts: &[&str], + size: i64, + root_kind: &str, +) -> StorageEntry { + let name = parts[parts.len() - 1]; + let top = parts[1]; + if parts.len() == 2 && CCBD_AUTHORITY_FILES.contains(&name) { + return entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + ); + } + if CCBD_RUNTIME_DIRS.contains(&top) + || name.ends_with(".pid") + || name.ends_with(".sock") + || name.ends_with(".lock") + { + return entry( + path, + relative_path, + StorageClass::RuntimeEphemeral, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + ); + } + if [ + "mailboxes", + "messages", + "attempts", + "replies", + "executions", + "snapshots", + ] + .contains(&top) + { + return entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + ); + } + if name.ends_with(".jsonl") || name.ends_with(".log") { + return entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + None, + None, + None, + None, + None, + Some("ccbd_event_log".into()), + ); + } + if name.ends_with(".json") { + return entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + None, + None, + None, + None, + None, + None, + None, + ) +} + +fn classify_agent( + path: &Utf8Path, + relative_path: &str, + parts: &[&str], + size: i64, + root_kind: &str, +) -> StorageEntry { + let agent = parts[1].to_string(); + let name = parts[parts.len() - 1]; + if parts.len() == 3 && AGENT_AUTHORITY_FILES.contains(&name) { + return entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + Some(agent), + None, + None, + None, + None, + None, + ); + } + if parts.len() == 3 && name == "memory.md" { + return entry( + path, + relative_path, + StorageClass::UserContent, + size, + root_kind, + None, + Some(agent), + None, + None, + None, + None, + Some("agent_private_memory".into()), + ); + } + if parts.len() == 3 && name.ends_with(".jsonl") { + return entry( + path, + relative_path, + StorageClass::Authority, + size, + root_kind, + None, + Some(agent), + None, + None, + None, + None, + Some("agent_event_log".into()), + ); + } + if parts.len() >= 4 && parts[2] == "provider-runtime" { + let provider = parts[3].to_string(); + return entry( + path, + relative_path, + StorageClass::RuntimeEphemeral, + size, + root_kind, + Some(provider), + Some(agent), + None, + None, + None, + None, + None, + ); + } + if parts.len() >= 5 && parts[2] == "provider-state" { + let provider = parts[3].to_string(); + let remainder: Vec<&str> = if parts.len() >= 6 && parts[4] == "home" { + parts[5..].to_vec() + } else { + parts[4..].to_vec() + }; + return classify_provider_home( + path, + relative_path, + &provider, + &agent, + &remainder, + size, + root_kind, + ); + } + if parts.len() >= 3 && parts[2] == "logs" { + return entry( + path, + relative_path, + StorageClass::RuntimeEphemeral, + size, + root_kind, + None, + Some(agent), + None, + None, + None, + None, + Some("agent_log".into()), + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + None, + Some(agent), + None, + None, + None, + None, + None, + ) +} + +pub fn classify_provider_home( + path: &Utf8Path, + relative_path: &str, + provider: &str, + agent: &str, + remainder: &[&str], + size: i64, + root_kind: &str, +) -> StorageEntry { + let provider = provider.trim().to_lowercase(); + if provider.is_empty() { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + None, + Some(agent.to_string()), + None, + None, + None, + None, + None, + ); + } + if remainder.is_empty() { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.clone()), + Some(agent.to_string()), + None, + None, + None, + None, + None, + ); + } + let name = remainder[remainder.len() - 1]; + if SECRET_FILENAMES.contains(&name) { + return entry( + path, + relative_path, + StorageClass::Secret, + size, + root_kind, + Some(provider.clone()), + Some(agent.to_string()), + None, + None, + None, + None, + Some("provider_secret".into()), + ); + } + if name.ends_with(".ccb-projection.json") { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.clone()), + Some(agent.to_string()), + None, + None, + None, + None, + Some("projected_asset_marker".into()), + ); + } + match provider.as_str() { + "codex" => classify_codex_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + "claude" => classify_claude_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + "gemini" => classify_gemini_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + "opencode" => classify_opencode_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + "droid" => classify_droid_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + "kimi" => classify_kimi_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + "mimo" => classify_mimo_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + _ if NATIVE_CLI_PROVIDERS.contains(&provider.as_str()) => classify_native_cli_home( + path, + relative_path, + remainder, + size, + &provider, + agent, + root_kind, + ), + _ => entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider), + Some(agent.to_string()), + None, + None, + None, + None, + None, + ), + } +} + +fn classify_codex_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + let name = remainder[remainder.len() - 1]; + if remainder[0] == "sessions" + || CODEX_SESSION_NAMES.contains(&name) + || remainder[0] == "log" + || remainder[0] == "logs" + || remainder[0] == "shell_snapshots" + { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == ".tmp" && remainder.len() >= 2 && remainder[1] == "plugins" { + return entry( + path, + relative_path, + StorageClass::StartupAuthorityBundle, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + Some(false), + Some("codex_plugin_bundle".into()), + ); + } + if name == "plugins.sha" && remainder.first() == Some(&".tmp") { + return entry( + path, + relative_path, + StorageClass::StartupAuthorityBundle, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + Some(false), + Some("codex_plugin_bundle_manifest".into()), + ); + } + if CODEX_PROJECTED_NAMES.contains(&name) + || remainder[0] == "skills" + || remainder[0] == "commands" + { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == ".tmp" || remainder[0] == ".cache" { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ) +} + +fn classify_claude_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + let name = remainder[remainder.len() - 1]; + if name == ".claude.json" { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + Some("claude_trust_authority".into()), + ); + } + if remainder.len() >= 2 && remainder[0] == "Library" && remainder[1] == "Keychains" { + return entry( + path, + relative_path, + StorageClass::Secret, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + Some("macos_keychain_link".into()), + ); + } + if remainder.len() >= 4 + && remainder[0] == ".local" + && remainder[1] == "share" + && remainder[2] == "claude" + && remainder[3] == "versions" + { + let is_active = claude_version_active(path, remainder); + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + Some(false), + Some(is_active), + Some(is_active), + if is_active { Some(false) } else { None }, + if is_active { + Some("active_claude_version_cache".into()) + } else { + Some("claude_version_cache".into()) + }, + ); + } + if remainder.len() >= 2 && remainder[0] == ".local" && remainder[1] == "bin" && name == "claude" + { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + Some(true), + Some(false), + Some(true), + Some(false), + Some("claude_current_binary_link".into()), + ); + } + if remainder[0] == ".claude" + && remainder.len() >= 2 + && ["projects", "session-env", "tasks"].contains(&remainder[1]) + { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == ".claude" + && (CLAUDE_PROJECTED_NAMES.contains(&name) + || (remainder.len() >= 2 && (remainder[1] == "skills" || remainder[1] == "commands"))) + { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == ".cache" || remainder[0] == ".npm" { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ) +} + +fn classify_gemini_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + let name = remainder[remainder.len() - 1]; + if remainder[0] == ".gemini" && remainder.len() >= 2 && remainder[1] == "tmp" { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == ".gemini" && GEMINI_PROJECTED_NAMES.contains(&name) { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == ".npm" { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + Some("npm_cache".into()), + ); + } + if remainder.len() >= 2 + && remainder[0] == ".cache" + && (remainder[1] == "node-gyp" || remainder[1] == "vscode-ripgrep") + { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + Some("tool_cache".into()), + ); + } + if remainder[0] == ".gemini" { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ) +} + +fn classify_opencode_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + let name = remainder[remainder.len() - 1]; + if OPENCODE_PROJECTED_NAMES.contains(&name) { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == ".cache" || remainder[0] == ".tmp" { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ) +} + +fn classify_droid_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + if remainder[0] == "sessions" { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == "skills" { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ) +} + +fn classify_kimi_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + if NATIVE_CLI_PROJECTED_ROOTS.contains(&remainder[0]) { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ) +} + +fn classify_mimo_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + let name = remainder[remainder.len() - 1]; + if MIMO_PROJECTED_NAMES.contains(&name) { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == "data" || remainder[0] == "state" { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if remainder[0] == "cache" { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + entry( + path, + relative_path, + StorageClass::Unknown, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ) +} + +fn classify_native_cli_home( + path: &Utf8Path, + relative_path: &str, + remainder: &[&str], + size: i64, + provider: &str, + agent: &str, + root_kind: &str, +) -> StorageEntry { + let name = remainder[remainder.len() - 1]; + if NATIVE_CLI_PROJECTED_ROOTS.contains(&remainder[0]) { + return entry( + path, + relative_path, + StorageClass::ProjectedConfig, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if NATIVE_CLI_CACHE_ROOTS.contains(&remainder[0]) { + return entry( + path, + relative_path, + StorageClass::RebuildableCache, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + None, + ); + } + if NATIVE_CLI_SESSION_ROOTS.contains(&remainder[0]) + || name.ends_with(".db") + || name.ends_with(".jsonl") + || name.ends_with(".log") + || name.ends_with(".sqlite") + || name.ends_with(".sqlite-shm") + || name.ends_with(".sqlite-wal") + { + return entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + Some("native_cli_provider_state".into()), + ); + } + entry( + path, + relative_path, + StorageClass::Session, + size, + root_kind, + Some(provider.into()), + Some(agent.into()), + None, + None, + None, + None, + Some("native_cli_provider_owned_state".into()), + ) +} + +fn claude_version_active(path: &Utf8Path, remainder: &[&str]) -> bool { + if remainder.len() < 6 { + return false; + } + let version = remainder[4]; + let home = provider_home_from_remainder(path, remainder); + let home = match home { + Some(h) => h, + None => return false, + }; + let link = home.join(".local/share/claude/versions").join(version); + if !link.exists() { + return false; + } + // Python checks whether the symlink at `.local/bin/claude` resolves into this version + // directory. We approximate by checking the bin link target. + let bin_link = home.join(".local/bin/claude"); + let target = match std::fs::read_link(&bin_link).ok() { + Some(t) => { + let resolved = if t.is_absolute() { + t + } else { + bin_link.parent().unwrap_or(&home).join(t) + }; + resolved.canonicalize().ok() + } + None => None, + }; + match target { + Some(t) => t.starts_with(&link), + None => false, + } +} + +fn provider_home_from_remainder(path: &Utf8Path, remainder: &[&str]) -> Option { + let mut current = PathBuf::from(path.as_str()); + for _ in remainder { + current = current.parent()?.to_path_buf(); + } + Some(current) +} + +#[allow(clippy::too_many_arguments)] +fn entry( + path: &Utf8Path, + relative_path: &str, + storage_class: StorageClass, + size: i64, + root_kind: &str, + provider: Option, + agent: Option, + active: Option, + is_active_version: Option, + reachable_from_current_symlink: Option, + reclaimable: Option, + reason: Option, +) -> StorageEntry { + StorageEntry { + path: path.to_string(), + relative_path: relative_path.to_string(), + storage_class, + size_bytes: size, + provider, + agent, + active, + is_active_version, + reachable_from_current_symlink, + reclaimable, + reason, + root_kind: root_kind.to_string(), + } +} + +fn summary_payload( + layout: &PathLayout, + entries: &[StorageEntry], +) -> serde_json::Map { + let mut by_class: HashMap> = HashMap::new(); + let mut by_provider: HashMap> = + HashMap::new(); + let mut by_agent: HashMap> = HashMap::new(); + let mut total_bytes: i64 = 0; + + for entry in entries { + total_bytes += entry.size_bytes; + accumulate( + by_class + .entry(entry.storage_class.as_str().to_string()) + .or_default(), + entry.size_bytes, + ); + if let Some(provider) = &entry.provider { + accumulate( + by_provider.entry(provider.clone()).or_default(), + entry.size_bytes, + ); + } + if let Some(agent) = &entry.agent { + accumulate(by_agent.entry(agent.clone()).or_default(), entry.size_bytes); + } + } + + let shared_cache_reason = shared_cache_disabled_reason(layout); + let shared_cache_enabled = shared_cache_reason.is_none(); + + let mut payload = serde_json::Map::new(); + payload.insert("schema_version".into(), SCHEMA_VERSION.into()); + payload.insert("generated_at".into(), utc_now().into()); + payload.insert("project".into(), layout.project_root.as_str().into()); + payload.insert("project_id".into(), layout.project_id().into()); + payload.insert( + "runtime_root_kind".into(), + layout.runtime_state_placement().root_kind.as_str().into(), + ); + payload.insert( + "runtime_state_root".into(), + layout.runtime_state_root().as_str().into(), + ); + payload.insert( + "shared_cache_root".into(), + shared_cache_root(layout, shared_cache_reason.as_deref().unwrap_or("")).into(), + ); + payload.insert( + "shared_cache_root_usable".into(), + shared_cache_enabled.into(), + ); + payload.insert( + "shared_cache_status".into(), + (if shared_cache_enabled { + "enabled" + } else { + "disabled" + }) + .into(), + ); + payload.insert( + "shared_cache_reason".into(), + (if shared_cache_enabled { + "enabled" + } else { + shared_cache_reason.as_deref().unwrap_or("") + }) + .into(), + ); + payload.insert("total_bytes".into(), total_bytes.into()); + payload.insert("total_count".into(), (entries.len() as i64).into()); + + let sort_map = |map: HashMap>| { + let mut keys: Vec = map.keys().cloned().collect(); + keys.sort(); + let mut sorted = serde_json::Map::new(); + for key in keys { + sorted.insert( + key.clone(), + serde_json::Value::Object(map.get(&key).unwrap().clone()), + ); + } + serde_json::Value::Object(sorted) + }; + + payload.insert("by_class".into(), sort_map(by_class)); + payload.insert("by_provider".into(), sort_map(by_provider)); + payload.insert("by_agent".into(), sort_map(by_agent)); + + let mut sorted_entries = entries.to_vec(); + sorted_entries.sort_by_key(|e| std::cmp::Reverse(e.size_bytes)); + payload.insert( + "entries".into(), + serde_json::Value::Array( + sorted_entries + .iter() + .map(|e| serde_json::Value::Object(e.to_record())) + .collect(), + ), + ); + + payload +} + +fn accumulate(bucket: &mut serde_json::Map, size: i64) { + let bytes = bucket.get("bytes").and_then(|v| v.as_i64()).unwrap_or(0) + size; + let count = bucket.get("count").and_then(|v| v.as_i64()).unwrap_or(0) + 1; + bucket.insert("bytes".into(), bytes.into()); + bucket.insert("count".into(), count.into()); +} + +fn shared_cache_root(layout: &PathLayout, disabled_reason: &str) -> Option { + if disabled_reason == "wsl_drvfs_requires_runtime_relocation" { + return None; + } + Some(layout.shared_cache_dir().to_string()) +} + +fn shared_cache_disabled_reason(layout: &PathLayout) -> Option { + let placement = layout.runtime_state_placement(); + if placement.filesystem_hint.as_deref() == Some("wsl_drvfs") + && !matches!(placement.root_kind, RootKind::Relocated) + { + Some("wsl_drvfs_requires_runtime_relocation".into()) + } else { + None + } +} + +fn relative_display( + _layout: &PathLayout, + root: &Utf8Path, + path: &Utf8Path, + _root_kind: &str, +) -> String { + path.strip_prefix(root).unwrap_or(path).to_string() +} + +fn safe_size(path: &Utf8Path) -> i64 { + fs::symlink_metadata(path) + .map(|m| m.len() as i64) + .unwrap_or(0) +} + +fn scan_identity(path: &Utf8Path) -> ccb_storage::Result<(u64, u64)> { + let stat = fs::symlink_metadata(path)?; + Ok((stat.dev(), stat.ino())) +} + +fn unsafe_symlink_reason(path: &Utf8Path, layout: &PathLayout) -> Option { + let metadata = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(_) => return Some("symlink_unreadable".into()), + }; + if !metadata.file_type().is_symlink() { + return None; + } + let target = match std::fs::read_link(path.as_str()) { + Ok(t) => Utf8PathBuf::from_path_buf(t) + .unwrap_or_else(|p| Utf8PathBuf::from(p.to_string_lossy().to_string())), + Err(_) => return Some("symlink_target_missing".into()), + }; + let resolved = { + let to_resolve = if target.as_str().starts_with('/') { + target.as_str().to_string() + } else { + path.parent() + .map(|p| format!("{}/{}", p, target)) + .unwrap_or_else(|| target.to_string()) + }; + match std::fs::canonicalize(&to_resolve) { + Ok(p) => p, + Err(_) => return Some("symlink_target_missing".into()), + } + }; + let allowed_roots = [layout.ccb_dir(), layout.runtime_state_root().to_path_buf()]; + if allowed_roots + .iter() + .any(|root| resolved.starts_with(root.as_str())) + { + None + } else { + Some("symlink_out_of_bounds".into()) + } +} + +fn is_allowed_provider_secret_symlink(path: &Utf8Path, layout: &PathLayout) -> bool { + let relative = match path.strip_prefix(layout.ccb_dir()) { + Ok(r) => r, + Err(_) => return false, + }; + let parts: Vec<&str> = relative.as_str().split('/').collect(); + parts.len() >= 7 + && parts[0] == "agents" + && parts[2] == "provider-state" + && parts[3] == "claude" + && parts[4] == "home" + && parts[5] == "Library" + && parts[6] == "Keychains" +} + +fn is_marked_projected_symlink(path: &Utf8Path) -> bool { + let metadata = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(_) => return false, + }; + if !metadata.file_type().is_symlink() { + return false; + } + let projection_path = format!("{}.ccb-projection.json", path); + let data = match fs::read_to_string(&projection_path) { + Ok(d) => d, + Err(_) => return false, + }; + let payload: serde_json::Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => return false, + }; + let obj = match payload.as_object() { + Some(o) => o, + None => return false, + }; + if obj.get("record_type").and_then(|v| v.as_str()) != Some("ccb_projected_asset") { + return false; + } + let valid_labels = [ + "claude-binary-versions", + "claude-inherited-skills", + "claude-inherited-commands", + "codex-inherited-skills", + "codex-inherited-commands", + "codex-plugin-bundle", + "droid-inherited-skills", + "kimi-inherited-skills", + "mimo-inherited-skills", + ]; + let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or(""); + let is_role_skill = label.starts_with("codex-role-skill:") + || label.starts_with("claude-role-skill:") + || label.starts_with("kimi-role-skill:"); + if !valid_labels.contains(&label) && !is_role_skill { + return false; + } + let source = obj + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if source.is_empty() { + return false; + } + let source_resolved = match std::fs::canonicalize(expand_user_path(source)) { + Ok(p) => p, + Err(_) => return false, + }; + let link_resolved = match std::fs::canonicalize(path.as_str()) { + Ok(p) => p, + Err(_) => return false, + }; + source_resolved == link_resolved +} + +fn expand_user_path(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix('~') { + if let Ok(home) = std::env::var("HOME") { + return home + rest; + } + } + raw.to_string() +} + +fn utc_now() -> String { + chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + .replace("+00:00", "Z") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_classify_provider_home_secret() { + let path = + Utf8Path::new("/repo/.ccb/agents/agent1/provider-state/unknownai/home/auth.json"); + let entry = classify_provider_home( + path, + "agents/agent1/provider-state/unknownai/home/auth.json", + "UnknownAI", + "agent1", + &["auth.json"], + 2, + "project", + ); + assert_eq!(entry.storage_class, StorageClass::Secret); + assert_eq!(entry.provider.as_deref(), Some("unknownai")); + assert_eq!(entry.reason.as_deref(), Some("provider_secret")); + } + + #[test] + fn test_classify_provider_home_unknown() { + let path = + Utf8Path::new("/repo/.ccb/agents/agent1/provider-state/unknownai/home/notes.txt"); + let entry = classify_provider_home( + path, + "agents/agent1/provider-state/unknownai/home/notes.txt", + "UnknownAI", + "agent1", + &["notes.txt"], + 5, + "project", + ); + assert_eq!(entry.storage_class, StorageClass::Unknown); + assert_eq!(entry.provider.as_deref(), Some("unknownai")); + } + + #[test] + fn test_classify_kimi_home_projected() { + let path = Utf8Path::new( + "/repo/.ccb/agents/agent1/provider-state/kimi/home/inherited-skills/demo/SKILL.md", + ); + let entry = classify_provider_home( + path, + "agents/agent1/provider-state/kimi/home/inherited-skills/demo/SKILL.md", + "kimi", + "agent1", + &["inherited-skills", "demo", "SKILL.md"], + 10, + "project", + ); + assert_eq!(entry.storage_class, StorageClass::ProjectedConfig); + assert_eq!(entry.provider.as_deref(), Some("kimi")); + } + + #[test] + fn test_classify_mimo_home() { + let path = + Utf8Path::new("/repo/.ccb/agents/agent1/provider-state/mimo/home/data/state.json"); + let entry = classify_provider_home( + path, + "agents/agent1/provider-state/mimo/home/data/state.json", + "mimo", + "agent1", + &["data", "state.json"], + 10, + "project", + ); + assert_eq!(entry.storage_class, StorageClass::Session); + assert_eq!(entry.provider.as_deref(), Some("mimo")); + + let config_path = + Utf8Path::new("/repo/.ccb/agents/agent1/provider-state/mimo/home/mimocode.json"); + let config_entry = classify_provider_home( + config_path, + "agents/agent1/provider-state/mimo/home/mimocode.json", + "mimo", + "agent1", + &["mimocode.json"], + 5, + "project", + ); + assert_eq!(config_entry.storage_class, StorageClass::ProjectedConfig); + } + + #[test] + fn test_classify_native_cli_home() { + let path = Utf8Path::new( + "/repo/.ccb/agents/agent1/provider-state/cursor/home/sessions/session.jsonl", + ); + let entry = classify_provider_home( + path, + "agents/agent1/provider-state/cursor/home/sessions/session.jsonl", + "cursor", + "agent1", + &["sessions", "session.jsonl"], + 10, + "project", + ); + assert_eq!(entry.storage_class, StorageClass::Session); + assert_eq!(entry.reason.as_deref(), Some("native_cli_provider_state")); + + let cache_path = + Utf8Path::new("/repo/.ccb/agents/agent1/provider-state/cursor/home/.cache/blob"); + let cache_entry = classify_provider_home( + cache_path, + "agents/agent1/provider-state/cursor/home/.cache/blob", + "cursor", + "agent1", + &[".cache", "blob"], + 3, + "project", + ); + assert_eq!(cache_entry.storage_class, StorageClass::RebuildableCache); + } + + #[test] + fn test_runtime_skills_classified_as_projected_config() { + let tmp = tempfile::TempDir::new().unwrap(); + let layout = PathLayout::new(Utf8PathBuf::from_path_buf(tmp.path().join("repo")).unwrap()); + let skills_dir = layout + .runtime_state_root() + .join("runtime/skills/agent1/codex"); + std::fs::create_dir_all(&skills_dir).unwrap(); + let skill_path = skills_dir.join("instruction.md"); + std::fs::write(&skill_path, "x").unwrap(); + + let payload = summarize_storage(&layout).unwrap(); + let entries = payload.get("entries").unwrap().as_array().unwrap(); + let found = entries + .iter() + .find(|e| { + e.get("relative_path").unwrap().as_str() + == Some("runtime/skills/agent1/codex/instruction.md") + }) + .unwrap(); + assert_eq!( + found.get("storage_class").unwrap().as_str(), + Some("projected_config") + ); + assert_eq!( + found.get("reason").unwrap().as_str(), + Some("provider_skill_instruction") + ); + } +} diff --git a/rust/crates/ccb-storage-classification/src/lib.rs b/rust/crates/ccb-storage-classification/src/lib.rs new file mode 100644 index 000000000..4d1c840d7 --- /dev/null +++ b/rust/crates/ccb-storage-classification/src/lib.rs @@ -0,0 +1,27 @@ +//! CCB provider-home storage classification. +//! +//! Mirrors `lib/storage_classification/` from Python v7.5.2. + +pub mod classification; + +pub use classification::{ + classify_provider_home, summarize_storage, StorageClass, StorageEntry, SCHEMA_VERSION, +}; + +pub fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +pub mod models; +pub mod provider_home; +pub mod service; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crate_smoke() { + assert_eq!(version(), env!("CARGO_PKG_VERSION")); + } +} diff --git a/rust/crates/ccb-storage-classification/src/models.rs b/rust/crates/ccb-storage-classification/src/models.rs new file mode 100644 index 000000000..45e5dce1a --- /dev/null +++ b/rust/crates/ccb-storage-classification/src/models.rs @@ -0,0 +1,3 @@ +//! Mirrors Python `lib/storage_classification/models.py`. +//! 1:1 file alignment stub. +// TODO: align with Python diff --git a/rust/crates/ccb-storage-classification/src/provider_home.rs b/rust/crates/ccb-storage-classification/src/provider_home.rs new file mode 100644 index 000000000..4418e1838 --- /dev/null +++ b/rust/crates/ccb-storage-classification/src/provider_home.rs @@ -0,0 +1,3 @@ +//! Mirrors Python `lib/storage_classification/provider_home.py`. +//! 1:1 file alignment stub. +// TODO: align with Python diff --git a/rust/crates/ccb-storage-classification/src/service.rs b/rust/crates/ccb-storage-classification/src/service.rs new file mode 100644 index 000000000..6e047efd8 --- /dev/null +++ b/rust/crates/ccb-storage-classification/src/service.rs @@ -0,0 +1,3 @@ +//! Mirrors Python `lib/storage_classification/service.py`. +//! 1:1 file alignment stub. +// TODO: align with Python diff --git a/rust/crates/ccb-storage/Cargo.toml b/rust/crates/ccb-storage/Cargo.toml new file mode 100644 index 000000000..c3f419fd3 --- /dev/null +++ b/rust/crates/ccb-storage/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ccb-storage" +version.workspace = true +edition.workspace = true + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +camino = { version = "1", features = ["serde1"] } +fs2 = "0.4" +sha2 = "0.10" +hex = "0.4" +ccb-project = { path = "../ccb-project" } + +[dev-dependencies] +tempfile = "3" +filetime = "0.2" +ccb-storage-classification = { path = "../ccb-storage-classification" } diff --git a/rust/crates/ccb-storage/src/atomic.rs b/rust/crates/ccb-storage/src/atomic.rs new file mode 100644 index 000000000..9a79e3b7a --- /dev/null +++ b/rust/crates/ccb-storage/src/atomic.rs @@ -0,0 +1,110 @@ +use camino::Utf8Path; +use serde::Serialize; +use std::fs; +use std::io::Write; +use uuid::Uuid; + +/// Serialize a value as JSON with Python-compatible 2-space indentation. +pub fn to_json_pretty_2(value: &T) -> crate::Result { + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + value.serialize(&mut ser)?; + // serde_json guarantees UTF-8 output. + Ok(String::from_utf8(buf).expect("json serializer produced invalid utf-8")) +} + +/// Write text to `path` atomically using a temp file + rename. +/// Mirrors Python `storage.atomic.atomic_write_text`. +pub fn atomic_write_text(path: &Utf8Path, text: &str) -> std::io::Result<()> { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent") + })?; + fs::create_dir_all(parent)?; + + let file_name = path.file_name().unwrap_or("tmp"); + let tmp_name = format!(".{}.{:x}.tmp", file_name, Uuid::new_v4().as_simple()); + let tmp_path = parent.join(tmp_name); + + let result = write_and_sync(&tmp_path, text); + if result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + result?; + + fs::rename(&tmp_path, path)?; + Ok(()) +} + +fn write_and_sync(path: &Utf8Path, text: &str) -> std::io::Result<()> { + let mut file = fs::File::create(path)?; + file.write_all(text.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +/// Serialize `value` as pretty JSON (2-space indent, unicode, trailing newline) +/// and atomically write it to `path`. +/// Mirrors Python `storage.atomic.atomic_write_json`. +pub fn atomic_write_json(path: &Utf8Path, value: &T) -> crate::Result<()> { + let json = to_json_pretty_2(value)? + "\n"; + atomic_write_text(path, &json)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + use std::io::Read; + use tempfile::TempDir; + + #[derive(Serialize)] + struct Sample { + name: String, + value: i32, + } + + #[test] + fn test_atomic_write_text_round_trip() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("target.txt"); + let path = Utf8Path::from_path(&p).unwrap(); + atomic_write_text(path, "hello world").unwrap(); + assert_eq!(fs::read_to_string(path).unwrap(), "hello world"); + } + + #[test] + fn test_atomic_write_json_format() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("target.json"); + let path = Utf8Path::from_path(&p).unwrap(); + atomic_write_json( + path, + &Sample { + name: "test".into(), + value: 42, + }, + ) + .unwrap(); + let mut content = String::new(); + fs::File::open(path) + .unwrap() + .read_to_string(&mut content) + .unwrap(); + assert!( + content.contains(" \"name\": \"test\""), + "content: {content}" + ); + assert!(content.ends_with('\n')); + } + + #[test] + fn test_atomic_write_creates_parent_dirs() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("a/b/c/nested.json"); + let path = Utf8Path::from_path(&p).unwrap(); + atomic_write_text(path, "{}").unwrap(); + assert!(path.exists()); + } +} diff --git a/rust/crates/ccb-storage/src/cursor_store.rs b/rust/crates/ccb-storage/src/cursor_store.rs new file mode 100644 index 000000000..7dbb25d53 --- /dev/null +++ b/rust/crates/ccb-storage/src/cursor_store.rs @@ -0,0 +1,91 @@ +use camino::Utf8PathBuf; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::json::JsonStore; +use crate::paths::PathLayout; + +/// Stores completion cursors on disk under `layout.cursor_path(job_id)`. +/// Mirrors Python `storage.cursor_store.CursorStore`. +#[derive(Clone, Default)] +pub struct CursorStore { + store: JsonStore, +} + +impl CursorStore { + pub fn new() -> Self { + Self { + store: JsonStore::new(), + } + } + + pub fn with_store(store: JsonStore) -> Self { + Self { store } + } + + /// Load the cursor for `job_id` if the file exists. + pub fn load( + &self, + layout: &PathLayout, + job_id: &str, + ) -> crate::Result> { + let path = layout.cursor_path(job_id); + if !path.exists() { + return Ok(None); + } + let value = self.store.load::(&path)?; + Ok(Some(value)) + } + + /// Save `cursor` for `job_id` and return the path written. + pub fn save( + &self, + layout: &PathLayout, + job_id: &str, + cursor: &C, + ) -> crate::Result { + let path = layout.cursor_path(job_id); + self.store.save(&path, cursor)?; + Ok(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct DummyCursor { + source_kind: String, + offset: u64, + } + + #[test] + fn test_cursor_store_round_trip() { + let tmp = tempfile::TempDir::new().unwrap(); + let layout = + PathLayout::new(camino::Utf8PathBuf::from_path_buf(tmp.path().join("repo")).unwrap()); + let store = CursorStore::new(); + let cursor = DummyCursor { + source_kind: "test".into(), + offset: 42, + }; + + let path = store.save(&layout, "job-1", &cursor).unwrap(); + assert!(path.exists()); + + let loaded: Option = store.load(&layout, "job-1").unwrap(); + assert_eq!(loaded, Some(cursor)); + } + + #[test] + fn test_cursor_store_missing_returns_none() { + let tmp = tempfile::TempDir::new().unwrap(); + let layout = + PathLayout::new(camino::Utf8PathBuf::from_path_buf(tmp.path().join("repo")).unwrap()); + let store = CursorStore::new(); + + let loaded: Option = store.load(&layout, "missing-job").unwrap(); + assert_eq!(loaded, None); + } +} diff --git a/rust/crates/ccb-storage/src/json.rs b/rust/crates/ccb-storage/src/json.rs new file mode 100644 index 000000000..378f1cc04 --- /dev/null +++ b/rust/crates/ccb-storage/src/json.rs @@ -0,0 +1,93 @@ +use camino::Utf8Path; +use serde::{de::DeserializeOwned, Serialize}; +use std::fs; + +use crate::atomic::{atomic_write_json, to_json_pretty_2}; + +/// Atomic JSON file store. Loads and saves JSON files with write-to-temp-then-rename +/// for crash safety. Mirrors Python `storage.json_store.JsonStore`. +#[derive(Clone)] +pub struct JsonStore; + +impl JsonStore { + pub fn new() -> Self { + Self + } + + pub fn load(&self, path: &Utf8Path) -> crate::Result { + let data = fs::read_to_string(path)?; + let value: T = serde_json::from_str(&data)?; + Ok(value) + } + + pub fn save(&self, path: &Utf8Path, value: &T) -> crate::Result<()> { + atomic_write_json(path, value) + } + + /// Serialize value as pretty JSON (2-space indentation) without writing to disk. + pub fn to_string(&self, value: &T) -> crate::Result { + to_json_pretty_2(value) + } +} + +impl Default for JsonStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use tempfile::TempDir; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestData { + name: String, + value: i32, + } + + #[test] + fn test_round_trip() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("test.json"); + let path = Utf8Path::from_path(&p).unwrap(); + let store = JsonStore::new(); + let data = TestData { + name: "hello".into(), + value: 42, + }; + store.save(path, &data).unwrap(); + let loaded: TestData = store.load(path).unwrap(); + assert_eq!(loaded, data); + } + + #[test] + fn test_load_missing_file() { + let store = JsonStore::new(); + let p = std::path::PathBuf::from("/nonexistent.json"); + let result = store.load::(Utf8Path::from_path(&p).unwrap()); + assert!(result.is_err()); + } + + #[test] + fn test_pretty_indent_is_two_spaces() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("pretty.json"); + let path = Utf8Path::from_path(&p).unwrap(); + let store = JsonStore::new(); + store + .save( + path, + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap(); + let text = fs::read_to_string(path).unwrap(); + assert!(text.contains(" \"name\": \"x\""), "content: {text}"); + assert!(text.ends_with('\n')); + } +} diff --git a/rust/crates/ccb-storage/src/json_store.rs b/rust/crates/ccb-storage/src/json_store.rs new file mode 100644 index 000000000..3f326aa34 --- /dev/null +++ b/rust/crates/ccb-storage/src/json_store.rs @@ -0,0 +1,3 @@ +//! Mirrors Python `lib/storage/json_store.py`. +//! 1:1 file alignment stub. +// TODO: align with Python diff --git a/rust/crates/ccb-storage/src/jsonl.rs b/rust/crates/ccb-storage/src/jsonl.rs new file mode 100644 index 000000000..b682d37c3 --- /dev/null +++ b/rust/crates/ccb-storage/src/jsonl.rs @@ -0,0 +1,353 @@ +use camino::Utf8Path; +use serde::{de::DeserializeOwned, Serialize}; +use std::fs; +use std::io::{self, BufRead, Read, Seek, Write}; + +const TAIL_CHUNK_SIZE: usize = 8192; +const FIND_LAST_CHUNK_SIZE: usize = 4096; + +/// JSONL (JSON Lines) append-only store. Mirrors Python `storage.jsonl_store.JsonlStore`. +#[derive(Clone)] +pub struct JsonlStore; + +impl JsonlStore { + pub fn new() -> Self { + Self + } + + pub fn append(&self, path: &Utf8Path, row: &T) -> crate::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut f = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + let json = serde_json::to_string(row)?; + f.write_all(json.as_bytes())?; + f.write_all(b"\n")?; + Ok(()) + } + + pub fn read_all(&self, path: &Utf8Path) -> crate::Result> { + let file = fs::File::open(path)?; + let reader = io::BufReader::new(file); + let mut results = Vec::new(); + for line in reader.lines() { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value: T = serde_json::from_str(trimmed)?; + results.push(value); + } + Ok(results) + } + + /// Read rows starting after `start_line` (0-based count of non-empty rows). + /// Returns the updated line count and the rows. + pub fn read_since( + &self, + path: &Utf8Path, + start_line: usize, + ) -> crate::Result<(usize, Vec)> { + let file = fs::File::open(path)?; + let reader = io::BufReader::new(file); + let mut results = Vec::new(); + let mut current = 0usize; + for line in reader.lines() { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + current += 1; + if current <= start_line { + continue; + } + let value: T = serde_json::from_str(trimmed)?; + results.push(value); + } + Ok((current, results)) + } + + /// Read the last `limit` non-empty rows without loading the whole file. + /// Mirrors Python `JsonlStore.read_tail`. + pub fn read_tail( + &self, + path: &Utf8Path, + limit: usize, + ) -> crate::Result> { + if limit == 0 { + return Ok(Vec::new()); + } + let mut file = fs::File::open(path)?; + let mut rows = Vec::new(); + let mut position = file.seek(io::SeekFrom::End(0))? as usize; + let mut carry: Vec = Vec::new(); + + while position > 0 && rows.len() < limit { + let read_size = TAIL_CHUNK_SIZE.min(position); + position -= read_size; + file.seek(io::SeekFrom::Start(position as u64))?; + let mut chunk = vec![0u8; read_size]; + file.read_exact(&mut chunk)?; + + let buffer = [chunk.as_slice(), carry.as_slice()].concat(); + let lines: Vec<&[u8]> = buffer.split(|&b| b == b'\n').collect(); + let starts_with_newline = + !buffer.is_empty() && (buffer[0] == b'\n' || buffer[0] == b'\r'); + let keep_first = position > 0 && !buffer.is_empty() && !starts_with_newline; + + let (lines_to_process, new_carry): (Vec<&[u8]>, Vec) = if keep_first { + let mut iter = lines.into_iter(); + let first = iter.next().unwrap_or(&[]); + (iter.rev().collect(), first.to_vec()) + } else { + (lines.into_iter().rev().collect(), Vec::new()) + }; + carry = new_carry; + + for raw in lines_to_process { + if rows.len() >= limit { + break; + } + let text = String::from_utf8_lossy(raw).trim().to_string(); + if text.is_empty() { + continue; + } + let value: T = serde_json::from_str(&text)?; + rows.push(value); + } + } + + if !carry.is_empty() && rows.len() < limit { + let text = String::from_utf8_lossy(&carry).trim().to_string(); + if !text.is_empty() { + let value: T = serde_json::from_str(&text)?; + rows.push(value); + } + } + + rows.reverse(); + Ok(rows) + } + + /// Find the last non-empty row matching `predicate` without loading the whole file. + /// Mirrors Python `JsonlStore.find_last`. + pub fn find_last( + &self, + path: &Utf8Path, + predicate: impl Fn(&T) -> bool, + ) -> crate::Result> { + let mut file = fs::File::open(path)?; + let mut position = file.seek(io::SeekFrom::End(0))? as usize; + let mut carry: Vec = Vec::new(); + + while position > 0 { + let read_size = FIND_LAST_CHUNK_SIZE.min(position); + position -= read_size; + file.seek(io::SeekFrom::Start(position as u64))?; + let mut chunk = vec![0u8; read_size]; + file.read_exact(&mut chunk)?; + + let buffer = [chunk.as_slice(), carry.as_slice()].concat(); + let lines: Vec<&[u8]> = buffer.split(|&b| b == b'\n').collect(); + let starts_with_newline = + !buffer.is_empty() && (buffer[0] == b'\n' || buffer[0] == b'\r'); + let keep_first = position > 0 && !buffer.is_empty() && !starts_with_newline; + + let (lines_to_process, new_carry): (Vec<&[u8]>, Vec) = if keep_first { + let mut iter = lines.into_iter(); + let first = iter.next().unwrap_or(&[]); + (iter.rev().collect(), first.to_vec()) + } else { + (lines.into_iter().rev().collect(), Vec::new()) + }; + carry = new_carry; + + for raw in lines_to_process { + let text = String::from_utf8_lossy(raw).trim().to_string(); + if text.is_empty() { + continue; + } + let value: T = serde_json::from_str(&text)?; + if predicate(&value) { + return Ok(Some(value)); + } + } + } + + if !carry.is_empty() { + let text = String::from_utf8_lossy(&carry).trim().to_string(); + if !text.is_empty() { + let value: T = serde_json::from_str(&text)?; + if predicate(&value) { + return Ok(Some(value)); + } + } + } + + Ok(None) + } +} + +impl Default for JsonlStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use tempfile::TempDir; + + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] + struct Row { + id: i32, + msg: String, + } + + #[test] + fn test_append_and_read() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("test.jsonl"); + let path = Utf8Path::from_path(&p).unwrap(); + let store = JsonlStore::new(); + store + .append( + path, + &Row { + id: 1, + msg: "a".into(), + }, + ) + .unwrap(); + store + .append( + path, + &Row { + id: 2, + msg: "b".into(), + }, + ) + .unwrap(); + store + .append( + path, + &Row { + id: 3, + msg: "c".into(), + }, + ) + .unwrap(); + let all: Vec = store.read_all(path).unwrap(); + assert_eq!(all.len(), 3); + assert_eq!(all[0].id, 1); + assert_eq!(all[2].id, 3); + } + + #[test] + fn test_read_since() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("since.jsonl"); + let path = Utf8Path::from_path(&p).unwrap(); + let store = JsonlStore::new(); + for i in 1..=5 { + store + .append( + path, + &Row { + id: i, + msg: format!("m{i}"), + }, + ) + .unwrap(); + } + let (count, rows): (usize, Vec) = store.read_since(path, 2).unwrap(); + assert_eq!(count, 5); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].id, 3); + } + + #[test] + fn test_read_tail() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("tail.jsonl"); + let path = Utf8Path::from_path(&p).unwrap(); + let store = JsonlStore::new(); + for i in 0..10 { + store + .append( + path, + &Row { + id: i, + msg: format!("m{i}"), + }, + ) + .unwrap(); + } + let tail: Vec = store.read_tail(path, 3).unwrap(); + assert_eq!(tail.len(), 3); + assert_eq!(tail[0].id, 7); + } + + #[test] + fn test_find_last() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("find.jsonl"); + let path = Utf8Path::from_path(&p).unwrap(); + let store = JsonlStore::new(); + store + .append( + path, + &Row { + id: 1, + msg: "a".into(), + }, + ) + .unwrap(); + store + .append( + path, + &Row { + id: 2, + msg: "target".into(), + }, + ) + .unwrap(); + store + .append( + path, + &Row { + id: 3, + msg: "b".into(), + }, + ) + .unwrap(); + let found: Option = store.find_last(path, |r: &Row| r.msg == "target").unwrap(); + assert_eq!(found.unwrap().id, 2); + } + + #[test] + fn test_read_tail_zero_limit() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("empty_tail.jsonl"); + let path = Utf8Path::from_path(&p).unwrap(); + let store = JsonlStore::new(); + store + .append( + path, + &Row { + id: 1, + msg: "a".into(), + }, + ) + .unwrap(); + let tail: Vec = store.read_tail(path, 0).unwrap(); + assert!(tail.is_empty()); + } +} diff --git a/rust/crates/ccb-storage/src/jsonl_store.rs b/rust/crates/ccb-storage/src/jsonl_store.rs new file mode 100644 index 000000000..c32b3ab95 --- /dev/null +++ b/rust/crates/ccb-storage/src/jsonl_store.rs @@ -0,0 +1,212 @@ +//! Mirrors Python `lib/storage/jsonl_store.py`. +//! +//! Provides the `JsonlStore` JSONL abstraction plus the v8.0.4 strict-tail +//! helper path gated by `CCB_RUST_JSONL_STORE`. + +use camino::Utf8Path; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::env; + +pub use crate::jsonl::JsonlStore; + +/// Kinds of errors reported by the strict JSONL tail helpers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JsonlStrictErrorKind { + NonObject, + InvalidJson, + InvalidUtf8, + ReadError, +} + +/// Error returned by strict JSONL tail helpers. +#[derive(Debug, Clone)] +pub struct JsonlStrictError { + pub kind: JsonlStrictErrorKind, + pub path: String, + pub message: String, +} + +impl std::fmt::Display for JsonlStrictError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} at {}: {}", self.kind_name(), self.path, self.message) + } +} + +impl std::error::Error for JsonlStrictError {} + +impl JsonlStrictError { + fn kind_name(&self) -> &'static str { + match self.kind { + JsonlStrictErrorKind::NonObject => "non_object", + JsonlStrictErrorKind::InvalidJson => "invalid_json", + JsonlStrictErrorKind::InvalidUtf8 => "invalid_utf8", + JsonlStrictErrorKind::ReadError => "read_error", + } + } +} + +/// One request in a strict tail batch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TailRequest { + pub id: String, + pub path: String, + pub n: usize, +} + +/// Rows returned for one request in a strict tail batch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TailResponse { + pub id: String, + pub rows: Vec>, +} + +/// Full response from a strict tail batch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TailBatchResponse { + pub requests: Vec, +} + +/// Returns true when the environment requests the strict JSONL helper path. +/// +/// Mirrors Python `_strict_jsonl_helper_required()`. +pub fn strict_jsonl_helper_required() -> bool { + match env::var("CCB_RUST_JSONL_STORE") { + Ok(v) => matches!( + v.trim().to_lowercase().as_str(), + "1" | "true" | "yes" | "on" | "required" + ), + Err(_) => false, + } +} + +/// Strictly read the last `n` JSON object rows from `path`. +/// +/// Returns an error if a row is not a JSON object or if the file cannot be +/// read/parsed. Mirrors Python `rust_helpers_jsonl.read_jsonl_tail_strict_required`. +pub fn read_jsonl_tail_strict_required( + path: &Utf8Path, + n: usize, +) -> Result>, JsonlStrictError> { + let store = JsonlStore::new(); + let rows: Vec = store.read_tail(path, n).map_err(|e| JsonlStrictError { + kind: classify_store_error(&e), + path: path.to_string(), + message: e.to_string(), + })?; + + rows.into_iter() + .map(|value| { + value.as_object().cloned().ok_or_else(|| JsonlStrictError { + kind: JsonlStrictErrorKind::NonObject, + path: path.to_string(), + message: "expected JSON object row".into(), + }) + }) + .collect() +} + +/// Strictly read the last `n` JSON object rows for each request in the batch. +pub fn read_jsonl_tail_strict_batch_required( + requests: &[TailRequest], +) -> Result { + let mut responses = Vec::with_capacity(requests.len()); + for request in requests { + let path = Utf8Path::new(&request.path); + let rows = read_jsonl_tail_strict_required(path, request.n)?; + responses.push(TailResponse { + id: request.id.clone(), + rows, + }); + } + Ok(TailBatchResponse { + requests: responses, + }) +} + +fn classify_store_error(error: &crate::StorageError) -> JsonlStrictErrorKind { + match error { + crate::StorageError::Json(_) => JsonlStrictErrorKind::InvalidJson, + crate::StorageError::Io(_) => JsonlStrictErrorKind::ReadError, + crate::StorageError::NotFound(_) => JsonlStrictErrorKind::ReadError, + crate::StorageError::Corrupt(_) => JsonlStrictErrorKind::InvalidJson, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn strict_helper_required_env() { + let original = env::var("CCB_RUST_JSONL_STORE").ok(); + env::remove_var("CCB_RUST_JSONL_STORE"); + assert!(!strict_jsonl_helper_required()); + env::set_var("CCB_RUST_JSONL_STORE", "1"); + assert!(strict_jsonl_helper_required()); + env::set_var("CCB_RUST_JSONL_STORE", "required"); + assert!(strict_jsonl_helper_required()); + env::set_var("CCB_RUST_JSONL_STORE", "0"); + assert!(!strict_jsonl_helper_required()); + match original { + Some(v) => env::set_var("CCB_RUST_JSONL_STORE", v), + None => env::remove_var("CCB_RUST_JSONL_STORE"), + } + } + + #[test] + fn strict_tail_returns_object_rows() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("rows.jsonl"); + let path = Utf8Path::from_path(&file_path).unwrap(); + let store = JsonlStore::new(); + for i in 0..5 { + let row = serde_json::json!({"id": i, "msg": format!("m{i}")}); + store.append(path, &row).unwrap(); + } + let rows = read_jsonl_tail_strict_required(path, 2).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["id"], 3); + assert_eq!(rows[1]["id"], 4); + } + + #[test] + fn strict_tail_rejects_non_object_rows() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("bad.jsonl"); + let path = Utf8Path::from_path(&file_path).unwrap(); + std::fs::write(path, "\"not an object\"\n").unwrap(); + let err = read_jsonl_tail_strict_required(path, 1).unwrap_err(); + assert_eq!(err.kind, JsonlStrictErrorKind::NonObject); + } + + #[test] + fn strict_tail_batch() { + let dir = TempDir::new().unwrap(); + let file_path_a = dir.path().join("a.jsonl"); + let file_path_b = dir.path().join("b.jsonl"); + let path_a = Utf8Path::from_path(&file_path_a).unwrap(); + let path_b = Utf8Path::from_path(&file_path_b).unwrap(); + let store = JsonlStore::new(); + store.append(path_a, &serde_json::json!({"id": 1})).unwrap(); + store.append(path_b, &serde_json::json!({"id": 2})).unwrap(); + + let resp = read_jsonl_tail_strict_batch_required(&[ + TailRequest { + id: "a".into(), + path: path_a.to_string(), + n: 1, + }, + TailRequest { + id: "b".into(), + path: path_b.to_string(), + n: 1, + }, + ]) + .unwrap(); + assert_eq!(resp.requests.len(), 2); + assert_eq!(resp.requests[0].rows[0]["id"], 1); + assert_eq!(resp.requests[1].rows[0]["id"], 2); + } +} diff --git a/rust/crates/ccb-storage/src/lib.rs b/rust/crates/ccb-storage/src/lib.rs new file mode 100644 index 000000000..281f6ec00 --- /dev/null +++ b/rust/crates/ccb-storage/src/lib.rs @@ -0,0 +1,30 @@ +pub mod atomic; +pub mod cursor_store; +pub mod json; +pub mod json_store; +pub mod jsonl; +pub mod jsonl_store; +pub mod locks; +pub mod path_helpers; +pub mod paths; +pub mod paths_agents; +pub mod paths_ccbd; +pub mod paths_targets; +pub mod project_identity; +pub mod text_artifacts; + +use std::io; + +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("not found: {0}")] + NotFound(String), + #[error("corrupt data: {0}")] + Corrupt(String), +} + +pub type Result = std::result::Result; diff --git a/rust/crates/ccb-storage/src/locks.rs b/rust/crates/ccb-storage/src/locks.rs new file mode 100644 index 000000000..92206dad7 --- /dev/null +++ b/rust/crates/ccb-storage/src/locks.rs @@ -0,0 +1,51 @@ +use camino::Utf8Path; +use fs2::FileExt; +use std::fs; +use std::io; + +/// RAII file lock guard. Acquires exclusive flock on creation, releases on drop. +/// Mirrors Python `storage.locks.file_lock`. +pub struct FileLock { + file: fs::File, +} + +impl FileLock { + pub fn acquire(path: &Utf8Path) -> io::Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path)?; + file.lock_exclusive()?; + Ok(Self { file }) + } +} + +impl Drop for FileLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_lock_acquire_release() { + let dir = TempDir::new().unwrap(); + let p = dir.path().join("test.lock"); + let lock_path = Utf8Path::from_path(&p).unwrap(); + { + let _lock = FileLock::acquire(lock_path).unwrap(); + // lock is held + } + // lock is released, can acquire again + let _lock2 = FileLock::acquire(lock_path).unwrap(); + } +} diff --git a/rust/crates/ccb-storage/src/path_helpers.rs b/rust/crates/ccb-storage/src/path_helpers.rs new file mode 100644 index 000000000..514dbcd26 --- /dev/null +++ b/rust/crates/ccb-storage/src/path_helpers.rs @@ -0,0 +1,638 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::fs; +use std::path::PathBuf; + +pub const RUNTIME_ROOT_MARKER_FILENAME: &str = "runtime-root.json"; +pub const RUNTIME_ROOT_REF_FILENAME: &str = "runtime-root-ref.json"; +pub const RUNTIME_ROOT_RECORD_TYPE: &str = "ccb_runtime_root"; +pub const RUNTIME_ROOT_REF_RECORD_TYPE: &str = "ccb_runtime_root_ref"; +pub const UNIX_SOCKET_SAFE_BYTES: usize = 100; +pub const TARGET_SEGMENT_PATTERN: &str = r"[^a-z0-9._-]+"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilesystemHint { + WslDrvfs, + Normal, +} + +impl FilesystemHint { + pub fn as_str(&self) -> Option<&'static str> { + match self { + FilesystemHint::WslDrvfs => Some("wsl_drvfs"), + FilesystemHint::Normal => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootKind { + Project, + Runtime, + Relocated, +} + +impl RootKind { + pub fn as_str(&self) -> &'static str { + match self { + RootKind::Project => "project", + RootKind::Runtime => "runtime", + RootKind::Relocated => "relocated", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SocketPlacement { + pub preferred_path: Utf8PathBuf, + pub effective_path: Utf8PathBuf, + pub root_kind: RootKind, + pub fallback_reason: Option, + pub filesystem_hint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeStatePlacement { + pub anchor_path: Utf8PathBuf, + pub effective_path: Utf8PathBuf, + pub root_kind: RootKind, + pub relocation_reason: Option, + pub filesystem_hint: Option, +} + +impl RuntimeStatePlacement { + pub fn is_project_scoped(&self) -> bool { + matches!(self.root_kind, RootKind::Project) + } +} + +/// Sanitize a path segment to `[a-z0-9._-]+`, collapsing runs and trimming edges. +/// Mirrors Python `storage.path_helpers.normalized_segment`. +pub fn normalized_segment(value: &str, label: &str) -> crate::Result { + let mut out = String::new(); + let mut prev_dash = true; // treat leading as edge + for ch in value.trim().to_lowercase().chars() { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.' || ch == '_' || ch == '-' { + out.push(ch); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + // trim trailing dashes/dots + let trimmed = out.trim_matches(&['-', '.'][..]); + if trimmed.is_empty() { + return Err(crate::StorageError::Corrupt(format!( + "{} cannot be empty", + label + ))); + } + Ok(trimmed.to_string()) +} + +const RESERVED_AGENT_NAMES: &[&str] = &[ + "all", "from", "user", "system", "ask", "cancel", "clear", "pend", "ping", "watch", "kill", + "ps", "logs", "doctor", "config", "cmd", "version", "update", "help", +]; + +/// Normalize an agent name for use in path segments. +/// Mirrors Python `agents.models_runtime.names.normalize_agent_name`. +pub fn normalize_agent_name(name: &str) -> crate::Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(crate::StorageError::Corrupt( + "agent name cannot be empty".into(), + )); + } + if trimmed.len() > 32 { + return Err(crate::StorageError::Corrupt( + "agent name must be 32 characters or fewer".into(), + )); + } + let mut chars = trimmed.chars(); + let first = chars.next().unwrap(); + if !first.is_ascii_alphabetic() { + return Err(crate::StorageError::Corrupt( + "agent name must start with a letter".into(), + )); + } + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err(crate::StorageError::Corrupt( + "agent name must contain only letters, digits, underscores, or hyphens".into(), + )); + } + let normalized = trimmed.to_lowercase(); + if RESERVED_AGENT_NAMES.contains(&normalized.as_str()) { + return Err(crate::StorageError::Corrupt(format!( + "agent name {:?} is reserved", + normalized + ))); + } + Ok(normalized) +} + +/// Normalize a mailbox owner name for use in path segments. +pub fn normalize_mailbox_owner_name(name: &str) -> crate::Result { + normalize_agent_name(name) +} + +/// Return the target segment for a given target kind/name. +/// Mirrors Python `storage.path_helpers.target_segment`. +pub fn target_segment(target_kind: &str, target_name: &str) -> crate::Result { + let kind = target_kind.trim().to_lowercase(); + let raw_name = target_name.trim(); + if kind == "agent" { + normalize_agent_name(raw_name) + } else { + normalized_segment(raw_name, "target_name") + } +} + +pub fn unix_socket_path_is_safe(path: &Utf8Path) -> bool { + path.as_str().len() <= UNIX_SOCKET_SAFE_BYTES +} + +pub fn is_wsl() -> bool { + if env::var("WSL_INTEROP").is_ok() || env::var("WSL_DISTRO_NAME").is_ok() { + return true; + } + if let Ok(contents) = fs::read_to_string("/proc/version") { + return contents.to_lowercase().contains("microsoft"); + } + false +} + +pub fn socket_filesystem_hint(path: &Utf8Path) -> FilesystemHint { + let normalized = path.as_str().replace('\\', "/"); + if is_wsl() && normalized.starts_with("/mnt/") { + FilesystemHint::WslDrvfs + } else { + FilesystemHint::Normal + } +} + +pub fn pathname_unix_socket_supported(path: &Utf8Path) -> bool { + !matches!(socket_filesystem_hint(path), FilesystemHint::WslDrvfs) +} + +pub fn pathname_runtime_state_supported(path: &Utf8Path) -> bool { + !matches!(socket_filesystem_hint(path), FilesystemHint::WslDrvfs) +} + +pub fn runtime_socket_root_candidates() -> Vec { + let mut candidates: Vec = Vec::new(); + if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") { + let trimmed = xdg.trim(); + if !trimmed.is_empty() { + candidates.push(Utf8PathBuf::from(format!("{}/ccb-runtime", trimmed))); + } + } + candidates.push(Utf8PathBuf::from("/tmp/ccb-runtime")); + if let Ok(tmp) = env::var("TMPDIR") { + if !tmp.trim().is_empty() { + candidates.push(Utf8PathBuf::from(format!("{}/ccb-runtime", tmp.trim()))); + } + } + candidates.push(Utf8PathBuf::from(format!( + "{}/ccb-runtime", + std::env::temp_dir().display() + ))); + + let mut unique: Vec = Vec::new(); + for candidate in candidates { + if !unique.contains(&candidate) { + unique.push(candidate); + } + } + unique +} + +pub fn runtime_socket_root() -> Utf8PathBuf { + for candidate in runtime_socket_root_candidates() { + if pathname_unix_socket_supported(&candidate) { + return candidate; + } + } + Utf8PathBuf::from("/tmp/ccb-runtime") +} + +fn absolute_path_from_env(env_name: &str) -> Option { + absolute_path_from_value(env::var(env_name).unwrap_or_default().as_str()) +} + +fn absolute_path_from_value(raw: &str) -> Option { + let text = raw.trim(); + if text.is_empty() { + return None; + } + let path = PathBuf::from(expand_user_path(text)); + if !path.is_absolute() { + return None; + } + Utf8PathBuf::from_path_buf(path).ok() +} + +fn expand_user_path(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix('~') { + if let Ok(home) = env::var("HOME") { + return home + rest; + } + } + raw.to_string() +} + +fn account_home_dir() -> Utf8PathBuf { + env::var("HOME") + .ok() + .and_then(|h| Utf8PathBuf::from_path_buf(PathBuf::from(expand_user_path(&h))).ok()) + .unwrap_or_else(|| Utf8PathBuf::from("/tmp")) +} + +pub fn runtime_state_root_candidates() -> Vec { + let mut candidates: Vec = Vec::new(); + if let Some(path) = absolute_path_from_env("CCB_RUNTIME_STATE_HOME") { + candidates.push(path); + } + if let Some(xdg_state) = + absolute_path_from_value(&env::var("XDG_STATE_HOME").unwrap_or_default()) + { + candidates.push(xdg_state.join("ccb/projects")); + } + candidates.push(account_home_dir().join(".local/state/ccb/projects")); + + let mut unique: Vec = Vec::new(); + for candidate in candidates { + if !unique.contains(&candidate) { + unique.push(candidate); + } + } + unique +} + +pub fn runtime_state_base_root() -> Utf8PathBuf { + for candidate in runtime_state_root_candidates() { + if pathname_runtime_state_supported(&candidate) { + return candidate; + } + } + account_home_dir().join(".local/state/ccb/projects") +} + +pub fn runtime_state_root_for_project(project_id: &str) -> Utf8PathBuf { + let normalized = project_id.trim(); + if normalized.is_empty() { + panic!("project_id cannot be empty"); + } + runtime_state_base_root().join(normalized) +} + +pub fn choose_runtime_state_placement( + _project_root: &Utf8Path, + project_id: &str, + anchor_path: &Utf8Path, +) -> RuntimeStatePlacement { + let anchor = Utf8PathBuf::from(expand_user_path(anchor_path.as_str())); + let hint = socket_filesystem_hint(&anchor); + if let Some(ref_root) = runtime_state_root_from_anchor_ref(&anchor, Some(project_id)) { + return RuntimeStatePlacement { + anchor_path: anchor.clone(), + effective_path: ref_root, + root_kind: RootKind::Relocated, + relocation_reason: Some("runtime_root_ref".into()), + filesystem_hint: hint.as_str().map(|s| s.to_string()), + }; + } + if matches!(hint, FilesystemHint::WslDrvfs) { + return RuntimeStatePlacement { + anchor_path: anchor.clone(), + effective_path: runtime_state_root_for_project(project_id), + root_kind: RootKind::Relocated, + relocation_reason: Some("wsl_drvfs".into()), + filesystem_hint: hint.as_str().map(|s| s.to_string()), + }; + } + RuntimeStatePlacement { + anchor_path: anchor.clone(), + effective_path: anchor.clone(), + root_kind: RootKind::Project, + relocation_reason: None, + filesystem_hint: hint.as_str().map(|s| s.to_string()), + } +} + +fn runtime_socket_placement( + preferred_path: &Utf8Path, + project_socket_key: &str, + fallback_reason: &str, + filesystem_hint: Option, +) -> SocketPlacement { + let stem = preferred_path.file_stem().unwrap_or("sock"); + let effective_root = runtime_socket_root(); + SocketPlacement { + preferred_path: preferred_path.to_path_buf(), + effective_path: effective_root.join(format!("{}-{}.sock", stem, project_socket_key)), + root_kind: RootKind::Runtime, + fallback_reason: Some(fallback_reason.into()), + filesystem_hint, + } +} + +pub fn choose_socket_placement( + preferred_path: &Utf8Path, + project_socket_key: &str, + preferred_root_kind: RootKind, +) -> SocketPlacement { + let preferred = Utf8PathBuf::from(expand_user_path(preferred_path.as_str())); + if !unix_socket_path_is_safe(&preferred) { + return runtime_socket_placement( + &preferred, + project_socket_key, + "path_too_long", + socket_filesystem_hint(&preferred) + .as_str() + .map(|s| s.to_string()), + ); + } + let hint = socket_filesystem_hint(&preferred); + if !pathname_unix_socket_supported(&preferred) { + return runtime_socket_placement( + &preferred, + project_socket_key, + "unsupported_filesystem", + hint.as_str().map(|s| s.to_string()), + ); + } + SocketPlacement { + preferred_path: preferred.clone(), + effective_path: preferred, + root_kind: preferred_root_kind, + fallback_reason: None, + filesystem_hint: hint.as_str().map(|s| s.to_string()), + } +} + +pub fn socket_placement_payload( + placement: &SocketPlacement, + prefix: &str, +) -> serde_json::Map { + let mut map = serde_json::Map::new(); + let p = |key: &str| format!("{}{}", prefix, key); + map.insert( + p("preferred_socket_path"), + placement.preferred_path.as_str().into(), + ); + map.insert( + p("effective_socket_path"), + placement.effective_path.as_str().into(), + ); + map.insert(p("socket_root_kind"), placement.root_kind.as_str().into()); + map.insert( + p("socket_fallback_reason"), + placement.fallback_reason.clone().into(), + ); + map.insert( + p("socket_filesystem_hint"), + placement.filesystem_hint.clone().into(), + ); + map +} + +pub fn runtime_state_placement_payload( + placement: &RuntimeStatePlacement, +) -> serde_json::Map { + let mut map = serde_json::Map::new(); + map.insert( + "project_anchor_path".into(), + placement.anchor_path.as_str().into(), + ); + map.insert( + "runtime_state_root".into(), + placement.effective_path.as_str().into(), + ); + map.insert( + "runtime_root_kind".into(), + placement.root_kind.as_str().into(), + ); + map.insert( + "runtime_relocation_reason".into(), + placement.relocation_reason.clone().into(), + ); + map.insert( + "runtime_filesystem_hint".into(), + placement.filesystem_hint.clone().into(), + ); + map +} + +pub fn runtime_root_marker_path(runtime_state_root: &Utf8Path) -> Utf8PathBuf { + Utf8PathBuf::from(expand_user_path(runtime_state_root.as_str())) + .join(RUNTIME_ROOT_MARKER_FILENAME) +} + +pub fn runtime_root_ref_path(anchor_path: &Utf8Path) -> Utf8PathBuf { + Utf8PathBuf::from(expand_user_path(anchor_path.as_str())).join(RUNTIME_ROOT_REF_FILENAME) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RuntimeRootRefPayload { + pub schema_version: Option, + pub record_type: Option, + pub project_id: Option, + pub runtime_state_root: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RuntimeRootMarkerPayload { + pub schema_version: Option, + pub record_type: Option, + pub project_id: Option, + pub project_root: Option, + pub anchor_path: Option, + pub runtime_root_path: Option, + pub created_at: Option, +} + +pub fn read_runtime_root_ref_payload( + anchor_path: &Utf8Path, + project_id: Option<&str>, +) -> Option> { + let path = runtime_root_ref_path(anchor_path); + let mut payload = read_json_object(&path)?; + if payload.get("record_type").and_then(|v| v.as_str()) != Some(RUNTIME_ROOT_REF_RECORD_TYPE) { + return None; + } + let recorded_project_id = payload + .get("project_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if recorded_project_id.is_empty() { + return None; + } + if let Some(expected) = project_id { + if recorded_project_id != expected.trim() { + return None; + } + } + let root = payload + .get("runtime_state_root") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + absolute_path_from_value(root)?; + let recorded_project_id = recorded_project_id.to_string(); + let root = root.to_string(); + payload.insert("project_id".into(), recorded_project_id.into()); + payload.insert("runtime_state_root".into(), root.into()); + Some(payload) +} + +pub fn runtime_state_root_from_anchor_ref( + anchor_path: &Utf8Path, + project_id: Option<&str>, +) -> Option { + let payload = read_runtime_root_ref_payload(anchor_path, project_id)?; + let root = payload.get("runtime_state_root").and_then(|v| v.as_str())?; + Some(Utf8PathBuf::from(expand_user_path(root))) +} + +/// Return the runtime state root for an anchor, falling back to the anchor itself. +/// Mirrors Python `storage.path_helpers.runtime_state_root_from_anchor`. +pub fn runtime_state_root_from_anchor( + anchor_path: &Utf8Path, + project_id: Option<&str>, +) -> Utf8PathBuf { + runtime_state_root_from_anchor_ref(anchor_path, project_id) + .unwrap_or_else(|| Utf8PathBuf::from(expand_user_path(anchor_path.as_str()))) +} + +pub fn find_runtime_root_marker_path(path: &Utf8Path) -> Option { + let current = Utf8PathBuf::from(expand_user_path(path.as_str())); + let mut candidates: Vec = vec![current.clone()]; + let mut parent = current.parent().map(|p| p.to_path_buf()); + while let Some(p) = parent { + candidates.push(p.clone()); + parent = p.parent().map(|p| p.to_path_buf()); + } + for candidate in candidates { + let marker = candidate.join(RUNTIME_ROOT_MARKER_FILENAME); + if marker.is_file() { + return Some(marker); + } + } + None +} + +pub fn read_runtime_root_marker_payload( + marker_path: &Utf8Path, +) -> Option> { + let path = Utf8PathBuf::from(expand_user_path(marker_path.as_str())); + let mut payload = read_json_object(&path)?; + if payload.get("record_type").and_then(|v| v.as_str()) != Some(RUNTIME_ROOT_RECORD_TYPE) { + return None; + } + let project_id = payload + .get("project_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if project_id.is_empty() { + return None; + } + let runtime_root = absolute_path_from_value( + payload + .get("runtime_root_path") + .and_then(|v| v.as_str()) + .unwrap_or(""), + )?; + if runtime_root != path.parent()? { + return None; + } + let project_root = absolute_path_from_value( + payload + .get("project_root") + .and_then(|v| v.as_str()) + .unwrap_or(""), + )?; + let anchor_path = absolute_path_from_value( + payload + .get("anchor_path") + .and_then(|v| v.as_str()) + .unwrap_or(""), + )?; + if anchor_path.file_name() != Some(".ccb") { + return None; + } + if anchor_path != project_root.join(".ccb") { + return None; + } + payload.insert("project_id".into(), project_id.into()); + payload.insert("project_root".into(), project_root.as_str().into()); + payload.insert("anchor_path".into(), anchor_path.as_str().into()); + payload.insert("runtime_root_path".into(), runtime_root.as_str().into()); + Some(payload) +} + +pub fn runtime_project_anchor_from_path(path: &Utf8Path) -> Option { + let marker_path = find_runtime_root_marker_path(path)?; + let payload = read_runtime_root_marker_payload(&marker_path)?; + let anchor = payload.get("anchor_path").and_then(|v| v.as_str())?; + Some(Utf8PathBuf::from(expand_user_path(anchor))) +} + +pub fn runtime_project_root_from_path(path: &Utf8Path) -> Option { + let marker_path = find_runtime_root_marker_path(path)?; + let payload = read_runtime_root_marker_payload(&marker_path)?; + let project_root = payload.get("project_root").and_then(|v| v.as_str())?; + Some(Utf8PathBuf::from(expand_user_path(project_root))) +} + +fn read_json_object(path: &Utf8Path) -> Option> { + let data = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&data).ok()?; + value.as_object().cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalized_segment_basic() { + assert_eq!( + normalized_segment("Hello World!", "label").unwrap(), + "hello-world" + ); + assert_eq!( + normalized_segment("--foo..bar__", "label").unwrap(), + "foo..bar__" + ); + } + + #[test] + fn test_target_segment_agent() { + assert_eq!(target_segment("agent", "Agent1").unwrap(), "agent1"); + } + + #[test] + fn test_runtime_socket_root_candidates_not_empty() { + assert!(!runtime_socket_root_candidates().is_empty()); + } + + #[test] + fn test_target_segment_pattern_constant() { + assert!(!TARGET_SEGMENT_PATTERN.is_empty()); + } + + #[test] + fn test_runtime_state_root_from_anchor_falls_back() { + let anchor = Utf8Path::new("/tmp/repo/.ccb"); + assert_eq!( + runtime_state_root_from_anchor(anchor, Some("proj-1")), + Utf8PathBuf::from("/tmp/repo/.ccb") + ); + } +} diff --git a/rust/crates/ccb-storage/src/paths.rs b/rust/crates/ccb-storage/src/paths.rs new file mode 100644 index 000000000..98a55c204 --- /dev/null +++ b/rust/crates/ccb-storage/src/paths.rs @@ -0,0 +1,468 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::atomic::atomic_write_json; +use crate::path_helpers::{ + choose_runtime_state_placement, read_runtime_root_marker_payload, + read_runtime_root_ref_payload, runtime_root_marker_path, runtime_root_ref_path, + runtime_state_placement_payload, RootKind, RuntimeStatePlacement, +}; +use crate::project_identity::{compute_project_id, project_slug}; + +const SHARED_CACHE_PROVIDERS: &[&str] = &["claude", "codex", "gemini"]; +const EXTERNAL_CACHE_PROVIDERS: &[&str] = &["claude", "gemini"]; + +/// Project-level path layout for a CCB project. +/// Mirrors Python `storage.paths.PathLayout`. +#[derive(Debug, Clone)] +pub struct PathLayout { + pub project_root: Utf8PathBuf, + project_id: String, + pub(crate) runtime_state_placement: RuntimeStatePlacement, + pub(crate) runtime_state_root: Utf8PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeStatePayload { + pub project_root: String, + pub project_slug: String, + pub project_id: String, + pub created_at: String, +} + +impl PathLayout { + pub fn new(project_root: impl Into) -> Self { + let mut root = project_root.into(); + // Try to resolve; fall back to absolute like Python. + root = if let Ok(resolved) = PathBuf::from(root.as_str()).canonicalize() { + Utf8PathBuf::from_path_buf(resolved).unwrap_or(root) + } else if let Ok(absolute) = std::path::absolute(PathBuf::from(root.as_str())) { + Utf8PathBuf::from_path_buf(absolute).unwrap_or(root) + } else { + root + }; + + let project_id = compute_project_id(root.as_str()); + let placement = choose_runtime_state_placement(&root, &project_id, &root.join(".ccb")); + let state_root = placement.effective_path.clone(); + + Self { + project_root: root, + project_id, + runtime_state_placement: placement, + runtime_state_root: state_root, + } + } + + pub fn project_slug(&self) -> String { + project_slug(self.project_root.as_str()) + } + + pub fn project_id(&self) -> &str { + &self.project_id + } + + pub fn project_socket_key(&self) -> String { + self.project_id[..12].to_string() + } + + pub fn runtime_state_placement(&self) -> &RuntimeStatePlacement { + &self.runtime_state_placement + } + + pub fn runtime_state_root(&self) -> &Utf8Path { + &self.runtime_state_root + } + + // --- Memory paths --- + + pub fn project_memory_path(&self) -> Utf8PathBuf { + self.ccb_dir().join("ccb_memory.md") + } + + pub fn memory_seed_path(&self) -> Utf8PathBuf { + self.runtime_state_root.join("state/memory.seed.json") + } + + pub fn runtime_memory_dir(&self) -> Utf8PathBuf { + self.runtime_state_root.join("runtime/memory") + } + + pub fn runtime_memory_bundle_path(&self, agent_name: &str) -> Utf8PathBuf { + let normalized = crate::path_helpers::normalize_agent_name(agent_name) + .unwrap_or_else(|_| agent_name.to_lowercase()); + self.runtime_memory_dir().join(format!("{}.md", normalized)) + } + + // --- Shared cache --- + + pub fn shared_cache_dir(&self) -> Utf8PathBuf { + self.runtime_state_root.join("shared-cache") + } + + pub fn provider_shared_cache_dir(&self, provider: &str) -> crate::Result { + let normalized = crate::path_helpers::normalized_segment(provider, "provider")?; + let original = provider.trim().to_lowercase(); + if normalized != original || !SHARED_CACHE_PROVIDERS.contains(&normalized.as_str()) { + return Err(crate::StorageError::Corrupt(format!( + "provider must be one of: {}", + SHARED_CACHE_PROVIDERS.join(", ") + ))); + } + Ok(self.shared_cache_dir().join(normalized)) + } + + pub fn ensure_provider_shared_cache_dir( + &self, + provider: &str, + created_at: Option<&str>, + ) -> crate::Result { + let placement = self.runtime_state_placement(); + if placement.filesystem_hint.as_deref() == Some("wsl_drvfs") + && !matches!(placement.root_kind, RootKind::Relocated) + { + return Err(crate::StorageError::Corrupt( + "shared cache requires relocated runtime state for WSL drvfs project anchors" + .into(), + )); + } + let cache_dir = self.provider_shared_cache_dir(provider)?; + let timestamp = created_at.map(|s| s.to_string()).unwrap_or_else(utc_now); + self.ensure_runtime_state_root(Some(×tamp))?; + fs::create_dir_all(&cache_dir)?; + let manifest_path = cache_dir.join("MANIFEST.json"); + if !manifest_path.exists() { + atomic_write_json( + &manifest_path, + &serde_json::json!({ + "schema_version": 1, + "record_type": "ccb_shared_cache_manifest", + "provider": cache_dir.file_name().unwrap_or("unknown"), + "project_id": self.project_id, + "runtime_state_root": self.runtime_state_root.as_str(), + "created_at": timestamp, + "entries": [], + }), + )?; + } + Ok(cache_dir) + } + + pub fn external_provider_cache_root(&self) -> Utf8PathBuf { + let root = user_cache_home(); + root.join("ccb/projects") + .join(&self.project_id[..16]) + .join("provider-cache") + } + + pub fn provider_external_cache_dir(&self, provider: &str) -> crate::Result { + let normalized = crate::path_helpers::normalized_segment(provider, "provider")?; + let original = provider.trim().to_lowercase(); + if normalized != original || !EXTERNAL_CACHE_PROVIDERS.contains(&normalized.as_str()) { + return Err(crate::StorageError::Corrupt(format!( + "provider must be one of: {}", + EXTERNAL_CACHE_PROVIDERS.join(", ") + ))); + } + Ok(self.external_provider_cache_root().join(normalized)) + } + + pub fn ensure_provider_external_cache_dir( + &self, + provider: &str, + created_at: Option<&str>, + ) -> crate::Result { + let cache_dir = self.provider_external_cache_dir(provider)?; + let timestamp = created_at.map(|s| s.to_string()).unwrap_or_else(utc_now); + fs::create_dir_all(&cache_dir)?; + let manifest_path = cache_dir.join("MANIFEST.json"); + if !manifest_path.exists() { + atomic_write_json( + &manifest_path, + &serde_json::json!({ + "schema_version": 1, + "record_type": "ccb_external_provider_cache_manifest", + "provider": cache_dir.file_name().unwrap_or("unknown"), + "project_id": self.project_id, + "project_root": self.project_root.as_str(), + "created_at": timestamp, + "entries": [], + }), + )?; + } + Ok(cache_dir) + } + + // --- Runtime root marker / ref --- + + pub fn runtime_root_marker_path(&self) -> Utf8PathBuf { + runtime_root_marker_path(&self.runtime_state_root) + } + + pub fn runtime_root_ref_path(&self) -> Utf8PathBuf { + runtime_root_ref_path(&self.ccb_dir()) + } + + pub fn runtime_marker_status(&self) -> String { + if self.runtime_state_placement.is_project_scoped() { + return "not_required".into(); + } + match self.validate_runtime_root_marker(false) { + Ok(()) => match self.validate_runtime_root_ref(true) { + Ok(()) => "ok".into(), + Err(_) => "mismatch".into(), + }, + Err(crate::StorageError::NotFound(_)) => "missing".into(), + Err(_) => "mismatch".into(), + } + } + + pub fn ensure_runtime_state_root(&self, created_at: Option<&str>) -> std::io::Result<()> { + if self.runtime_state_placement.is_project_scoped() { + return Ok(()); + } + fs::create_dir_all(self.ccb_dir())?; + fs::create_dir_all(&self.runtime_state_root)?; + let timestamp = created_at.map(|s| s.to_string()).unwrap_or_else(utc_now); + if let Err(e) = self.validate_runtime_root_marker(true) { + if !e.to_string().contains("No such file") { + return Err(std::io::Error::other(e)); + } + } + if let Err(e) = self.validate_runtime_root_ref(true) { + if !e.to_string().contains("No such file") { + return Err(std::io::Error::other(e)); + } + } + atomic_write_json( + &self.runtime_root_marker_path(), + &self.runtime_root_marker_payload(×tamp), + ) + .map_err(std::io::Error::other)?; + atomic_write_json( + &self.runtime_root_ref_path(), + &self.runtime_root_ref_payload(×tamp), + ) + .map_err(std::io::Error::other)?; + Ok(()) + } + + pub fn runtime_state_payload(&self) -> serde_json::Map { + let mut payload = runtime_state_placement_payload(&self.runtime_state_placement); + payload.insert( + "runtime_marker_status".into(), + self.runtime_marker_status().into(), + ); + payload.insert( + "runtime_root_marker_path".into(), + self.runtime_root_marker_path().as_str().into(), + ); + payload.insert( + "runtime_root_ref_path".into(), + self.runtime_root_ref_path().as_str().into(), + ); + payload + } + + fn runtime_root_marker_payload(&self, created_at: &str) -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "record_type": "ccb_runtime_root", + "project_id": self.project_id, + "project_root": self.project_root.as_str(), + "anchor_path": self.ccb_dir().as_str(), + "runtime_root_path": self.runtime_state_root.as_str(), + "created_at": created_at, + }) + } + + fn runtime_root_ref_payload(&self, created_at: &str) -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "record_type": "ccb_runtime_root_ref", + "project_id": self.project_id, + "runtime_state_root": self.runtime_state_root.as_str(), + "created_at": created_at, + }) + } + + fn validate_runtime_root_marker(&self, allow_missing: bool) -> crate::Result<()> { + let payload = read_runtime_root_marker_payload(&self.runtime_root_marker_path()); + if payload.is_none() { + if allow_missing && !self.runtime_root_marker_path().exists() { + return Ok(()); + } + if !self.runtime_root_marker_path().exists() { + return Err(crate::StorageError::NotFound( + self.runtime_root_marker_path().to_string(), + )); + } + return Err(crate::StorageError::Corrupt(format!( + "{} is invalid", + self.runtime_root_marker_path() + ))); + } + let payload = payload.unwrap(); + let ccb_dir = self.ccb_dir(); + let expected = [ + ("project_id", self.project_id.as_str()), + ("project_root", self.project_root.as_str()), + ("anchor_path", ccb_dir.as_str()), + ("runtime_root_path", self.runtime_state_root.as_str()), + ]; + for (key, value) in expected { + let recorded = payload + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if recorded != value { + return Err(crate::StorageError::Corrupt(format!( + "{} field {} mismatch: expected {}, found {}", + self.runtime_root_marker_path(), + key, + value, + if recorded.is_empty() { + "" + } else { + recorded + } + ))); + } + } + Ok(()) + } + + fn validate_runtime_root_ref(&self, allow_missing: bool) -> crate::Result<()> { + let payload = read_runtime_root_ref_payload(&self.ccb_dir(), Some(&self.project_id)); + if payload.is_none() { + if allow_missing && !self.runtime_root_ref_path().exists() { + return Ok(()); + } + if !self.runtime_root_ref_path().exists() { + return Err(crate::StorageError::NotFound( + self.runtime_root_ref_path().to_string(), + )); + } + return Err(crate::StorageError::Corrupt(format!( + "{} is invalid", + self.runtime_root_ref_path() + ))); + } + let payload = payload.unwrap(); + let expected = [ + ("project_id", self.project_id.as_str()), + ("runtime_state_root", self.runtime_state_root.as_str()), + ]; + for (key, value) in expected { + let recorded = payload + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if recorded != value { + return Err(crate::StorageError::Corrupt(format!( + "{} field {} mismatch: expected {}, found {}", + self.runtime_root_ref_path(), + key, + value, + if recorded.is_empty() { + "" + } else { + recorded + } + ))); + } + } + Ok(()) + } +} + +pub(crate) fn tmux_safe_name(value: &str, fallback: &str) -> String { + let sanitized: String = value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect(); + let sanitized = sanitized.trim_matches(&['_', '-'][..]); + if sanitized.is_empty() { + fallback.to_string() + } else { + sanitized.to_string() + } +} + +pub(crate) fn user_cache_home() -> Utf8PathBuf { + if let Ok(raw) = std::env::var("XDG_CACHE_HOME") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + if let Ok(path) = Utf8PathBuf::from_path_buf(PathBuf::from(expand_user_path(trimmed))) { + return path; + } + } + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + Utf8PathBuf::from(format!("{}/.cache", expand_user_path(&home))) +} + +pub(crate) fn expand_user_path(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix('~') { + if let Ok(home) = std::env::var("HOME") { + return home + rest; + } + } + raw.to_string() +} + +pub(crate) fn utc_now() -> String { + chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + .replace("+00:00", "Z") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_slug() { + let layout = PathLayout::new("/home/user/my-project"); + assert!(layout.project_slug().starts_with("my-project-")); + } + + #[test] + fn test_project_id_deterministic() { + let a = PathLayout::new("/home/user/project"); + let b = PathLayout::new("/home/user/project"); + assert_eq!(a.project_id, b.project_id); + } + + #[test] + fn test_socket_key_length() { + let layout = PathLayout::new("/home/user/project"); + assert_eq!(layout.project_socket_key().len(), 12); + } + + #[test] + fn test_provider_shared_cache_dir() { + let layout = PathLayout::new("/project"); + assert_eq!( + layout.provider_shared_cache_dir("claude").unwrap(), + Utf8PathBuf::from("/project/.ccb/shared-cache/claude") + ); + } + + #[test] + fn test_rejects_noncanonical_shared_cache_provider() { + let layout = PathLayout::new("/project"); + assert!(layout.provider_shared_cache_dir("Claude Code").is_err()); + } +} diff --git a/rust/crates/ccb-storage/src/paths_agents.rs b/rust/crates/ccb-storage/src/paths_agents.rs new file mode 100644 index 000000000..7e65493c2 --- /dev/null +++ b/rust/crates/ccb-storage/src/paths_agents.rs @@ -0,0 +1,160 @@ +use camino::Utf8PathBuf; + +use crate::path_helpers::{normalize_agent_name, normalize_mailbox_owner_name}; +use crate::paths::PathLayout; + +impl PathLayout { + // --- Agent paths --- + + pub fn agents_dir(&self) -> Utf8PathBuf { + self.runtime_state_root.join("agents") + } + + pub fn provider_profiles_dir(&self) -> Utf8PathBuf { + self.ccb_dir().join("provider-profiles") + } + + pub fn agent_dir(&self, agent_name: &str) -> Utf8PathBuf { + self.agents_dir() + .join(normalize_agent_name(agent_name).unwrap_or_else(|_| agent_name.to_lowercase())) + } + + pub fn agent_anchor_dir(&self, agent_name: &str) -> Utf8PathBuf { + self.ccb_dir() + .join("agents") + .join(normalize_agent_name(agent_name).unwrap_or_else(|_| agent_name.to_lowercase())) + } + + pub fn agent_private_memory_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_anchor_dir(agent_name).join("memory.md") + } + + pub fn agent_spec_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("agent.json") + } + + pub fn agent_runtime_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("runtime.json") + } + + pub fn agent_helper_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("helper.json") + } + + pub fn agent_provider_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("provider.json") + } + + pub fn agent_restore_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("restore.json") + } + + pub fn agent_jobs_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("jobs.jsonl") + } + + pub fn job_store_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_jobs_path(agent_name) + } + + pub fn agent_events_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("events.jsonl") + } + + pub fn agent_provider_runtime_dir(&self, agent_name: &str, provider: &str) -> Utf8PathBuf { + let normalized = provider.trim().to_lowercase(); + self.agent_dir(agent_name) + .join("provider-runtime") + .join(normalized) + } + + pub fn agent_provider_state_dir(&self, agent_name: &str, provider: &str) -> Utf8PathBuf { + let normalized = provider.trim().to_lowercase(); + self.agent_dir(agent_name) + .join("provider-state") + .join(normalized) + } + + pub fn agent_logs_dir(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_dir(agent_name).join("logs") + } + + // --- Agent mailbox paths --- + + pub fn agent_mailbox_dir(&self, agent_name: &str) -> Utf8PathBuf { + self.ccbd_mailboxes_dir().join( + normalize_mailbox_owner_name(agent_name).unwrap_or_else(|_| agent_name.to_lowercase()), + ) + } + + pub fn agent_mailbox_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_mailbox_dir(agent_name).join("mailbox.json") + } + + pub fn agent_inbox_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_mailbox_dir(agent_name).join("inbox.jsonl") + } + + pub fn agent_outbox_path(&self, agent_name: &str) -> Utf8PathBuf { + self.agent_mailbox_dir(agent_name).join("outbox.jsonl") + } + + pub fn mailbox_lease_path(&self, agent_name: &str) -> Utf8PathBuf { + self.ccbd_leases_dir().join(format!( + "{}.json", + normalize_mailbox_owner_name(agent_name).unwrap_or_else(|_| agent_name.to_lowercase()) + )) + } + + // --- Workspace paths --- + + pub fn workspaces_dir(&self) -> Utf8PathBuf { + self.ccb_dir().join("workspaces") + } + + pub fn workspace_path(&self, agent_name: &str, workspace_root: Option<&str>) -> Utf8PathBuf { + let normalized = + normalize_agent_name(agent_name).unwrap_or_else(|_| agent_name.to_lowercase()); + if let Some(root) = workspace_root { + Utf8PathBuf::from(crate::paths::expand_user_path(root)) + .join(self.project_slug()) + .join(normalized) + } else { + self.workspaces_dir().join(normalized) + } + } + + pub fn workspace_group_path(&self, group_name: &str) -> Utf8PathBuf { + self.workspaces_dir() + .join("groups") + .join(normalize_agent_name(group_name).unwrap_or_else(|_| group_name.to_lowercase())) + } + + pub fn workspace_binding_path( + &self, + agent_name: &str, + workspace_root: Option<&str>, + ) -> Utf8PathBuf { + self.workspace_path(agent_name, workspace_root) + .join(".ccb-workspace.json") + } + + pub fn workspace_group_binding_path(&self, group_name: &str) -> Utf8PathBuf { + self.workspace_group_path(group_name) + .join(".ccb-workspace.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_agent_mailbox_path() { + let layout = PathLayout::new("/project"); + assert_eq!( + layout.agent_mailbox_path("Agent1"), + Utf8PathBuf::from("/project/.ccb/ccbd/mailboxes/agent1/mailbox.json") + ); + } +} diff --git a/rust/crates/ccb-storage/src/paths_ccbd.rs b/rust/crates/ccb-storage/src/paths_ccbd.rs new file mode 100644 index 000000000..9ed2f3f4c --- /dev/null +++ b/rust/crates/ccb-storage/src/paths_ccbd.rs @@ -0,0 +1,281 @@ +use camino::Utf8PathBuf; + +use crate::path_helpers::{choose_socket_placement, RootKind, SocketPlacement}; +use crate::paths::PathLayout; + +impl PathLayout { + // --- Project anchor paths --- + + pub fn project_anchor_dir(&self) -> Utf8PathBuf { + self.ccb_dir() + } + + pub fn ccb_dir(&self) -> Utf8PathBuf { + self.project_root.join(".ccb") + } + + pub fn config_path(&self) -> Utf8PathBuf { + self.ccb_dir().join("ccb.config") + } + + // --- CCBD paths --- + + pub fn ccbd_dir(&self) -> Utf8PathBuf { + self.runtime_state_root.join("ccbd") + } + + pub fn ccbd_submissions_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("submissions.jsonl") + } + + pub fn ccbd_mailboxes_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("mailboxes") + } + + pub fn ccbd_messages_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("messages") + } + + pub fn ccbd_messages_path(&self) -> Utf8PathBuf { + self.ccbd_messages_dir().join("messages.jsonl") + } + + pub fn ccbd_attempts_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("attempts") + } + + pub fn ccbd_attempts_path(&self) -> Utf8PathBuf { + self.ccbd_attempts_dir().join("attempts.jsonl") + } + + pub fn ccbd_replies_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("replies") + } + + pub fn ccbd_replies_path(&self) -> Utf8PathBuf { + self.ccbd_replies_dir().join("replies.jsonl") + } + + pub fn ccbd_callback_edges_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("callbacks/edges.jsonl") + } + + pub fn ccbd_leases_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("leases") + } + + pub fn ccbd_dead_letters_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("dead-letters") + } + + pub fn ccbd_dead_letters_path(&self) -> Utf8PathBuf { + self.ccbd_dead_letters_dir().join("dead_letters.jsonl") + } + + pub fn ccbd_provider_health_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("provider-health") + } + + // --- Message bureau paths (legacy aliases used by other crates) --- + + pub fn message_bureau_dir(&self) -> Utf8PathBuf { + self.ccbd_messages_dir() + } + + pub fn message_store_path(&self) -> Utf8PathBuf { + self.ccbd_messages_path() + } + + pub fn attempt_store_path(&self) -> Utf8PathBuf { + self.ccbd_attempts_path() + } + + pub fn reply_store_path(&self) -> Utf8PathBuf { + self.ccbd_replies_path() + } + + // --- CCBD mount / lifecycle paths --- + + fn project_socket_placement(&self, stem: &str) -> SocketPlacement { + let preferred_root_kind = + if matches!(self.runtime_state_placement.root_kind, RootKind::Relocated) { + RootKind::Runtime + } else { + RootKind::Project + }; + choose_socket_placement( + &self.ccbd_dir().join(format!("{}.sock", stem)), + &self.project_socket_key(), + preferred_root_kind, + ) + } + + pub fn ccbd_socket_placement(&self) -> SocketPlacement { + self.project_socket_placement("ccbd") + } + + pub fn ccbd_socket_path(&self) -> Utf8PathBuf { + self.ccbd_socket_placement().effective_path + } + + pub fn ccbd_pid_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("ccbd.pid") + } + + pub fn ccbd_lifecycle_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("lifecycle.json") + } + + pub fn ccbd_lease_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("lease.json") + } + + pub fn ccbd_state_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("state.json") + } + + pub fn ccbd_project_view_state_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("project-view-state.json") + } + + pub fn ccbd_start_policy_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("start-policy.json") + } + + pub fn ccbd_restore_report_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("restore-report.json") + } + + pub fn ccbd_startup_report_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("startup-report.json") + } + + pub fn ccbd_shutdown_report_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("shutdown-report.json") + } + + pub fn ccbd_tmux_socket_placement(&self) -> SocketPlacement { + self.project_socket_placement("tmux") + } + + pub fn ccbd_tmux_socket_path(&self) -> Utf8PathBuf { + self.ccbd_tmux_socket_placement().effective_path + } + + pub fn ccbd_tmux_session_name(&self) -> String { + let safe = crate::paths::tmux_safe_name(&self.project_slug(), "project"); + format!("ccb-{}", safe) + } + + pub fn ccbd_tmux_control_window_name(&self) -> &'static str { + "__ccb_ctl" + } + + pub fn ccbd_tmux_workspace_window_name(&self) -> &'static str { + "ccb" + } + + // --- CCBD ops paths --- + + pub fn ccbd_supervision_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("supervision.jsonl") + } + + pub fn ccbd_lifecycle_log_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("lifecycle.jsonl") + } + + pub fn ccbd_keeper_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("keeper.json") + } + + pub fn ccbd_shutdown_intent_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("shutdown-intent.json") + } + + pub fn ccbd_tmux_cleanup_history_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("tmux-cleanup-history.jsonl") + } + + pub fn ccbd_maintenance_heartbeat_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("maintenance-heartbeat") + } + + pub fn ccbd_maintenance_heartbeat_schedule_path(&self) -> Utf8PathBuf { + self.ccbd_maintenance_heartbeat_dir().join("schedule.json") + } + + pub fn ccbd_maintenance_heartbeat_status_path(&self) -> Utf8PathBuf { + self.ccbd_maintenance_heartbeat_dir().join("status.json") + } + + pub fn ccbd_maintenance_heartbeat_runner_path(&self) -> Utf8PathBuf { + self.ccbd_maintenance_heartbeat_dir().join("runner.json") + } + + pub fn ccbd_maintenance_heartbeat_lock_path(&self) -> Utf8PathBuf { + self.ccbd_maintenance_heartbeat_dir().join("lock.json") + } + + pub fn ccbd_maintenance_heartbeat_activations_path(&self) -> Utf8PathBuf { + self.ccbd_maintenance_heartbeat_dir() + .join("activations.jsonl") + } + + pub fn ccbd_fault_injection_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("fault-injection.json") + } + + pub fn ccbd_reload_drain_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("reload-drain.json") + } + + pub fn ccbd_reload_handoff_path(&self) -> Utf8PathBuf { + self.ccbd_dir().join("reload-handoff.json") + } + + // --- CCBD artifact paths --- + + pub fn ccbd_artifacts_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("artifacts") + } + + pub fn ccbd_text_artifacts_dir(&self) -> Utf8PathBuf { + self.ccbd_artifacts_dir().join("text") + } + + pub fn ccbd_support_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("support") + } + + pub fn ccbd_executions_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("executions") + } + + pub fn ccbd_snapshots_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("snapshots") + } + + pub fn ccbd_cursors_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("cursors") + } + + pub fn ccbd_heartbeats_dir(&self) -> Utf8PathBuf { + self.ccbd_dir().join("heartbeats") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ccb_dir_structure() { + let layout = PathLayout::new("/project"); + assert_eq!(layout.ccb_dir(), Utf8PathBuf::from("/project/.ccb")); + assert_eq!( + layout.ccbd_dir(), + Utf8PathBuf::from("/project/.ccb/ccbd") + ); + } +} diff --git a/rust/crates/ccb-storage/src/paths_targets.rs b/rust/crates/ccb-storage/src/paths_targets.rs new file mode 100644 index 000000000..88bf870da --- /dev/null +++ b/rust/crates/ccb-storage/src/paths_targets.rs @@ -0,0 +1,121 @@ +use camino::Utf8PathBuf; + +use crate::path_helpers::normalized_segment; +use crate::paths::PathLayout; + +impl PathLayout { + // --- Target paths --- + + pub fn target_dir(&self, target_kind: &str, target_name: &str) -> crate::Result { + let segment = crate::path_helpers::target_segment(target_kind, target_name)?; + if target_kind.trim().to_lowercase() == "agent" { + Ok(self.agent_dir(&segment)) + } else { + Ok(self.ccbd_dir().join("targets").join(segment)) + } + } + + pub fn target_jobs_path( + &self, + target_kind: &str, + target_name: &str, + ) -> crate::Result { + Ok(self + .target_dir(target_kind, target_name)? + .join("jobs.jsonl")) + } + + pub fn target_events_path( + &self, + target_kind: &str, + target_name: &str, + ) -> crate::Result { + Ok(self + .target_dir(target_kind, target_name)? + .join("events.jsonl")) + } + + pub fn snapshot_path(&self, job_id: &str) -> Utf8PathBuf { + self.ccbd_snapshots_dir().join(format!("{}.json", job_id)) + } + + pub fn cursor_path(&self, job_id: &str) -> Utf8PathBuf { + self.ccbd_cursors_dir().join(format!("{}.json", job_id)) + } + + pub fn execution_state_path(&self, job_id: &str) -> Utf8PathBuf { + self.ccbd_executions_dir().join(format!("{}.json", job_id)) + } + + pub fn heartbeat_subject_dir(&self, subject_kind: &str) -> crate::Result { + Ok(self + .ccbd_heartbeats_dir() + .join(normalized_segment(subject_kind, "subject_kind")?)) + } + + pub fn heartbeat_subject_path( + &self, + subject_kind: &str, + subject_id: &str, + ) -> crate::Result { + let normalized_id = normalized_segment(subject_id, "subject_id")?; + Ok(self + .heartbeat_subject_dir(subject_kind)? + .join(format!("{}.json", normalized_id))) + } + + pub fn provider_health_path(&self, job_id: &str) -> Utf8PathBuf { + self.ccbd_provider_health_dir() + .join(format!("{}.jsonl", job_id.trim())) + } + + pub fn support_bundle_path(&self, bundle_id: &str) -> crate::Result { + let normalized = normalized_segment(bundle_id, "bundle_id")?; + Ok(self + .ccbd_support_dir() + .join(format!("{}.tar.gz", normalized))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_target_paths() { + let layout = PathLayout::new("/project"); + assert_eq!( + layout.target_dir("agent", "Agent1").unwrap(), + Utf8PathBuf::from("/project/.ccb/agents/agent1") + ); + assert_eq!( + layout.target_dir("service", "svc-1").unwrap(), + Utf8PathBuf::from("/project/.ccb/ccbd/targets/svc-1") + ); + assert_eq!( + layout.snapshot_path("job-1"), + Utf8PathBuf::from("/project/.ccb/ccbd/snapshots/job-1.json") + ); + assert_eq!( + layout.cursor_path("job-1"), + Utf8PathBuf::from("/project/.ccb/ccbd/cursors/job-1.json") + ); + assert_eq!( + layout.provider_health_path("job-1"), + Utf8PathBuf::from("/project/.ccb/ccbd/provider-health/job-1.jsonl") + ); + assert_eq!( + layout.support_bundle_path("bundle-1").unwrap(), + Utf8PathBuf::from("/project/.ccb/ccbd/support/bundle-1.tar.gz") + ); + } + + #[test] + fn test_heartbeat_subject_path() { + let layout = PathLayout::new("/project"); + assert_eq!( + layout.heartbeat_subject_path("provider", "sub-1").unwrap(), + Utf8PathBuf::from("/project/.ccb/ccbd/heartbeats/provider/sub-1.json") + ); + } +} diff --git a/rust/crates/ccb-storage/src/project_identity.rs b/rust/crates/ccb-storage/src/project_identity.rs new file mode 100644 index 000000000..5f0a32434 --- /dev/null +++ b/rust/crates/ccb-storage/src/project_identity.rs @@ -0,0 +1,6 @@ +//! Project identity helpers. +//! +//! The canonical implementation now lives in `ccb-project::identity`. This +//! module re-exports it for backward compatibility with existing callers. + +pub use ccb_project::identity::*; diff --git a/rust/crates/ccb-storage/src/text_artifacts.rs b/rust/crates/ccb-storage/src/text_artifacts.rs new file mode 100644 index 000000000..8cb05e737 --- /dev/null +++ b/rust/crates/ccb-storage/src/text_artifacts.rs @@ -0,0 +1,369 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use uuid::Uuid; + +use crate::atomic::atomic_write_text; +use crate::paths::PathLayout; + +pub const TEXT_ARTIFACT_SPILL_BYTES: usize = 4 * 1024; +pub const TEXT_ARTIFACT_PREVIEW_CHARS: usize = 1200; +pub const TEXT_ARTIFACT_TTL_S: i64 = 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextArtifact { + pub schema_version: i32, + pub kind: String, + pub artifact_id: String, + pub path: String, + pub bytes: usize, + pub sha256: String, + pub encoding: String, + pub preview: String, + pub created_at: String, + pub expires_at: String, +} + +impl TextArtifact { + pub fn to_record(&self) -> serde_json::Map { + serde_json::to_value(self) + .unwrap_or_default() + .as_object() + .cloned() + .unwrap_or_default() + } +} + +pub fn utf8_size(text: &str) -> usize { + text.len() +} + +pub fn should_spill_text(text: &str, threshold_bytes: usize) -> bool { + utf8_size(text) > threshold_bytes +} + +#[allow(clippy::too_many_arguments)] +pub fn maybe_spill_text( + layout: &PathLayout, + text: &str, + kind: &str, + owner_id: &str, + prefix: &str, + threshold_bytes: Option, + ttl_seconds: Option, + now: Option<&str>, +) -> crate::Result<(String, Option)> { + let body = text; + if !should_spill_text(body, threshold_bytes.unwrap_or(TEXT_ARTIFACT_SPILL_BYTES)) { + return Ok((body.to_string(), None)); + } + let artifact = write_text_artifact(layout, body, kind, owner_id, ttl_seconds, now)?; + Ok((artifact_stub(prefix, &artifact, true), Some(artifact))) +} + +pub fn write_text_artifact( + layout: &PathLayout, + text: &str, + kind: &str, + owner_id: &str, + ttl_seconds: Option, + now: Option<&str>, +) -> crate::Result { + let body = text; + let data = body.as_bytes(); + let digest = sha256_hex(data); + let timestamp = now.map(|s| s.to_string()).unwrap_or_else(utc_now); + layout.ensure_runtime_state_root(Some(×tamp))?; + let artifact_id = format!("art_{}", &Uuid::new_v4().as_simple().to_string()[..16]); + let safe_kind = safe_segment(kind, "text"); + let safe_owner = safe_segment(owner_id, "unknown"); + let directory = layout.ccbd_text_artifacts_dir().join(&safe_kind); + fs::create_dir_all(&directory)?; + let path = directory.join(format!("{}-{}.txt", safe_owner, artifact_id)); + atomic_write_text(&path, body)?; + if let Err(_e) = fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) { + // Ignore permission errors, mirroring Python. + } + Ok(TextArtifact { + schema_version: 1, + kind: safe_kind, + artifact_id, + path: path.to_string(), + bytes: data.len(), + sha256: digest, + encoding: "utf-8".into(), + preview: preview_text(body, TEXT_ARTIFACT_PREVIEW_CHARS), + created_at: timestamp.clone(), + expires_at: expires_at(×tamp, ttl_seconds.unwrap_or(TEXT_ARTIFACT_TTL_S)), + }) +} + +pub fn artifact_stub(prefix: &str, artifact: &TextArtifact, include_preview: bool) -> String { + let preview = artifact.preview.trim_end(); + let show_preview = include_preview && !preview.is_empty(); + let mut lines = vec![ + prefix.trim_end().to_string(), + format!("Full text: {}", artifact.path), + format!("Bytes: {}", artifact.bytes), + format!("SHA256: {}", artifact.sha256), + ]; + if show_preview { + lines.push(String::new()); + lines.push("Preview:".into()); + lines.push(preview.to_string()); + } + let instruction = if show_preview { + "Instruction: read the full text file above before acting when the preview is insufficient." + } else { + "Instruction: read the full text file above before acting." + }; + lines.push(String::new()); + lines.push(instruction.into()); + lines.join("\n").trim_end().to_string() +} + +pub fn preview_text(text: &str, max_chars: usize) -> String { + let body = text.trim(); + if body.len() <= max_chars { + return body.to_string(); + } + format!("{}\n...[truncated]", body[..max_chars].trim_end()) +} + +pub fn validate_text_artifact_ref( + layout: &PathLayout, + artifact: Option<&TextArtifact>, +) -> crate::Result> { + let artifact = match artifact { + Some(a) => a, + None => return Ok(None), + }; + let mut ref_record = artifact.to_record(); + let path = validated_artifact_path(layout, &artifact.path)?; + let data = fs::read(&path)?; + if let Some(expected_size) = ref_record.get("bytes").and_then(|v| v.as_u64()) { + if expected_size as usize != data.len() { + return Err(crate::StorageError::Corrupt( + "text artifact byte size mismatch".into(), + )); + } + } + let expected_sha = ref_record + .get("sha256") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let actual_sha = sha256_hex(&data); + if !expected_sha.is_empty() && expected_sha != actual_sha { + return Err(crate::StorageError::Corrupt( + "text artifact sha256 mismatch".into(), + )); + } + ref_record.insert("path".into(), path.to_string_lossy().to_string().into()); + ref_record.insert("bytes".into(), data.len().into()); + ref_record.insert("sha256".into(), actual_sha.into()); + ref_record + .entry("encoding") + .or_insert_with(|| "utf-8".into()); + let preview = preview_text( + &String::from_utf8_lossy(&data).replace('\u{FFFD}', "?"), + TEXT_ARTIFACT_PREVIEW_CHARS, + ); + ref_record + .entry("preview") + .or_insert_with(|| preview.into()); + let value = serde_json::Value::Object(ref_record); + let artifact: TextArtifact = serde_json::from_value(value)?; + Ok(Some(artifact)) +} + +pub fn read_text_artifact(layout: &PathLayout, artifact: &TextArtifact) -> crate::Result { + let ref_record = + validate_text_artifact_ref(layout, Some(artifact))?.unwrap_or_else(|| artifact.clone()); + let encoding = if ref_record.encoding == "utf-8" { + None + } else { + Some(ref_record.encoding.clone()) + }; + let path = PathBuf::from(&ref_record.path); + let data = fs::read(&path)?; + if encoding.as_deref() == Some("utf-8") || encoding.is_none() { + Ok(String::from_utf8_lossy(&data).to_string()) + } else { + // Only utf-8 is supported in practice; fall back to lossy utf-8. + Ok(String::from_utf8_lossy(&data).to_string()) + } +} + +pub fn sweep_expired_text_artifacts( + layout: &PathLayout, + now: Option<&str>, +) -> crate::Result> { + let root = layout.ccbd_text_artifacts_dir(); + if !root.exists() { + return Ok(Vec::new()); + } + let current = parse_utc(now.unwrap_or(&utc_now())); + let mut removed = Vec::new(); + for entry in walk_txt_files(&root)? { + let path = entry?; + let metadata = match fs::metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + let mtime = metadata.modified().ok(); + let age_s = mtime + .map(|t| { + current + .signed_duration_since(chrono::DateTime::::from(t)) + .num_seconds() + }) + .unwrap_or(0) + .max(0); + if age_s < TEXT_ARTIFACT_TTL_S { + continue; + } + match fs::remove_file(&path) { + Ok(()) => removed.push(path), + Err(_) => continue, + } + } + Ok(removed) +} + +fn walk_txt_files(root: &Utf8Path) -> std::io::Result>> { + let mut results = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = entry.metadata()?; + if metadata.is_dir() { + if let Ok(p) = Utf8PathBuf::from_path_buf(path.clone()) { + stack.push(p); + } + } else if metadata.is_file() { + if let Some(name) = path.file_name().and_then(|s| s.to_str()) { + if name.ends_with(".txt") { + if let Ok(p) = Utf8PathBuf::from_path_buf(path) { + results.push(Ok(p)); + } + } + } + } + } + } + Ok(results) +} + +fn validated_artifact_path(layout: &PathLayout, value: &str) -> crate::Result { + let root = layout.ccbd_text_artifacts_dir(); + let root = std::fs::canonicalize(&root).unwrap_or_else(|_| PathBuf::from(root.as_str())); + let path = PathBuf::from(expand_user_path(value)); + let resolved = std::fs::canonicalize(&path) + .map_err(|_| crate::StorageError::Corrupt("text artifact path does not exist".into()))?; + if !resolved.starts_with(&root) { + return Err(crate::StorageError::Corrupt( + "text artifact path escapes CCB artifact directory".into(), + )); + } + Ok(resolved) +} + +fn safe_segment(value: &str, fallback: &str) -> String { + let mut out = String::new(); + for ch in value.trim().chars() { + if ch.is_alphanumeric() || ch == '-' || ch == '_' { + out.push(ch); + } else { + out.push('-'); + } + } + let out = out.trim_matches(&['-', '_'][..]).to_lowercase(); + if out.is_empty() { + fallback.to_string() + } else { + out + } +} + +fn sha256_hex(input: impl AsRef<[u8]>) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(input); + hex::encode(hasher.finalize()) +} + +fn utc_now() -> String { + chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + .replace("+00:00", "Z") +} + +fn expires_at(created_at: &str, ttl_seconds: i64) -> String { + let base = parse_utc(created_at); + (base + chrono::TimeDelta::seconds(ttl_seconds.max(0))) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + .replace("+00:00", "Z") +} + +fn parse_utc(value: &str) -> chrono::DateTime { + let text = value.trim(); + let text = if let Some(rest) = text.strip_suffix('Z') { + format!("{}+00:00", rest) + } else { + text.to_string() + }; + chrono::DateTime::parse_from_rfc3339(&text) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()) +} + +fn expand_user_path(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix('~') { + if let Ok(home) = std::env::var("HOME") { + return home + rest; + } + } + raw.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_spill_text() { + assert!(!should_spill_text("short", TEXT_ARTIFACT_SPILL_BYTES)); + assert!(should_spill_text( + &"x".repeat(5000), + TEXT_ARTIFACT_SPILL_BYTES + )); + } + + #[test] + fn test_preview_text() { + assert_eq!(preview_text("hello", 10), "hello"); + assert!(preview_text(&"x".repeat(2000), 10).ends_with("...[truncated]")); + } + + #[test] + fn test_write_and_read_text_artifact() { + let layout = PathLayout::new("/tmp/ccb-test-artifact-repo"); + let text = "x".repeat(5000); + let artifact = write_text_artifact( + &layout, + &text, + "ask-request", + "agent1", + None, + Some("2026-05-22T00:00:00Z"), + ) + .unwrap(); + assert!(artifact.path.ends_with(".txt")); + assert_eq!(read_text_artifact(&layout, &artifact).unwrap(), text); + } +}