From f05cf307c287df17c518d9c674f3544d229df3a2 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Sun, 14 Jun 2026 22:38:55 +0530 Subject: [PATCH 01/19] feat(storage): add storage foundation module (PR1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the pure-logic core of the bubbaloop storage subsystem from docs/superpowers/specs/2026-05-26-bubbaloop-storage-design.md (spec §17 "PR1"), with no Zenoh dependencies and full unit coverage: - integrity: streaming + one-shot SHA-256, hex/prefix8/base64 helpers, verify (mandatory per-chunk integrity, §11) - recording: manifest.json data model (§4.4) with forward-compatible unknown-field preservation and derived lifecycle/upload state - manifest: atomic write_tmp_then_rename save, load, structural validation (§3.4.5) - profile: profile YAML + full v1 validator (§4.3.1), reserved `pipelines:`->v2 gate (§4.3.2), canonical profile_sha256 (§4.3.3) - secrets: opaque zeroize-on-drop Secret (redacted Debug), secrets.toml chmod 0600 (§4.2) - backend: async StorageBackend trait + LocalFs impl with checksum verification and path-traversal guards; deterministic object-key builders (§8) S3Compat backend, discover/sync/reconcile/replay, CLI/MCP surfaces and the dashboard tab are deferred to later PRs per the spec phasing. Adds sha2, zeroize, async-trait deps. 57 unit tests; clippy-clean. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 17 + crates/bubbaloop/Cargo.toml | 5 + crates/bubbaloop/src/lib.rs | 3 + crates/bubbaloop/src/storage/backend.rs | 122 +++ crates/bubbaloop/src/storage/backend/local.rs | 252 ++++++ crates/bubbaloop/src/storage/integrity.rs | 223 ++++++ crates/bubbaloop/src/storage/manifest.rs | 267 +++++++ crates/bubbaloop/src/storage/mod.rs | 122 +++ crates/bubbaloop/src/storage/profile.rs | 732 ++++++++++++++++++ crates/bubbaloop/src/storage/recording.rs | 423 ++++++++++ crates/bubbaloop/src/storage/secrets.rs | 274 +++++++ 11 files changed, 2440 insertions(+) create mode 100644 crates/bubbaloop/src/storage/backend.rs create mode 100644 crates/bubbaloop/src/storage/backend/local.rs create mode 100644 crates/bubbaloop/src/storage/integrity.rs create mode 100644 crates/bubbaloop/src/storage/manifest.rs create mode 100644 crates/bubbaloop/src/storage/mod.rs create mode 100644 crates/bubbaloop/src/storage/profile.rs create mode 100644 crates/bubbaloop/src/storage/recording.rs create mode 100644 crates/bubbaloop/src/storage/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index a41185b6..b58576b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ version = "0.0.15" dependencies = [ "anyhow", "argh", + "async-trait", "axum", "base64", "chrono", @@ -385,6 +386,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "sysinfo", "tempfile", "thiserror 2.0.18", @@ -400,6 +402,7 @@ dependencies = [ "whoami", "zbus", "zenoh", + "zeroize", ] [[package]] @@ -6269,6 +6272,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/crates/bubbaloop/Cargo.toml b/crates/bubbaloop/Cargo.toml index 7aefc52b..a939ab9e 100644 --- a/crates/bubbaloop/Cargo.toml +++ b/crates/bubbaloop/Cargo.toml @@ -87,6 +87,11 @@ toml = "0.8" # Camera vision: base64 for encoding grab_frame JPEG responses base64 = "0.22" +# Storage: integrity hashing + secret zeroization + async backend trait +sha2 = "0.10" +zeroize = { version = "1", features = ["derive"] } +async-trait = "0.1" + # TUI for agent chat REPL ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } crossterm = { version = "0.28", features = ["event-stream"] } diff --git a/crates/bubbaloop/src/lib.rs b/crates/bubbaloop/src/lib.rs index 7b9215ff..2e2591f8 100644 --- a/crates/bubbaloop/src/lib.rs +++ b/crates/bubbaloop/src/lib.rs @@ -35,6 +35,9 @@ pub mod skills; /// Agent layer: OpenClaw-inspired rewrite (Soul, 3-tier memory, adaptive heartbeat) pub mod agent; +/// Storage subsystem: fleet recordings, manifests, profiles, integrity, backends +pub mod storage; + /// Protobuf schemas for bubbaloop pub mod schemas { pub mod header { diff --git a/crates/bubbaloop/src/storage/backend.rs b/crates/bubbaloop/src/storage/backend.rs new file mode 100644 index 00000000..be2e339d --- /dev/null +++ b/crates/bubbaloop/src/storage/backend.rs @@ -0,0 +1,122 @@ +//! Storage backend abstraction (spec §3.2, §3.5). +//! +//! A [`StorageBackend`] is the put/get/list/delete/head surface that `sync` and +//! `reconcile` drive. Two implementations are planned: [`local::LocalFs`] (this +//! slice) and an `S3Compat` backend over `aws-sdk-s3` for R2/AWS/GCS/MinIO +//! (deferred to PR2 — it pulls a heavy SDK and needs network/credential plumbing +//! that belongs with the sync work). +//! +//! The trait is async and object-safe (via `async-trait`) so the daemon can hold +//! a `Box` chosen at runtime from `[storage].backend`. + +pub mod local; + +use async_trait::async_trait; + +use super::integrity::Sha256Digest; + +/// Metadata about a stored object, as returned by `head`/`list`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectMeta { + /// Object key (path relative to the backend root / bucket prefix). + pub key: String, + /// Size in bytes. + pub size_bytes: u64, + /// Backend ETag, when available. + pub etag: Option, + /// SHA-256 of the object content, when the backend can supply it cheaply + /// (HEAD on a checksum-aware backend). `list` may leave this `None`. + pub sha256: Option, +} + +/// Result of a successful `put`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PutResult { + /// ETag assigned by the backend, when available. + pub etag: Option, +} + +/// The backend interface shared by local and cloud storage. +#[async_trait] +pub trait StorageBackend: Send + Sync { + /// Store `bytes` at `key`. If `checksum_sha256` is supplied, the backend + /// verifies the content against it and returns [`BackendError::BadDigest`] on + /// mismatch — mirroring R2's server-side `x-amz-checksum-sha256` validation. + async fn put( + &self, + key: &str, + bytes: &[u8], + checksum_sha256: Option<&Sha256Digest>, + ) -> Result; + + /// Fetch the full object at `key`. + async fn get(&self, key: &str) -> Result, BackendError>; + + /// Return metadata for `key`, or `None` if it does not exist. + async fn head(&self, key: &str) -> Result, BackendError>; + + /// List objects whose key starts with `prefix`. + async fn list(&self, prefix: &str) -> Result, BackendError>; + + /// Delete the object at `key`. Deleting a missing key is **not** an error + /// (idempotent), matching S3 `DeleteObject` semantics. + async fn delete(&self, key: &str) -> Result<(), BackendError>; +} + +/// Errors returned by storage backends. The retryable/terminal split mirrors the +/// sync retry policy (spec §3.4.3). +#[derive(Debug, thiserror::Error)] +pub enum BackendError { + /// The requested key does not exist. + #[error("object not found: {key}")] + NotFound { key: String }, + /// Content did not match the supplied checksum (terminal — local corruption). + #[error("checksum mismatch for {key}: expected {expected}, got {actual}")] + BadDigest { + key: String, + expected: String, + actual: String, + }, + /// The key is invalid (e.g. path traversal, empty). + #[error("invalid object key: {0}")] + InvalidKey(String), + /// Filesystem / transport error (generally retryable). + #[error("backend io error for {key}: {detail}")] + Io { key: String, detail: String }, +} + +/// Reject keys that are empty, absolute, or contain `..` traversal components. +/// Keys are forward-slash separated, S3-style, even on Windows. +pub(crate) fn validate_key(key: &str) -> Result<(), BackendError> { + if key.is_empty() { + return Err(BackendError::InvalidKey("empty key".into())); + } + if key.starts_with('/') { + return Err(BackendError::InvalidKey(format!("absolute key: {key}"))); + } + for component in key.split('/') { + if component == ".." { + return Err(BackendError::InvalidKey(format!( + "key contains traversal component: {key}" + ))); + } + if component.contains('\0') { + return Err(BackendError::InvalidKey("key contains NUL".into())); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_validation() { + assert!(validate_key("machine/rec/chunks/chunk-000000-aabbccdd.mcap").is_ok()); + assert!(validate_key("").is_err()); + assert!(validate_key("/abs/path").is_err()); + assert!(validate_key("a/../../etc/passwd").is_err()); + assert!(validate_key("a/b\0c").is_err()); + } +} diff --git a/crates/bubbaloop/src/storage/backend/local.rs b/crates/bubbaloop/src/storage/backend/local.rs new file mode 100644 index 00000000..447d8d16 --- /dev/null +++ b/crates/bubbaloop/src/storage/backend/local.rs @@ -0,0 +1,252 @@ +//! Local filesystem backend (spec §3.2 `backend/local.rs`). +//! +//! `LocalFs` roots all objects under a directory (e.g. +//! `~/.bubbaloop/recordings/`) and maps S3-style forward-slash keys onto paths +//! beneath it. It is useful both as the `backend = "local"` target and as a +//! checksum-aware stand-in for R2 in tests, since it honours the same +//! put-with-checksum contract. + +use super::{validate_key, BackendError, ObjectMeta, PutResult, StorageBackend}; +use crate::storage::integrity; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; + +/// A filesystem-backed [`StorageBackend`] rooted at `root`. +#[derive(Debug, Clone)] +pub struct LocalFs { + root: PathBuf, +} + +impl LocalFs { + /// Create a backend rooted at `root` (created on first write if absent). + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// Resolve a validated key to an absolute path beneath the root. + fn path_for(&self, key: &str) -> Result { + validate_key(key)?; + Ok(self.root.join(key)) + } +} + +#[async_trait] +impl StorageBackend for LocalFs { + async fn put( + &self, + key: &str, + bytes: &[u8], + checksum_sha256: Option<&integrity::Sha256Digest>, + ) -> Result { + let path = self.path_for(key)?; + + if let Some(expected) = checksum_sha256 { + let actual = integrity::sha256(bytes); + if &actual != expected { + return Err(BackendError::BadDigest { + key: key.to_string(), + expected: integrity::to_hex(expected), + actual: integrity::to_hex(&actual), + }); + } + } + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| io_err(key, e))?; + } + tokio::fs::write(&path, bytes) + .await + .map_err(|e| io_err(key, e))?; + + let etag = format!("\"{}\"", integrity::to_hex(&integrity::sha256(bytes))); + Ok(PutResult { etag: Some(etag) }) + } + + async fn get(&self, key: &str) -> Result, BackendError> { + let path = self.path_for(key)?; + match tokio::fs::read(&path).await { + Ok(bytes) => Ok(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(BackendError::NotFound { + key: key.to_string(), + }), + Err(e) => Err(io_err(key, e)), + } + } + + async fn head(&self, key: &str) -> Result, BackendError> { + let path = self.path_for(key)?; + match tokio::fs::read(&path).await { + Ok(bytes) => { + let digest = integrity::sha256(&bytes); + Ok(Some(ObjectMeta { + key: key.to_string(), + size_bytes: bytes.len() as u64, + etag: Some(format!("\"{}\"", integrity::to_hex(&digest))), + sha256: Some(digest), + })) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(io_err(key, e)), + } + } + + async fn list(&self, prefix: &str) -> Result, BackendError> { + let root = self.root.clone(); + let prefix = prefix.to_string(); + let prefix_for_err = prefix.clone(); + // walkdir is synchronous; run it off the async runtime's worker threads. + tokio::task::spawn_blocking(move || list_blocking(&root, &prefix)) + .await + .map_err(|e| BackendError::Io { + key: prefix_for_err, + detail: format!("list task panicked: {e}"), + })? + } + + async fn delete(&self, key: &str) -> Result<(), BackendError> { + let path = self.path_for(key)?; + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + // Deleting a missing key is a no-op (idempotent), like S3. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(io_err(key, e)), + } + } +} + +fn io_err(key: &str, e: std::io::Error) -> BackendError { + BackendError::Io { + key: key.to_string(), + detail: e.to_string(), + } +} + +fn list_blocking(root: &Path, prefix: &str) -> Result, BackendError> { + let mut out = Vec::new(); + if !root.exists() { + return Ok(out); + } + for entry in walkdir::WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let rel = match entry.path().strip_prefix(root) { + Ok(r) => r, + Err(_) => continue, + }; + // Normalize to forward-slash, S3-style keys. + let key = rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + if !key.starts_with(prefix) { + continue; + } + let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0); + out.push(ObjectMeta { + key, + size_bytes, + etag: None, + sha256: None, + }); + } + out.sort_by(|a, b| a.key.cmp(&b.key)); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn backend() -> (tempfile::TempDir, LocalFs) { + let dir = tempfile::tempdir().unwrap(); + let fs = LocalFs::new(dir.path()); + (dir, fs) + } + + #[tokio::test] + async fn put_get_head_delete_roundtrip() { + let (_dir, fs) = backend(); + let key = "jetson/rec_a/chunks/chunk-000000-aabbccdd.mcap"; + let data = b"hello chunk"; + + assert!(fs.head(key).await.unwrap().is_none()); + fs.put(key, data, None).await.unwrap(); + + let got = fs.get(key).await.unwrap(); + assert_eq!(got, data); + + let meta = fs.head(key).await.unwrap().unwrap(); + assert_eq!(meta.size_bytes, data.len() as u64); + assert_eq!(meta.sha256, Some(integrity::sha256(data))); + assert!(meta.etag.is_some()); + + fs.delete(key).await.unwrap(); + assert!(fs.head(key).await.unwrap().is_none()); + // Idempotent delete. + fs.delete(key).await.unwrap(); + } + + #[tokio::test] + async fn put_verifies_checksum() { + let (_dir, fs) = backend(); + let data = b"verified payload"; + let good = integrity::sha256(data); + fs.put("k/ok", data, Some(&good)).await.unwrap(); + + let mut bad = good; + bad[0] ^= 0xff; + let err = fs.put("k/bad", data, Some(&bad)).await.unwrap_err(); + assert!(matches!(err, BackendError::BadDigest { .. })); + // The bad object must not have been written. + assert!(fs.head("k/bad").await.unwrap().is_none()); + } + + #[tokio::test] + async fn get_missing_is_not_found() { + let (_dir, fs) = backend(); + let err = fs.get("nope").await.unwrap_err(); + assert!(matches!(err, BackendError::NotFound { .. })); + } + + #[tokio::test] + async fn list_filters_by_prefix_and_sorts() { + let (_dir, fs) = backend(); + fs.put("m/recA/chunks/c0", b"0", None).await.unwrap(); + fs.put("m/recA/chunks/c1", b"11", None).await.unwrap(); + fs.put("m/recB/chunks/c0", b"222", None).await.unwrap(); + + let a = fs.list("m/recA/").await.unwrap(); + let keys: Vec<&str> = a.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, vec!["m/recA/chunks/c0", "m/recA/chunks/c1"]); + + let all = fs.list("m/").await.unwrap(); + assert_eq!(all.len(), 3); + } + + #[tokio::test] + async fn rejects_traversal_keys() { + let (_dir, fs) = backend(); + assert!(matches!( + fs.put("a/../../escape", b"x", None).await, + Err(BackendError::InvalidKey(_)) + )); + assert!(matches!( + fs.get("/etc/passwd").await, + Err(BackendError::InvalidKey(_)) + )); + } + + #[tokio::test] + async fn list_empty_root_is_ok() { + let dir = tempfile::tempdir().unwrap(); + let fs = LocalFs::new(dir.path().join("not-created-yet")); + assert!(fs.list("").await.unwrap().is_empty()); + } +} diff --git a/crates/bubbaloop/src/storage/integrity.rs b/crates/bubbaloop/src/storage/integrity.rs new file mode 100644 index 00000000..6968f37f --- /dev/null +++ b/crates/bubbaloop/src/storage/integrity.rs @@ -0,0 +1,223 @@ +//! SHA-256 integrity primitives for the storage subsystem. +//! +//! Integrity is **mandatory** (spec §11): every finalized chunk carries a +//! SHA-256 digest embedded in its filename, recorded in `manifest.json`, sent +//! to R2 via `x-amz-checksum-sha256`, and re-verified on download. This module +//! provides the streaming digest used during chunk writes (no second pass over +//! the bytes) plus the helpers that format and verify those digests. + +use sha2::{Digest, Sha256}; +use std::io::{self, Read}; +use std::path::Path; + +/// A 32-byte SHA-256 digest. +pub type Sha256Digest = [u8; 32]; + +/// Streaming SHA-256 hasher. +/// +/// Feed bytes incrementally with [`Hasher::update`] as chunk data is written, +/// then call [`Hasher::finalize`]. This yields the same digest as hashing the +/// full buffer in one shot, which is what lets the recorder compute the chunk +/// hash during the write rather than re-reading the finalized file (spec §3.3.6). +#[derive(Clone, Default)] +pub struct Hasher { + inner: Sha256, +} + +impl Hasher { + /// Create a fresh hasher. + pub fn new() -> Self { + Self::default() + } + + /// Feed more bytes into the digest. + pub fn update(&mut self, bytes: &[u8]) { + self.inner.update(bytes); + } + + /// Consume the hasher and return the final digest. + pub fn finalize(self) -> Sha256Digest { + self.inner.finalize().into() + } +} + +impl std::fmt::Debug for Hasher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Hasher(sha256, in-progress)") + } +} + +/// Compute the SHA-256 of an in-memory buffer in one shot. +pub fn sha256(bytes: &[u8]) -> Sha256Digest { + let mut h = Hasher::new(); + h.update(bytes); + h.finalize() +} + +/// Stream a reader to completion and return its SHA-256 without buffering the +/// whole input in memory. Used by `sync`/`reconcile` to re-verify a chunk file +/// against its manifest digest before upload (spec §3.4.4 step 2). +pub fn sha256_reader(mut reader: R) -> io::Result { + let mut hasher = Hasher::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hasher.finalize()) +} + +/// Compute the SHA-256 of a file on disk, streaming it block by block. +pub fn sha256_file(path: impl AsRef) -> io::Result { + let file = std::fs::File::open(path)?; + sha256_reader(std::io::BufReader::new(file)) +} + +/// Lowercase 64-char hex encoding of a digest (the form stored in `manifest.json`). +pub fn to_hex(digest: &Sha256Digest) -> String { + hex::encode(digest) +} + +/// The first 8 hex characters of a digest — the prefix embedded in chunk +/// filenames (`chunk-{idx:06d}-{sha256_prefix8}.mcap`, spec §3.3.6). +pub fn hex_prefix8(digest: &Sha256Digest) -> String { + let full = to_hex(digest); + full[..8].to_string() +} + +/// Base64 (standard, padded) encoding of a digest — the value for the +/// `x-amz-checksum-sha256` upload header (spec §8). +pub fn to_base64(digest: &Sha256Digest) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(digest) +} + +/// Parse a 64-char hex string back into a digest. +pub fn from_hex(s: &str) -> Result { + let bytes = hex::decode(s).map_err(|_| IntegrityError::InvalidHex { + value: s.to_string(), + })?; + bytes + .try_into() + .map_err(|_| IntegrityError::WrongLength { hex: s.to_string() }) +} + +/// Verify that `actual` equals `expected`, returning a typed mismatch error +/// carrying both hex digests so callers can surface a precise corruption report. +pub fn verify(expected: &Sha256Digest, actual: &Sha256Digest) -> Result<(), IntegrityError> { + if expected == actual { + Ok(()) + } else { + Err(IntegrityError::Mismatch { + expected: to_hex(expected), + actual: to_hex(actual), + }) + } +} + +/// Errors raised by integrity checks. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum IntegrityError { + /// A digest did not match its expected value (corruption / tampering). + #[error("sha256 mismatch: expected {expected}, got {actual}")] + Mismatch { expected: String, actual: String }, + /// A string was not valid hexadecimal. + #[error("invalid hex digest: {value}")] + InvalidHex { value: String }, + /// A hex digest decoded to the wrong number of bytes (not 32). + #[error("hex digest has wrong length (expected 32 bytes): {hex}")] + WrongLength { hex: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + // Known SHA-256 vectors (NIST / RFC 6234). + const EMPTY_HEX: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const ABC_HEX: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + #[test] + fn known_vectors_one_shot() { + assert_eq!(to_hex(&sha256(b"")), EMPTY_HEX); + assert_eq!(to_hex(&sha256(b"abc")), ABC_HEX); + } + + #[test] + fn streaming_equals_one_shot() { + let data: Vec = (0..10_000u32).map(|i| (i % 251) as u8).collect(); + let one_shot = sha256(&data); + + // Feed in irregular chunks to exercise the incremental path. + let mut h = Hasher::new(); + for piece in data.chunks(7) { + h.update(piece); + } + assert_eq!(h.finalize(), one_shot); + + // Reader path must agree too. + let via_reader = sha256_reader(std::io::Cursor::new(&data)).unwrap(); + assert_eq!(via_reader, one_shot); + } + + #[test] + fn file_digest_matches() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chunk.bin"); + let data = b"the quick brown fox jumps over the lazy dog"; + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(data).unwrap(); + f.sync_all().unwrap(); + + assert_eq!(sha256_file(&path).unwrap(), sha256(data)); + } + + #[test] + fn hex_roundtrip_and_prefix() { + let d = sha256(b"abc"); + assert_eq!(from_hex(&to_hex(&d)).unwrap(), d); + assert_eq!(hex_prefix8(&d), "ba7816bf"); + assert_eq!(hex_prefix8(&d).len(), 8); + } + + #[test] + fn base64_checksum_header() { + // Empty-input SHA-256 in base64 is the canonical x-amz-checksum-sha256 + // value AWS docs use for an empty object. + assert_eq!( + to_base64(&sha256(b"")), + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + ); + } + + #[test] + fn from_hex_rejects_bad_input() { + assert_eq!( + from_hex("zz"), + Err(IntegrityError::InvalidHex { + value: "zz".to_string() + }) + ); + // Valid hex but wrong length. + assert!(matches!( + from_hex("ab"), + Err(IntegrityError::WrongLength { .. }) + )); + } + + #[test] + fn verify_detects_corruption() { + let good = sha256(b"payload"); + let mut bad = good; + bad[0] ^= 0xff; // flip a bit + assert!(verify(&good, &good).is_ok()); + assert!(matches!( + verify(&good, &bad), + Err(IntegrityError::Mismatch { .. }) + )); + } +} diff --git a/crates/bubbaloop/src/storage/manifest.rs b/crates/bubbaloop/src/storage/manifest.rs new file mode 100644 index 00000000..f6470ebc --- /dev/null +++ b/crates/bubbaloop/src/storage/manifest.rs @@ -0,0 +1,267 @@ +//! Load, validate, and atomically persist `manifest.json` (spec §3.4.5, §4.4). +//! +//! Every manifest write is `write_tmp_then_rename`: serialize to a sibling +//! `manifest.json.tmp`, fsync it, then `rename` over the target. A crash +//! mid-write therefore leaves either the old or the new manifest intact, never a +//! half-written one (spec §3.4.5 atomicity). +//! +//! Forward compatibility: loading preserves unknown fields (see +//! [`crate::storage::recording`]). Validation accepts manifests whose +//! `schema_version` is newer than this build — it only rejects manifests that are +//! structurally broken (missing names, non-contiguous chunk indices, malformed +//! digests), so a future writer's extra fields never trip an older reader. + +use super::integrity; +use super::recording::{Recording, MANIFEST_SCHEMA_VERSION}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Standard manifest filename at a recording's root. +pub const MANIFEST_FILE: &str = "manifest.json"; + +/// Resolve the manifest path inside a recording directory. +pub fn manifest_path(recording_dir: impl AsRef) -> PathBuf { + recording_dir.as_ref().join(MANIFEST_FILE) +} + +/// Parse a manifest from a JSON string and validate it. +pub fn parse(json: &str) -> Result { + let recording: Recording = + serde_json::from_str(json).map_err(|e| ManifestError::Parse(e.to_string()))?; + validate(&recording)?; + Ok(recording) +} + +/// Load and validate a manifest from a file path. +pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = std::fs::read_to_string(path).map_err(|e| ManifestError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + parse(&contents) +} + +/// Load the `manifest.json` from inside a recording directory. +pub fn load_dir(recording_dir: impl AsRef) -> Result { + load(manifest_path(recording_dir)) +} + +/// Atomically persist a manifest to `path` via write-tmp-then-rename. +/// +/// The temp file is created as a sibling of the target (same directory) so the +/// final `rename` stays within one filesystem and is therefore atomic. +pub fn save_atomic(path: impl AsRef, recording: &Recording) -> Result<(), ManifestError> { + let path = path.as_ref(); + validate(recording)?; + + let bytes = serde_json::to_vec_pretty(recording) + .map_err(|e| ManifestError::Serialize(e.to_string()))?; + + let parent = path.parent().ok_or_else(|| ManifestError::Io { + path: path.display().to_string(), + detail: "manifest path has no parent directory".to_string(), + })?; + std::fs::create_dir_all(parent).map_err(|e| ManifestError::Io { + path: parent.display().to_string(), + detail: e.to_string(), + })?; + + // Sibling temp file keeps the rename on the same filesystem. + let tmp = path.with_extension("json.tmp"); + { + let mut f = std::fs::File::create(&tmp).map_err(|e| ManifestError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + f.write_all(&bytes).map_err(|e| ManifestError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + f.sync_all().map_err(|e| ManifestError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + } + std::fs::rename(&tmp, path).map_err(|e| ManifestError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + Ok(()) +} + +/// Save the manifest into a recording directory (`{dir}/manifest.json`). +pub fn save_dir( + recording_dir: impl AsRef, + recording: &Recording, +) -> Result<(), ManifestError> { + save_atomic(manifest_path(recording_dir), recording) +} + +/// Structural validation. Intentionally permissive about *forward* schema +/// versions (newer manifests are accepted, their extra fields preserved) but +/// strict about internal consistency. +pub fn validate(r: &Recording) -> Result<(), ManifestError> { + if r.schema_version == 0 { + return Err(ManifestError::Invalid("schema_version must be >= 1".into())); + } + if r.name.trim().is_empty() { + return Err(ManifestError::Invalid("recording name is empty".into())); + } + if r.machine_id.trim().is_empty() { + return Err(ManifestError::Invalid("machine_id is empty".into())); + } + + // Chunk indices must be contiguous from 0, in order, with valid digests. + for (expected_idx, chunk) in r.chunks.iter().enumerate() { + let expected_idx = expected_idx as u32; + if chunk.index != expected_idx { + return Err(ManifestError::Invalid(format!( + "chunk indices must be contiguous from 0: expected index {expected_idx}, found {}", + chunk.index + ))); + } + integrity::from_hex(&chunk.sha256).map_err(|_| { + ManifestError::Invalid(format!( + "chunk {} has an invalid sha256: {}", + chunk.index, chunk.sha256 + )) + })?; + } + Ok(()) +} + +/// True if a manifest's schema version is newer than this build can natively +/// understand. Such manifests are still loaded (unknown fields preserved); this +/// just lets callers warn before re-saving and potentially dropping semantics. +pub fn is_future_version(r: &Recording) -> bool { + r.schema_version > MANIFEST_SCHEMA_VERSION +} + +/// Errors from manifest IO and validation. +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + /// Filesystem error reading or writing the manifest. + #[error("manifest io error at {path}: {detail}")] + Io { path: String, detail: String }, + /// JSON failed to parse. + #[error("manifest parse error: {0}")] + Parse(String), + /// JSON failed to serialize. + #[error("manifest serialize error: {0}")] + Serialize(String), + /// The manifest parsed but is structurally invalid. + #[error("invalid manifest: {0}")] + Invalid(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::recording::{Chunk, Recording}; + + fn chunk(index: u32, byte: u8) -> Chunk { + let sha = format!("{byte:02x}").repeat(32); + Chunk { + name: Chunk::canonical_name(index, &sha), + index, + size_bytes: 100, + sha256: sha, + log_time_first_ns: None, + log_time_last_ns: None, + uploaded_at_ns: None, + remote_etag: None, + extra: Default::default(), + } + } + + fn valid_recording() -> Recording { + let mut r = Recording::new("rec_a", "jetson_alpha", 1_000); + r.chunks.push(chunk(0, 0xab)); + r.chunks.push(chunk(1, 0xcd)); + r + } + + #[test] + fn atomic_save_then_load_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let r = valid_recording(); + + save_dir(&rec_dir, &r).unwrap(); + assert!(manifest_path(&rec_dir).exists()); + // No temp file left behind. + assert!(!rec_dir.join("manifest.json.tmp").exists()); + + let loaded = load_dir(&rec_dir).unwrap(); + assert_eq!(loaded, r); + } + + #[test] + fn save_overwrites_atomically() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let mut r = valid_recording(); + save_dir(&rec_dir, &r).unwrap(); + + // Mutate (mark a chunk uploaded) and re-save — simulates sync's update path. + r.chunks[0].uploaded_at_ns = Some(42); + save_dir(&rec_dir, &r).unwrap(); + + let loaded = load_dir(&rec_dir).unwrap(); + assert_eq!(loaded.chunks[0].uploaded_at_ns, Some(42)); + assert!(!rec_dir.join("manifest.json.tmp").exists()); + } + + #[test] + fn rejects_non_contiguous_chunk_indices() { + let mut r = valid_recording(); + r.chunks[1].index = 5; // gap + let err = validate(&r).unwrap_err(); + assert!(matches!(err, ManifestError::Invalid(_))); + } + + #[test] + fn rejects_bad_sha256() { + let mut r = valid_recording(); + r.chunks[0].sha256 = "not-hex".into(); + assert!(matches!(validate(&r), Err(ManifestError::Invalid(_)))); + } + + #[test] + fn rejects_empty_name() { + let mut r = valid_recording(); + r.name = " ".into(); + assert!(matches!(validate(&r), Err(ManifestError::Invalid(_)))); + } + + #[test] + fn parse_validates() { + // index gap should be rejected at parse time too. + let json = r#"{ + "schema_version": 1, "name": "x", "machine_id": "m", + "started_at_ns": 0, "mode": "streaming", + "selection": {"topics": [], "exclude": [], "include_local": false}, + "chunks": [ + {"name": "chunk-000000-aaaaaaaa.mcap", "index": 0, "size_bytes": 1, "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"name": "chunk-000002-bbbbbbbb.mcap", "index": 2, "size_bytes": 1, "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + ] + }"#; + assert!(matches!(parse(json), Err(ManifestError::Invalid(_)))); + } + + #[test] + fn future_version_is_detected_but_loadable() { + let mut r = valid_recording(); + r.schema_version = 99; + assert!(is_future_version(&r)); + assert!(validate(&r).is_ok()); + } + + #[test] + fn load_missing_file_is_io_error() { + let dir = tempfile::tempdir().unwrap(); + let err = load_dir(dir.path().join("does_not_exist")).unwrap_err(); + assert!(matches!(err, ManifestError::Io { .. })); + } +} diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs new file mode 100644 index 00000000..92307fe9 --- /dev/null +++ b/crates/bubbaloop/src/storage/mod.rs @@ -0,0 +1,122 @@ +//! Storage subsystem — fleet-aware recordings (spec +//! `docs/superpowers/specs/2026-05-26-bubbaloop-storage-design.md`). +//! +//! This is the foundation layer (spec §17, "PR1"): the pure, Zenoh-free core that +//! everything else builds on. +//! +//! - [`integrity`] — mandatory SHA-256 per chunk (streaming digest + verify). +//! - [`recording`] — the `manifest.json` data model (the source of truth). +//! - [`manifest`] — atomic load/save/validate of manifests. +//! - [`profile`] — `~/.bubbaloop/profiles/*.yaml` with the v1 validator + canonical hash. +//! - [`secrets`] — opaque, zeroize-on-drop credentials in `secrets.toml` (chmod 0600). +//! - [`backend`] — the `StorageBackend` trait + `LocalFs` (S3Compat lands in PR2). +//! +//! Still to come (later PRs from the spec): `discover`, `sync`, `reconcile`, +//! `replay`, `ring_buffer`, the `mcap-recorder` node, the CLI/MCP surfaces, and +//! the dashboard tab. + +pub mod backend; +pub mod integrity; +pub mod manifest; +pub mod profile; +pub mod recording; +pub mod secrets; + +use std::path::{Path, PathBuf}; + +// Re-export the most commonly used types at the subsystem root. +pub use backend::{local::LocalFs, BackendError, ObjectMeta, PutResult, StorageBackend}; +pub use profile::Profile; +pub use recording::{Channel, Chunk, Lifecycle, Recording, RecordingMode, Selection, Trigger}; + +/// Base bubbaloop directory: `~/.bubbaloop`. +pub fn bubbaloop_dir() -> Result { + let home = dirs::home_dir().ok_or(StoragePathError::NoHomeDir)?; + Ok(home.join(".bubbaloop")) +} + +/// Local recordings root: `~/.bubbaloop/recordings`. +pub fn recordings_dir() -> Result { + Ok(bubbaloop_dir()?.join("recordings")) +} + +/// Directory for one recording: `~/.bubbaloop/recordings/{name}`. +pub fn recording_dir(name: &str) -> Result { + Ok(recordings_dir()?.join(name)) +} + +/// Chunks directory for one recording: `.../{name}/chunks`. +pub fn chunks_dir(name: &str) -> Result { + Ok(recording_dir(name)?.join("chunks")) +} + +/// Storage state directory (dead-letter list, future file-backed queue): +/// `~/.bubbaloop/storage`. +pub fn storage_state_dir() -> Result { + Ok(bubbaloop_dir()?.join("storage")) +} + +/// The `chunks/` subpath under a recording directory. +pub fn chunks_path(recording_dir: impl AsRef) -> PathBuf { + recording_dir.as_ref().join("chunks") +} + +/// Deterministic remote object key for a recording's manifest (spec §8): +/// `{machine_id}/{recording_name}/manifest.json`. +pub fn object_key_manifest(machine_id: &str, recording_name: &str) -> String { + format!("{machine_id}/{recording_name}/manifest.json") +} + +/// Deterministic remote object key for a chunk (spec §8): +/// `{machine_id}/{recording_name}/chunks/chunk-{idx:06}-{sha256_prefix8}.mcap`. +/// +/// The SHA-256 prefix in the key makes re-uploads idempotent by HEAD check: +/// identical content → identical key → no-op; changed content → different key → +/// no silent overwrite. +pub fn object_key_chunk( + machine_id: &str, + recording_name: &str, + chunk_index: u32, + sha256_hex: &str, +) -> String { + let name = Chunk::canonical_name(chunk_index, sha256_hex); + format!("{machine_id}/{recording_name}/chunks/{name}") +} + +/// Errors resolving storage paths. +#[derive(Debug, thiserror::Error)] +pub enum StoragePathError { + /// No home directory could be determined. + #[error("could not determine home directory")] + NoHomeDir, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_object_key() { + assert_eq!( + object_key_manifest("jetson_alpha", "outdoor_test_3"), + "jetson_alpha/outdoor_test_3/manifest.json" + ); + } + + #[test] + fn chunk_object_key_embeds_index_and_prefix() { + let hex = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; + assert_eq!( + object_key_chunk("jetson_alpha", "rec", 7, hex), + "jetson_alpha/rec/chunks/chunk-000007-a1b2c3d4.mcap" + ); + } + + #[test] + fn chunk_key_is_a_valid_backend_key() { + let hex = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; + let key = object_key_chunk("m", "r", 0, hex); + // The deterministic key must be accepted by the backend key validator. + assert!(backend::validate_key(&key).is_ok()); + } +} diff --git a/crates/bubbaloop/src/storage/profile.rs b/crates/bubbaloop/src/storage/profile.rs new file mode 100644 index 00000000..e7cc6b48 --- /dev/null +++ b/crates/bubbaloop/src/storage/profile.rs @@ -0,0 +1,732 @@ +//! Recording profiles (`~/.bubbaloop/profiles/*.yaml`, spec §4.3). +//! +//! Profiles are small, committable YAML files that capture "the record button" +//! a team uses. Field names borrow from the OpenTelemetry Collector +//! (`sending_queue`, `retry_on_failure`) for forward compatibility with the v2 +//! multi-pipeline mode. The top-level `pipelines:` key is **reserved** in v1 — +//! its presence triggers [`ProfileError::RequiresV2`] rather than being silently +//! ignored (spec §4.3.2). +//! +//! The loader is strict (spec §4.3.1): unknown keys (top-level and nested) are +//! rejected as typos, ring-buffer mode requires `window_secs` + `trigger`, +//! `trigger: on-event` is Phase 3, an empty selection is rejected, and +//! `chunk_size_bytes` is range-checked. + +use super::recording::{RecordingMode, Trigger}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// rosbag2's validated default chunk size (spec §3.3.7). +pub const DEFAULT_CHUNK_SIZE_BYTES: u32 = 786_432; +/// Minimum accepted chunk size (4 KiB, spec §4.3.1). +pub const MIN_CHUNK_SIZE_BYTES: u32 = 4 * 1024; +/// Maximum accepted chunk size (16 MiB, spec §4.3.1). +pub const MAX_CHUNK_SIZE_BYTES: u32 = 16 * 1024 * 1024; + +/// Top-level keys accepted in a v1 profile. Anything else is a typo and fails +/// loud — except `pipelines`, which is the reserved v2 entry point handled +/// separately, and `schema_version`, validated for the v1/v2 boundary. +const ALLOWED_TOP_LEVEL: &[&str] = &[ + "schema_version", + "name", + "description", + "topics", + "regex", + "exclude", + "include_local", + "chunk_size_bytes", + "compression", + "compression_level", + "chunk_crc", + "mode", + "window_secs", + "trigger", + "sending_queue", + "retry_on_failure", +]; + +/// A parsed, validated v1 profile. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Profile { + /// Profile name. + pub name: String, + /// Human description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + // --- Topic selection (additive composition, §4.3) --- + /// Additive literal/wildcard include patterns. + #[serde(default)] + pub topics: Vec, + /// Additive regex include. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub regex: Option, + /// Subtractive exclude patterns. + #[serde(default)] + pub exclude: Vec, + /// Include SHM-only `bubbaloop/local/**` topics. + #[serde(default)] + pub include_local: bool, + + // --- MCAP writer config --- + /// Target chunk size in bytes. Accepts YAML underscores (`786_432`). + #[serde(default = "default_chunk_size", deserialize_with = "de_flexible_u32")] + pub chunk_size_bytes: u32, + /// Compression codec. + #[serde(default)] + pub compression: CompressionKind, + /// Compression level. + #[serde(default)] + pub compression_level: CompressionLevel, + /// Whether to write per-chunk CRCs. + #[serde(default = "default_true")] + pub chunk_crc: bool, + + // --- Session config --- + /// Capture mode. + #[serde(default)] + pub mode: RecordingMode, + /// Sliding-window length (required when `mode: ring_buffer`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_secs: Option, + /// Ring-buffer trigger (required when `mode: ring_buffer`; `manual` only in v1). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger: Option, + + // --- Upload behavior (OTel-style names) --- + /// Bounded send queue config. + #[serde(default)] + pub sending_queue: SendingQueue, + /// Retry/backoff config. + #[serde(default)] + pub retry_on_failure: RetryOnFailure, +} + +/// Compression codec for chunks. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompressionKind { + /// zstd (rosbag2 default). + #[default] + Zstd, + /// lz4. + Lz4, + /// No compression. + None, +} + +/// Compression level. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompressionLevel { + /// Fast (rosbag2 default). + #[default] + Fast, + /// Balanced default. + Default, + /// Maximum ratio. + High, +} + +/// Bounded send-queue config (`sending_queue:`). Unknown nested keys are +/// rejected (spec §4.3.1). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SendingQueue { + /// Whether background upload is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + /// Jobs in flight before backpressure. + #[serde(default = "default_queue_size", deserialize_with = "de_flexible_u32")] + pub queue_size: u32, + /// Reserved for a future file-backed queue (forward-compat only in v1, §3.4.2). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage_dir: Option, +} + +impl Default for SendingQueue { + fn default() -> Self { + Self { + enabled: true, + queue_size: default_queue_size(), + storage_dir: None, + } + } +} + +/// Retry/backoff config (`retry_on_failure:`). Unknown nested keys are rejected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetryOnFailure { + /// Whether retries are enabled. + #[serde(default = "default_true")] + pub enabled: bool, + /// Initial backoff interval. + #[serde(default = "default_initial_interval")] + pub initial_interval: HumanDuration, + /// Maximum backoff interval (clamp). + #[serde(default = "default_max_interval")] + pub max_interval: HumanDuration, + /// Total time before a job is dead-lettered. + #[serde(default = "default_max_elapsed")] + pub max_elapsed_time: HumanDuration, +} + +impl Default for RetryOnFailure { + fn default() -> Self { + Self { + enabled: true, + initial_interval: default_initial_interval(), + max_interval: default_max_interval(), + max_elapsed_time: default_max_elapsed(), + } + } +} + +/// A duration written human-style in YAML (`5s`, `60s`, `5m`, `1h`, `500ms`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HumanDuration(pub Duration); + +impl HumanDuration { + /// The wrapped [`Duration`]. + pub fn as_duration(&self) -> Duration { + self.0 + } +} + +impl Serialize for HumanDuration { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&format_duration(self.0)) + } +} + +impl<'de> Deserialize<'de> for HumanDuration { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + parse_duration(&s) + .map(HumanDuration) + .map_err(serde::de::Error::custom) + } +} + +fn default_chunk_size() -> u32 { + DEFAULT_CHUNK_SIZE_BYTES +} +fn default_true() -> bool { + true +} +fn default_queue_size() -> u32 { + 1000 +} +fn default_initial_interval() -> HumanDuration { + HumanDuration(Duration::from_secs(5)) +} +fn default_max_interval() -> HumanDuration { + HumanDuration(Duration::from_secs(60)) +} +fn default_max_elapsed() -> HumanDuration { + HumanDuration(Duration::from_secs(300)) +} + +/// Deserialize a u32 that may be written with YAML underscores (`786_432`) or as +/// a plain integer. libyaml's core schema treats `786_432` as a string, so we +/// accept both forms and strip underscores (spec §4.3.3 number normalization). +fn de_flexible_u32<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v = serde_yaml::Value::deserialize(d)?; + match v { + serde_yaml::Value::Number(n) => n + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| serde::de::Error::custom(format!("not a valid u32: {n}"))), + serde_yaml::Value::String(s) => s + .replace('_', "") + .parse::() + .map_err(|_| serde::de::Error::custom(format!("not a valid u32: {s}"))), + other => Err(serde::de::Error::custom(format!( + "expected an integer, found {other:?}" + ))), + } +} + +/// Parse `5s` / `500ms` / `5m` / `1h` into a [`Duration`]. +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + let (num, unit) = if let Some(rest) = s.strip_suffix("ms") { + (rest, "ms") + } else if let Some(rest) = s.strip_suffix('s') { + (rest, "s") + } else if let Some(rest) = s.strip_suffix('m') { + (rest, "m") + } else if let Some(rest) = s.strip_suffix('h') { + (rest, "h") + } else { + return Err(format!("duration '{s}' missing a unit (ms/s/m/h)")); + }; + let value: u64 = num + .trim() + .parse() + .map_err(|_| format!("duration '{s}' has a non-integer value"))?; + let dur = match unit { + "ms" => Duration::from_millis(value), + "s" => Duration::from_secs(value), + "m" => Duration::from_secs(value * 60), + "h" => Duration::from_secs(value * 3600), + _ => unreachable!(), + }; + Ok(dur) +} + +/// Render a [`Duration`] back to the most natural human form. +fn format_duration(d: Duration) -> String { + let secs = d.as_secs(); + let millis = d.subsec_millis(); + if secs == 0 && millis > 0 { + return format!("{millis}ms"); + } + // Prefer the largest exact unit. Division-then-multiply (rather than `% == 0`) + // keeps this MSRV-1.75 friendly — `u64::is_multiple_of` is only 1.87+. + let hours = secs / 3600; + let mins = secs / 60; + if secs > 0 && hours * 3600 == secs { + format!("{hours}h") + } else if secs > 0 && mins * 60 == secs { + format!("{mins}m") + } else { + format!("{secs}s") + } +} + +/// Default profiles directory: `~/.bubbaloop/profiles`. +pub fn profiles_dir() -> Result { + let home = dirs::home_dir().ok_or(ProfileError::NoHomeDir)?; + Ok(home.join(".bubbaloop").join("profiles")) +} + +/// Parse and fully validate a profile from a YAML string. +pub fn parse(yaml: &str) -> Result { + // 1. Inspect the raw mapping for the reserved v2 key and unknown top-level keys. + let raw: serde_yaml::Value = + serde_yaml::from_str(yaml).map_err(|e| ProfileError::Parse(e.to_string()))?; + let map = raw + .as_mapping() + .ok_or_else(|| ProfileError::Parse("profile must be a YAML mapping".into()))?; + + if map.contains_key("pipelines") { + return Err(ProfileError::RequiresV2 { + reason: "found reserved `pipelines:` key".into(), + }); + } + if let Some(sv) = map.get("schema_version") { + let v = sv.as_u64().unwrap_or(1); + if v > 1 { + return Err(ProfileError::RequiresV2 { + reason: format!("schema_version {v} requires bubbaloop v2"), + }); + } + } + for key in map.keys() { + if let Some(k) = key.as_str() { + if !ALLOWED_TOP_LEVEL.contains(&k) { + return Err(ProfileError::UnknownField { + field: k.to_string(), + }); + } + } + } + + // 2. Deserialize into the typed profile (nested deny_unknown_fields catches + // typos under sending_queue / retry_on_failure). + let profile: Profile = + serde_yaml::from_str(yaml).map_err(|e| ProfileError::Parse(e.to_string()))?; + + // 3. Semantic validation. + validate(&profile)?; + Ok(profile) +} + +/// Semantic validation rules (§4.3.1), applied after structural parsing. +pub fn validate(p: &Profile) -> Result<(), ProfileError> { + if p.name.trim().is_empty() { + return Err(ProfileError::UnknownField { + field: "name (must be non-empty)".into(), + }); + } + + if p.mode == RecordingMode::RingBuffer { + if p.window_secs.is_none() { + return Err(ProfileError::MissingField { + field: "window_secs", + required_when: "mode=ring_buffer", + }); + } + match p.trigger { + None => { + return Err(ProfileError::MissingField { + field: "trigger", + required_when: "mode=ring_buffer", + }) + } + Some(Trigger::OnEvent) => { + return Err(ProfileError::NotYetImplemented { + feature: "on-event trigger", + available_in: "Phase 3", + }) + } + Some(Trigger::Manual) => {} + } + } else if p.trigger == Some(Trigger::OnEvent) { + return Err(ProfileError::NotYetImplemented { + feature: "on-event trigger", + available_in: "Phase 3", + }); + } + + if p.topics.is_empty() && p.regex.is_none() { + return Err(ProfileError::EmptySelection); + } + + if p.chunk_size_bytes < MIN_CHUNK_SIZE_BYTES || p.chunk_size_bytes > MAX_CHUNK_SIZE_BYTES { + return Err(ProfileError::OutOfRange { + field: "chunk_size_bytes", + value: p.chunk_size_bytes as u64, + min: MIN_CHUNK_SIZE_BYTES as u64, + max: MAX_CHUNK_SIZE_BYTES as u64, + }); + } + + Ok(()) +} + +/// Load and validate a profile from a file. +pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = std::fs::read_to_string(path).map_err(|e| ProfileError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + parse(&contents) +} + +/// Load a profile by name from the default profiles directory. +pub fn load_named(name: &str) -> Result { + load(profiles_dir()?.join(format!("{name}.yaml"))) +} + +/// Validate and save a profile to a file (atomically via tmp + rename). +pub fn save(path: impl AsRef, profile: &Profile) -> Result<(), ProfileError> { + validate(profile)?; + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| ProfileError::Io { + path: parent.display().to_string(), + detail: e.to_string(), + })?; + } + let yaml = + serde_yaml::to_string(profile).map_err(|e| ProfileError::Serialize(e.to_string()))?; + let tmp = path.with_extension("yaml.tmp"); + std::fs::write(&tmp, yaml.as_bytes()).map_err(|e| ProfileError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + std::fs::rename(&tmp, path).map_err(|e| ProfileError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + Ok(()) +} + +/// Canonical SHA-256 of a profile for manifest provenance (spec §4.3.3). +/// +/// Computed over a canonical JSON form (recursively key-sorted, numbers +/// normalized by the typed round-trip) so whitespace, comment, key-order, and +/// `786_432`-vs-`786432` edits do **not** change the hash, while semantic edits +/// do. Returns the 64-char lowercase hex digest. +pub fn canonical_sha256(p: &Profile) -> String { + let value = serde_json::to_value(p).expect("profile is always serializable"); + let canonical = canonicalize_json(&value); + super::integrity::to_hex(&super::integrity::sha256(canonical.as_bytes())) +} + +/// Serialize a JSON value with object keys recursively sorted — a deterministic +/// canonical form independent of serde_json's key-order feature flags. +fn canonicalize_json(v: &serde_json::Value) -> String { + let mut out = String::new(); + write_canonical(v, &mut out); + out +} + +fn write_canonical(v: &serde_json::Value, out: &mut String) { + match v { + serde_json::Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + out.push('{'); + for (i, k) in keys.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&serde_json::to_string(k).unwrap()); + out.push(':'); + write_canonical(&map[k.as_str()], out); + } + out.push('}'); + } + serde_json::Value::Array(arr) => { + out.push('['); + for (i, e) in arr.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(e, out); + } + out.push(']'); + } + other => out.push_str(&serde_json::to_string(other).unwrap()), + } +} + +/// Errors from profile loading and validation. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProfileError { + /// No home directory could be determined. + #[error("could not determine home directory")] + NoHomeDir, + /// Filesystem error. + #[error("profile io error at {path}: {detail}")] + Io { path: String, detail: String }, + /// YAML parse error (includes nested unknown-field rejections). + #[error("profile parse error: {0}")] + Parse(String), + /// Serialization error. + #[error("profile serialize error: {0}")] + Serialize(String), + /// An unknown top-level key was found (typo). + #[error("unknown profile field: {field}")] + UnknownField { field: String }, + /// The profile uses the reserved v2 `pipelines:` surface. + #[error("this profile requires bubbaloop v2: {reason}")] + RequiresV2 { reason: String }, + /// A field is required given another field's value. + #[error("missing field `{field}` (required when {required_when})")] + MissingField { + field: &'static str, + required_when: &'static str, + }, + /// A feature is reserved for a later phase. + #[error("`{feature}` is not implemented in v1 (available in {available_in})")] + NotYetImplemented { + feature: &'static str, + available_in: &'static str, + }, + /// No topic selection was provided. + #[error("profile has an empty selection (set `topics` and/or `regex`)")] + EmptySelection, + /// A numeric field is out of its accepted range. + #[error("`{field}` = {value} is out of range [{min}, {max}]")] + OutOfRange { + field: &'static str, + value: u64, + min: u64, + max: u64, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_yaml() -> &'static str { + "name: demo\ntopics:\n - 'bubbaloop/global/*/cam_*/compressed'\n" + } + + #[test] + fn parses_minimal_profile_with_defaults() { + let p = parse(minimal_yaml()).unwrap(); + assert_eq!(p.name, "demo"); + assert_eq!(p.chunk_size_bytes, DEFAULT_CHUNK_SIZE_BYTES); + assert_eq!(p.compression, CompressionKind::Zstd); + assert_eq!(p.compression_level, CompressionLevel::Fast); + assert!(p.chunk_crc); + assert_eq!(p.mode, RecordingMode::Streaming); + assert!(p.sending_queue.enabled); + assert_eq!(p.sending_queue.queue_size, 1000); + assert_eq!( + p.retry_on_failure.initial_interval.as_duration(), + Duration::from_secs(5) + ); + assert_eq!( + p.retry_on_failure.max_elapsed_time.as_duration(), + Duration::from_secs(300) + ); + } + + #[test] + fn parses_full_otel_style_profile() { + let yaml = r#" +name: demo +description: "Camera + IMU" +topics: + - 'bubbaloop/global/*/cam_*/compressed' +regex: '^bubbaloop/global/.+/imu/.+' +exclude: + - '**/health' +chunk_size_bytes: 786_432 +compression: zstd +compression_level: fast +chunk_crc: true +mode: streaming +sending_queue: + enabled: true + queue_size: 1000 +retry_on_failure: + enabled: true + initial_interval: 5s + max_interval: 60s + max_elapsed_time: 5m +"#; + let p = parse(yaml).unwrap(); + assert_eq!(p.chunk_size_bytes, 786_432); + assert_eq!(p.regex.as_deref(), Some("^bubbaloop/global/.+/imu/.+")); + assert_eq!( + p.retry_on_failure.max_interval.as_duration(), + Duration::from_secs(60) + ); + } + + #[test] + fn reserved_pipelines_key_requires_v2() { + let yaml = "name: demo\ntopics: [a]\npipelines:\n - id: x\n"; + assert!(matches!(parse(yaml), Err(ProfileError::RequiresV2 { .. }))); + } + + #[test] + fn schema_version_2_requires_v2() { + let yaml = "schema_version: 2\nname: demo\ntopics: [a]\n"; + assert!(matches!(parse(yaml), Err(ProfileError::RequiresV2 { .. }))); + } + + #[test] + fn unknown_top_level_key_rejected() { + let yaml = "name: demo\ntopics: [a]\ncompresssion: zstd\n"; // typo + assert_eq!( + parse(yaml), + Err(ProfileError::UnknownField { + field: "compresssion".into() + }) + ); + } + + #[test] + fn unknown_nested_key_rejected() { + let yaml = "name: demo\ntopics: [a]\nsending_queue:\n enabled: true\n bogus: 1\n"; + assert!(matches!(parse(yaml), Err(ProfileError::Parse(_)))); + } + + #[test] + fn ring_buffer_requires_window_and_trigger() { + let no_window = "name: d\ntopics: [a]\nmode: ring_buffer\ntrigger: manual\n"; + assert_eq!( + parse(no_window), + Err(ProfileError::MissingField { + field: "window_secs", + required_when: "mode=ring_buffer" + }) + ); + + let no_trigger = "name: d\ntopics: [a]\nmode: ring_buffer\nwindow_secs: 60\n"; + assert_eq!( + parse(no_trigger), + Err(ProfileError::MissingField { + field: "trigger", + required_when: "mode=ring_buffer" + }) + ); + } + + #[test] + fn ring_buffer_manual_ok() { + let yaml = "name: d\ntopics: [a]\nmode: ring_buffer\nwindow_secs: 60\ntrigger: manual\n"; + let p = parse(yaml).unwrap(); + assert_eq!(p.mode, RecordingMode::RingBuffer); + assert_eq!(p.trigger, Some(Trigger::Manual)); + } + + #[test] + fn on_event_trigger_not_yet_implemented() { + let yaml = "name: d\ntopics: [a]\nmode: ring_buffer\nwindow_secs: 60\ntrigger: on-event\n"; + assert!(matches!( + parse(yaml), + Err(ProfileError::NotYetImplemented { .. }) + )); + } + + #[test] + fn empty_selection_rejected() { + let yaml = "name: d\n"; + assert_eq!(parse(yaml), Err(ProfileError::EmptySelection)); + } + + #[test] + fn chunk_size_out_of_range_rejected() { + let too_small = "name: d\ntopics: [a]\nchunk_size_bytes: 100\n"; + assert!(matches!( + parse(too_small), + Err(ProfileError::OutOfRange { .. }) + )); + let too_big = "name: d\ntopics: [a]\nchunk_size_bytes: 99999999\n"; + assert!(matches!( + parse(too_big), + Err(ProfileError::OutOfRange { .. }) + )); + } + + #[test] + fn save_then_load_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("demo.yaml"); + let p = parse(minimal_yaml()).unwrap(); + save(&path, &p).unwrap(); + let loaded = load(&path).unwrap(); + assert_eq!(loaded, p); + } + + #[test] + fn canonical_sha256_ignores_cosmetic_edits() { + // Same semantics, different whitespace / comments / underscores / key order. + let a = "name: demo\ntopics: [a]\nchunk_size_bytes: 786_432\n"; + let b = "# a comment\nchunk_size_bytes: 786432\nname: demo\ntopics:\n - a\n"; + let pa = parse(a).unwrap(); + let pb = parse(b).unwrap(); + assert_eq!(canonical_sha256(&pa), canonical_sha256(&pb)); + } + + #[test] + fn canonical_sha256_changes_on_semantic_edit() { + let a = parse("name: demo\ntopics: [a]\n").unwrap(); + let b = parse("name: demo\ntopics: [b]\n").unwrap(); // different topic + assert_ne!(canonical_sha256(&a), canonical_sha256(&b)); + } + + #[test] + fn duration_parsing_units() { + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("5s").unwrap(), Duration::from_secs(5)); + assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300)); + assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); + assert!(parse_duration("5").is_err()); // missing unit + assert!(parse_duration("xs").is_err()); + } + + #[test] + fn duration_format_roundtrips() { + for s in ["500ms", "5s", "5m", "1h"] { + let d = parse_duration(s).unwrap(); + assert_eq!(format_duration(d), s); + } + } +} diff --git a/crates/bubbaloop/src/storage/recording.rs b/crates/bubbaloop/src/storage/recording.rs new file mode 100644 index 00000000..adebf525 --- /dev/null +++ b/crates/bubbaloop/src/storage/recording.rs @@ -0,0 +1,423 @@ +//! Recording data model — the `manifest.json` schema (spec §4.4). +//! +//! The manifest is the **sole source of truth** for a recording (spec §11): no +//! SQLite index, no separate metadata DB. `storage list` scans +//! `~/.bubbaloop/recordings/*/manifest.json`. These structs are the typed view +//! of that file; [`crate::storage::manifest`] handles load/save/validation. +//! +//! Forward compatibility: every struct that maps to a JSON object captures +//! unknown fields in an `extra` map (`#[serde(flatten)]`) so a manifest written +//! by a newer recorder round-trips through an older binary without data loss +//! (spec §15, schema-version drift risk). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Current manifest schema version written by this build. +pub const MANIFEST_SCHEMA_VERSION: u32 = 1; + +/// A recording's `manifest.json` — the source of truth for one recording. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Recording { + /// Manifest schema version. Loaders preserve unknown fields across versions. + pub schema_version: u32, + /// Recording name (caller-assigned; never generated by the recorder, §3.3.2). + pub name: String, + /// Machine that hosted the recorder for this recording. + pub machine_id: String, + /// Zenoh router endpoint the recorder was a client of, for provenance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fleet_router: Option, + /// Recorder-clock timestamp of session start (nanoseconds since epoch). + pub started_at_ns: u64, + /// Recorder-clock timestamp of `stop`. `None` while recording or if the + /// session was interrupted by a crash (manifest stays "open", §3.3.9). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ended_at_ns: Option, + /// Wall-clock duration once finalized. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ns: Option, + /// Total bytes across all finalized chunks. + #[serde(default)] + pub size_bytes: u64, + /// Capture mode. + pub mode: RecordingMode, + /// Sliding-window length for ring-buffer mode (§3.3.5). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_secs: Option, + /// Ring-buffer trigger policy (`manual` in v1). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger: Option, + /// The resolved topic selection used for this recording. + pub selection: Selection, + /// One entry per recorded topic/channel. + #[serde(default)] + pub channels: Vec, + /// Finalized chunks in index order. Per-chunk `uploaded_at_ns` is the only + /// upload-status signal — the recording is fully uploaded iff every chunk has it. + #[serde(default)] + pub chunks: Vec, + /// Profile this recording was started from, for provenance only (§3.3.2). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_name: Option, + /// Canonical hash of the profile (§4.3.3), durable across cosmetic edits. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_sha256: Option, + /// Version string of the recorder that produced this manifest. + #[serde(default)] + pub recorder_version: String, + /// Free-form tags (Phase 3 search; carried through verbatim in v1). + #[serde(default)] + pub tags: Vec, + /// Unknown fields from newer schema versions, preserved on round-trip. + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// Capture mode (`mode` in the manifest). Distinct from the command-envelope +/// `SessionMode`, which carries the window inline; the manifest keeps `mode` and +/// `window_secs` as separate fields per §4.4. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordingMode { + /// Continuous capture until `stop`. + #[default] + Streaming, + /// Sliding in-memory window, sealed on `flush` (§3.3.5). + RingBuffer, +} + +/// Ring-buffer trigger policy. Only `Manual` is accepted in v1; `OnEvent` is +/// Phase 3 (spec §4.3.1) but is representable so newer manifests round-trip. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Trigger { + /// User calls `record flush` to seal the window. + Manual, + /// External event seals the window (Phase 3). + OnEvent, +} + +/// The resolved topic selection (§4.4 `selection`). Patterns compose additively: +/// `(topics ∪ regex_match) - exclude` (spec §11). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Selection { + /// Literal/wildcard include patterns. + #[serde(default)] + pub topics: Vec, + /// Additive regex include. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub regex: Option, + /// Subtractive exclude patterns removed from the union. + #[serde(default)] + pub exclude: Vec, + /// Whether `bubbaloop/local/**` (SHM-only) topics were included. + #[serde(default)] + pub include_local: bool, +} + +/// One recorded channel (§4.4 `channels`). Preserves the original Zenoh encoding +/// so replay can be byte-identical (§4.5). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Channel { + /// Full Zenoh topic key. + pub topic: String, + /// MCAP channel id assigned at start. + pub channel_id: u16, + /// Messages captured on this channel. + #[serde(default)] + pub message_count: u64, + /// MCAP `message_encoding` (`cbor` / `protobuf` / `raw`). + pub message_encoding: String, + /// Original Zenoh sample encoding (e.g. `application/cbor`). + pub zenoh_encoding: String, + /// Fully-qualified protobuf schema name, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_name: Option, + /// First/last publish-time bounds on this channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publish_time_first_ns: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub publish_time_last_ns: Option, + /// Unknown fields preserved on round-trip. + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// One finalized chunk (§4.4 `chunks`, §3.3.6). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Chunk { + /// Filename `chunk-{index:06}-{sha256_prefix8}.mcap`. + pub name: String, + /// Zero-based chunk index. + pub index: u32, + /// On-disk size in bytes. + pub size_bytes: u64, + /// Full 64-char lowercase hex SHA-256 of the finalized chunk. + pub sha256: String, + /// Recorder-clock bounds of messages in this chunk. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_time_first_ns: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_time_last_ns: Option, + /// When this chunk was confirmed in the remote backend. `None` = not yet + /// uploaded; this is the only per-chunk upload-status signal (§4.4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uploaded_at_ns: Option, + /// Remote ETag from the successful PUT, for cross-checking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_etag: Option, + /// Unknown fields preserved on round-trip. + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Chunk { + /// The canonical filename for a chunk given its index and hex digest, + /// per §3.3.6: `chunk-{index:06}-{prefix8}.mcap`. + pub fn canonical_name(index: u32, sha256_hex: &str) -> String { + let prefix = &sha256_hex[..sha256_hex.len().min(8)]; + format!("chunk-{index:06}-{prefix}.mcap") + } + + /// Whether this chunk's recorded `name` matches the canonical form derived + /// from its `index` and `sha256`. A mismatch means the manifest is internally + /// inconsistent (e.g. hand-edited). + pub fn name_is_canonical(&self) -> bool { + self.name == Self::canonical_name(self.index, &self.sha256) + } + + /// Whether this chunk has been confirmed uploaded. + pub fn is_uploaded(&self) -> bool { + self.uploaded_at_ns.is_some() + } +} + +/// Derived lifecycle state of a recording (§4.4). `Recording` vs `Interrupted` +/// cannot be told from the manifest alone — it depends on whether the recorder +/// currently has a live session of the same name — so callers pass that in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifecycle { + /// Live session, manifest still open. + Recording, + /// Normal stop — `ended_at_ns` set. + Complete, + /// Manifest open but no live session (crash, §3.3.9). + Interrupted, +} + +impl Recording { + /// Construct a fresh, open (not-yet-ended) recording manifest. + pub fn new(name: impl Into, machine_id: impl Into, started_at_ns: u64) -> Self { + Self { + schema_version: MANIFEST_SCHEMA_VERSION, + name: name.into(), + machine_id: machine_id.into(), + fleet_router: None, + started_at_ns, + ended_at_ns: None, + duration_ns: None, + size_bytes: 0, + mode: RecordingMode::Streaming, + window_secs: None, + trigger: None, + selection: Selection::default(), + channels: Vec::new(), + chunks: Vec::new(), + profile_name: None, + profile_sha256: None, + recorder_version: String::new(), + tags: Vec::new(), + extra: BTreeMap::new(), + } + } + + /// Number of chunks confirmed uploaded. + pub fn uploaded_chunk_count(&self) -> usize { + self.chunks.iter().filter(|c| c.is_uploaded()).count() + } + + /// True when every chunk has been uploaded (and there is at least one chunk). + pub fn is_fully_uploaded(&self) -> bool { + !self.chunks.is_empty() && self.chunks.iter().all(Chunk::is_uploaded) + } + + /// Whether the manifest is "open" (no `ended_at_ns`). + pub fn is_open(&self) -> bool { + self.ended_at_ns.is_none() + } + + /// Derive lifecycle state. `session_active` = the recorder currently reports + /// a live session with this recording's name (§4.4). + pub fn lifecycle(&self, session_active: bool) -> Lifecycle { + match (self.ended_at_ns, session_active) { + (Some(_), _) => Lifecycle::Complete, + (None, true) => Lifecycle::Recording, + (None, false) => Lifecycle::Interrupted, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_manifest_json() -> &'static str { + r#"{ + "schema_version": 1, + "name": "outdoor_test_3", + "machine_id": "jetson_alpha", + "fleet_router": "zenoh://10.0.0.1:7447", + "started_at_ns": 1748275200000000000, + "ended_at_ns": 1748275300000000000, + "duration_ns": 100000000000, + "size_bytes": 4567890123, + "mode": "streaming", + "window_secs": null, + "trigger": null, + "selection": { + "topics": ["bubbaloop/global/*/cam_*/compressed"], + "regex": null, + "exclude": ["**/health"], + "include_local": false + }, + "channels": [{ + "topic": "bubbaloop/global/jetson_alpha/cam_front/compressed", + "channel_id": 0, + "message_count": 12345, + "message_encoding": "cbor", + "zenoh_encoding": "application/cbor", + "schema_name": "bubbaloop.camera.v1.CompressedImage", + "publish_time_first_ns": 1748275200123000000, + "publish_time_last_ns": 1748275299987000000 + }], + "chunks": [{ + "name": "chunk-000000-a1b2c3d4.mcap", + "index": 0, + "size_bytes": 786432, + "sha256": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "log_time_first_ns": 1748275200000000000, + "log_time_last_ns": 1748275260000000000, + "uploaded_at_ns": 1748275305000000000, + "remote_etag": "\"a1b2c3d4e5f6\"" + }], + "profile_name": "demo", + "profile_sha256": "abc123def456", + "recorder_version": "0.1.0", + "tags": [] + }"# + } + + #[test] + fn parses_spec_example_manifest() { + let r: Recording = serde_json::from_str(sample_manifest_json()).unwrap(); + assert_eq!(r.name, "outdoor_test_3"); + assert_eq!(r.mode, RecordingMode::Streaming); + assert_eq!(r.channels.len(), 1); + assert_eq!(r.chunks.len(), 1); + assert_eq!(r.chunks[0].sha256.len(), 64); + assert!(r.is_fully_uploaded()); + assert!(!r.is_open()); + assert_eq!(r.lifecycle(false), Lifecycle::Complete); + } + + #[test] + fn roundtrip_is_stable() { + let r: Recording = serde_json::from_str(sample_manifest_json()).unwrap(); + let json = serde_json::to_string(&r).unwrap(); + let r2: Recording = serde_json::from_str(&json).unwrap(); + assert_eq!(r, r2); + } + + #[test] + fn preserves_unknown_fields_across_versions() { + // A manifest from a hypothetical schema_version 2 with new fields. + let future = r#"{ + "schema_version": 2, + "name": "future_rec", + "machine_id": "jetson_beta", + "started_at_ns": 1, + "mode": "ring_buffer", + "window_secs": 60, + "trigger": "manual", + "selection": {"topics": ["a"], "exclude": [], "include_local": false}, + "retention_policy": {"tier": "cold", "days": 30}, + "encryption": "aes-256-gcm" + }"#; + let r: Recording = serde_json::from_str(future).unwrap(); + assert_eq!(r.mode, RecordingMode::RingBuffer); + assert_eq!(r.window_secs, Some(60)); + assert_eq!(r.trigger, Some(Trigger::Manual)); + // Unknown top-level fields are retained verbatim. + assert!(r.extra.contains_key("retention_policy")); + assert_eq!( + r.extra.get("encryption").unwrap(), + &serde_json::Value::String("aes-256-gcm".into()) + ); + + // ...and survive a round-trip so an old writer doesn't drop them. + let json = serde_json::to_string(&r).unwrap(); + assert!(json.contains("retention_policy")); + assert!(json.contains("aes-256-gcm")); + } + + #[test] + fn upload_status_derives_from_chunks() { + let mut r = Recording::new("r", "m", 0); + assert!(!r.is_fully_uploaded()); // no chunks yet + r.chunks.push(Chunk { + name: "chunk-000000-aaaaaaaa.mcap".into(), + index: 0, + size_bytes: 10, + sha256: "aa".repeat(32), + log_time_first_ns: None, + log_time_last_ns: None, + uploaded_at_ns: Some(5), + remote_etag: None, + extra: Default::default(), + }); + r.chunks.push(Chunk { + name: "chunk-000001-bbbbbbbb.mcap".into(), + index: 1, + size_bytes: 10, + sha256: "bb".repeat(32), + log_time_first_ns: None, + log_time_last_ns: None, + uploaded_at_ns: None, + remote_etag: None, + extra: Default::default(), + }); + assert_eq!(r.uploaded_chunk_count(), 1); + assert!(!r.is_fully_uploaded()); + r.chunks[1].uploaded_at_ns = Some(7); + assert!(r.is_fully_uploaded()); + } + + #[test] + fn open_manifest_lifecycle_depends_on_session() { + let r = Recording::new("live", "m", 1); + assert!(r.is_open()); + assert_eq!(r.lifecycle(true), Lifecycle::Recording); + assert_eq!(r.lifecycle(false), Lifecycle::Interrupted); + } + + #[test] + fn canonical_chunk_name() { + let hex = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; + assert_eq!(Chunk::canonical_name(0, hex), "chunk-000000-a1b2c3d4.mcap"); + assert_eq!(Chunk::canonical_name(42, hex), "chunk-000042-a1b2c3d4.mcap"); + + let c = Chunk { + name: "chunk-000000-a1b2c3d4.mcap".into(), + index: 0, + size_bytes: 1, + sha256: hex.into(), + log_time_first_ns: None, + log_time_last_ns: None, + uploaded_at_ns: None, + remote_etag: None, + extra: Default::default(), + }; + assert!(c.name_is_canonical()); + } +} diff --git a/crates/bubbaloop/src/storage/secrets.rs b/crates/bubbaloop/src/storage/secrets.rs new file mode 100644 index 00000000..fcd2aa23 --- /dev/null +++ b/crates/bubbaloop/src/storage/secrets.rs @@ -0,0 +1,274 @@ +//! Secret storage for cloud backend credentials (spec §4.2). +//! +//! Secrets live **only** in `~/.bubbaloop/secrets.toml` (chmod 0600) — never in +//! `config.toml`, `node.yaml`, profile YAML, or logs. [`Secret`] is an opaque +//! wrapper whose `Debug` is redacted and whose backing string is zeroized on +//! drop, so a credential can never be accidentally formatted into a log line. +//! +//! `Secret` deliberately does **not** implement `Display`, `Serialize`, or +//! `Clone`: the only way to read the value is the explicit [`Secret::expose`], +//! which makes every access auditable in review. + +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// An opaque, redacted, zeroize-on-drop secret string. +#[derive(Clone, Zeroize, ZeroizeOnDrop, PartialEq, Eq)] +pub struct Secret(String); + +impl Secret { + /// Wrap a secret value. + pub fn new(value: impl Into) -> Self { + Secret(value.into()) + } + + /// Explicitly read the secret. Every call site is an auditable disclosure — + /// keep the borrow as short-lived as possible and never log the result. + pub fn expose(&self) -> &str { + &self.0 + } + + /// Whether the secret is empty (e.g. unconfigured). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Secret(***redacted***)") + } +} + +// Deserialize directly into a Secret so credential structs never materialize a +// bare String. We do NOT implement Serialize — saving goes through the explicit, +// redaction-aware [`save`] path below. +impl<'de> Deserialize<'de> for Secret { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Secret::new(s)) + } +} + +/// R2 / S3-compatible access credentials (spec §4.2 `[storage.r2]`). +#[derive(Debug, Deserialize)] +pub struct R2Credentials { + /// Access key id. + pub access_key_id: Secret, + /// Secret access key. + pub secret_access_key: Secret, +} + +/// Parsed `secrets.toml`. +#[derive(Debug, Deserialize, Default)] +pub struct Secrets { + #[serde(default)] + storage: StorageSecrets, +} + +#[derive(Debug, Deserialize, Default)] +struct StorageSecrets { + #[serde(default)] + r2: Option, +} + +impl Secrets { + /// The R2 credentials, if configured. + pub fn r2(&self) -> Option<&R2Credentials> { + self.storage.r2.as_ref() + } +} + +/// Default secrets path: `~/.bubbaloop/secrets.toml`. +pub fn default_path() -> Result { + let home = dirs::home_dir().ok_or(SecretsError::NoHomeDir)?; + Ok(home.join(".bubbaloop").join("secrets.toml")) +} + +/// Load and parse `secrets.toml`. Returns an empty [`Secrets`] if the file does +/// not exist (unconfigured is a valid state, not an error). +pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Ok(Secrets::default()); + } + let contents = std::fs::read_to_string(path).map_err(|e| SecretsError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + toml::from_str(&contents).map_err(|e| SecretsError::Parse(e.to_string())) +} + +/// Write R2 credentials to `secrets.toml` with 0600 permissions (owner-only). +/// +/// The file is created/truncated with owner-only mode on Unix before any secret +/// bytes are written, so the window where a credential exists at a wider mode is +/// eliminated. +pub fn save_r2(path: impl AsRef, creds: &R2Credentials) -> Result<(), SecretsError> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| SecretsError::Io { + path: parent.display().to_string(), + detail: e.to_string(), + })?; + } + + // TOML built by hand so a stray `Serialize` impl can never leak a secret. + let body = format!( + "[storage.r2]\naccess_key_id = {}\nsecret_access_key = {}\n", + toml_quote(creds.access_key_id.expose()), + toml_quote(creds.secret_access_key.expose()), + ); + + write_owner_only(path, body.as_bytes())?; + Ok(()) +} + +/// Minimal TOML basic-string quoting (escapes backslash and double-quote). +fn toml_quote(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +#[cfg(unix)] +fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), SecretsError> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(|e| SecretsError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + // Re-assert mode in case the file pre-existed with a wider mode. + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, perms).map_err(|e| SecretsError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + f.write_all(bytes).map_err(|e| SecretsError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + f.sync_all().map_err(|e| SecretsError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), SecretsError> { + std::fs::write(path, bytes).map_err(|e| SecretsError::Io { + path: path.display().to_string(), + detail: e.to_string(), + }) +} + +/// Errors from secret IO. +#[derive(Debug, thiserror::Error)] +pub enum SecretsError { + /// No home directory could be determined. + #[error("could not determine home directory")] + NoHomeDir, + /// Filesystem error. + #[error("secrets io error at {path}: {detail}")] + Io { path: String, detail: String }, + /// TOML parse error. + #[error("secrets parse error: {0}")] + Parse(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_is_redacted() { + let s = Secret::new("super-secret-value"); + let dbg = format!("{s:?}"); + assert_eq!(dbg, "Secret(***redacted***)"); + assert!(!dbg.contains("super-secret-value")); + + // And nested inside a credentials struct. + let creds = R2Credentials { + access_key_id: Secret::new("AKIA_VISIBLE_ID"), + secret_access_key: Secret::new("THE_SECRET_KEY"), + }; + let dbg = format!("{creds:?}"); + assert!(!dbg.contains("AKIA_VISIBLE_ID")); + assert!(!dbg.contains("THE_SECRET_KEY")); + } + + #[test] + fn expose_returns_value() { + let s = Secret::new("value"); + assert_eq!(s.expose(), "value"); + assert!(Secret::new("").is_empty()); + } + + #[test] + fn save_then_load_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.toml"); + let creds = R2Credentials { + access_key_id: Secret::new("id-123"), + secret_access_key: Secret::new("key-456"), + }; + save_r2(&path, &creds).unwrap(); + + let loaded = load(&path).unwrap(); + let r2 = loaded.r2().expect("r2 creds present"); + assert_eq!(r2.access_key_id.expose(), "id-123"); + assert_eq!(r2.secret_access_key.expose(), "key-456"); + } + + #[test] + fn load_missing_file_is_empty_not_error() { + let dir = tempfile::tempdir().unwrap(); + let loaded = load(dir.path().join("nope.toml")).unwrap(); + assert!(loaded.r2().is_none()); + } + + #[test] + fn special_characters_survive_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.toml"); + let creds = R2Credentials { + access_key_id: Secret::new(r#"id"with\backslash"#), + secret_access_key: Secret::new(r#"se"cret\\value"#), + }; + save_r2(&path, &creds).unwrap(); + let loaded = load(&path).unwrap(); + let r2 = loaded.r2().unwrap(); + assert_eq!(r2.access_key_id.expose(), r#"id"with\backslash"#); + assert_eq!(r2.secret_access_key.expose(), r#"se"cret\\value"#); + } + + #[cfg(unix)] + #[test] + fn secrets_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.toml"); + save_r2( + &path, + &R2Credentials { + access_key_id: Secret::new("a"), + secret_access_key: Secret::new("b"), + }, + ) + .unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "secrets.toml must be chmod 0600"); + } +} From ddabd7296f29e159d5d86d985a69c9e4df5a5272 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Sun, 14 Jun 2026 22:39:03 +0530 Subject: [PATCH 02/19] chore: add osx-arm64 platform to pixi workspace Enables building/testing on Apple Silicon (the workspace previously declared only linux-aarch64 and linux-64). No dependency changes. Co-Authored-By: Claude Opus 4.8 --- pixi.lock | 7852 ++++++++++++++++++++++++++++++++++------------------- pixi.toml | 2 +- 2 files changed, 4988 insertions(+), 2866 deletions(-) diff --git a/pixi.lock b/pixi.lock index 5555f9ca..0e9d8486 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,4 +1,8 @@ -version: 6 +version: 7 +platforms: +- name: linux-64 +- name: linux-aarch64 +- name: osx-arm64 environments: default: channels: @@ -10,42 +14,24 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.1-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45-default_hfdba357_104.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/cmake-4.2.1-hc85cc9f_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.7.3-hecca717_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.0.0-gpl_hc3e963e_905.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-hc5723f1_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.4-h2b0a6b4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h3f43e3d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h3f43e3d_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-2.86.3-h5192d8d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-networking-2.80.0-h2ef3c98_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.3-hf516916_0.conda @@ -58,19 +44,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-good-1.26.6-h7d367ba_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-ugly-1.26.6-h8c6bc89_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.26.6-h17cb667_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.15-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.9.0-hb700be7_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-media-driver-25.3.4-hecca717_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/jack-1.9.22-hf4617a5_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 @@ -97,7 +75,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_116.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-devel-0.25.1-h3f43e3d_1.conda @@ -139,7 +116,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.1-h0c1763c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_116.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.10-hd0affe5_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda @@ -159,69 +135,37 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.11.0-he8b52b9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.13.9-h04c0eec_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.3-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-h5888daf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openh264-2.6.0-hc22cd8d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/process-compose-1.64.1-h643be8f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/protobuf-6.31.1-py314h503b32b_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.19.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.2-h32b2ec7_100_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.92.0-h53717f1_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.92.0-h2c6d0dc_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.2.24-h68140b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2025.3-h3e344bc_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2025.4-hb700be7_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-3.1.2-hecca717_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2022.3.0-h8d10470_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-h8577fbf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.0.1-py314h9891dd4_6.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/watchdog-6.0.0-py314hdafbbf9_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.47-hd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda @@ -240,50 +184,92 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxshmfence-1.3.3-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.2.1-pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_116.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_116.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.19.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.92.0-h2c6d0dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-h8577fbf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.47-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/alsa-lib-1.2.15.1-he30d5cf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/aom-3.9.1-hcccb83c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/attr-2.5.1-h4e544f5_1.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/binutils_impl_linux-aarch64-2.45-default_h5f4c503_104.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/brotli-python-1.2.0-py314h352cb57_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/c-ares-1.34.6-he30d5cf_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py314h0bd77cf_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cmake-4.2.1-hc9d863e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dav1d-1.2.1-h31becfc_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-h70963c4_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/expat-2.7.3-hfae3067_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ffmpeg-8.0.0-gpl_h8d881e6_905.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fribidi-1.0.16-he30d5cf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gcc_impl_linux-aarch64-15.2.0-habb1d5c_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gdk-pixbuf-2.44.4-h90308e0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gettext-0.25.1-h5ad3122_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gettext-tools-0.25.1-h5ad3122_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-2.86.3-hc66a092_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-networking-2.80.0-h27184f6_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-tools-2.86.3-hc87f4d4_0.conda @@ -296,16 +282,8 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-good-1.26.6-h18b12b6_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-ugly-1.26.6-hd087be5_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gstreamer-1.26.6-hc24f651_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.15-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-aarch64-4.18.0-h05a177a_9.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lame-3.100-h4e544f5_1003.tar.bz2 @@ -331,7 +309,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_16.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-aarch64-15.2.0-h55c397f_116.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgettextpo-0.25.1-h5ad3122_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgettextpo-devel-0.25.1-h5ad3122_0.conda @@ -371,7 +348,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.1-h022381a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libssh2-1.11.1-h18c354c_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_16.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-aarch64-15.2.0-ha7b1723_116.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hdbbeba8_16.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsystemd0-257.10-hf9559e3_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda @@ -389,64 +365,33 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.11.0-h95ca766_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.13.9-he58860d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/mpg123-1.32.9-h65af167_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/nodejs-25.2.1-h244045a_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openh264-2.6.0-h0564a2a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.0-h8e36d6e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pango-1.56.4-he55ef5b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.47-hf841c20_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pkg-config-0.29.2-hce167ba_1009.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/process-compose-1.64.1-hb5cd7dd_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/protobuf-6.31.1-py314h0cf174a_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pugixml-1.15-h6ef32b0_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pulseaudio-client-17.0-hcf98165_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.19.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.2-hb06a95a_100_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/rhash-1.4.6-h86ecc28_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/rust-1.92.0-h6cf38e9_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-unknown-linux-gnu-1.92.0-hbe8e118_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl2-2.32.56-h7ac5ae9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl3-3.2.24-h506f210_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/shaderc-2025.3-h8c88b8f_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/snappy-1.2.2-he774c54_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/spirv-tools-2025.4-hfefdfc9_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/svt-av1-3.1.2-hfae3067_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-aarch64-2.28-h585391f_9.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tbb-2022.3.0-h0eac15c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h561c983_103.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-h8577fbf_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ukkonen-1.0.1-py314hd7d8586_6.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/watchdog-6.0.0-py314ha42fa4b_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/x264-1!164.3095-h4e544f5_2.tar.bz2 @@ -466,127 +411,303 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxshmfence-1.3.3-h86ecc28_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/yaml-0.2.5-h80f16a2_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.1-h86ecc28_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda -packages: -- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 - md5: d7c89558ba9fa0495403155b64376d81 - license: None - size: 2562 - timestamp: 1578324546067 -- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - build_number: 16 - sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 - md5: 73aaf86a425cc6e73fcf236a5a46396d - depends: - - _libgcc_mutex 0.1 conda_forge - - libgomp >=7.5.0 - constrains: - - openmp_impl 9999 - license: BSD-3-Clause - license_family: BSD - size: 23621 - timestamp: 1650670423406 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 - build_number: 16 - sha256: 3702bef2f0a4d38bd8288bbe54aace623602a1343c2cfbefd3fa188e015bebf0 - md5: 6168d71addc746e8f2b8d57dfd2edcea - depends: - - libgomp >=7.5.0 - constrains: - - openmp_impl 9999 - license: BSD-3-Clause - license_family: BSD - size: 23712 - timestamp: 1650670790230 -- conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.1-hb03c661_0.conda - sha256: 224f1a55a9ba7e877bce980f14fc3e3c0f0fb6d3cbf3c5f1a8f5dd8391ce8bba - md5: bba37fb066adb90e1d876dff0fd5d09d - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: LGPL-2.1-or-later - license_family: GPL - size: 585491 - timestamp: 1766155792553 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/alsa-lib-1.2.15.1-he30d5cf_0.conda - sha256: cb8c79ff99e2e36958e088278971cfec4aeed8e44084e968c906b7bbc3cd8de1 - md5: 50a88426e78ae8eb7d52072ba2e8db21 - depends: - - libgcc >=14 - license: LGPL-2.1-or-later - license_family: GPL - size: 615491 - timestamp: 1766156819056 -- conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda - sha256: b08ef033817b5f9f76ce62dfcac7694e7b6b4006420372de22494503decac855 - md5: 346722a0be40f6edc53f12640d301338 - depends: - - libgcc-ng >=12 - - libstdcxx-ng >=12 - license: BSD-2-Clause - license_family: BSD - size: 2706396 - timestamp: 1718551242397 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/aom-3.9.1-hcccb83c_0.conda - sha256: ac438ce5d3d3673a9188b535fc7cda413b479f0d52536aeeac1bd82faa656ea0 - md5: cc744ac4efe5bcaa8cca51ff5b850df0 - depends: - - libgcc-ng >=12 - - libstdcxx-ng >=12 - license: BSD-2-Clause - license_family: BSD - size: 3250813 - timestamp: 1718551360260 -- conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda - sha256: a9c114cbfeda42a226e2db1809a538929d2f118ef855372293bd188f71711c48 - md5: 791365c5f65975051e4e017b5da3abf5 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - license: GPL-2.0-or-later - license_family: GPL - size: 68072 - timestamp: 1756738968573 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/attr-2.5.1-h4e544f5_1.tar.bz2 - sha256: 2c793b48e835a8fac93f1664c706442972a0206963bf8ca202e83f7f4d29a7d7 - md5: 1ef6c06fec1b6f5ee99ffe2152e53568 - depends: - - libgcc-ng >=12 - license: GPL-2.0-or-later - license_family: GPL - size: 74992 - timestamp: 1660065534958 -- conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - sha256: 1c656a35800b7f57f7371605bc6507c8d3ad60fbaaec65876fce7f73df1fc8ac - md5: 0a01c169f0ab0f91b26e77a3301fbfe4 + - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.2.1-pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.15-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-aarch64-4.18.0-h05a177a_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-aarch64-15.2.0-h55c397f_116.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-aarch64-15.2.0-ha7b1723_116.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.19.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-unknown-linux-gnu-1.92.0-hbe8e118_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-aarch64-2.28-h585391f_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-h8577fbf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-7.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.2.1-pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.17-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.6-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.21.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.96.0-hf6ec828_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/aom-3.14.1-pl5321h513545f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py314h44086f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmake-4.3.3-h8cb302d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dav1d-1.2.1-hb547adb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dbus-1.16.2-h3ff7a7c_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/expat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ffmpeg-8.1.1-gpl_he97032f_104.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.1-h2b252f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.6-h4e57454_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-0.25.1-h3dcc1bd_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-tools-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-2.88.1-h92d5a4f_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-networking-2.80.0-h8ad88b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.88.1-h37541a8_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glslang-16.3.0-h7cb4797_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gmp-6.3.0-h7bae524_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.15-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-libav-1.28.4-hb6f6a77_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-bad-1.28.4-pl5321h6e07036_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-base-1.28.4-h6b9204b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-good-1.28.4-hb52ed23_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-ugly-1.28.4-hb9cbea2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gstreamer-1.28.4-h087694b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.2.1-h3103d1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h385eeb1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lame-3.100-h1a8c8d9_1003.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.19.1-hdfa7624_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.1.0-h1eee2c3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260107.1-cxx17_h2062a1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libass-0.17.4-hcbd7ca7_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.20.0-hd5a2499_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.7-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdovi-3.3.2-h78f8ca3_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libflac-1.5.0-h6824b09_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.1-ha08bb59_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwloc-2.13.0-default_ha97f43a_1000.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-ha332bbd_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-devel-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.4.1-h84a0fba_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjxl-0.11.2-h934fa54_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libogg-1.3.5-h48c0fde_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-2026.2.0-h3e6d54f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-arm-cpu-plugin-2026.2.0-h3e6d54f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-batch-plugin-2026.2.0-h2406d2e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-plugin-2026.2.0-h2406d2e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-hetero-plugin-2026.2.0-h85cbfa6_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-ir-frontend-2026.2.0-h85cbfa6_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-onnx-frontend-2026.2.0-h41365f2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-paddle-frontend-2026.2.0-h41365f2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-pytorch-frontend-2026.2.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-frontend-2026.2.0-hc295da0_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-lite-frontend-2026.2.0-hf6b4638_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopus-1.6.1-h1a92334_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libplacebo-7.360.1-h176d363_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.33.5-h2d4b707_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.21.5-hb427e8f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.3-he8aa2a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsndfile-1.2.2-hf95f74e_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsoup-3.6.6-hcf8573c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.2-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libusb-1.0.29-hbc156a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvorbis-1.3.7-h81086ad_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvpx-1.15.2-ha759d40_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.341.0-h3feff0a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py314h6e9b3f0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/mpg123-1.32.9-hf642e45_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.3.0-h7039424_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openh264-2.6.0-hb5b2745_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.56.4-hf80efc4_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hde07d2e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/process-compose-1.103.0-hfb368cc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/protobuf-6.33.5-py314he407d35_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pugixml-1.15-hd3d436d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py314h6e9b3f0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rhash-1.4.6-h5505292_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.96.0-h4ff7c5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl2-2.32.56-h248ca61_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl3-3.4.10-h6fa9c73_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/shaderc-2026.2-hf31e910_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.2-h4ddebb9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.0.1-h0cb729a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tbb-2023.0.0-he0260a5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py314h6cfcd04_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchdog-6.0.0-py314ha14b1ff_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x264-1!164.3095-h57fd34a_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-hbc6ce65_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 + md5: d7c89558ba9fa0495403155b64376d81 + license: None + size: 2562 + timestamp: 1578324546067 +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + build_number: 16 + sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 + md5: 73aaf86a425cc6e73fcf236a5a46396d depends: - - python >=3.9 - - pytz >=2015.7 + - _libgcc_mutex 0.1 conda_forge + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 license: BSD-3-Clause license_family: BSD - size: 6938256 - timestamp: 1738490268466 -- conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda - noarch: generic - sha256: de90f762aecfa4b8680ae7299398bd4a1634870a01db8351e5e22affc6bbf313 - md5: 25e227ee028a17c2f2ef6eaf97e86734 + size: 23621 + timestamp: 1650670423406 +- conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.15.1-hb03c661_0.conda + sha256: 224f1a55a9ba7e877bce980f14fc3e3c0f0fb6d3cbf3c5f1a8f5dd8391ce8bba + md5: bba37fb066adb90e1d876dff0fd5d09d depends: - - python >=3.14 - license: BSD-3-Clause AND MIT AND EPL-2.0 - size: 7512 - timestamp: 1765057691766 -- conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - sha256: 3a0af23d357a07154645c41d035a4efbd15b7a642db397fa9ea0193fd58ae282 - md5: b16e2595d3a9042aa9d570375978835f + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-or-later + license_family: GPL + size: 585491 + timestamp: 1766155792553 +- conda: https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda + sha256: b08ef033817b5f9f76ce62dfcac7694e7b6b4006420372de22494503decac855 + md5: 346722a0be40f6edc53f12640d301338 depends: - - python >=3.9 - license: MIT - license_family: MIT - size: 143810 - timestamp: 1740887689966 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: BSD-2-Clause + license_family: BSD + size: 2706396 + timestamp: 1718551242397 +- conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda + sha256: a9c114cbfeda42a226e2db1809a538929d2f118ef855372293bd188f71711c48 + md5: 791365c5f65975051e4e017b5da3abf5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: GPL-2.0-or-later + license_family: GPL + size: 68072 + timestamp: 1756738968573 - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45-default_hfdba357_104.conda sha256: 054a77ccab631071a803737ea8e5d04b5b18e57db5b0826a04495bd3fdf39a7c md5: a7a67bf132a4a2dea92a7cb498cdc5b1 @@ -598,17 +719,6 @@ packages: license_family: GPL size: 3747046 timestamp: 1764007847963 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/binutils_impl_linux-aarch64-2.45-default_h5f4c503_104.conda - sha256: b7694c53943941a5234406b77b168e28d92227f8e69c697edda3faf436dd26c1 - md5: 8107322440b07ab4234815368d1785a9 - depends: - - ld_impl_linux-aarch64 2.45 default_h1979696_104 - - sysroot_linux-aarch64 - - zstd >=1.5.7,<1.6.0a0 - license: GPL-3.0-only - license_family: GPL - size: 4850743 - timestamp: 1764007931341 - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda sha256: 3ad3500bff54a781c29f16ce1b288b36606e2189d0b0ef2f67036554f47f12b0 md5: 8910d2c46f7e7b519129f486e0fe927a @@ -624,21 +734,6 @@ packages: license_family: MIT size: 367376 timestamp: 1764017265553 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/brotli-python-1.2.0-py314h352cb57_1.conda - sha256: 5a5b0cdcd7ed89c6a8fb830924967f6314a2b71944bc1ebc2c105781ba97aa75 - md5: a1b5c571a0923a205d663d8678df4792 - depends: - - libgcc >=14 - - libstdcxx >=14 - - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - - python_abi 3.14.* *_cp314 - constrains: - - libbrotlicommon 1.2.0 he30d5cf_1 - license: MIT - license_family: MIT - size: 373193 - timestamp: 1764017486851 - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5 md5: 51a19bba1b8ebfb60df25cde030b7ebc @@ -649,15 +744,6 @@ packages: license_family: BSD size: 260341 timestamp: 1757437258798 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda - sha256: d2a296aa0b5f38ed9c264def6cf775c0ccb0f110ae156fcde322f3eccebf2e01 - md5: 2921ac0b541bf37c69e66bd6d9a43bca - depends: - - libgcc >=14 - license: bzip2-1.0.6 - license_family: BSD - size: 192536 - timestamp: 1757437302703 - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda sha256: cc9accf72fa028d31c2a038460787751127317dcfa991f8d1f1babf216bb454e md5: 920bb03579f15389b9e512095ad995b7 @@ -668,23 +754,6 @@ packages: license_family: MIT size: 207882 timestamp: 1765214722852 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/c-ares-1.34.6-he30d5cf_0.conda - sha256: 7ec8a68efe479e2e298558cbc2e79d29430d5c7508254268818c0ae19b206519 - md5: 1dfbec0d08f112103405756181304c16 - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 217215 - timestamp: 1765214743735 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda - sha256: b986ba796d42c9d3265602bc038f6f5264095702dd546c14bc684e60c385e773 - md5: f0991f0f84902f6b6009b4d2350a83aa - depends: - - __unix - license: ISC - size: 152432 - timestamp: 1762967197890 - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda sha256: 3bd6a391ad60e471de76c0e9db34986c4b5058587fbf2efa5a7f54645e28c2c7 md5: 09262e66b19567aff4f592fb53b28760 @@ -710,38 +779,6 @@ packages: license: LGPL-2.1-only or MPL-1.1 size: 978114 timestamp: 1741554591855 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda - sha256: 37cfff940d2d02259afdab75eb2dbac42cf830adadee78d3733d160a1de2cc66 - md5: cd55953a67ec727db5dc32b167201aa6 - depends: - - fontconfig >=2.15.0,<3.0a0 - - fonts-conda-ecosystem - - freetype >=2.12.1,<3.0a0 - - icu >=75.1,<76.0a0 - - libexpat >=2.6.4,<3.0a0 - - libgcc >=13 - - libglib >=2.82.2,<3.0a0 - - libpng >=1.6.47,<1.7.0a0 - - libstdcxx >=13 - - libxcb >=1.17.0,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - pixman >=0.44.2,<1.0a0 - - xorg-libice >=1.1.2,<2.0a0 - - xorg-libsm >=1.2.5,<2.0a0 - - xorg-libx11 >=1.8.11,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - xorg-libxrender >=0.9.12,<0.10.0a0 - license: LGPL-2.1-only or MPL-1.1 - size: 966667 - timestamp: 1741554768968 -- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda - sha256: 083a2bdad892ccf02b352ecab38ee86c3e610ba9a4b11b073ea769d55a115d32 - md5: 96a02a5c1a65470a7e4eedb644c872fd - depends: - - python >=3.10 - license: ISC - size: 157131 - timestamp: 1762976260320 - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda sha256: c6339858a0aaf5d939e00d345c98b99e4558f285942b27232ac098ad17ac7f8e md5: cf45f4278afd6f4e6d03eda0f435d527 @@ -756,51 +793,9 @@ packages: license_family: MIT size: 300271 timestamp: 1761203085220 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py314h0bd77cf_1.conda - sha256: 728e55b32bf538e792010308fbe55d26d02903ddc295fbe101167903a123dd6f - md5: f333c475896dbc8b15efd8f7c61154c7 - depends: - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - pycparser - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - size: 318357 - timestamp: 1761203973223 -- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 - md5: 381bd45fb7aa032691f3063aff47e3a1 - depends: - - python >=3.10 - license: MIT - license_family: MIT - size: 13589 - timestamp: 1763607964133 -- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda - sha256: b32f8362e885f1b8417bac2b3da4db7323faa12d5db62b7fd6691c02d60d6f59 - md5: a22d1fd9bf98827e280a02875d9a007a - depends: - - python >=3.10 - license: MIT - license_family: MIT - size: 50965 - timestamp: 1760437331772 -- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - sha256: 38cfe1ee75b21a8361c8824f5544c3866f303af1762693a178266d7f198e8715 - md5: ea8a6c3256897cc31263de9f455e25d9 - depends: - - python >=3.10 - - __unix - - python - license: BSD-3-Clause - license_family: BSD - size: 97676 - timestamp: 1764518652276 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cmake-4.2.1-hc85cc9f_0.conda - sha256: 655db6eddb370306d6d0ed3ac1d679ca044e45e03a43fc98cccfc5cafc341c5f - md5: e4afa0cb7943cc9810546f70f02223d5 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cmake-4.2.1-hc85cc9f_0.conda + sha256: 655db6eddb370306d6d0ed3ac1d679ca044e45e03a43fc98cccfc5cafc341c5f + md5: e4afa0cb7943cc9810546f70f02223d5 depends: - __glibc >=2.17,<3.0.a0 - bzip2 >=1.0.8,<2.0a0 @@ -818,34 +813,6 @@ packages: license_family: BSD size: 22303088 timestamp: 1765229009574 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cmake-4.2.1-hc9d863e_0.conda - sha256: 4e9c507d0a37fd7b05e17e965e60ca4409abbd0008433dd485a07f461605b367 - md5: f3adb6a5d8c22853698ad18217b738d0 - depends: - - bzip2 >=1.0.8,<2.0a0 - - libcurl >=8.17.0,<9.0a0 - - libexpat >=2.7.3,<3.0a0 - - libgcc >=14 - - liblzma >=5.8.1,<6.0a0 - - libstdcxx >=14 - - libuv >=1.51.0,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - rhash >=1.4.6,<2.0a0 - - zstd >=1.5.7,<1.6.0a0 - license: BSD-3-Clause - license_family: BSD - size: 21474851 - timestamp: 1765229065445 -- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 - md5: 962b9857ee8e7018c22f2776ffa0b2d7 - depends: - - python >=3.9 - license: BSD-3-Clause - license_family: BSD - size: 27011 - timestamp: 1733218222191 - conda: https://conda.anaconda.org/conda-forge/linux-64/dav1d-1.2.1-hd590300_0.conda sha256: 22053a5842ca8ee1cf8e1a817138cdb5e647eb2c46979f84153f6ad7bde73020 md5: 418c6ca5929a611cbd69204907a83995 @@ -855,15 +822,6 @@ packages: license_family: BSD size: 760229 timestamp: 1685695754230 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dav1d-1.2.1-h31becfc_0.conda - sha256: 33fe66d025cf5bac7745196d1a3dd7a437abcf2dbce66043e9745218169f7e17 - md5: 6e5a87182d66b2d1328a96b61ca43a62 - depends: - - libgcc-ng >=12 - license: BSD-2-Clause - license_family: BSD - size: 347363 - timestamp: 1685696690003 - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h24cb091_1.conda sha256: 8bb557af1b2b7983cf56292336a1a1853f26555d9c6cecf1e5b2b96838c9da87 md5: ce96f2f470d39bd96ce03945af92e280 @@ -877,27 +835,6 @@ packages: license: AFL-2.1 OR GPL-2.0-or-later size: 447649 timestamp: 1764536047944 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-h70963c4_1.conda - sha256: 3af801577431af47c0b72a82bb93c654f03072dece0a2a6f92df8a6802f52a22 - md5: a4b6b82427d15f0489cef0df2d82f926 - depends: - - libstdcxx >=14 - - libgcc >=14 - - libglib >=2.86.2,<3.0a0 - - libzlib >=1.3.1,<2.0a0 - - libexpat >=2.7.3,<3.0a0 - license: AFL-2.1 OR GPL-2.0-or-later - size: 480416 - timestamp: 1764536098891 -- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda - sha256: 6d977f0b2fc24fee21a9554389ab83070db341af6d6f09285360b2e09ef8b26e - md5: 003b8ba0a94e2f1e117d0bd46aebc901 - depends: - - python >=3.9 - license: Apache-2.0 - license_family: APACHE - size: 275642 - timestamp: 1752823081585 - conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.7.3-hecca717_0.conda sha256: c5d573e6831fb41177fb5ae0f1ee09caed55a868ec9887bc80ccc22c3e57b9b4 md5: c81f6fa1865526f5ab1e6b669b3ee877 @@ -909,16 +846,6 @@ packages: license_family: MIT size: 143991 timestamp: 1763549744569 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/expat-2.7.3-hfae3067_0.conda - sha256: a113f31d0d2645e9991ad8685ca5a8699a70ddc314f87317702d6e36fbcfeb88 - md5: b3389e27c0cf1f8df60114cf03ed7575 - depends: - - libexpat 2.7.3 hfae3067_0 - - libgcc >=14 - license: MIT - license_family: MIT - size: 137213 - timestamp: 1763549921101 - conda: https://conda.anaconda.org/conda-forge/linux-64/ffmpeg-8.0.0-gpl_hc3e963e_905.conda sha256: fe4827510a76dd8bff965789c2e5eca98dd2cde9c96ecdde3491751a66e44ab0 md5: f715bf1751deb09b6407a67af4b5eec4 @@ -978,96 +905,6 @@ packages: license_family: GPL size: 12466275 timestamp: 1757195048142 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ffmpeg-8.0.0-gpl_h8d881e6_905.conda - sha256: 4d3ca22b46f8319cbdcc14a15378d0d89520a21a756d338ad5589a343290251f - md5: 466d482f18378eba3c422dd5ed281691 - depends: - - alsa-lib >=1.2.14,<1.3.0a0 - - aom >=3.9.1,<3.10.0a0 - - bzip2 >=1.0.8,<2.0a0 - - dav1d >=1.2.1,<1.2.2.0a0 - - fontconfig >=2.15.0,<3.0a0 - - fonts-conda-ecosystem - - gmp >=6.3.0,<7.0a0 - - harfbuzz >=11.4.5 - - lame >=3.100,<3.101.0a0 - - libass >=0.17.4,<0.17.5.0a0 - - libexpat >=2.7.1,<3.0a0 - - libfreetype >=2.13.3 - - libfreetype6 >=2.13.3 - - libgcc >=14 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.1,<6.0a0 - - libopenvino >=2025.2.0,<2025.2.1.0a0 - - libopenvino-arm-cpu-plugin >=2025.2.0,<2025.2.1.0a0 - - libopenvino-auto-batch-plugin >=2025.2.0,<2025.2.1.0a0 - - libopenvino-auto-plugin >=2025.2.0,<2025.2.1.0a0 - - libopenvino-hetero-plugin >=2025.2.0,<2025.2.1.0a0 - - libopenvino-ir-frontend >=2025.2.0,<2025.2.1.0a0 - - libopenvino-onnx-frontend >=2025.2.0,<2025.2.1.0a0 - - libopenvino-paddle-frontend >=2025.2.0,<2025.2.1.0a0 - - libopenvino-pytorch-frontend >=2025.2.0,<2025.2.1.0a0 - - libopenvino-tensorflow-frontend >=2025.2.0,<2025.2.1.0a0 - - libopenvino-tensorflow-lite-frontend >=2025.2.0,<2025.2.1.0a0 - - libopus >=1.5.2,<2.0a0 - - librsvg >=2.58.4,<3.0a0 - - libstdcxx >=14 - - libvorbis >=1.3.7,<1.4.0a0 - - libvpx >=1.14.1,<1.15.0a0 - - libxcb >=1.17.0,<2.0a0 - - libxml2 >=2.13.8,<2.14.0a0 - - libzlib >=1.3.1,<2.0a0 - - openh264 >=2.6.0,<2.6.1.0a0 - - openssl >=3.5.2,<4.0a0 - - pulseaudio-client >=17.0,<17.1.0a0 - - sdl2 >=2.32.54,<3.0a0 - - shaderc >=2025.3,<2025.4.0a0 - - svt-av1 >=3.1.2,<3.1.3.0a0 - - x264 >=1!164.3095,<1!165 - - x265 >=3.5,<3.6.0a0 - - xorg-libx11 >=1.8.12,<2.0a0 - constrains: - - __cuda >=12.8 - license: GPL-2.0-or-later - license_family: GPL - size: 12010889 - timestamp: 1757195113491 -- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.1-pyhd8ed1ab_0.conda - sha256: 8028582d956ab76424f6845fa1bdf5cb3e629477dd44157ca30d45e06d8a9c7c - md5: 81a651287d3000eb12f0860ade0a1b41 - depends: - - python >=3.10 - license: Unlicense - size: 18609 - timestamp: 1765846639623 -- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 - sha256: 58d7f40d2940dd0a8aa28651239adbf5613254df0f75789919c4e6762054403b - md5: 0c96522c6bdaed4b1566d11387caaf45 - license: BSD-3-Clause - license_family: BSD - size: 397370 - timestamp: 1566932522327 -- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 - sha256: c52a29fdac682c20d252facc50f01e7c2e7ceac52aa9817aaf0bb83f7559ec5c - md5: 34893075a5c9e55cdafac56607368fc6 - license: OFL-1.1 - license_family: Other - size: 96530 - timestamp: 1620479909603 -- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 - sha256: 00925c8c055a2275614b4d983e1df637245e19058d79fc7dd1a93b8d9fb4b139 - md5: 4d59c254e01d9cde7957100457e2d5fb - license: OFL-1.1 - license_family: Other - size: 700814 - timestamp: 1620479612257 -- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda - sha256: 2821ec1dc454bd8b9a31d0ed22a7ce22422c0aef163c59f49dfdf915d0f0ca14 - md5: 49023d73832ef61042f6a237cb2687e7 - license: LicenseRef-Ubuntu-Font-Licence-Version-1.0 - license_family: Other - size: 1620504 - timestamp: 1727511233259 - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda sha256: 7093aa19d6df5ccb6ca50329ef8510c6acb6b0d8001191909397368b65b02113 md5: 8f5b0b297b59e1ac160ad4beec99dbee @@ -1082,40 +919,6 @@ packages: license_family: MIT size: 265599 timestamp: 1730283881107 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda - sha256: fe023bb8917c8a3138af86ef537b70c8c5d60c44f93946a87d1e8bb1a6634b55 - md5: 112b71b6af28b47c624bcbeefeea685b - depends: - - freetype >=2.12.1,<3.0a0 - - libexpat >=2.6.3,<3.0a0 - - libgcc >=13 - - libuuid >=2.38.1,<3.0a0 - - libzlib >=1.3.1,<2.0a0 - license: MIT - license_family: MIT - size: 277832 - timestamp: 1730284967179 -- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - sha256: a997f2f1921bb9c9d76e6fa2f6b408b7fa549edd349a77639c9fe7a23ea93e61 - md5: fee5683a3f04bd15cbd8318b096a27ab - depends: - - fonts-conda-forge - license: BSD-3-Clause - license_family: BSD - size: 3667 - timestamp: 1566974674465 -- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - sha256: 54eea8469786bc2291cc40bca5f46438d3e062a399e8f53f013b6a9f50e98333 - md5: a7970cd949a077b7cb9696379d338681 - depends: - - font-ttf-ubuntu - - font-ttf-inconsolata - - font-ttf-dejavu-sans-mono - - font-ttf-source-code-pro - license: BSD-3-Clause - license_family: BSD - size: 4059 - timestamp: 1762351264405 - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda sha256: bf8e4dffe46f7d25dc06f31038cacb01672c47b9f45201f065b0f4d00ab0a83e md5: 4afc585cd97ba8a23809406cd8a9eda8 @@ -1125,15 +928,6 @@ packages: license: GPL-2.0-only OR FTL size: 173114 timestamp: 1757945422243 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda - sha256: 9f8de35e95ce301cecfe01bc9d539c7cc045146ffba55efe9733ff77ad1cfb21 - md5: 0c8f36ebd3678eed1685f0fc93fc2175 - depends: - - libfreetype 2.14.1 h8af1aa0_0 - - libfreetype6 2.14.1 hdae7a39_0 - license: GPL-2.0-only OR FTL - size: 173174 - timestamp: 1757945489158 - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda sha256: 858283ff33d4c033f4971bf440cebff217d5552a5222ba994c49be990dacd40d md5: f9f81ea472684d75b9dd8d0b328cf655 @@ -1143,14 +937,6 @@ packages: license: LGPL-2.1-or-later size: 61244 timestamp: 1757438574066 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fribidi-1.0.16-he30d5cf_0.conda - sha256: 1bfcd715bcb49a0b22d5d1899a22c6ff884b06f8e141eb746f3949752469a422 - md5: f3ac54914f7d3e1d68cb8d891765e5f9 - depends: - - libgcc >=14 - license: LGPL-2.1-or-later - size: 62909 - timestamp: 1757438620177 - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-hc5723f1_16.conda sha256: dfd180b9df441b57aa539dfcfcc416c804638b3bc5ec9dbb5d7bdbc009eba497 md5: 83c672f0e373c37436953413b2272a42 @@ -1167,25 +953,9 @@ packages: license_family: GPL size: 80309755 timestamp: 1765256937267 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gcc_impl_linux-aarch64-15.2.0-habb1d5c_16.conda - sha256: 9b7e56534fa3029e0caf6dbbf4daa2d567e630672f977f01ad0c356933fb1b0d - md5: af391ca6347927b4e067a8be221d1b3a - depends: - - binutils_impl_linux-aarch64 >=2.45 - - libgcc >=15.2.0 - - libgcc-devel_linux-aarch64 15.2.0 h55c397f_116 - - libgomp >=15.2.0 - - libsanitizer 15.2.0 he19c465_16 - - libstdcxx >=15.2.0 - - libstdcxx-devel_linux-aarch64 15.2.0 ha7b1723_116 - - sysroot_linux-aarch64 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 74461928 - timestamp: 1765257095042 -- conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.4-h2b0a6b4_0.conda - sha256: f47222f58839bcc77c15f11a8814c1d8cb8080c5ca6ba83398a12b640fd3c85c - md5: c379d67c686fb83475c1a6ed41cc41ff +- conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.4-h2b0a6b4_0.conda + sha256: f47222f58839bcc77c15f11a8814c1d8cb8080c5ca6ba83398a12b640fd3c85c + md5: c379d67c686fb83475c1a6ed41cc41ff depends: - __glibc >=2.17,<3.0.a0 - libgcc >=14 @@ -1198,20 +968,6 @@ packages: license_family: LGPL size: 572093 timestamp: 1761082340749 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gdk-pixbuf-2.44.4-h90308e0_0.conda - sha256: 78a1d69c3d0da73b4d54a35001abd4e273605180d21365b4f31e9a241d9fb715 - md5: 4c8c0d2f7620467869d41f29304362dc - depends: - - libgcc >=14 - - libglib >=2.86.0,<3.0a0 - - libjpeg-turbo >=3.1.0,<4.0a0 - - liblzma >=5.8.1,<6.0a0 - - libpng >=1.6.50,<1.7.0a0 - - libtiff >=4.7.1,<4.8.0a0 - license: LGPL-2.1-or-later - license_family: LGPL - size: 580454 - timestamp: 1761083738779 - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-0.25.1-h3f43e3d_1.conda sha256: cbfa8c80771d1842c2687f6016c5e200b52d4ca8f2cc119f6377f64f899ba4ff md5: c42356557d7f2e37676e121515417e3b @@ -1228,20 +984,6 @@ packages: license: LGPL-2.1-or-later AND GPL-3.0-or-later size: 541357 timestamp: 1753343006214 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gettext-0.25.1-h5ad3122_0.conda - sha256: 510e7eba15e6ba71cd5a2ae403128d56b3bb990878c8110f3abc652f823b4af8 - md5: 1e99d353785a5302bce1a5a86d249b2b - depends: - - gettext-tools 0.25.1 h5ad3122_0 - - libasprintf 0.25.1 h5e0f5ae_0 - - libasprintf-devel 0.25.1 h5e0f5ae_0 - - libgcc >=13 - - libgettextpo 0.25.1 h5ad3122_0 - - libgettextpo-devel 0.25.1 h5ad3122_0 - - libstdcxx >=13 - license: LGPL-2.1-or-later AND GPL-3.0-or-later - size: 534760 - timestamp: 1751557634743 - conda: https://conda.anaconda.org/conda-forge/linux-64/gettext-tools-0.25.1-h3f43e3d_1.conda sha256: c792729288bdd94f21f25f80802d4c66957b4e00a57f7cb20513f07aadfaff06 md5: a59c05d22bdcbb4e984bf0c021a2a02f @@ -1253,25 +995,6 @@ packages: license_family: GPL size: 3644103 timestamp: 1753342966311 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gettext-tools-0.25.1-h5ad3122_0.conda - sha256: 7b03cc531c9c2d567eb81dffe9f5688c83fbcdfa4882eec3a2045ec43218806f - md5: 4215d91c0eaae5274a36a3f211898c91 - depends: - - libgcc >=13 - license: GPL-3.0-or-later - license_family: GPL - size: 3999301 - timestamp: 1751557600737 -- conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - sha256: 40fdf5a9d5cc7a3503cd0c33e1b90b1e6eab251aaaa74e6b965417d089809a15 - md5: 93f742fe078a7b34c29a182958d4d765 - depends: - - python >=3.9 - - python-dateutil >=2.8.1 - license: Apache-2.0 - license_family: APACHE - size: 16538 - timestamp: 1734344477841 - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-2.86.3-h5192d8d_0.conda sha256: d5aa3c17a7b6fafff945be2c3f8134d9864a7812a29cd440630fd03229ae8d38 md5: 48560c0be24568c3d53a944d2d496818 @@ -1284,18 +1007,6 @@ packages: license: LGPL-2.1-or-later size: 612084 timestamp: 1765221962711 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-2.86.3-hc66a092_0.conda - sha256: a777c72511a5ed8bd2f9a3ae2850e62e2705b3352fbd4fdb10b35ec6c84bdeba - md5: ec0c021efe7251a9be4e4f5219c54e4c - depends: - - glib-tools 2.86.3 hc87f4d4_0 - - libffi >=3.5.2,<3.6.0a0 - - libglib 2.86.3 hf53f6bf_0 - - packaging - - python * - license: LGPL-2.1-or-later - size: 624186 - timestamp: 1765221848944 - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-networking-2.80.0-h2ef3c98_0.conda sha256: 9aecd58fe3f3f8179fe7fe8b08c6494d5ea35ccf44ec42b58d5a0d25d782708c md5: 3081eba855a71319a77a5325a43d755a @@ -1310,20 +1021,6 @@ packages: license_family: LGPL size: 160408 timestamp: 1710665753531 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-networking-2.80.0-h27184f6_0.conda - sha256: feb66f3fe9aee4a6a8113bb71df07bd1919e9e33a011865cfafd5a7dc9588805 - md5: 8f96bb956ab9f94ea2f2e2b15202d3b8 - depends: - - gettext - - glib >=2.74.0 - - libgcc-ng >=12 - - libglib >=2.74.1,<3.0a0 - - libzlib >=1.2.13,<2.0.0a0 - - openssl >=3.2.1,<4.0a0 - license: LGPL-2.1-or-later - license_family: LGPL - size: 162731 - timestamp: 1710667824117 - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.3-hf516916_0.conda sha256: 591e948c56f40e7fbcbd63362814736d9c9a3f0cd3cf4284002eff0bec7abe4e md5: fd6acbf37b40cbe919450fa58309fbe1 @@ -1334,15 +1031,6 @@ packages: license: LGPL-2.1-or-later size: 116337 timestamp: 1765221915390 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-tools-2.86.3-hc87f4d4_0.conda - sha256: 96f13110da7c93b62e81b97f520c6da392a886917f4cf4fe87a224e5816c07b3 - md5: d8de978ec69147991fc5d9acb96dfb36 - depends: - - libgcc >=14 - - libglib 2.86.3 hf53f6bf_0 - license: LGPL-2.1-or-later - size: 127526 - timestamp: 1765221824012 - conda: https://conda.anaconda.org/conda-forge/linux-64/glslang-15.4.0-h7d2aa7d_0.conda sha256: f5c862af017fc7133ced3470a45234a2a62eb21277de9c077304fd375a1daf05 md5: 7b8580757837b637316ed415a5463ad1 @@ -1355,17 +1043,6 @@ packages: license_family: BSD size: 1313595 timestamp: 1751107437294 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glslang-15.4.0-h9cbfd48_0.conda - sha256: b3b2efed30a9406cba0d6d87a0039e8bfc37b323e3f60ef0ff1fcf11c9d41394 - md5: 86c75de5d651ef9fe3dcbf006ce6ebdb - depends: - - libgcc >=13 - - libstdcxx >=13 - - spirv-tools >=2025,<2026.0a0 - license: BSD-3-Clause - license_family: BSD - size: 1314443 - timestamp: 1751107474911 - conda: https://conda.anaconda.org/conda-forge/linux-64/gmp-6.3.0-hac33072_2.conda sha256: 309cf4f04fec0c31b6771a5809a1909b4b3154a2208f52351e1ada006f4c750c md5: c94a5994ef49749880a8139cf9afcbe1 @@ -1375,15 +1052,6 @@ packages: license: GPL-2.0-or-later OR LGPL-3.0-or-later size: 460055 timestamp: 1718980856608 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gmp-6.3.0-h0a1ffab_2.conda - sha256: a5e341cbf797c65d2477b27d99091393edbaa5178c7d69b7463bb105b0488e69 - md5: 7cbfb3a8bb1b78a7f5518654ac6725ad - depends: - - libgcc-ng >=12 - - libstdcxx-ng >=12 - license: GPL-2.0-or-later OR LGPL-3.0-or-later - size: 417323 - timestamp: 1718980707330 - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda sha256: 25ba37da5c39697a77fce2c9a15e48cf0a84f1464ad2aafbe53d8357a9f6cc8c md5: 2cd94587f3a401ae05e03a6caf09539d @@ -1395,16 +1063,6 @@ packages: license_family: LGPL size: 99596 timestamp: 1755102025473 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphite2-1.3.14-hfae3067_2.conda - sha256: c9b1781fe329e0b77c5addd741e58600f50bef39321cae75eba72f2f381374b7 - md5: 4aa540e9541cc9d6581ab23ff2043f13 - depends: - - libgcc >=14 - - libstdcxx >=14 - license: LGPL-2.0-or-later - license_family: LGPL - size: 102400 - timestamp: 1755102000043 - conda: https://conda.anaconda.org/conda-forge/linux-64/gst-libav-1.26.6-h5186e55_1.conda sha256: 6c8535a88ed6d12260ce3ab89fb73e1204421c82a60fe2be42a11b98c6f53273 md5: a40fb9d5adff99972986f30199e2a8cd @@ -1426,26 +1084,6 @@ packages: license_family: LGPL size: 128186 timestamp: 1762769486843 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-libav-1.26.6-hf1974e4_1.conda - sha256: 396da6fcef5ef61f2d0794db8edaae15be596413d06756ce7a817ee54161cda2 - md5: ecd48cd09a40b244252960ff210a7c66 - depends: - - expat - - ffmpeg >=8.0.0,<9.0a0 - - gst-plugins-base >=1.26.6,<1.27.0a0 - - gstreamer 1.26.6.* - - gstreamer >=1.26.6,<1.27.0a0 - - libexpat >=2.7.1,<3.0a0 - - libgcc >=14 - - libglib >=2.86.1,<3.0a0 - - liblzma >=5.8.1,<6.0a0 - - liblzma-devel - - libstdcxx >=14 - - libzlib >=1.3.1,<2.0a0 - license: LGPL-2.1-or-later - license_family: LGPL - size: 125880 - timestamp: 1762769510324 - conda: https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-bad-1.26.6-hea4e0fb_0.conda sha256: a38464520424fcee4dc5a6ed9319febbfd8c23fc29f8a62ad0fe907abb5f80da md5: 09273752d106a8bb2b77c6a1e5446bf1 @@ -1478,37 +1116,6 @@ packages: license: LGPL-2.1-or-later size: 3527254 timestamp: 1762180554377 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-bad-1.26.6-hd7fd44e_0.conda - sha256: 4ba5c63928550c3d1897b332d47e2c1533d8f9aba95155e0e08e32eb315c519a - md5: beaf8c51985967a3c070250c27e2976a - depends: - - gst-plugins-base >=1.26.6,<1.27.0a0 - - gstreamer 1.26.6.* - - gstreamer >=1.26.6,<1.27.0a0 - - libdrm >=2.4.125,<2.5.0a0 - - libegl >=1.7.0,<2.0a0 - - libexpat >=2.7.1,<3.0a0 - - libgcc >=14 - - libgl >=1.7.0,<2.0a0 - - libglib >=2.86.1,<3.0a0 - - libiconv >=1.18,<2.0a0 - - libopus >=1.5.2,<2.0a0 - - libsndfile >=1.2.2,<1.3.0a0 - - libstdcxx >=14 - - libxcb >=1.17.0,<2.0a0 - - libxml2 >=2.13.9,<2.14.0a0 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.4,<4.0a0 - - xorg-libx11 >=1.8.12,<2.0a0 - - xorg-libxau >=1.0.12,<2.0a0 - - xorg-libxdamage >=1.1.6,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - xorg-libxfixes >=6.0.2,<7.0a0 - - xorg-libxrender >=0.9.12,<0.10.0a0 - - xorg-libxxf86vm >=1.1.6,<2.0a0 - license: LGPL-2.1-or-later - size: 3297993 - timestamp: 1762180459303 - conda: https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-base-1.26.6-hfe64121_0.conda sha256: 3c08e5f6b42e122dbc0ccd84c0bd58cc946f4a74baff78b1412ed17e81f652e5 md5: 774a69840120afee72bf2afb4f65a09b @@ -1542,38 +1149,6 @@ packages: license_family: LGPL size: 2917025 timestamp: 1762006090863 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-base-1.26.6-hb189aef_0.conda - sha256: df183b29cac2ad2e76bb5145c3b229220c3ffdde22e943bea95adeefd08b4386 - md5: 8612184919d3ea505c302e418efc831c - depends: - - alsa-lib >=1.2.14,<1.3.0a0 - - gstreamer 1.26.6 hc24f651_0 - - libdrm >=2.4.125,<2.5.0a0 - - libegl >=1.7.0,<2.0a0 - - libexpat >=2.7.1,<3.0a0 - - libgcc >=14 - - libgl >=1.7.0,<2.0a0 - - libglib >=2.86.1,<3.0a0 - - libogg >=1.3.5,<1.4.0a0 - - libopus >=1.5.2,<2.0a0 - - libpng >=1.6.50,<1.7.0a0 - - libstdcxx >=14 - - libvorbis >=1.3.7,<1.4.0a0 - - libxcb >=1.17.0,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - pango >=1.56.4,<2.0a0 - - xorg-libx11 >=1.8.12,<2.0a0 - - xorg-libxau >=1.0.12,<2.0a0 - - xorg-libxdamage >=1.1.6,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - xorg-libxfixes >=6.0.2,<7.0a0 - - xorg-libxrender >=0.9.12,<0.10.0a0 - - xorg-libxshmfence >=1.3.3,<2.0a0 - - xorg-libxxf86vm >=1.1.6,<2.0a0 - license: LGPL-2.0-or-later - license_family: LGPL - size: 2897369 - timestamp: 1762010868865 - conda: https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-good-1.26.6-h7d367ba_0.conda sha256: 0044cd199d8fcb8dfa188e7f5fbde41e13cca3f51b0186154aaba4deb221d04e md5: 56a53bf622f77b0c039c5bfb351645d6 @@ -1614,43 +1189,6 @@ packages: license_family: LGPL size: 2596572 timestamp: 1762006263212 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-good-1.26.6-h18b12b6_0.conda - sha256: 9b702a4f30517c515d0cb6db82150e638e7a290e616e6b4eaf7d7f2350d1cc25 - md5: 11eedd12f9ac0769fd3d311562fc45da - depends: - - bzip2 >=1.0.8,<2.0a0 - - glib-networking - - gst-plugins-base 1.26.6 hb189aef_0 - - gstreamer 1.26.6 hc24f651_0 - - lame >=3.100,<3.101.0a0 - - libdrm >=2.4.125,<2.5.0a0 - - libegl >=1.7.0,<2.0a0 - - libexpat >=2.7.1,<3.0a0 - - libgcc >=14 - - libgl >=1.7.0,<2.0a0 - - libglib >=2.86.1,<3.0a0 - - libjpeg-turbo >=3.1.0,<4.0a0 - - libpng >=1.6.50,<1.7.0a0 - - libsoup >=3.4.4,<4.0a0 - - libstdcxx >=14 - - libvpx >=1.14.1,<1.15.0a0 - - libxcb >=1.17.0,<2.0a0 - - libxml2 >=2.13.9,<2.14.0a0 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.4,<4.0a0 - - pulseaudio-client >=17.0,<17.1.0a0 - - xorg-libx11 >=1.8.12,<2.0a0 - - xorg-libxau >=1.0.12,<2.0a0 - - xorg-libxdamage >=1.1.6,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - xorg-libxfixes >=6.0.2,<7.0a0 - - xorg-libxrender >=0.9.12,<0.10.0a0 - - xorg-libxshmfence >=1.3.3,<2.0a0 - - xorg-libxxf86vm >=1.1.6,<2.0a0 - license: LGPL-2.0-or-later - license_family: LGPL - size: 2547962 - timestamp: 1762013550436 - conda: https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-ugly-1.26.6-h8c6bc89_1.conda sha256: 918a4295849d37c6600e043bcc082942460b0beaee397f75108ae35ded86bd7d md5: b27c1dd2170c1a9de2bec32dcdba0cb9 @@ -1679,39 +1217,12 @@ packages: license: LGPL-2.1-or-later size: 176263 timestamp: 1763220302359 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-ugly-1.26.6-hd087be5_1.conda - sha256: 32ee6d4e2c31704e0be63048b93fd7fc1f635b90f0eb3c3a93933b04fcaf9704 - md5: 4db4926bbfc37758835f6fabcb3a0671 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.26.6-h17cb667_0.conda + sha256: 2ce2504cf5168e04bfe72eba08676d530bb442b3a5edfb17b2083192ab250fdf + md5: 6d2f8cebb9413b3d3d1bbbffa37fac66 depends: - - gst-plugins-base >=1.26.6,<1.27.0a0 - - gstreamer 1.26.6.* - - gstreamer >=1.26.6,<1.27.0a0 - - libdrm >=2.4.125,<2.5.0a0 - - libegl >=1.7.0,<2.0a0 - - libexpat >=2.7.1,<3.0a0 - - libgcc >=14 - - libgl >=1.7.0,<2.0a0 - - libglib >=2.86.1,<3.0a0 - - libiconv >=1.18,<2.0a0 - - libxcb >=1.17.0,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - x264 >=1!164.3095,<1!165 - - xorg-libx11 >=1.8.12,<2.0a0 - - xorg-libxau >=1.0.12,<2.0a0 - - xorg-libxdamage >=1.1.6,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - xorg-libxfixes >=6.0.2,<7.0a0 - - xorg-libxrender >=0.9.12,<0.10.0a0 - - xorg-libxxf86vm >=1.1.6,<2.0a0 - license: LGPL-2.1-or-later - size: 172777 - timestamp: 1763220396305 -- conda: https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.26.6-h17cb667_0.conda - sha256: 2ce2504cf5168e04bfe72eba08676d530bb442b3a5edfb17b2083192ab250fdf - md5: 6d2f8cebb9413b3d3d1bbbffa37fac66 - depends: - - __glibc >=2.17,<3.0.a0 - - glib >=2.86.1,<3.0a0 + - __glibc >=2.17,<3.0.a0 + - glib >=2.86.1,<3.0a0 - libgcc >=14 - libglib >=2.86.1,<3.0a0 - libiconv >=1.18,<2.0a0 @@ -1721,32 +1232,6 @@ packages: license_family: LGPL size: 2069770 timestamp: 1762005928832 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gstreamer-1.26.6-hc24f651_0.conda - sha256: 307fe215f986c9eb1e8f50f5a820cad349a2730c9db1cbb6cde20427a8ec47da - md5: 80d41f95bf79b9dc666eac9bfcd5685a - depends: - - glib >=2.86.1,<3.0a0 - - libgcc >=14 - - libglib >=2.86.1,<3.0a0 - - libiconv >=1.18,<2.0a0 - - libstdcxx >=14 - - libzlib >=1.3.1,<2.0a0 - license: LGPL-2.0-or-later - license_family: LGPL - size: 2076345 - timestamp: 1762008407247 -- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - sha256: 84c64443368f84b600bfecc529a1194a3b14c3656ee2e832d15a20e0329b6da3 - md5: 164fc43f0b53b6e3a7bc7dce5e4f1dc9 - depends: - - python >=3.10 - - hyperframe >=6.1,<7 - - hpack >=4.1,<5 - - python - license: MIT - license_family: MIT - size: 95967 - timestamp: 1756364871835 - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.2.0-h15599e2_0.conda sha256: 6bd8b22beb7d40562b2889dc68232c589ff0d11a5ad3addd41a8570d11f039d9 md5: b8690f53007e9b5ee2c2178dd4ac778c @@ -1766,42 +1251,6 @@ packages: license_family: MIT size: 2411408 timestamp: 1762372726141 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda - sha256: 5cfd74a3fbce0921af5beff93a3fe7edc5b1344d9b9668b2de1c1be932b54993 - md5: 1437bf9690976948f90175a65407b65f - depends: - - cairo >=1.18.4,<2.0a0 - - graphite2 >=1.3.14,<2.0a0 - - icu >=75.1,<76.0a0 - - libexpat >=2.7.1,<3.0a0 - - libfreetype >=2.14.1 - - libfreetype6 >=2.14.1 - - libgcc >=14 - - libglib >=2.86.1,<3.0a0 - - libstdcxx >=14 - - libzlib >=1.3.1,<2.0a0 - license: MIT - license_family: MIT - size: 2156041 - timestamp: 1762376447693 -- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - sha256: 6ad78a180576c706aabeb5b4c8ceb97c0cb25f1e112d76495bff23e3779948ba - md5: 0a802cb9888dd14eeefc611f05c40b6e - depends: - - python >=3.9 - license: MIT - license_family: MIT - size: 30731 - timestamp: 1737618390337 -- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - sha256: 77af6f5fe8b62ca07d09ac60127a30d9069fdc3c68d6b256754d0ffb1f7779f8 - md5: 8e6923fc12f1fe8f8c4e5c9f343256ac - depends: - - python >=3.9 - license: MIT - license_family: MIT - size: 17397 - timestamp: 1737618427549 - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda sha256: 71e750d509f5fa3421087ba88ef9a7b9be11c53174af3aa4d06aff4c18b38e8e md5: 8b189310083baabfb622af68fd9d3ae3 @@ -1813,46 +1262,6 @@ packages: license_family: MIT size: 12129203 timestamp: 1720853576813 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda - sha256: 813298f2e54ef087dbfc9cc2e56e08ded41de65cff34c639cc8ba4e27e4540c9 - md5: 268203e8b983fddb6412b36f2024e75c - depends: - - libgcc-ng >=12 - - libstdcxx-ng >=12 - license: MIT - license_family: MIT - size: 12282786 - timestamp: 1720853454991 -- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.15-pyhd8ed1ab_0.conda - sha256: 32d5007d12e5731867908cbf5345f5cd44a6c8755a2e8e63e15a184826a51f82 - md5: 25f954b7dae6dd7b0dc004dab74f1ce9 - depends: - - python >=3.10 - - ukkonen - license: MIT - license_family: MIT - size: 79151 - timestamp: 1759437561529 -- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda - sha256: ae89d0299ada2a3162c2614a9d26557a92aa6a77120ce142f8e0109bbf0342b0 - md5: 53abe63df7e10a6ba605dc5f9f961d36 - depends: - - python >=3.10 - license: BSD-3-Clause - license_family: BSD - size: 50721 - timestamp: 1760286526795 -- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - sha256: c18ab120a0613ada4391b15981d86ff777b5690ca461ea7e9e49531e8f374745 - md5: 63ccfdc3a3ce25b027b8767eb722fca8 - depends: - - python >=3.9 - - zipp >=3.20 - - python - license: Apache-2.0 - license_family: APACHE - size: 34641 - timestamp: 1747934053147 - conda: https://conda.anaconda.org/conda-forge/linux-64/intel-gmmlib-22.9.0-hb700be7_0.conda sha256: edad668db79c6c4899d46e1cd4a331f5d008f9ed8f7d2e39e1dfe1a2d81acec0 md5: 26311c5112b5c713f472bdfbb5ec5aa3 @@ -1890,35 +1299,6 @@ packages: license_family: LGPL size: 461260 timestamp: 1747574434594 -- conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - sha256: fc9ca7348a4f25fed2079f2153ecdcf5f9cf2a0bc36c4172420ca09e1849df7b - md5: 04558c96691bed63104678757beb4f8d - depends: - - markupsafe >=2.0 - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - size: 120685 - timestamp: 1764517220861 -- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda - sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a - md5: 86d9cba083cd041bfbf242a01a7a1999 - constrains: - - sysroot_linux-64 ==2.28 - license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later - license_family: GPL - size: 1278712 - timestamp: 1765578681495 -- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-aarch64-4.18.0-h05a177a_9.conda - sha256: 5d224bf4df9bac24e69de41897c53756108c5271a0e5d2d2f66fd4e2fbc1d84b - md5: bb3b7cad9005f2cbf9d169fb30263f3e - constrains: - - sysroot_linux-aarch64 ==2.28 - license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later - license_family: GPL - size: 1248134 - timestamp: 1765578613607 - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4 md5: b38117a3c920364aff79f870c984b4a3 @@ -1928,14 +1308,6 @@ packages: license: LGPL-2.1-or-later size: 134088 timestamp: 1754905959823 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda - sha256: 5ce830ca274b67de11a7075430a72020c1fb7d486161a82839be15c2b84e9988 - md5: e7df0aab10b9cbb73ab2a467ebfaf8c7 - depends: - - libgcc >=13 - license: LGPL-2.1-or-later - size: 129048 - timestamp: 1754906002667 - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda sha256: 99df692f7a8a5c27cd14b5fb1374ee55e756631b9c3d659ed3ee60830249b238 md5: 3f43953b7d3fb3aaa1d0d0723d91e368 @@ -1950,20 +1322,6 @@ packages: license_family: MIT size: 1370023 timestamp: 1719463201255 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda - sha256: 0ec272afcf7ea7fbf007e07a3b4678384b7da4047348107b2ae02630a570a815 - md5: 29c10432a2ca1472b53f299ffb2ffa37 - depends: - - keyutils >=1.6.1,<2.0a0 - - libedit >=3.1.20191231,<3.2.0a0 - - libedit >=3.1.20191231,<4.0a0 - - libgcc-ng >=12 - - libstdcxx-ng >=12 - - openssl >=3.3.1,<4.0a0 - license: MIT - license_family: MIT - size: 1474620 - timestamp: 1719463205834 - conda: https://conda.anaconda.org/conda-forge/linux-64/lame-3.100-h166bdaf_1003.tar.bz2 sha256: aad2a703b9d7b038c0f745b853c6bb5f122988fe1a7a096e0e606d9cbec4eaab md5: a8832b479f93521a9e7b5b743803be51 @@ -1973,15 +1331,6 @@ packages: license_family: LGPL size: 508258 timestamp: 1664996250081 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lame-3.100-h4e544f5_1003.tar.bz2 - sha256: 2502904a42df6d94bd743f7b73915415391dd6d31d5f50cb57c0a54a108e7b0a - md5: ab05bcf82d8509b4243f07e93bada144 - depends: - - libgcc-ng >=12 - license: LGPL-2.0-only - license_family: LGPL - size: 604863 - timestamp: 1664997611416 - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_104.conda sha256: 9e191baf2426a19507f1d0a17be0fdb7aa155cdf0f61d5a09c808e0a69464312 md5: a6abd2796fc332536735f68ba23f7901 @@ -1994,17 +1343,6 @@ packages: license_family: GPL size: 725545 timestamp: 1764007826689 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45-default_h1979696_104.conda - sha256: 7a13072581fa23f658a04f62f62c4677c57d3c9696fbc01cc954a88fc354b44d - md5: 28035705fe0c977ea33963489cd008ad - depends: - - zstd >=1.5.7,<1.6.0a0 - constrains: - - binutils_impl_linux-aarch64 2.45 - license: GPL-3.0-only - license_family: GPL - size: 875534 - timestamp: 1764007911054 - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda sha256: 412381a43d5ff9bbed82cd52a0bbca5b90623f62e41007c9c42d3870c60945ff md5: 9344155d33912347b37f0ae6c410a835 @@ -2016,16 +1354,6 @@ packages: license_family: Apache size: 264243 timestamp: 1745264221534 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.0.0-hfdc4d58_1.conda - sha256: f01df5bbf97783fac9b89be602b4d02f94353f5221acfd80c424ec1c9a8d276c - md5: 60dceb7e876f4d74a9cbd42bbbc6b9cf - depends: - - libgcc >=13 - - libstdcxx >=13 - license: Apache-2.0 - license_family: Apache - size: 227184 - timestamp: 1745265544057 - conda: https://conda.anaconda.org/conda-forge/linux-64/level-zero-1.26.3-hb700be7_0.conda sha256: 5dfff8a79f1e6433d84ea924ef71ae472980c2e7248c44e6e171c4eaa4db5c68 md5: 8dea0df062e53c80274a09150f0c2941 @@ -2051,19 +1379,6 @@ packages: license_family: Apache size: 1310612 timestamp: 1750194198254 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libabseil-20250512.1-cxx17_h201e9ed_0.conda - sha256: 28bb0a5f3177bb3b45a89d309b93bef65645671d1c97ae7bbcfa74481bf33f3c - md5: 4db30fe7ba05e2ce66595ed646064861 - depends: - - libgcc >=13 - - libstdcxx >=13 - constrains: - - abseil-cpp =20250512.1 - - libabseil-static =20250512.1=cxx17* - license: Apache-2.0 - license_family: Apache - size: 1327580 - timestamp: 1750194149128 - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-0.25.1-h3f43e3d_1.conda sha256: cb728a2a95557bb6a5184be2b8be83a6f2083000d0c7eff4ad5bbe5792133541 md5: 3b0d184bc9404516d418d4509e418bdc @@ -2074,15 +1389,6 @@ packages: license: LGPL-2.1-or-later size: 53582 timestamp: 1753342901341 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libasprintf-0.25.1-h5e0f5ae_0.conda - sha256: 146be90c237cf3d8399e44afe5f5d21ef9a15a7983ccea90e72d4ae0362f9b28 - md5: 1c5813f6be57f087b6659593248daf00 - depends: - - libgcc >=13 - - libstdcxx >=13 - license: LGPL-2.1-or-later - size: 53434 - timestamp: 1751557548397 - conda: https://conda.anaconda.org/conda-forge/linux-64/libasprintf-devel-0.25.1-h3f43e3d_1.conda sha256: 2fc95060efc3d76547b7872875af0b7212d4b1407165be11c5f830aeeb57fc3a md5: fd9cf4a11d07f0ef3e44fc061611b1ed @@ -2093,15 +1399,6 @@ packages: license: LGPL-2.1-or-later size: 34734 timestamp: 1753342921605 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libasprintf-devel-0.25.1-h5e0f5ae_0.conda - sha256: cc2bb8ca349ba4dd4af7971a3dba006bc8643353acd9757b4d645a817ec0f899 - md5: 5df92d925fba917586f3ca31c96d8e6d - depends: - - libasprintf 0.25.1 h5e0f5ae_0 - - libgcc >=13 - license: LGPL-2.1-or-later - size: 34824 - timestamp: 1751557562978 - conda: https://conda.anaconda.org/conda-forge/linux-64/libass-0.17.4-h96ad9f0_0.conda sha256: 035eb8b54e03e72e42ef707420f9979c7427776ea99e0f1e3c969f92eb573f19 md5: d3be7b2870bf7aff45b12ea53165babd @@ -2119,22 +1416,6 @@ packages: license: ISC size: 152179 timestamp: 1749328931930 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libass-0.17.4-hcfe818d_0.conda - sha256: cb19ad0b8f9cb469c78d26af9c49c790e5f746bb8a348ec10b681a98f05d1dc7 - md5: 8df67d209c9f7e8d40281a4ebf8ffd6d - depends: - - libgcc >=13 - - libiconv >=1.18,<2.0a0 - - harfbuzz >=11.0.1 - - fontconfig >=2.15.0,<3.0a0 - - fonts-conda-ecosystem - - fribidi >=1.0.10,<2.0a0 - - libfreetype >=2.13.3 - - libfreetype6 >=2.13.3 - - libzlib >=1.3.1,<2.0a0 - license: ISC - size: 171287 - timestamp: 1749328949722 - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e md5: 72c8fd1af66bd67bf580645b426513ed @@ -2145,15 +1426,6 @@ packages: license_family: MIT size: 79965 timestamp: 1764017188531 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlicommon-1.2.0-he30d5cf_1.conda - sha256: 5fa8c163c8d776503aa68cdaf798ff9440c76a0a1c3ea84e0c43dbf1ece8af4d - md5: 8ec1d03f3000108899d1799d9964f281 - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 80030 - timestamp: 1764017273715 - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b md5: 366b40a69f0ad6072561c1d09301c886 @@ -2165,16 +1437,6 @@ packages: license_family: MIT size: 34632 timestamp: 1764017199083 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlidec-1.2.0-he30d5cf_1.conda - sha256: 494365e8f58799ea95a6e82334ef696e9c2120aecd6626121694b30a15033301 - md5: 47e5b71b77bb8b47b4ecf9659492977f - depends: - - libbrotlicommon 1.2.0 he30d5cf_1 - - libgcc >=14 - license: MIT - license_family: MIT - size: 33166 - timestamp: 1764017282936 - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d md5: 4ffbb341c8b616aa2494b6afb26a0c5f @@ -2186,16 +1448,6 @@ packages: license_family: MIT size: 298378 timestamp: 1764017210931 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlienc-1.2.0-he30d5cf_1.conda - sha256: f998c03257b9aa1f7464446af2cf424862f0e54258a2a588309853e45ae771df - md5: 6553a5d017fe14859ea8a4e6ea5def8f - depends: - - libbrotlicommon 1.2.0 he30d5cf_1 - - libgcc >=14 - license: MIT - license_family: MIT - size: 309304 - timestamp: 1764017292044 - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-h3ff7636_0.conda sha256: 9517cce5193144af0fcbf19b7bd67db0a329c2cc2618f28ffecaa921a1cbe9d3 md5: 09c264d40c67b82b49a3f3b89037bd2e @@ -2207,16 +1459,6 @@ packages: license_family: BSD size: 121429 timestamp: 1762349484074 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcap-2.77-h68e9139_0.conda - sha256: 154eefd8f94010d89ba76a057949b9b1f75c7379bd0d19d4657c952bedcf5904 - md5: 10fe36ec0a9f7b1caae0331c9ba50f61 - depends: - - attr >=2.5.1,<2.6.0a0 - - libgcc >=14 - license: BSD-3-Clause - license_family: BSD - size: 108542 - timestamp: 1762350753349 - conda: https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.17.0-h4e3cde8_1.conda sha256: 2d7be2fe0f58a0945692abee7bb909f8b19284b518d958747e5ff51d0655c303 md5: 117499f93e892ea1e57fdca16c2e8351 @@ -2233,21 +1475,6 @@ packages: license_family: MIT size: 459417 timestamp: 1765379027010 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcurl-8.17.0-h7bfdcfb_1.conda - sha256: 1976e96cb86f1e9f0993cbba7a0b482e5f5dc9c3a0be23870b70125c95d96ddb - md5: 3b71a8bb2b714aa8d0a34c9a90e0eec2 - depends: - - krb5 >=1.21.3,<1.22.0a0 - - libgcc >=14 - - libnghttp2 >=1.67.0,<2.0a0 - - libssh2 >=1.11.1,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.4,<4.0a0 - - zstd >=1.5.7,<1.6.0a0 - license: curl - license_family: MIT - size: 479017 - timestamp: 1765378979432 - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda sha256: aa8e8c4be9a2e81610ddf574e05b64ee131fab5e0e3693210c9d6d2fba32c680 md5: 6c77a605a7a689d17d4819c0f8ac9a00 @@ -2258,15 +1485,6 @@ packages: license_family: MIT size: 73490 timestamp: 1761979956660 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdeflate-1.25-h1af38f5_0.conda - sha256: 48814b73bd462da6eed2e697e30c060ae16af21e9fbed30d64feaf0aad9da392 - md5: a9138815598fe6b91a1d6782ca657b0c - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 71117 - timestamp: 1761979776756 - conda: https://conda.anaconda.org/conda-forge/linux-64/libdrm-2.4.125-hb03c661_1.conda sha256: c076a213bd3676cc1ef22eeff91588826273513ccc6040d9bea68bccdc849501 md5: 9314bc5a1fe7d1044dc9dfd3ef400535 @@ -2278,16 +1496,6 @@ packages: license_family: MIT size: 310785 timestamp: 1757212153962 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdrm-2.4.125-he30d5cf_1.conda - sha256: 4e6cdb5dd37db794b88bec714b4418a0435b04d14e9f7afc8cc32f2a3ced12f2 - md5: 2079727b538f6dd16f3fa579d4c3c53f - depends: - - libgcc >=14 - - libpciaccess >=0.18,<0.19.0a0 - license: MIT - license_family: MIT - size: 344548 - timestamp: 1757212128414 - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 md5: c277e0a4d549b03ac1e9d6cbbe3d017b @@ -2300,17 +1508,6 @@ packages: license_family: BSD size: 134676 timestamp: 1738479519902 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libedit-3.1.20250104-pl5321h976ea20_0.conda - sha256: c0b27546aa3a23d47919226b3a1635fccdb4f24b94e72e206a751b33f46fd8d6 - md5: fb640d776fc92b682a14e001980825b1 - depends: - - ncurses - - libgcc >=13 - - ncurses >=6.5,<7.0a0 - license: BSD-2-Clause - license_family: BSD - size: 148125 - timestamp: 1738479808948 - conda: https://conda.anaconda.org/conda-forge/linux-64/libegl-1.7.0-ha4b6fd6_2.conda sha256: 7fd5408d359d05a969133e47af580183fbf38e2235b562193d427bb9dad79723 md5: c151d5eb730e9b7480e6d48c0fc44048 @@ -2320,14 +1517,6 @@ packages: license: LicenseRef-libglvnd size: 44840 timestamp: 1731330973553 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-1.7.0-hd24410f_2.conda - sha256: 8962abf38a58c235611ce356b9899f6caeb0352a8bce631b0bcc59352fda455e - md5: cf105bce884e4ef8c8ccdca9fe6695e7 - depends: - - libglvnd 1.7.0 hd24410f_2 - license: LicenseRef-libglvnd - size: 53551 - timestamp: 1731330990477 - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda sha256: 1cd6048169fa0395af74ed5d8f1716e22c19a81a8a36f934c110ca3ad4dd27b4 md5: 172bf1cd1ff8629f2b1179945ed45055 @@ -2337,15 +1526,6 @@ packages: license_family: BSD size: 112766 timestamp: 1702146165126 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libev-4.33-h31becfc_2.conda - sha256: 973af77e297f1955dd1f69c2cbdc5ab9dfc88388a5576cd152cda178af0fd006 - md5: a9a13cb143bbaa477b1ebaefbe47a302 - depends: - - libgcc-ng >=12 - license: BSD-2-Clause - license_family: BSD - size: 115123 - timestamp: 1702146237623 - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda sha256: 1e1b08f6211629cbc2efe7a5bca5953f8f6b3cae0eeb04ca4dacee1bd4e2db2f md5: 8b09ae86839581147ef2e5c5e229d164 @@ -2358,17 +1538,6 @@ packages: license_family: MIT size: 76643 timestamp: 1763549731408 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.3-hfae3067_0.conda - sha256: cc2581a78315418cc2e0bb2a273d37363203e79cefe78ba6d282fed546262239 - md5: b414e36fbb7ca122030276c75fa9c34a - depends: - - libgcc >=14 - constrains: - - expat 2.7.3.* - license: MIT - license_family: MIT - size: 76201 - timestamp: 1763549910086 - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda sha256: 25cbdfa65580cfab1b8d15ee90b4c9f1e0d72128f1661449c9a999d341377d54 md5: 35f29eec58405aaf55e01cb470d8c26a @@ -2379,15 +1548,6 @@ packages: license_family: MIT size: 57821 timestamp: 1760295480630 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-hd65408f_0.conda - sha256: 6c3332e78a975e092e54f87771611db81dcb5515a3847a3641021621de76caea - md5: 0c5ad486dcfb188885e3cf8ba209b97b - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 55586 - timestamp: 1760295405021 - conda: https://conda.anaconda.org/conda-forge/linux-64/libflac-1.5.0-he200343_1.conda sha256: e755e234236bdda3d265ae82e5b0581d259a9279e3e5b31d745dc43251ad64fb md5: 47595b9d53054907a00d95e4d47af1d6 @@ -2401,18 +1561,6 @@ packages: license_family: BSD size: 424563 timestamp: 1764526740626 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libflac-1.5.0-he9c94f4_1.conda - sha256: 175cdc1865c3d6becc87e96bf44010a8e14f3021600ddad59417ed36e677b1ea - md5: cbe37f1d15f60b5e5272955b55b65325 - depends: - - libgcc >=14 - - libiconv >=1.18,<2.0a0 - - libogg >=1.3.5,<1.4.0a0 - - libstdcxx >=14 - license: BSD-3-Clause - license_family: BSD - size: 397272 - timestamp: 1764526699497 - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda sha256: 4641d37faeb97cf8a121efafd6afd040904d4bca8c46798122f417c31d5dfbec md5: f4084e4e6577797150f9b04a4560ceb0 @@ -2421,14 +1569,6 @@ packages: license: GPL-2.0-only OR FTL size: 7664 timestamp: 1757945417134 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda - sha256: 342c07e4be3d09d04b531c889182a11a488e7e9ba4b75f642040e4681c1e9b98 - md5: 1e61fb236ccd3d6ccaf9e91cb2d7e12d - depends: - - libfreetype6 >=2.14.1 - license: GPL-2.0-only OR FTL - size: 7753 - timestamp: 1757945484817 - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda sha256: 4a7af818a3179fafb6c91111752954e29d3a2a950259c14a2fc7ba40a8b03652 md5: 8e7251989bca326a28f4a5ffbd74557a @@ -2442,18 +1582,6 @@ packages: license: GPL-2.0-only OR FTL size: 386739 timestamp: 1757945416744 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda - sha256: cedc83d9733363aca353872c3bfed2e188aa7caf57b57842ba0c6d2765652b7c - md5: 9c2f56b6e011c6d8010ff43b796aab2f - depends: - - libgcc >=14 - - libpng >=1.6.50,<1.7.0a0 - - libzlib >=1.3.1,<2.0a0 - constrains: - - freetype >=2.14.1 - license: GPL-2.0-only OR FTL - size: 423210 - timestamp: 1757945484108 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda sha256: 6eed58051c2e12b804d53ceff5994a350c61baf117ec83f5f10c953a3f311451 md5: 6d0363467e6ed84f11435eb309f2ff06 @@ -2467,36 +1595,6 @@ packages: license_family: GPL size: 1042798 timestamp: 1765256792743 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_16.conda - sha256: 44bfc6fe16236babb271e0c693fe7fd978f336542e23c9c30e700483796ed30b - md5: cf9cd6739a3b694dcf551d898e112331 - depends: - - _openmp_mutex >=4.5 - constrains: - - libgomp 15.2.0 h8acb6b2_16 - - libgcc-ng ==15.2.0=*_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 620637 - timestamp: 1765256938043 -- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_116.conda - sha256: 48d7d8dded34100d9065d1c0df86a11ab2cd8ddfd1590512b304527ed25b6d93 - md5: e67832fdbf2382757205bb4b38800643 - depends: - - __unix - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 3094906 - timestamp: 1765256682321 -- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-aarch64-15.2.0-h55c397f_116.conda - sha256: 594e4f22a4b6aae1bca5e22ea3a075c070642ca4c27c53e0c0973926ca711e09 - md5: 8ba6e9b5866b6a5429ca5d9fa12bc964 - depends: - - __unix - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 2343262 - timestamp: 1765256811670 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_16.conda sha256: 5f07f9317f596a201cc6e095e5fc92621afca64829785e483738d935f8cab361 md5: 5a68259fac2da8f2ee6f7bfe49c9eb8b @@ -2506,15 +1604,6 @@ packages: license_family: GPL size: 27256 timestamp: 1765256804124 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_16.conda - sha256: 22d7e63a00c880bd14fbbc514ec6f553b9325d705f08582e9076c7e73c93a2e1 - md5: 3e54a6d0f2ff0172903c0acfda9efc0e - depends: - - libgcc 15.2.0 h8acb6b2_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 27356 - timestamp: 1765256948637 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-0.25.1-h3f43e3d_1.conda sha256: 50a9e9815cf3f5bce1b8c5161c0899cc5b6c6052d6d73a4c27f749119e607100 md5: 2f4de899028319b27eb7a4023be5dfd2 @@ -2526,15 +1615,6 @@ packages: license_family: GPL size: 188293 timestamp: 1753342911214 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgettextpo-0.25.1-h5ad3122_0.conda - sha256: c8e5590166f4931a3ab01e444632f326e1bb00058c98078eb46b6e8968f1b1e9 - md5: ad7b109fbbff1407b1a7eeaa60d7086a - depends: - - libgcc >=13 - license: GPL-3.0-or-later - license_family: GPL - size: 225352 - timestamp: 1751557555903 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgettextpo-devel-0.25.1-h3f43e3d_1.conda sha256: c7ea10326fd450a2a21955987db09dde78c99956a91f6f05386756a7bfe7cc04 md5: 3f7a43b3160ec0345c9535a9f0d7908e @@ -2547,16 +1627,6 @@ packages: license_family: GPL size: 37407 timestamp: 1753342931100 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgettextpo-devel-0.25.1-h5ad3122_0.conda - sha256: a26e1982d062daba5bdd3a90a2ef77b323803d21d27cf4e941135f07037d6649 - md5: 0d9d56bac6e4249da2bede0588ae1c1b - depends: - - libgcc >=13 - - libgettextpo 0.25.1 h5ad3122_0 - license: GPL-3.0-or-later - license_family: GPL - size: 37460 - timestamp: 1751557569909 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_2.conda sha256: dc2752241fa3d9e40ce552c1942d0a4b5eeb93740c9723873f6fcf8d39ef8d2d md5: 928b8be80851f5d8ffb016f9c81dae7a @@ -2567,15 +1637,6 @@ packages: license: LicenseRef-libglvnd size: 134712 timestamp: 1731330998354 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda - sha256: 3e954380f16255d1c8ae5da3bd3044d3576a0e1ac2e3c3ff2fe8f2f1ad2e467a - md5: 0d00176464ebb25af83d40736a2cd3bb - depends: - - libglvnd 1.7.0 hd24410f_2 - - libglx 1.7.0 hd24410f_2 - license: LicenseRef-libglvnd - size: 145442 - timestamp: 1731331005019 - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.3-h6548e54_0.conda sha256: 82d6c2ee9f548c84220fb30fb1b231c64a53561d6e485447394f0a0eeeffe0e6 md5: 034bea55a4feef51c98e8449938e9cee @@ -2591,20 +1652,6 @@ packages: license: LGPL-2.1-or-later size: 3946542 timestamp: 1765221858705 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.3-hf53f6bf_0.conda - sha256: 35f4262131e4d42514787fdc3d45c836e060e18fcb2441abd9dd8ecd386214f4 - md5: f226b9798c6c176d2a94eea1350b3b6b - depends: - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - libiconv >=1.18,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - pcre2 >=10.47,<10.48.0a0 - constrains: - - glib 2.86.3 *_0 - license: LGPL-2.1-or-later - size: 4041779 - timestamp: 1765221790843 - conda: https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_2.conda sha256: 1175f8a7a0c68b7f81962699751bb6574e6f07db4c9f72825f978e3016f46850 md5: 434ca7e50e40f4918ab701e3facd59a0 @@ -2613,12 +1660,6 @@ packages: license: LicenseRef-libglvnd size: 132463 timestamp: 1731330968309 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglvnd-1.7.0-hd24410f_2.conda - sha256: 57ec3898a923d4bcc064669e90e8abfc4d1d945a13639470ba5f3748bd3090da - md5: 9e115653741810778c9a915a2f8439e7 - license: LicenseRef-libglvnd - size: 152135 - timestamp: 1731330986070 - conda: https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_2.conda sha256: 2d35a679624a93ce5b3e9dd301fff92343db609b79f0363e6d0ceb3a6478bfa7 md5: c8013e438185f33b13814c5c488acd5c @@ -2629,15 +1670,6 @@ packages: license: LicenseRef-libglvnd size: 75504 timestamp: 1731330988898 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda - sha256: 6591af640cb05a399fab47646025f8b1e1a06a0d4bbb4d2e320d6629b47a1c61 - md5: 1d4269e233636148696a67e2d30dad2a - depends: - - libglvnd 1.7.0 hd24410f_2 - - xorg-libx11 >=1.8.9,<2.0a0 - license: LicenseRef-libglvnd - size: 77736 - timestamp: 1731330998960 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda sha256: 5b3e5e4e9270ecfcd48f47e3a68f037f5ab0f529ccb223e8e5d5ac75a58fc687 md5: 26c46f90d0e727e95c6c9498a33a09f3 @@ -2647,13 +1679,6 @@ packages: license_family: GPL size: 603284 timestamp: 1765256703881 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_16.conda - sha256: 0a9d77c920db691eb42b78c734d70c5a1d00b3110c0867cfff18e9dd69bc3c29 - md5: 4d2f224e8186e7881d53e3aead912f6c - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 587924 - timestamp: 1765256821307 - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwloc-2.12.1-default_h3d81e11_1000.conda sha256: eecaf76fdfc085d8fed4583b533c10cb7f4a6304be56031c43a107e01a56b7e2 md5: d821210ab60be56dd27b5525ed18366d @@ -2666,17 +1691,6 @@ packages: license_family: BSD size: 2450422 timestamp: 1752761850672 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libhwloc-2.12.1-default_h6f258fa_1000.conda - sha256: d25c10fd894ce6c5d3eba5667bef98be0e82d8e4d2ec20425d89a5baee715304 - md5: eea9ada077bda5f4a32889b9285af9c0 - depends: - - libgcc >=14 - - libstdcxx >=14 - - libxml2 >=2.13.8,<2.14.0a0 - license: BSD-3-Clause - license_family: BSD - size: 2468653 - timestamp: 1752761831524 - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f md5: 915f5995e94f60e9a4826e0b0920ee88 @@ -2686,14 +1700,6 @@ packages: license: LGPL-2.1-only size: 790176 timestamp: 1754908768807 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h90929bb_2.conda - sha256: 1473451cd282b48d24515795a595801c9b65b567fe399d7e12d50b2d6cdb04d9 - md5: 5a86bf847b9b926f3a4f203339748d78 - depends: - - libgcc >=14 - license: LGPL-2.1-only - size: 791226 - timestamp: 1754910975665 - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.2-hb03c661_0.conda sha256: cc9aba923eea0af8e30e0f94f2ad7156e2984d80d1e8e7fe6be5a1f257f0eb32 md5: 8397539e3a0bbd1695584fb4f927485a @@ -2705,16 +1711,6 @@ packages: license: IJG AND BSD-3-Clause AND Zlib size: 633710 timestamp: 1762094827865 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.2-he30d5cf_0.conda - sha256: 84064c7c53a64291a585d7215fe95ec42df74203a5bf7615d33d49a3b0f08bb6 - md5: 5109d7f837a3dfdf5c60f60e311b041f - depends: - - libgcc >=14 - constrains: - - jpeg <0.0.0a - license: IJG AND BSD-3-Clause AND Zlib - size: 691818 - timestamp: 1762094728337 - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda sha256: f2591c0069447bbe28d4d696b7fcb0c5bd0b4ac582769b89addbcf26fb3430d8 md5: 1a580f7796c7bf6393fddb8bbbde58dc @@ -2726,16 +1722,6 @@ packages: license: 0BSD size: 112894 timestamp: 1749230047870 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.1-h86ecc28_2.conda - sha256: 498ea4b29155df69d7f20990a7028d75d91dbea24d04b2eb8a3d6ef328806849 - md5: 7d362346a479256857ab338588190da0 - depends: - - libgcc >=13 - constrains: - - xz 5.8.1.* - license: 0BSD - size: 125103 - timestamp: 1749232230009 - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-devel-5.8.1-hb9d3cd8_2.conda sha256: 329e66330a8f9cbb6a8d5995005478188eb4ba8a6b6391affa849744f4968492 md5: f61edadbb301530bd65a32646bd81552 @@ -2746,15 +1732,6 @@ packages: license: 0BSD size: 439868 timestamp: 1749230061968 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-devel-5.8.1-h86ecc28_2.conda - sha256: 3bd4de89c0cf559a944408525460b3de5495b4c21fb92c831ff0cc96398a7272 - md5: 236d1ebc954a963b3430ce403fbb0896 - depends: - - libgcc >=13 - - liblzma 5.8.1 h86ecc28_2 - license: 0BSD - size: 440873 - timestamp: 1749232400775 - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda sha256: 3aa92d4074d4063f2a162cd8ecb45dccac93e543e565c01a787e16a43501f7ee md5: c7e925f37e3b40d893459e625f6a53f1 @@ -2765,15 +1742,6 @@ packages: license_family: BSD size: 91183 timestamp: 1748393666725 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-h86ecc28_0.conda - sha256: ef8697f934c80b347bf9d7ed45650928079e303bad01bd064995b0e3166d6e7a - md5: 78cfed3f76d6f3f279736789d319af76 - depends: - - libgcc >=13 - license: BSD-2-Clause - license_family: BSD - size: 114064 - timestamp: 1748393729243 - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.67.0-had1ee68_0.conda sha256: a4a7dab8db4dc81c736e9a9b42bdfd97b087816e029e221380511960ac46c690 md5: b499ce4b026493a13774bcf0f4c33849 @@ -2790,21 +1758,6 @@ packages: license_family: MIT size: 666600 timestamp: 1756834976695 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnghttp2-1.67.0-ha888d0e_0.conda - sha256: b03f406fd5c3f865a5e08c89b625245a9c4e026438fd1a445e45e6a0d69c2749 - md5: 981082c1cc262f514a5a2cf37cab9b81 - depends: - - c-ares >=1.34.5,<2.0a0 - - libev >=4.33,<4.34.0a0 - - libev >=4.33,<5.0a0 - - libgcc >=14 - - libstdcxx >=14 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.2,<4.0a0 - license: MIT - license_family: MIT - size: 728661 - timestamp: 1756835019535 - conda: https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.5-hd0c01bc_1.conda sha256: ffb066ddf2e76953f92e06677021c73c85536098f1c21fcd15360dbc859e22e4 md5: 68e52064ed3897463c0e958ab5c8f91b @@ -2815,15 +1768,6 @@ packages: license_family: BSD size: 218500 timestamp: 1745825989535 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libogg-1.3.5-h86ecc28_1.conda - sha256: 2c1b7c59badc2fd6c19b6926eabfce906c996068d38c2972bd1cfbe943c07420 - md5: 319df383ae401c40970ee4e9bc836c7a - depends: - - libgcc >=13 - license: BSD-3-Clause - license_family: BSD - size: 220653 - timestamp: 1745826021156 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-2025.2.0-hb617929_1.conda sha256: 235e7d474c90ad9d8955401b8a91dbe373aa1dc65db3c8232a5e22e4eaf41976 md5: 1da20cc4ff32dc74424dec68ec087dba @@ -2835,27 +1779,6 @@ packages: - tbb >=2021.13.0 size: 6244771 timestamp: 1753211097492 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-2025.2.0-hcd21e76_1.conda - sha256: f5c7a24d9918b1f637ca11a7c0b5594e14469ccc5b1f3bafcd248df252d2bdfb - md5: 76baf6bb7a63e310210d91595e245d24 - depends: - - libgcc >=14 - - libstdcxx >=14 - - pugixml >=1.15,<1.16.0a0 - - tbb >=2021.13.0 - size: 5535917 - timestamp: 1753203182299 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-arm-cpu-plugin-2025.2.0-hcd21e76_1.conda - sha256: 018a0ea563bc2e91efee8a07f7b2ff769cd66d03d1c466c8bb7407075023ac85 - md5: 794c3f49774bd710aec2b0602ae38313 - depends: - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libstdcxx >=14 - - pugixml >=1.15,<1.16.0a0 - - tbb >=2021.13.0 - size: 9257629 - timestamp: 1753203203327 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-batch-plugin-2025.2.0-hed573e4_1.conda sha256: 193f760e828b0dd5168dd1d28580d4bf429c5f14a4eee5e0c02ff4c6d4cf8093 md5: 94f9d17be1d658213b66b22f63cc6578 @@ -2867,16 +1790,6 @@ packages: - tbb >=2021.13.0 size: 114760 timestamp: 1753211116381 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-auto-batch-plugin-2025.2.0-h3890994_1.conda - sha256: 59a159c547fca34e8a0c600fcca428793da2ad4ecef0f47b58f1ea16d756c521 - md5: ad9768777a654205fa46aed8a829bd7e - depends: - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libstdcxx >=14 - - tbb >=2021.13.0 - size: 111599 - timestamp: 1753203233477 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-auto-plugin-2025.2.0-hed573e4_1.conda sha256: a6f9f996e64e6d2f295f017a833eda7018ff58b6894503272d72f0002dfd6f33 md5: 071b3a82342715a411f216d379ab6205 @@ -2888,16 +1801,6 @@ packages: - tbb >=2021.13.0 size: 250500 timestamp: 1753211127339 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-auto-plugin-2025.2.0-h3890994_1.conda - sha256: 3353f616cf72dad02d974698a74fa89eb5ff1beeaa64cebcdd1f87c52d2a0516 - md5: 4cec7bb2362ece08d0d1799f1ed4fbe7 - depends: - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libstdcxx >=14 - - tbb >=2021.13.0 - size: 235379 - timestamp: 1753203244808 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-hetero-plugin-2025.2.0-hd41364c_1.conda sha256: f43f9049338ef9735b6815bac3f483d1e3adddecbfdeb13be365bc3f601fe156 md5: 77c0c7028a8110076d40314dc7b1fa98 @@ -2909,16 +1812,6 @@ packages: - pugixml >=1.15,<1.16.0a0 size: 194815 timestamp: 1753211138624 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-hetero-plugin-2025.2.0-he07c6df_1.conda - sha256: 97f6a555d73d96efe26521527ce4e4c6ea49e46d5e5fd07a5e535e7de34bb6b5 - md5: 00d0206cb4358182c856700e1c1dae8b - depends: - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libstdcxx >=14 - - pugixml >=1.15,<1.16.0a0 - size: 187747 - timestamp: 1753203256494 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-intel-cpu-plugin-2025.2.0-hb617929_1.conda sha256: a4a1cd320fa010a45d01f438dc3431b7a60271ee19188a901f884399fe744268 md5: e4cc6db5bdc8b554c06bf569de57f85f @@ -2968,16 +1861,6 @@ packages: - pugixml >=1.15,<1.16.0a0 size: 204890 timestamp: 1753211224567 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-ir-frontend-2025.2.0-he07c6df_1.conda - sha256: 935341a98e129d3fd792609de5e85b959c3b31661d1a95c2a655771611383a05 - md5: f86c16f077043c9b1e87dbc07bf5ec42 - depends: - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libstdcxx >=14 - - pugixml >=1.15,<1.16.0a0 - size: 195451 - timestamp: 1753203267888 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-onnx-frontend-2025.2.0-h1862bb8_1.conda sha256: 3937b028e7192ed3805581ac0ea171725843056c8544537754fad45a1791e864 md5: 68f5ad9d8e3979362bb9dfc9388980aa @@ -2991,18 +1874,6 @@ packages: - libstdcxx >=14 size: 1724503 timestamp: 1753211235981 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-onnx-frontend-2025.2.0-h07d5dce_1.conda - sha256: 576c1ba122fb58d1c0ea6540d5480809196a884d3e56c05ab49b97ccc99e2c90 - md5: f8d90a982f95366614c568eac3157a90 - depends: - - libabseil * cxx17* - - libabseil >=20250512.1,<20250513.0a0 - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libprotobuf >=6.31.1,<6.31.2.0a0 - - libstdcxx >=14 - size: 1530030 - timestamp: 1753203281815 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-paddle-frontend-2025.2.0-h1862bb8_1.conda sha256: c7ac3d4187323ab37ef62ec0896a41c8ca7da426c7f587494c72fe74852269e5 md5: a032d03468dee9fb5b8eaf635b4571c2 @@ -3016,18 +1887,6 @@ packages: - libstdcxx >=14 size: 744746 timestamp: 1753211248776 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-paddle-frontend-2025.2.0-h07d5dce_1.conda - sha256: b080ca352d8d4526b73815bdbdb12ba5caf5de4621c10e9ad41eac73a7a6a713 - md5: 098597aa6f19b2851f295f47c7105658 - depends: - - libabseil * cxx17* - - libabseil >=20250512.1,<20250513.0a0 - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libprotobuf >=6.31.1,<6.31.2.0a0 - - libstdcxx >=14 - size: 674194 - timestamp: 1753203295461 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-pytorch-frontend-2025.2.0-hecca717_1.conda sha256: 2d4a680a16509b8dd06ccd7a236655e46cc7c242bb5b6e88b83a834b891658db md5: cd40cf2d10a3279654c9769f3bc8caf5 @@ -3038,15 +1897,6 @@ packages: - libstdcxx >=14 size: 1243134 timestamp: 1753211260154 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-pytorch-frontend-2025.2.0-hfae3067_1.conda - sha256: 0dddd3e274c156a2b8ced3009444d99c04d75ab50a748968b94d3890b6dfab65 - md5: d00d92fbb31f8f9dc2cfb78f44286925 - depends: - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libstdcxx >=14 - size: 1123835 - timestamp: 1753203307507 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-frontend-2025.2.0-h0767aad_1.conda sha256: 311ec1118448a28e76f0359c4393c7f7f5e64761c48ac7b169bf928a391eae77 md5: f71c6b4e342b560cc40687063ef62c50 @@ -3061,19 +1911,6 @@ packages: - snappy >=1.2.2,<1.3.0a0 size: 1325059 timestamp: 1753211272484 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-tensorflow-frontend-2025.2.0-h38473e3_1.conda - sha256: fcdb5623415c9f5d8c8635f579e5706647e2c97b543ebba621b5b31df096de3d - md5: b42a48c1052c5b576170212c2a834614 - depends: - - libabseil * cxx17* - - libabseil >=20250512.1,<20250513.0a0 - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libprotobuf >=6.31.1,<6.31.2.0a0 - - libstdcxx >=14 - - snappy >=1.2.2,<1.3.0a0 - size: 1224816 - timestamp: 1753203320621 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenvino-tensorflow-lite-frontend-2025.2.0-hecca717_1.conda sha256: 581f4951e645e820c4a6ffe40fb0174b56d6e31fb1fefd2d64913fea01f8f69e md5: fd9dacd7101f80ff1110ea6b76adb95d @@ -3084,15 +1921,6 @@ packages: - libstdcxx >=14 size: 497047 timestamp: 1753211285617 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-tensorflow-lite-frontend-2025.2.0-hfae3067_1.conda - sha256: cd4651c37e45fe6779a32ebfb3000fb3e9742409cd9bd0ac141c130b2f8f8d56 - md5: 274b11e7ed763c4964a6b6d2130ec1cb - depends: - - libgcc >=14 - - libopenvino 2025.2.0 hcd21e76_1 - - libstdcxx >=14 - size: 456714 - timestamp: 1753203333676 - conda: https://conda.anaconda.org/conda-forge/linux-64/libopus-1.5.2-hd0c01bc_0.conda sha256: 786d43678d6d1dc5f88a6bad2d02830cfd5a0184e84a8caa45694049f0e3ea5f md5: b64523fb87ac6f87f0790f324ad43046 @@ -3103,15 +1931,6 @@ packages: license_family: BSD size: 312472 timestamp: 1744330953241 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopus-1.5.2-h86ecc28_0.conda - sha256: c887543068308fb0fd50175183a3513f60cd8eb1defc23adc3c89769fde80d48 - md5: 44b2cfec6e1b94723a960f8a5e6206ae - depends: - - libgcc >=13 - license: BSD-3-Clause - license_family: BSD - size: 357115 - timestamp: 1744331282621 - conda: https://conda.anaconda.org/conda-forge/linux-64/libpciaccess-0.18-hb9d3cd8_0.conda sha256: 0bd91de9b447a2991e666f284ae8c722ffb1d84acb594dbd0c031bd656fa32b2 md5: 70e3400cbbfa03e96dcde7fc13e38c7b @@ -3122,15 +1941,6 @@ packages: license_family: MIT size: 28424 timestamp: 1749901812541 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpciaccess-0.18-h86ecc28_0.conda - sha256: 7641dfdfe9bda7069ae94379e9924892f0b6604c1a016a3f76b230433bb280f2 - md5: 5044e160c5306968d956c2a0a2a440d6 - depends: - - libgcc >=13 - license: MIT - license_family: MIT - size: 29512 - timestamp: 1749901899881 - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.53-h421ea60_0.conda sha256: 8acdeb9a7e3d2630176ba8e947caf6bf4985a5148dec69b801e5eb797856688b md5: 00d4e66b1f746cb14944cad23fffb405 @@ -3141,15 +1951,6 @@ packages: license: zlib-acknowledgement size: 317748 timestamp: 1764981060755 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.53-h1abf092_0.conda - sha256: 31c2b22aa4cb2b8d1456ad5aa92d1b95a8db234572cd29772c58e0b0c5be8823 - md5: 7591d867dbcba9eb7fb5e88a5f756591 - depends: - - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 - license: zlib-acknowledgement - size: 340043 - timestamp: 1764981067899 - conda: https://conda.anaconda.org/conda-forge/linux-64/libprotobuf-6.31.1-h49aed37_2.conda sha256: 1679f16c593d769f3dab219adb1117cbaaddb019080c5a59f79393dc9f45b84f md5: 94cb88daa0892171457d9fdc69f43eca @@ -3164,19 +1965,6 @@ packages: license_family: BSD size: 4645876 timestamp: 1760550892361 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libprotobuf-6.31.1-h2cf3c76_2.conda - sha256: e1bfa4ee03ddfa3a5e347d6796757a373878b2f277ed48dbc32412b05e16e776 - md5: 8eb7b485dcbb81166e340a07ccb40e67 - depends: - - libabseil * cxx17* - - libabseil >=20250512.1,<20250513.0a0 - - libgcc >=14 - - libstdcxx >=14 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - size: 4465754 - timestamp: 1760550264433 - conda: https://conda.anaconda.org/conda-forge/linux-64/libpsl-0.21.5-h792ea30_0.conda sha256: 7d71a13e7daab188f504e455254b461cad2c6d2ee1f200cf7e5383785f2cfc04 md5: 384c9d8c83d98505a4e55e58b07f2ff3 @@ -3189,17 +1977,6 @@ packages: license_family: MIT size: 77926 timestamp: 1721115462353 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpsl-0.21.5-h8ebb4f9_0.conda - sha256: 0649e5101392e4b8862f855961c19568440c29a94334dc49ded2112e9720be80 - md5: 38c66dcbe4c75a1f872add1668ae97dd - depends: - - icu >=75.1,<76.0a0 - - libgcc-ng >=12 - - libstdcxx-ng >=12 - license: MIT - license_family: MIT - size: 79295 - timestamp: 1721115527303 - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.58.4-h49af25d_2.conda sha256: 475013475a3209c24a82f9e80c545d56ccca2fa04df85952852f3d73caa38ff9 md5: b9846db0abffb09847e2cb0fec4b4db6 @@ -3219,27 +1996,9 @@ packages: license: LGPL-2.1-or-later size: 6342757 timestamp: 1734902068235 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/librsvg-2.58.4-h9b423fc_2.conda - sha256: 6ce5fb6eb20e8754c025a8f758b5ecaf071f00751fed570063719a8feb792208 - md5: 57122e6d1d085802579a32ec502c6699 - depends: - - cairo >=1.18.2,<2.0a0 - - freetype >=2.12.1,<3.0a0 - - gdk-pixbuf >=2.42.12,<3.0a0 - - harfbuzz >=10.1.0 - - libgcc >=13 - - libglib >=2.82.2,<3.0a0 - - libpng >=1.6.44,<1.7.0a0 - - libxml2 >=2.13.5,<2.14.0a0 - - pango >=1.54.0,<2.0a0 - constrains: - - __glibc >=2.17 - license: LGPL-2.1-or-later - size: 6019802 - timestamp: 1734908318062 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-15.2.0-h90f66d4_16.conda - sha256: 50d8082749e760454fb1489c2a47c6fa80cbf3893ec1c1a085747d46484ffd7f - md5: 0841a98bda756af037eb07d36cacada5 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-15.2.0-h90f66d4_16.conda + sha256: 50d8082749e760454fb1489c2a47c6fa80cbf3893ec1c1a085747d46484ffd7f + md5: 0841a98bda756af037eb07d36cacada5 depends: - __glibc >=2.17,<3.0.a0 - libgcc >=15.2.0 @@ -3248,16 +2007,6 @@ packages: license_family: GPL size: 7660762 timestamp: 1765256861607 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsanitizer-15.2.0-he19c465_16.conda - sha256: 71be6819f928574caf929aa4764a69e3df0429d686a4c5d6a8985b4c2c14b965 - md5: 4e30740acf8527cc06ca6a8d81432536 - depends: - - libgcc >=15.2.0 - - libstdcxx >=15.2.0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 7460968 - timestamp: 1765257008136 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsndfile-1.2.2-hc7d488a_2.conda sha256: 57cb5f92110324c04498b96563211a1bca6a74b2918b1e8df578bfed03cc32e4 md5: 067590f061c9f6ea7e61e3b2112ed6b3 @@ -3275,22 +2024,6 @@ packages: license_family: LGPL size: 355619 timestamp: 1765181778282 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsndfile-1.2.2-h30591a0_2.conda - sha256: f0b6844c09cdec608ca504bd97c5d64a5596a25f66ad806381f9d63dfc89e432 - md5: 362bc94148039b77c6a42b1f7e7ef537 - depends: - - lame >=3.100,<3.101.0a0 - - libflac >=1.5.0,<1.6.0a0 - - libgcc >=14 - - libogg >=1.3.5,<1.4.0a0 - - libopus >=1.5.2,<2.0a0 - - libstdcxx >=14 - - libvorbis >=1.3.7,<1.4.0a0 - - mpg123 >=1.32.9,<1.33.0a0 - license: LGPL-2.1-or-later - license_family: LGPL - size: 406978 - timestamp: 1765181892661 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsoup-3.6.5-h037dbf2_0.conda sha256: 7754c865e8991be4a66a7f644df53487b0d6f3e1ae301916dfece6a2aa33938a md5: 1aa959e3f0e03d09461aac45ef4c9cd8 @@ -3310,24 +2043,6 @@ packages: license_family: LGPL size: 432392 timestamp: 1766241622773 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsoup-3.6.5-hd76604a_0.conda - sha256: f678d85c08d75b63b8444dd8f01b2fdf74de0fd62510bdeaf307f39743d42e56 - md5: c1ca5c355fba650c5bd63be4e853305b - depends: - - glib-networking - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libgcc >=14 - - libglib >=2.86.3,<3.0a0 - - libnghttp2 >=1.67.0,<2.0a0 - - libpsl >=0.21.5,<0.22.0a0 - - libsqlite >=3.51.1,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - license: LGPL-2.1-or-later - license_family: LGPL - size: 444161 - timestamp: 1766243520579 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.1-h0c1763c_0.conda sha256: 6f0e8a812e8e33a4d8b7a0e595efe28373080d27b78ee4828aa4f6649a088454 md5: 2e1b84d273b01835256e53fd938de355 @@ -3338,15 +2053,6 @@ packages: license: blessing size: 938979 timestamp: 1764359444435 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.1-h022381a_0.conda - sha256: e394dd772b71dbcd653d078f3aacf6e26e3478bd6736a687ab86e463a2f153a8 - md5: 233efdd411317d2dc5fde72464b3df7a - depends: - - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 - license: blessing - size: 939207 - timestamp: 1764359457549 - conda: https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda sha256: fa39bfd69228a13e553bd24601332b7cfeb30ca11a3ca50bb028108fe90a7661 md5: eecce068c7e4eddeb169591baac20ac4 @@ -3359,17 +2065,6 @@ packages: license_family: BSD size: 304790 timestamp: 1745608545575 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libssh2-1.11.1-h18c354c_0.conda - sha256: 1e289bcce4ee6a5817a19c66e296f3c644dcfa6e562e5c1cba807270798814e7 - md5: eecc495bcfdd9da8058969656f916cc2 - depends: - - libgcc >=13 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.0,<4.0a0 - license: BSD-3-Clause - license_family: BSD - size: 311396 - timestamp: 1745609845915 - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda sha256: 813427918316a00c904723f1dfc3da1bbc1974c5cfe1ed1e704c6f4e0798cbc6 md5: 68f68355000ec3f1d6f26ea13e8f525f @@ -3382,35 +2077,6 @@ packages: license_family: GPL size: 5856456 timestamp: 1765256838573 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_16.conda - sha256: 4db11a903707068ae37aa6909511c68e9af6a2e97890d1b73b0a8d87cb74aba9 - md5: 52d9df8055af3f1665ba471cce77da48 - depends: - - libgcc 15.2.0 h8acb6b2_16 - constrains: - - libstdcxx-ng ==15.2.0=*_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 5541149 - timestamp: 1765256980783 -- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_116.conda - sha256: cb331c51739cc68257c7d7eef0e29c355b46b2d72f630854506dbc99240057c1 - md5: 2730e07e576ffbd7bf13f8de34835d41 - depends: - - __unix - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 20763949 - timestamp: 1765256724565 -- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-aarch64-15.2.0-ha7b1723_116.conda - sha256: 06be0d20cb3784e1d625f316f26962085dd14f74e166bd668ee9c089b5fa3efa - md5: 48cfd02ec4f1308109e5daaccb99aa30 - depends: - - __unix - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 17639950 - timestamp: 1765256847600 - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_16.conda sha256: 81f2f246c7533b41c5e0c274172d607829019621c4a0823b5c0b4a8c7028ee84 md5: 1b3152694d236cf233b76b8c56bf0eae @@ -3420,15 +2086,6 @@ packages: license_family: GPL size: 27300 timestamp: 1765256885128 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hdbbeba8_16.conda - sha256: dd5c813ae5a4dac6fa946352674e0c21b1847994a717ef67bd6cc77bc15920be - md5: 20b7f96f58ccbe8931c3a20778fb3b32 - depends: - - libstdcxx 15.2.0 hef695bb_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - size: 27376 - timestamp: 1765257033344 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.10-hd0affe5_3.conda sha256: b3a7f89462dc95c1bba9f663210d20ff3ac5f7db458684e0f3a7ae5784f8c132 md5: 70d1de6301b58ed99fea01490a9802a3 @@ -3439,15 +2096,6 @@ packages: license: LGPL-2.1-or-later size: 491268 timestamp: 1765552759709 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsystemd0-257.10-hf9559e3_3.conda - sha256: 57fe7a9f0c289e4f2fdf5200271848adc9f102921056d5904173942628b472cd - md5: 254474a19793a5f06de7cf3e3e2359fb - depends: - - libcap >=2.77,<2.78.0a0 - - libgcc >=14 - license: LGPL-2.1-or-later - size: 517687 - timestamp: 1765552618501 - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda sha256: e5f8c38625aa6d567809733ae04bb71c161a42e44a9fa8227abe61fa5c60ebe0 md5: cd5a90476766d53e901500df9215e927 @@ -3465,22 +2113,6 @@ packages: license: HPND size: 435273 timestamp: 1762022005702 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda - sha256: 7ff79470db39e803e21b8185bc8f19c460666d5557b1378d1b1e857d929c6b39 - md5: 8c6fd84f9c87ac00636007c6131e457d - depends: - - lerc >=4.0.0,<5.0a0 - - libdeflate >=1.25,<1.26.0a0 - - libgcc >=14 - - libjpeg-turbo >=3.1.0,<4.0a0 - - liblzma >=5.8.1,<6.0a0 - - libstdcxx >=14 - - libwebp-base >=1.6.0,<2.0a0 - - libzlib >=1.3.1,<2.0a0 - - zstd >=1.5.7,<1.6.0a0 - license: HPND - size: 488407 - timestamp: 1762022048105 - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.10-hd0affe5_3.conda sha256: 977e7e4955ea1581e441e429c2c1b498bc915767f1cac77a97b283c469d5298c md5: 3934f4cf65a06100d526b33395fb9cd2 @@ -3491,15 +2123,6 @@ packages: license: LGPL-2.1-or-later size: 145023 timestamp: 1765552781358 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libudev1-257.10-hf9559e3_3.conda - sha256: 39bdad22998d1ef5b366d9c557b5ca8a2ee2bea1f05eab9e1b20fbfef9d6d7a4 - md5: 8da19c1b9138b2f0a57012c31e3ad81d - depends: - - libcap >=2.77,<2.78.0a0 - - libgcc >=14 - license: LGPL-2.1-or-later - size: 156695 - timestamp: 1765552629955 - conda: https://conda.anaconda.org/conda-forge/linux-64/libunwind-1.8.3-h65a8314_0.conda sha256: 71c8b9d5c72473752a0bb6e91b01dd209a03916cb71f36cc6a564e3a2a132d7a md5: e179a69edd30d75c0144d7a380b88f28 @@ -3511,16 +2134,6 @@ packages: license_family: MIT size: 75995 timestamp: 1757032240102 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libunwind-1.8.3-h6470e1d_0.conda - sha256: 86c013d522975b76e16a74341bfcb22f6ec2e9b8b87ec3e15380f46c435eaa7b - md5: 5d8191a950e492a06dc29b491dd5f7c5 - depends: - - libgcc >=14 - - libstdcxx >=14 - license: MIT - license_family: MIT - size: 94555 - timestamp: 1757032278900 - conda: https://conda.anaconda.org/conda-forge/linux-64/liburing-2.12-hb700be7_0.conda sha256: 880b1f76b24814c9f07b33402e82fa66d5ae14738a35a943c21c4434eef2403d md5: f0531fc1ebc0902555670e9cb0127758 @@ -3532,16 +2145,6 @@ packages: license_family: MIT size: 127967 timestamp: 1756125594973 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liburing-2.12-hfefdfc9_0.conda - sha256: 43daf21754c0d8618c2fcc1ac1cad8740f9a107358cc31d8619554463f366609 - md5: 63a654dceff75b84fe8ff32ddb66b7fe - depends: - - libgcc >=14 - - libstdcxx >=14 - license: MIT - license_family: MIT - size: 129619 - timestamp: 1756126369793 - conda: https://conda.anaconda.org/conda-forge/linux-64/libusb-1.0.29-h73b1eb8_0.conda sha256: 89c84f5b26028a9d0f5c4014330703e7dff73ba0c98f90103e9cef6b43a5323c md5: d17e3fb595a9f24fa9e149239a33475d @@ -3552,15 +2155,6 @@ packages: license: LGPL-2.1-or-later size: 89551 timestamp: 1748856210075 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libusb-1.0.29-h06eaf92_0.conda - sha256: a60aae6b529cd7caa7842f9781ef95b93014e618f71fb005e404af434d76a33f - md5: 9a86e7473e16fe25c5c47f6c1376ac82 - depends: - - libgcc >=13 - - libudev1 >=257.4 - license: LGPL-2.1-or-later - size: 93129 - timestamp: 1748856228398 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-h5347b49_1.conda sha256: 030447cf827c471abd37092ab9714fde82b8222106f22fde94bc7a64e2704c40 md5: 41f5c09a211985c3ce642d60721e7c3e @@ -3571,15 +2165,6 @@ packages: license_family: BSD size: 40235 timestamp: 1764790744114 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.2-h1022ec0_1.conda - sha256: 3113c857e36779d94cf9a18236a710ceca0e94230b3bfeba0d134f33ee8c9ecd - md5: 15b2cc72b9b05bcb141810b1bada654f - depends: - - libgcc >=14 - license: BSD-3-Clause - license_family: BSD - size: 43415 - timestamp: 1764790752623 - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda sha256: c180f4124a889ac343fc59d15558e93667d894a966ec6fdb61da1604481be26b md5: 0f03292cc56bf91a077a134ea8747118 @@ -3590,15 +2175,6 @@ packages: license_family: MIT size: 895108 timestamp: 1753948278280 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuv-1.51.0-he30d5cf_1.conda - sha256: 7a0fb5638582efc887a18b7d270b0c4a6f6e681bf401cab25ebafa2482569e90 - md5: 8e62bf5af966325ee416f19c6f14ffa3 - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 629238 - timestamp: 1753948296190 - conda: https://conda.anaconda.org/conda-forge/linux-64/libva-2.23.0-he1eb515_0.conda sha256: 255c7d00b54e26f19fad9340db080716bced1d8539606e2b8396c57abd40007c md5: 25813fe38b3e541fc40007592f12bae5 @@ -3633,18 +2209,6 @@ packages: license_family: BSD size: 285894 timestamp: 1753879378005 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libvorbis-1.3.7-h7ac5ae9_2.conda - sha256: 066708ca7179a1c6e5639d015de7ed6e432b93ad50525843db67d57eb1ba1faf - md5: 9d099329070afe52d797462ca7bf35f3 - depends: - - libogg - - libstdcxx >=14 - - libgcc >=14 - - libogg >=1.3.5,<1.4.0a0 - license: BSD-3-Clause - license_family: BSD - size: 289391 - timestamp: 1753879417231 - conda: https://conda.anaconda.org/conda-forge/linux-64/libvpl-2.15.0-h54a6638_1.conda sha256: bf0010d93f5b154c59bd9d3cc32168698c1d24f2904729f4693917cce5b27a9f md5: a41a299c157cc6d0eff05e5fc298cc45 @@ -3669,16 +2233,6 @@ packages: license_family: BSD size: 1022466 timestamp: 1717859935011 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libvpx-1.14.1-h0a1ffab_0.conda - sha256: 918493354f78cb3bb2c3d91264afbcb312b2afe287237e7d1c85ee7e96d15b47 - md5: 3cb63f822a49e4c406639ebf8b5d87d7 - depends: - - libgcc-ng >=12 - - libstdcxx-ng >=12 - license: BSD-3-Clause - license_family: BSD - size: 1211700 - timestamp: 1717859955539 - conda: https://conda.anaconda.org/conda-forge/linux-64/libvulkan-loader-1.4.328.1-h5279c79_0.conda sha256: bbabc5c48b63ff03f440940a11d4648296f5af81bb7630d98485405cd32ac1ce md5: 372a62464d47d9e966b630ffae3abe73 @@ -3694,20 +2248,6 @@ packages: license_family: APACHE size: 197672 timestamp: 1759972155030 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libvulkan-loader-1.4.328.1-h8b8848b_0.conda - sha256: f1b32481c65008087c64dec21cc141dec9b80921ff2a3f5571c24c8f531b18ea - md5: e5a3ff3a266b68398bd28ed1d4363e65 - depends: - - libstdcxx >=14 - - libgcc >=14 - - xorg-libxrandr >=1.5.4,<2.0a0 - - xorg-libx11 >=1.8.12,<2.0a0 - constrains: - - libvulkan-headers 1.4.328.1.* - license: Apache-2.0 - license_family: APACHE - size: 214593 - timestamp: 1759972148472 - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda sha256: 3aed21ab28eddffdaf7f804f49be7a7d701e8f0e46c856d801270b470820a37b md5: aea31d2e5b1091feca96fcfe945c3cf9 @@ -3720,17 +2260,6 @@ packages: license_family: BSD size: 429011 timestamp: 1752159441324 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libwebp-base-1.6.0-ha2e29f5_0.conda - sha256: b03700a1f741554e8e5712f9b06dd67e76f5301292958cd3cb1ac8c6fdd9ed25 - md5: 24e92d0942c799db387f5c9d7b81f1af - depends: - - libgcc >=14 - constrains: - - libwebp 1.6.0 - license: BSD-3-Clause - license_family: BSD - size: 359496 - timestamp: 1752160685488 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda sha256: 666c0c431b23c6cec6e492840b176dde533d48b7e6fb8883f5071223433776aa md5: 92ed62436b625154323d40d5f2f11dd7 @@ -3744,18 +2273,6 @@ packages: license_family: MIT size: 395888 timestamp: 1727278577118 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcb-1.17.0-h262b8f6_0.conda - sha256: 461cab3d5650ac6db73a367de5c8eca50363966e862dcf60181d693236b1ae7b - md5: cd14ee5cca2464a425b1dbfc24d90db2 - depends: - - libgcc >=13 - - pthread-stubs - - xorg-libxau >=1.0.11,<2.0a0 - - xorg-libxdmcp - license: MIT - license_family: MIT - size: 397493 - timestamp: 1727280745441 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.11.0-he8b52b9_0.conda sha256: 23f47e86cc1386e7f815fa9662ccedae151471862e971ea511c5c886aa723a54 md5: 74e91c36d0eef3557915c68b6c2bef96 @@ -3771,20 +2288,6 @@ packages: license_family: MIT size: 791328 timestamp: 1754703902365 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.11.0-h95ca766_0.conda - sha256: b23355766092c62b32a7fc8d5729f40d693d2d8491f52e12f3a2f184ec552f6a - md5: 21efa5fee8795bc04bd79bfc02f05c65 - depends: - - libgcc >=14 - - libstdcxx >=14 - - libxcb >=1.17.0,<2.0a0 - - libxml2 >=2.13.8,<2.14.0a0 - - xkeyboard-config - - xorg-libxau >=1.0.12,<2.0a0 - license: MIT/X11 Derivative - license_family: MIT - size: 811243 - timestamp: 1754703942072 - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.13.9-h04c0eec_0.conda sha256: 5d12e993894cb8e9f209e2e6bef9c90fa2b7a339a1f2ab133014b71db81f5d88 md5: 35eeb0a2add53b1e50218ed230fa6a02 @@ -3799,19 +2302,6 @@ packages: license_family: MIT size: 697033 timestamp: 1761766011241 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.13.9-he58860d_0.conda - sha256: e7a1c9cf56046b85383f99d0931a3b8a603419c830d45cf1c8691f13aae3f655 - md5: 1e22b9412f9cb2eb7e5a65dd9475534a - depends: - - icu >=75.1,<76.0a0 - - libgcc >=14 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.1,<6.0a0 - - libzlib >=1.3.1,<2.0a0 - license: MIT - license_family: MIT - size: 737147 - timestamp: 1761766137531 - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4 md5: edb0dca6bc32e4f4789199455a1dbeb8 @@ -3824,119 +2314,6 @@ packages: license_family: Other size: 60963 timestamp: 1727963148474 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda - sha256: 5a2c1eeef69342e88a98d1d95bff1603727ab1ff4ee0e421522acd8813439b84 - md5: 08aad7cbe9f5a6b460d0976076b6ae64 - depends: - - libgcc >=13 - constrains: - - zlib 1.3.1 *_2 - license: Zlib - license_family: Other - size: 66657 - timestamp: 1727963199518 -- conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10-pyhcf101f3_1.conda - sha256: 32af5d32e3193b7c0ea02c33cc8753bfc0965d07e1aa58418a851d0bb94a7792 - md5: 934afb77580165027b869d4104ee002f - depends: - - importlib-metadata >=4.4 - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - size: 85401 - timestamp: 1762856570927 -- conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda - sha256: e0cbfea51a19b3055ca19428bd9233a25adca956c208abb9d00b21e7259c7e03 - md5: fab1be106a50e20f10fe5228fd1d1651 - depends: - - python >=3.10 - constrains: - - jinja2 >=3.0.0 - track_features: - - markupsafe_no_compile - license: BSD-3-Clause - license_family: BSD - size: 15499 - timestamp: 1759055275624 -- conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - sha256: e5b555fd638334a253d83df14e3c913ef8ce10100090e17fd6fb8e752d36f95d - md5: d9a8fc1f01deae61735c88ec242e855c - depends: - - python >=3.9 - license: MIT - license_family: MIT - size: 11676 - timestamp: 1734157119152 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - sha256: 902d2e251f9a7ffa7d86a3e62be5b2395e28614bd4dbe5f50acf921fd64a8c35 - md5: 14661160be39d78f2b210f2cc2766059 - depends: - - click >=7.0 - - colorama >=0.4 - - ghp-import >=1.0 - - importlib-metadata >=4.4 - - jinja2 >=2.11.1 - - markdown >=3.3.6 - - markupsafe >=2.0.1 - - mergedeep >=1.3.4 - - mkdocs-get-deps >=0.2.0 - - packaging >=20.5 - - pathspec >=0.11.1 - - python >=3.9 - - pyyaml >=5.1 - - pyyaml-env-tag >=0.1 - - watchdog >=2.0 - constrains: - - babel >=2.9.0 - license: BSD-2-Clause - license_family: BSD - size: 3524754 - timestamp: 1734344673481 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - sha256: e0b501b96f7e393757fb2a61d042015966f6c5e9ac825925e43f9a6eafa907b6 - md5: 84382acddb26c27c70f2de8d4c830830 - depends: - - importlib-metadata >=4.3 - - mergedeep >=1.3.4 - - platformdirs >=2.2.0 - - python >=3.9 - - pyyaml >=5.1 - license: MIT - license_family: MIT - size: 14757 - timestamp: 1734353035244 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda - sha256: e3c9ad7beece49540a4de5a9a3136081af64ceae0745336819a8c40a9e25f336 - md5: ab5cf0f1cd513e87bbd5736bdc13a399 - depends: - - python >=3.10 - - jinja2 >=3.0,<4.dev0 - - markdown >=3.2,<4.dev0 - - mkdocs >=1.6,<2.dev0 - - mkdocs-material-extensions >=1.3,<2.dev0 - - pygments >=2.16,<3.dev0 - - pymdown-extensions >=10.2,<11.dev0 - - babel >=2.10,<3.dev0 - - colorama >=0.4,<1.dev0 - - paginate >=0.5,<1.dev0 - - backrefs >=5.7.post1,<6.dev0 - - requests >=2.26,<3.dev0 - - python - license: MIT - size: 4795211 - timestamp: 1766061978730 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - sha256: f62955d40926770ab65cc54f7db5fde6c073a3ba36a0787a7a5767017da50aa3 - md5: de8af4000a4872e16fb784c649679c8e - depends: - - python >=3.9 - constrains: - - mkdocs-material >=5.0.0 - license: MIT - license_family: MIT - size: 16122 - timestamp: 1734641109286 - conda: https://conda.anaconda.org/conda-forge/linux-64/mpg123-1.32.9-hc50e24c_0.conda sha256: 39c4700fb3fbe403a77d8cc27352fa72ba744db487559d5d44bf8411bb4ea200 md5: c7f302fd11eeb0987a6a5e1f3aed6a21 @@ -3948,16 +2325,6 @@ packages: license_family: LGPL size: 491140 timestamp: 1730581373280 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/mpg123-1.32.9-h65af167_0.conda - sha256: d65d5a00278544639ba4f99887154be00a1f57afb0b34d80b08e5cba40a17072 - md5: cdf140c7690ab0132106d3bc48bce47d - depends: - - libgcc >=13 - - libstdcxx >=13 - license: LGPL-2.1-only - license_family: LGPL - size: 558708 - timestamp: 1730581372400 - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 md5: 47e340acb35de30501a76c7c799c41d7 @@ -3967,24 +2334,6 @@ packages: license: X11 AND BSD-3-Clause size: 891641 timestamp: 1738195959188 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda - sha256: 91cfb655a68b0353b2833521dc919188db3d8a7f4c64bea2c6a7557b24747468 - md5: 182afabe009dc78d8b73100255ee6868 - depends: - - libgcc >=13 - license: X11 AND BSD-3-Clause - size: 926034 - timestamp: 1738196018799 -- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda - sha256: 3636eec0e60466a00069b47ce94b6d88b01419b6577d8e393da44bb5bc8d3468 - md5: 7ba3f09fceae6a120d664217e58fe686 - depends: - - python >=3.9 - - setuptools - license: BSD-3-Clause - license_family: BSD - size: 34574 - timestamp: 1734112236147 - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda sha256: 6516f99fe400181ebe27cba29180ca0c7425c15d7392f74220a028ad0e0064a2 md5: d8005b3a90515c952b51026f6b7d005d @@ -4009,30 +2358,6 @@ packages: license_family: MIT size: 17246248 timestamp: 1765444698486 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/nodejs-25.2.1-h244045a_1.conda - sha256: 062377e6f2681fab3c5804ea04ccd02b7ec622209496ea82b5ae9a428231e92a - md5: 235c5f49d2974eddfc5fa3f21160adff - depends: - - __glibc >=2.28,<3.0.a0 - - libgcc >=14 - - libstdcxx >=14 - - libuv >=1.51.0,<2.0a0 - - zstd >=1.5.7,<1.6.0a0 - - libnghttp2 >=1.67.0,<2.0a0 - - icu >=75.1,<76.0a0 - - c-ares >=1.34.6,<2.0a0 - - libsqlite >=3.51.1,<4.0a0 - - libabseil >=20250512.1,<20250513.0a0 - - libabseil * cxx17* - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 - - openssl >=3.5.4,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - license: MIT - license_family: MIT - size: 23169197 - timestamp: 1765444723899 - conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.3-hb9d3cd8_0.conda sha256: 2254dae821b286fb57c61895f2b40e3571a070910fdab79a948ff703e1ea807b md5: 56f8947aa9d5cf37b0b3d43b83f34192 @@ -4066,16 +2391,6 @@ packages: license_family: BSD size: 731471 timestamp: 1739400677213 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openh264-2.6.0-h0564a2a_0.conda - sha256: 3b7a519e3b7d7721a0536f6cba7f1909b878c71962ee67f02242958314748341 - md5: 0abed5d78c07a64e85c54f705ba14d30 - depends: - - libgcc >=13 - - libstdcxx >=13 - license: BSD-2-Clause - license_family: BSD - size: 774512 - timestamp: 1739400731652 - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.0-h26f9b46_0.conda sha256: a47271202f4518a484956968335b2521409c8173e123ab381e775c358c67fe6d md5: 9ee58d5c534af06558933af3c845a780 @@ -4087,35 +2402,6 @@ packages: license_family: Apache size: 3165399 timestamp: 1762839186699 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.0-h8e36d6e_0.conda - sha256: 8dd3b4c31fe176a3e51c5729b2c7f4c836a2ce3bd5c82082dc2a503ba9ee0af3 - md5: 7624c6e01aecba942e9115e0f5a2af9d - depends: - - ca-certificates - - libgcc >=14 - license: Apache-2.0 - license_family: Apache - size: 3705625 - timestamp: 1762841024958 -- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda - sha256: 289861ed0c13a15d7bbb408796af4de72c2fe67e2bcb0de98f4c3fce259d7991 - md5: 58335b26c38bf4a20f399384c33cbcf9 - depends: - - python >=3.8 - - python - license: Apache-2.0 - license_family: APACHE - size: 62477 - timestamp: 1745345660407 -- conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - sha256: f6fef1b43b0d3d92476e1870c08d7b9c229aebab9a0556b073a5e1641cf453bd - md5: c3f35453097faf911fd3f6023fc2ab24 - depends: - - python >=3.9 - license: MIT - license_family: MIT - size: 18865 - timestamp: 1734618649164 - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda sha256: 3613774ad27e48503a3a6a9d72017087ea70f1426f6e5541dbdb59a3b626eaaf md5: 79f71230c069a287efe3a8614069ddf1 @@ -4136,34 +2422,6 @@ packages: license: LGPL-2.1-or-later size: 455420 timestamp: 1751292466873 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pango-1.56.4-he55ef5b_0.conda - sha256: dd36cd5b6bc1c2988291a6db9fa4eb8acade9b487f6f1da4eaa65a1eebb0a12d - md5: a22cc88bf6059c9bcc158c94c9aab5b8 - depends: - - cairo >=1.18.4,<2.0a0 - - fontconfig >=2.15.0,<3.0a0 - - fonts-conda-ecosystem - - fribidi >=1.0.10,<2.0a0 - - harfbuzz >=11.0.1 - - libexpat >=2.7.0,<3.0a0 - - libfreetype >=2.13.3 - - libfreetype6 >=2.13.3 - - libgcc >=13 - - libglib >=2.84.2,<3.0a0 - - libpng >=1.6.49,<1.7.0a0 - - libzlib >=1.3.1,<2.0a0 - license: LGPL-2.1-or-later - size: 468811 - timestamp: 1751293869070 -- conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda - sha256: 9f64009cdf5b8e529995f18e03665b03f5d07c0b17445b8badef45bde76249ee - md5: 617f15191456cc6a13db418a275435e5 - depends: - - python >=3.9 - license: MPL-2.0 - license_family: MOZILLA - size: 41075 - timestamp: 1733233471940 - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda sha256: 5e6f7d161356fefd981948bea5139c5aa0436767751a6930cb1ca801ebb113ff md5: 7a3bff861a6583f1889021facefc08b1 @@ -4176,17 +2434,6 @@ packages: license_family: BSD size: 1222481 timestamp: 1763655398280 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.47-hf841c20_0.conda - sha256: 04df2cee95feba440387f33f878e9f655521e69f4be33a0cd637f07d3d81f0f9 - md5: 1a30c42e32ca0ea216bd0bfe6f842f0b - depends: - - bzip2 >=1.0.8,<2.0a0 - - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - size: 1166552 - timestamp: 1763655534263 - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda sha256: 43d37bc9ca3b257c5dd7bf76a8426addbdec381f6786ff441dc90b1a49143b6a md5: c01af13bdc553d1a8fbfff6e8db075f0 @@ -4199,17 +2446,6 @@ packages: license_family: MIT size: 450960 timestamp: 1754665235234 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda - sha256: e6b0846a998f2263629cfeac7bca73565c35af13251969f45d385db537a514e4 - md5: 1587081d537bd4ae77d1c0635d465ba5 - depends: - - libgcc >=14 - - libstdcxx >=14 - - libgcc >=14 - license: MIT - license_family: MIT - size: 357913 - timestamp: 1754665583353 - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda sha256: c9601efb1af5391317e04eca77c6fe4d716bf1ca1ad8da2a05d15cb7c28d7d4e md5: 1bee70681f504ea424fb07cdb090c001 @@ -4220,40 +2456,6 @@ packages: license_family: GPL size: 115175 timestamp: 1720805894943 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pkg-config-0.29.2-hce167ba_1009.conda - sha256: 6468cbfaf1d3140be46dd315ec383d373dbbafd770ce2efe77c3f0cdbc4576c1 - md5: 05eda637f6465f7e8c5ab7e341341ea9 - depends: - - libgcc-ng >=12 - - libglib >=2.80.3,<3.0a0 - license: GPL-2.0-or-later - license_family: GPL - size: 54834 - timestamp: 1720806008171 -- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda - sha256: 04c64fb78c520e5c396b6e07bc9082735a5cc28175dbe23138201d0a9441800b - md5: 1bd2e65c8c7ef24f4639ae6e850dacc2 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - size: 23922 - timestamp: 1764950726246 -- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda - sha256: 5b81b7516d4baf43d0c185896b245fa7384b25dc5615e7baa504b7fa4e07b706 - md5: 7f3ac694319c7eaf81a0325d6405e974 - depends: - - cfgv >=2.0.0 - - identify >=1.0.0 - - nodeenv >=0.11.1 - - python >=3.10 - - pyyaml >=5.1 - - virtualenv >=20.10.0 - license: MIT - license_family: MIT - size: 200827 - timestamp: 1765937577534 - conda: https://conda.anaconda.org/conda-forge/linux-64/process-compose-1.64.1-h643be8f_0.conda sha256: 8de80ca53278093f810bb93f25f18851719f3847a75e24e69d675f29395aad23 md5: db2ed75f8cfa34b19fa8b76c687b8a5a @@ -4263,13 +2465,6 @@ packages: license_family: APACHE size: 8287352 timestamp: 1746946077894 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/process-compose-1.64.1-hb5cd7dd_0.conda - sha256: 39df9a47c7bcd0ad3cfafed5f083d18be363237f34eedee0ff56c356908e3225 - md5: 17887ab4a067b595db691d1d8f3715a2 - license: Apache-2.0 - license_family: APACHE - size: 7683265 - timestamp: 1746946096767 - conda: https://conda.anaconda.org/conda-forge/linux-64/protobuf-6.31.1-py314h503b32b_2.conda sha256: 55c4d82eaa400d3d21701ce152397489b077177527564674aff8021fae536401 md5: 1699ff22b094378d3a4b20019a995cf3 @@ -4288,24 +2483,6 @@ packages: license_family: BSD size: 487685 timestamp: 1760393455342 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/protobuf-6.31.1-py314h0cf174a_2.conda - sha256: 57df564f7231ef64ab844c3db34ec5d527d332f57f675c3da626a64dc5b52af7 - md5: c214bbe8181c78539850bb90f2a7fdfa - depends: - - libabseil * cxx17* - - libabseil >=20250512.1,<20250513.0a0 - - libgcc >=14 - - libstdcxx >=14 - - libzlib >=1.3.1,<2.0a0 - - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - - python_abi 3.14.* *_cp314 - constrains: - - libprotobuf 6.31.1 - license: BSD-3-Clause - license_family: BSD - size: 497884 - timestamp: 1760393469054 - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda sha256: 9c88f8c64590e9567c6c80823f0328e58d3b1efb0e1c539c0315ceca764e0973 md5: b3c17d95b5a10c6e64a21fa17573e70e @@ -4316,15 +2493,6 @@ packages: license_family: MIT size: 8252 timestamp: 1726802366959 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda - sha256: 977dfb0cb3935d748521dd80262fe7169ab82920afd38ed14b7fee2ea5ec01ba - md5: bb5a90c93e3bac3d5690acf76b4a6386 - depends: - - libgcc >=13 - license: MIT - license_family: MIT - size: 8342 - timestamp: 1726803319942 - conda: https://conda.anaconda.org/conda-forge/linux-64/pugixml-1.15-h3f63f65_0.conda sha256: 23c98a5000356e173568dc5c5770b53393879f946f3ace716bbdefac2a8b23d2 md5: b11a4c6bf6f6f44e5e143f759ffa2087 @@ -4336,16 +2504,6 @@ packages: license_family: MIT size: 118488 timestamp: 1736601364156 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pugixml-1.15-h6ef32b0_0.conda - sha256: adc17205a87e064508d809fe5542b7cf49f9b9a458418f8448e2fc895fcd04f3 - md5: 53e14f45d38558aa2b9a15b07416e472 - depends: - - libgcc >=13 - - libstdcxx >=13 - license: MIT - license_family: MIT - size: 113424 - timestamp: 1737355438448 - conda: https://conda.anaconda.org/conda-forge/linux-64/pulseaudio-client-17.0-h9a6aba3_3.conda sha256: 0a0858c59805d627d02bdceee965dd84fde0aceab03a2f984325eec08d822096 md5: b8ea447fdf62e3597cb8d2fae4eb1a90 @@ -4364,63 +2522,6 @@ packages: license_family: LGPL size: 750785 timestamp: 1763148198088 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pulseaudio-client-17.0-hcf98165_3.conda - sha256: bb55db0dfe120f6063ad3ac74524b37c0bf92c6002cc059c31a5506f96a67f22 - md5: 8d73cfc699cd0a5ed2ea04bfb73eee0a - depends: - - dbus >=1.16.2,<2.0a0 - - libgcc >=14 - - libglib >=2.86.1,<3.0a0 - - libiconv >=1.18,<2.0a0 - - libsndfile >=1.2.2,<1.3.0a0 - - libsystemd0 >=257.10 - - libxcb >=1.17.0,<2.0a0 - constrains: - - pulseaudio 17.0 *_3 - license: LGPL-2.1-or-later - license_family: LGPL - size: 760306 - timestamp: 1763148231117 -- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda - sha256: 79db7928d13fab2d892592223d7570f5061c192f27b9febd1a418427b719acc6 - md5: 12c566707c80111f9799308d9e265aef - depends: - - python >=3.9 - - python - license: BSD-3-Clause - license_family: BSD - size: 110100 - timestamp: 1733195786147 -- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - sha256: 5577623b9f6685ece2697c6eb7511b4c9ac5fb607c9babc2646c811b428fd46a - md5: 6b6ece66ebcae2d5f326c77ef2c5a066 - depends: - - python >=3.9 - license: BSD-2-Clause - license_family: BSD - size: 889287 - timestamp: 1750615908735 -- conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.19.1-pyhd8ed1ab_0.conda - sha256: 91cef23b12e050411432920e370c52c36a603aee65d7cdedf61a8a9d138db53e - md5: f6b5b95cde9c86578b4d45ce9aa1501e - depends: - - markdown >=3.6 - - python >=3.10 - - pyyaml - license: MIT - license_family: MIT - size: 170266 - timestamp: 1765736761772 -- conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - sha256: ba3b032fa52709ce0d9fd388f63d330a026754587a2f461117cac9ab73d8d0d8 - md5: 461219d1a5bd61342293efa2c0c90eac - depends: - - __unix - - python >=3.9 - license: BSD-3-Clause - license_family: BSD - size: 21085 - timestamp: 1733217331982 - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.2-h32b2ec7_100_cp314.conda build_number: 100 sha256: a120fb2da4e4d51dd32918c149b04a08815fd2bd52099dad1334647984bb07f1 @@ -4448,84 +2549,6 @@ packages: size: 36790521 timestamp: 1765021515427 python_site_packages_path: lib/python3.14/site-packages -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.2-hb06a95a_100_cp314.conda - build_number: 100 - sha256: 41adf6ee7a953ef4f35551a4a910a196b0a75e1ded458df5e73ef321863cb3f2 - md5: 432459e6961a5bc4cfe7cd080aee721a - depends: - - bzip2 >=1.0.8,<2.0a0 - - ld_impl_linux-aarch64 >=2.36.1 - - libexpat >=2.7.3,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - liblzma >=5.8.1,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.51.1,<4.0a0 - - libuuid >=2.41.2,<3.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.4,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.2,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - zstd >=1.5.7,<1.6.0a0 - license: Python-2.0 - size: 37217543 - timestamp: 1765020325291 - python_site_packages_path: lib/python3.14/site-packages -- conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - sha256: d6a17ece93bbd5139e02d2bd7dbfa80bee1a4261dced63f65f679121686bf664 - md5: 5b8d21249ff20967101ffa321cab24e8 - depends: - - python >=3.9 - - six >=1.5 - - python - license: Apache-2.0 - license_family: APACHE - size: 233310 - timestamp: 1751104122689 -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - build_number: 8 - sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 - md5: 0539938c55b6b1a59b560e843ad864a4 - constrains: - - python 3.14.* *_cp314 - license: BSD-3-Clause - license_family: BSD - size: 6989 - timestamp: 1752805904792 -- conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - sha256: 8d2a8bf110cc1fc3df6904091dead158ba3e614d8402a83e51ed3a8aa93cdeb0 - md5: bc8e3267d44011051f2eb14d22fb0960 - depends: - - python >=3.9 - license: MIT - license_family: MIT - size: 189015 - timestamp: 1742920947249 -- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - sha256: 828af2fd7bb66afc9ab1c564c2046be391aaf66c0215f05afaf6d7a9a270fe2a - md5: b12f41c0d7fb5ab81709fcc86579688f - depends: - - python >=3.10.* - - yaml - track_features: - - pyyaml_no_compile - license: MIT - license_family: MIT - size: 45223 - timestamp: 1758891992558 -- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - sha256: 69ab63bd45587406ae911811fc4d4c1bf972d643fa57a009de7c01ac978c4edd - md5: e8e53c4150a1bba3b160eacf9d53a51b - depends: - - python >=3.9 - - pyyaml - license: MIT - license_family: MIT - size: 11137 - timestamp: 1747237061448 - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 md5: d7d95fc8287ea7bf33e0e7116d2b95ec @@ -4537,56 +2560,22 @@ packages: license_family: GPL size: 345073 timestamp: 1765813471974 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda - sha256: fe695f9d215e9a2e3dd0ca7f56435ab4df24f5504b83865e3d295df36e88d216 - md5: 3d49cad61f829f4f0e0611547a9cda12 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda + sha256: d5c73079c1dd2c2a313c3bfd81c73dbd066b7eb08d213778c8bff520091ae894 + md5: c1c9b02933fdb2cfb791d936c20e887e + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: MIT + license_family: MIT + size: 193775 + timestamp: 1748644872902 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.92.0-h53717f1_0.conda + sha256: c82a58098e06e887e41c4a08591218ec38e11c0bb0890c9ad0bd28ab9f261810 + md5: a78c3f096ec96b2b505a148fa3984101 depends: - - libgcc >=14 - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - size: 357597 - timestamp: 1765815673644 -- conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda - sha256: 8dc54e94721e9ab545d7234aa5192b74102263d3e704e6d0c8aa7008f2da2a7b - md5: db0c6b99149880c8ba515cf4abe93ee4 - depends: - - certifi >=2017.4.17 - - charset-normalizer >=2,<4 - - idna >=2.5,<4 - - python >=3.9 - - urllib3 >=1.21.1,<3 - constrains: - - chardet >=3.0.2,<6 - license: Apache-2.0 - license_family: APACHE - size: 59263 - timestamp: 1755614348400 -- conda: https://conda.anaconda.org/conda-forge/linux-64/rhash-1.4.6-hb9d3cd8_1.conda - sha256: d5c73079c1dd2c2a313c3bfd81c73dbd066b7eb08d213778c8bff520091ae894 - md5: c1c9b02933fdb2cfb791d936c20e887e - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - license: MIT - license_family: MIT - size: 193775 - timestamp: 1748644872902 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/rhash-1.4.6-h86ecc28_1.conda - sha256: 0fe6f40213f2d8af4fcb7388eeb782a4e496c8bab32c189c3a34b37e8004e5a4 - md5: 745d02c0c22ea2f28fbda2cb5dbec189 - depends: - - libgcc >=13 - license: MIT - license_family: MIT - size: 207475 - timestamp: 1748644952027 -- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.92.0-h53717f1_0.conda - sha256: c82a58098e06e887e41c4a08591218ec38e11c0bb0890c9ad0bd28ab9f261810 - md5: a78c3f096ec96b2b505a148fa3984101 - depends: - - __glibc >=2.17,<3.0.a0 - - gcc_impl_linux-64 + - __glibc >=2.17,<3.0.a0 + - gcc_impl_linux-64 - libgcc >=14 - libzlib >=1.3.1,<2.0a0 - rust-std-x86_64-unknown-linux-gnu 1.92.0 h2c6d0dc_0 @@ -4595,41 +2584,6 @@ packages: license_family: MIT size: 232828649 timestamp: 1765821075605 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/rust-1.92.0-h6cf38e9_0.conda - sha256: 981c225ced01310ff0f136ad2ddbd1eefe786448facb860edbbc8a9642549d82 - md5: 61f46f8777fa1f836657e334372a977e - depends: - - gcc_impl_linux-aarch64 - - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 - - rust-std-aarch64-unknown-linux-gnu 1.92.0 hbe8e118_0 - - sysroot_linux-aarch64 >=2.17 - license: MIT - license_family: MIT - size: 195814654 - timestamp: 1765824047879 -- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-unknown-linux-gnu-1.92.0-hbe8e118_0.conda - sha256: f8c7d9d4e6d92fe8b27d039c12b233f7dbda77c3b4c45a2cd0e3b74926111ce0 - md5: a03ac1fb6befa71e59c1e067d2ffa8b6 - depends: - - __unix - constrains: - - rust >=1.92.0,<1.92.1.0a0 - license: MIT - license_family: MIT - size: 37826357 - timestamp: 1765822105181 -- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.92.0-h2c6d0dc_0.conda - sha256: 19570f26206e2635f78d987233ba8960c684576f8571298a6108eed4967e7c9a - md5: ee54789987e177271d9f95ef7fd7fa31 - depends: - - __unix - constrains: - - rust >=1.92.0,<1.92.1.0a0 - license: MIT - license_family: MIT - size: 38587633 - timestamp: 1765820881154 - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl2-2.32.56-h54a6638_0.conda sha256: 987ad072939fdd51c92ea8d3544b286bb240aefda329f9b03a51d9b7e777f9de md5: cdd138897d94dc07d99afe7113a07bec @@ -4642,19 +2596,7 @@ packages: - libegl >=1.7.0,<2.0a0 license: Zlib size: 589145 - timestamp: 1757842881 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl2-2.32.56-h7ac5ae9_0.conda - sha256: 47f4ef4cd2313906840f146b18fee95c2a3a4fa9bd0afdb2d519e6c0aa8ca2ed - md5: 54747a3f3c468c5f446c78974c8c1234 - depends: - - libstdcxx >=14 - - libgcc >=14 - - sdl3 >=3.2.22,<4.0a0 - - libgl >=1.7.0,<2.0a0 - - libegl >=1.7.0,<2.0a0 - license: Zlib - size: 597756 - timestamp: 1757842928996 + timestamp: 1757842881000 - conda: https://conda.anaconda.org/conda-forge/linux-64/sdl3-3.2.24-h68140b3_0.conda sha256: 47156cd71d4e235f7ce6731f1f6bcf4ee1ff65c3c20b126ac66c86231d0d3d57 md5: eeb4cfa6070a7882ad50936c7ade65ec @@ -4683,40 +2625,6 @@ packages: license: Zlib size: 1936357 timestamp: 1759445826544 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl3-3.2.24-h506f210_0.conda - sha256: fb8915f5cb1aab477b6ba7b6176f2f324d4e50884502909aa0cf2c94c9f25205 - md5: e165931e7fdf10278063adfdafe02ae6 - depends: - - libstdcxx >=14 - - libgcc >=14 - - libusb >=1.0.29,<2.0a0 - - dbus >=1.16.2,<2.0a0 - - xorg-libxfixes >=6.0.2,<7.0a0 - - xorg-libx11 >=1.8.12,<2.0a0 - - libxkbcommon >=1.11.0,<2.0a0 - - libegl >=1.7.0,<2.0a0 - - xorg-libxcursor >=1.2.3,<2.0a0 - - libunwind >=1.8.3,<1.9.0a0 - - libgl >=1.7.0,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - pulseaudio-client >=17.0,<17.1.0a0 - - libvulkan-loader >=1.4.313.0,<2.0a0 - - liburing >=2.12,<2.13.0a0 - - libudev1 >=257.9 - - wayland >=1.24.0,<2.0a0 - - libdrm >=2.4.125,<2.5.0a0 - license: Zlib - size: 1929704 - timestamp: 1759445835424 -- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - sha256: 972560fcf9657058e3e1f97186cc94389144b46dbdf58c807ce62e83f977e863 - md5: 4de79c071274a53dcaf2a8c749d1499e - depends: - - python >=3.9 - license: MIT - license_family: MIT - size: 748788 - timestamp: 1748804951958 - conda: https://conda.anaconda.org/conda-forge/linux-64/shaderc-2025.3-h3e344bc_1.conda sha256: f0c29646e8696497ce10ba81a3242278783c6373b035aba92794f4099b2c8b60 md5: 9e03d0601c6e6582b4e86482e8a180aa @@ -4730,28 +2638,6 @@ packages: license_family: Apache size: 113547 timestamp: 1756649518540 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/shaderc-2025.3-h8c88b8f_1.conda - sha256: 460f1b433f8da3e64809b931b57ab875b119fab0d25af1bf58c6da65e14da554 - md5: 721cb8b53a40a0ea73618d8e321179a3 - depends: - - glslang >=15,<16.0a0 - - libgcc >=14 - - libstdcxx >=14 - - spirv-tools >=2025,<2026.0a0 - license: Apache-2.0 - license_family: Apache - size: 114964 - timestamp: 1756649548867 -- conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d - md5: 3339e3b65d58accf4ca4fb8748ab16b3 - depends: - - python >=3.9 - - python - license: MIT - license_family: MIT - size: 18455 - timestamp: 1753199211006 - conda: https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda sha256: 48f3f6a76c34b2cfe80de9ce7f2283ecb55d5ed47367ba91e8bb8104e12b8f11 md5: 98b6c9dc80eb87b2519b97bcf7e578dd @@ -4764,17 +2650,6 @@ packages: license_family: BSD size: 45829 timestamp: 1762948049098 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/snappy-1.2.2-he774c54_1.conda - sha256: a8a79c53852fb07286407907402caa5a96b6e22b518c4f010be40647f9ee3726 - md5: 3dec912091fb88614afa0af2712c1362 - depends: - - libgcc >=14 - - libstdcxx >=14 - - libgcc >=14 - license: BSD-3-Clause - license_family: BSD - size: 47096 - timestamp: 1762948094646 - conda: https://conda.anaconda.org/conda-forge/linux-64/spirv-tools-2025.4-hb700be7_0.conda sha256: aa0f0fc41646ef5a825d5725a2d06659df1c1084f15155936319e1909ac9cd16 md5: aace50912e0f7361d0d223e7f7cfa6e5 @@ -4788,18 +2663,6 @@ packages: license_family: APACHE size: 2248062 timestamp: 1759805790709 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/spirv-tools-2025.4-hfefdfc9_0.conda - sha256: 8132f3e06572896a4d9f672c5cb989c08bda2855e45eac95eed7012cfc5e5428 - md5: 6cbec31663722c23b6b4b217f6846e3c - depends: - - libgcc >=14 - - libstdcxx >=14 - constrains: - - spirv-headers >=1.4.328.0,<1.4.328.1.0a0 - license: Apache-2.0 - license_family: APACHE - size: 2511309 - timestamp: 1759805874123 - conda: https://conda.anaconda.org/conda-forge/linux-64/svt-av1-3.1.2-hecca717_0.conda sha256: 34e2e9c505cd25dba0a9311eb332381b15147cf599d972322a7c197aedfc8ce2 md5: 9859766c658e78fec9afa4a54891d920 @@ -4811,38 +2674,6 @@ packages: license_family: BSD size: 2741200 timestamp: 1756086702093 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/svt-av1-3.1.2-hfae3067_0.conda - sha256: e4b482062da7cf259f21465274a0f3613d1dbd8ea649aca6072625f5038ac40d - md5: 7602d3004ed53b3f8e5e0e04e5de4de7 - depends: - - libgcc >=14 - - libstdcxx >=14 - license: BSD-2-Clause - license_family: BSD - size: 2106252 - timestamp: 1756090698097 -- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 - md5: 13dc3adbc692664cd3beabd216434749 - depends: - - __glibc >=2.28 - - kernel-headers_linux-64 4.18.0 he073ed8_9 - - tzdata - license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later - license_family: GPL - size: 24008591 - timestamp: 1765578833462 -- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-aarch64-2.28-h585391f_9.conda - sha256: 1bd2db6b2e451247bab103e4a0128cf6c7595dd72cb26d70f7fadd9edd1d1bc3 - md5: fdf07ab944a222ff28c754914fdb0740 - depends: - - __glibc >=2.28 - - kernel-headers_linux-aarch64 4.18.0 h05a177a_9 - - tzdata - license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later - license_family: GPL - size: 23644746 - timestamp: 1765578629426 - conda: https://conda.anaconda.org/conda-forge/linux-64/tbb-2022.3.0-h8d10470_1.conda sha256: 2e3238234ae094d5a5f7c559410ea8875351b6bac0d9d0e576bf64b732b8029e md5: e3259be3341da4bc06c5b7a78c8bf1bd @@ -4855,17 +2686,6 @@ packages: license_family: APACHE size: 181262 timestamp: 1762509955687 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tbb-2022.3.0-h0eac15c_1.conda - sha256: 3fd3d1ba6b81c5edee8d8fa0d2757f7ba3bf4d4a8ecc68f515c90e737eaa02e4 - md5: eda1e9439d903e3fdd7ff9e086da2018 - depends: - - libgcc >=14 - - libhwloc >=2.12.1,<2.12.2.0a0 - - libstdcxx >=14 - license: Apache-2.0 - license_family: APACHE - size: 144223 - timestamp: 1762511489745 - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda sha256: 1544760538a40bcd8ace2b1d8ebe3eb5807ac268641f8acdc18c69c5ebfeaf64 md5: 86bc20552bf46075e3d92b67f089172d @@ -4879,34 +2699,6 @@ packages: license_family: BSD size: 3284905 timestamp: 1763054914403 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h561c983_103.conda - sha256: 154e73f6269f92ad5257aa2039278b083998fd19d371e150f307483fb93c07ae - md5: 631db4799bc2bfe4daccf80bb3cbc433 - depends: - - libgcc >=13 - - libzlib >=1.3.1,<2.0a0 - constrains: - - xorg-libx11 >=1.8.12,<2.0a0 - license: TCL - license_family: BSD - size: 3333495 - timestamp: 1763059192223 -- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 - md5: 0caa1af407ecff61170c9437a808404d - depends: - - python >=3.10 - - python - license: PSF-2.0 - license_family: PSF - size: 51692 - timestamp: 1756220668932 -- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-h8577fbf_0.conda - sha256: 50fad5db6734d1bb73df1cf5db73215e326413d4b2137933f70708aa1840e25b - md5: 338201218b54cadff2e774ac27733990 - license: LicenseRef-Public-Domain - size: 119204 - timestamp: 1765745742795 - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.0.1-py314h9891dd4_6.conda sha256: ef6753f6febaa74d35253e4e0dd09dc9497af8e370893bd97c479f59346daa57 md5: 28303a78c48916ab07b95ffdbffdfd6c @@ -4921,49 +2713,9 @@ packages: license_family: MIT size: 14762 timestamp: 1761594960135 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ukkonen-1.0.1-py314hd7d8586_6.conda - sha256: 0e323578e0def2dda684dad27f619dadea6ffa7364641c0ff6610d10aa285464 - md5: 64e3941607577d1fe2deb09f45a8c90d - depends: - - cffi - - libgcc >=14 - - libstdcxx >=14 - - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - size: 15565 - timestamp: 1761595014547 -- conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.2-pyhd8ed1ab_0.conda - sha256: f4302a80ee9b76279ad061df05003abc2a29cc89751ffab2fd2919b43455dac0 - md5: 4949ca7b83065cfe94ebe320aece8c72 - depends: - - backports.zstd >=1.0.0 - - brotli-python >=1.2.0 - - h2 >=4,<5 - - pysocks >=1.5.6,<2.0,!=1.5.7 - - python >=3.10 - license: MIT - license_family: MIT - size: 102842 - timestamp: 1765719817255 -- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda - sha256: 77193c99c6626c58446168d3700f9643d8c0dab1f6deb6b9dd039e6872781bfb - md5: cfccfd4e8d9de82ed75c8e2c91cab375 - depends: - - distlib >=0.3.7,<1 - - filelock >=3.12.2,<4 - - platformdirs >=3.9.1,<5 - - python >=3.10 - - typing_extensions >=4.13.2 - license: MIT - license_family: MIT - size: 4401341 - timestamp: 1761726489722 -- conda: https://conda.anaconda.org/conda-forge/linux-64/watchdog-6.0.0-py314hdafbbf9_2.conda - sha256: 45d141a99385d53bde38db3f8a45534f8401f27f4cb23a5c5c58f83599e574de - md5: 63ecab5c42d962e2c5bfdab8252c0880 +- conda: https://conda.anaconda.org/conda-forge/linux-64/watchdog-6.0.0-py314hdafbbf9_2.conda + sha256: 45d141a99385d53bde38db3f8a45534f8401f27f4cb23a5c5c58f83599e574de + md5: 63ecab5c42d962e2c5bfdab8252c0880 depends: - python >=3.14,<3.15.0a0 - python_abi 3.14.* *_cp314 @@ -4972,17 +2724,6 @@ packages: license_family: APACHE size: 151798 timestamp: 1763021736811 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/watchdog-6.0.0-py314ha42fa4b_2.conda - sha256: 3a49352d016ecbd0cf1dac23e7b890787f626133794d76f0dccf12c6f363027a - md5: 55928bb865d89e6dcbe32add5e5a4b71 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - pyyaml >=3.10 - license: Apache-2.0 - license_family: APACHE - size: 152046 - timestamp: 1763022885329 - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-hd6090a7_1.conda sha256: 3aa04ae8e9521d9b56b562376d944c3e52b69f9d2a0667f77b8953464822e125 md5: 035da2e4f5770f036ff704fa17aace24 @@ -4996,25 +2737,6 @@ packages: license_family: MIT size: 329779 timestamp: 1761174273487 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda - sha256: d94af8f287db764327ac7b48f6c0cd5c40da6ea2606afd34ac30671b7c85d8ee - md5: f6966cb1f000c230359ae98c29e37d87 - depends: - - libexpat >=2.7.1,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - libstdcxx >=14 - license: MIT - license_family: MIT - size: 331480 - timestamp: 1761174368396 -- conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.47-hd8ed1ab_0.conda - sha256: 9ab2c12053ea8984228dd573114ffc6d63df42c501d59fda3bf3aeb1eaa1d23e - md5: 7da1571f560d4ba3343f7f4c48a79c76 - license: MIT - license_family: MIT - size: 140476 - timestamp: 1765821981856 - conda: https://conda.anaconda.org/conda-forge/linux-64/x264-1!164.3095-h166bdaf_2.tar.bz2 sha256: 175315eb3d6ea1f64a6ce470be00fa2ee59980108f246d3072ab8b977cb048a5 md5: 6c99772d483f566d59e25037fea2c4b1 @@ -5024,15 +2746,6 @@ packages: license_family: GPL size: 897548 timestamp: 1660323080555 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/x264-1!164.3095-h4e544f5_2.tar.bz2 - sha256: b48f150db8c052c197691c9d76f59e252d3a7f01de123753d51ebf2eed1cf057 - md5: 0efaf807a0b5844ce5f605bd9b668281 - depends: - - libgcc-ng >=12 - license: GPL-2.0-or-later - license_family: GPL - size: 1000661 - timestamp: 1660324722559 - conda: https://conda.anaconda.org/conda-forge/linux-64/x265-3.5-h924138e_3.tar.bz2 sha256: 76c7405bcf2af639971150f342550484efac18219c0203c5ee2e38b8956fe2a0 md5: e7f6ed84d4623d52ee581325c1587a6b @@ -5043,16 +2756,6 @@ packages: license_family: GPL size: 3357188 timestamp: 1646609687141 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/x265-3.5-hdd96247_3.tar.bz2 - sha256: cb2227f2441499900bdc0168eb423d7b2056c8fd5a3541df4e2d05509a88c668 - md5: 786853760099c74a1d4f0da98dd67aea - depends: - - libgcc-ng >=10.3.0 - - libstdcxx-ng >=10.3.0 - license: GPL-2.0-or-later - license_family: GPL - size: 1018181 - timestamp: 1646610147365 - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.46-hb03c661_0.conda sha256: aa03b49f402959751ccc6e21932d69db96a65a67343765672f7862332aa32834 md5: 71ae752a748962161b4740eaff510258 @@ -5064,16 +2767,6 @@ packages: license_family: MIT size: 396975 timestamp: 1759543819846 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.46-he30d5cf_0.conda - sha256: c440a757d210e84c7f315ac3b034266980a8b4c986600649d296b9198b5b4f5e - md5: 9524f30d9dea7dd5d6ead43a8823b6c2 - depends: - - libgcc >=14 - - xorg-libx11 >=1.8.12,<2.0a0 - license: MIT - license_family: MIT - size: 396706 - timestamp: 1759543850920 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda sha256: c12396aabb21244c212e488bbdc4abcdef0b7404b15761d9329f5a4a39113c4b md5: fb901ff28063514abb6046c9ec2c4a45 @@ -5084,15 +2777,6 @@ packages: license_family: MIT size: 58628 timestamp: 1734227592886 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libice-1.1.2-h86ecc28_0.conda - sha256: a2ba1864403c7eb4194dacbfe2777acf3d596feae43aada8d1b478617ce45031 - md5: c8d8ec3e00cd0fd8a231789b91a7c5b7 - depends: - - libgcc >=13 - license: MIT - license_family: MIT - size: 60433 - timestamp: 1734229908988 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda sha256: 277841c43a39f738927145930ff963c5ce4c4dacf66637a3d95d802a64173250 md5: 1c74ff8c35dcadf952a16f752ca5aa49 @@ -5105,17 +2789,6 @@ packages: license_family: MIT size: 27590 timestamp: 1741896361728 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libsm-1.2.6-h0808dbd_0.conda - sha256: b86a819cd16f90c01d9d81892155126d01555a20dabd5f3091da59d6309afd0a - md5: 2d1409c50882819cb1af2de82e2b7208 - depends: - - libgcc >=13 - - libuuid >=2.38.1,<3.0a0 - - xorg-libice >=1.1.2,<2.0a0 - license: MIT - license_family: MIT - size: 28701 - timestamp: 1741897678254 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda sha256: 51909270b1a6c5474ed3978628b341b4d4472cd22610e5f22b506855a5e20f67 md5: db038ce880f100acc74dba10302b5630 @@ -5127,16 +2800,6 @@ packages: license_family: MIT size: 835896 timestamp: 1741901112627 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.12-hca56bd8_0.conda - sha256: 452977d8ad96f04ec668ba74f46e70a53e00f99c0e0307956aeca75894c8131d - md5: 3df132f0048b9639bc091ef22937c111 - depends: - - libgcc >=13 - - libxcb >=1.17.0,<2.0a0 - license: MIT - license_family: MIT - size: 864850 - timestamp: 1741901264068 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda sha256: 6bc6ab7a90a5d8ac94c7e300cc10beb0500eeba4b99822768ca2f2ef356f731b md5: b2895afaf55bf96a8c8282a2e47a5de0 @@ -5147,15 +2810,6 @@ packages: license_family: MIT size: 15321 timestamp: 1762976464266 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxau-1.0.12-he30d5cf_1.conda - sha256: e9f6e931feeb2f40e1fdbafe41d3b665f1ab6cb39c5880a1fcf9f79a3f3c84a5 - md5: 1c246e1105000c3660558459e2fd6d43 - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 16317 - timestamp: 1762977521691 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda sha256: 832f538ade441b1eee863c8c91af9e69b356cd3e9e1350fff4fe36cc573fc91a md5: 2ccd714aa2242315acaf0a67faea780b @@ -5169,18 +2823,6 @@ packages: license_family: MIT size: 32533 timestamp: 1730908305254 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcursor-1.2.3-h86ecc28_0.conda - sha256: c5d3692520762322a9598e7448492309f5ee9d8f3aff72d787cf06e77c42507f - md5: f2054759c2203d12d0007005e1f1296d - depends: - - libgcc >=13 - - xorg-libx11 >=1.8.9,<2.0a0 - - xorg-libxfixes >=6.0.1,<7.0a0 - - xorg-libxrender >=0.9.11,<0.10.0a0 - license: MIT - license_family: MIT - size: 34596 - timestamp: 1730908388714 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda sha256: 43b9772fd6582bf401846642c4635c47a9b0e36ca08116b3ec3df36ab96e0ec0 md5: b5fcc7172d22516e1f965490e65e33a4 @@ -5194,18 +2836,6 @@ packages: license_family: MIT size: 13217 timestamp: 1727891438799 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdamage-1.1.6-h86ecc28_0.conda - sha256: 3afaa2f43eb4cb679fc0c3d9d7c50f0f2c80cc5d3df01d5d5fd60655d0bfa9be - md5: d5773c4e4d64428d7ddaa01f6f845dc7 - depends: - - libgcc >=13 - - xorg-libx11 >=1.8.9,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - xorg-libxfixes >=6.0.1,<7.0a0 - license: MIT - license_family: MIT - size: 13794 - timestamp: 1727891406431 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda sha256: 25d255fb2eef929d21ff660a0c687d38a6d2ccfbcbf0cc6aa738b12af6e9d142 md5: 1dafce8548e38671bea82e3f5c6ce22f @@ -5216,15 +2846,6 @@ packages: license_family: MIT size: 20591 timestamp: 1762976546182 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdmcp-1.1.5-he30d5cf_1.conda - sha256: 128d72f36bcc8d2b4cdbec07507542e437c7d67f677b7d77b71ed9eeac7d6df1 - md5: bff06dcde4a707339d66d45d96ceb2e2 - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 21039 - timestamp: 1762979038025 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda sha256: da5dc921c017c05f38a38bd75245017463104457b63a1ce633ed41f214159c14 md5: febbab7d15033c913d53c7a2c102309d @@ -5236,16 +2857,6 @@ packages: license_family: MIT size: 50060 timestamp: 1727752228921 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.6-h57736b2_0.conda - sha256: 8e216b024f52e367463b4173f237af97cf7053c77d9ce3e958bc62473a053f71 - md5: bd1e86dd8aa3afd78a4bfdb4ef918165 - depends: - - libgcc >=13 - - xorg-libx11 >=1.8.9,<2.0a0 - license: MIT - license_family: MIT - size: 50746 - timestamp: 1727754268156 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda sha256: 83c4c99d60b8784a611351220452a0a85b080668188dce5dfa394b723d7b64f4 md5: ba231da7fccf9ea1e768caf5c7099b84 @@ -5257,16 +2868,6 @@ packages: license_family: MIT size: 20071 timestamp: 1759282564045 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda - sha256: 8cb9c88e25c57e47419e98f04f9ef3154ad96b9f858c88c570c7b91216a64d0e - md5: e8b4056544341daf1d415eaeae7a040c - depends: - - libgcc >=14 - - xorg-libx11 >=1.8.12,<2.0a0 - license: MIT - license_family: MIT - size: 20704 - timestamp: 1759284028146 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda sha256: ac0f037e0791a620a69980914a77cb6bb40308e26db11698029d6708f5aa8e0d md5: 2de7f99d6581a4a7adbff607b5c278ca @@ -5280,18 +2881,6 @@ packages: license_family: MIT size: 29599 timestamp: 1727794874300 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.4-h86ecc28_0.conda - sha256: b2588a2b101d1b0a4e852532c8b9c92c59ef584fc762dd700567bdbf8cd00650 - md5: dd3e74283a082381aa3860312e3c721e - depends: - - libgcc >=13 - - xorg-libx11 >=1.8.9,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - - xorg-libxrender >=0.9.11,<0.10.0a0 - license: MIT - license_family: MIT - size: 30197 - timestamp: 1727794957221 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda sha256: 044c7b3153c224c6cedd4484dd91b389d2d7fd9c776ad0f4a34f099b3389f4a1 md5: 96d57aba173e878a2089d5638016dc5e @@ -5303,16 +2892,6 @@ packages: license_family: MIT size: 33005 timestamp: 1734229037766 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda - sha256: ffd77ee860c9635a28cfda46163dcfe9224dc6248c62404c544ae6b564a0be1f - md5: ae2c2dd0e2d38d249887727db2af960e - depends: - - libgcc >=13 - - xorg-libx11 >=1.8.10,<2.0a0 - license: MIT - license_family: MIT - size: 33649 - timestamp: 1734229123157 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxscrnsaver-1.2.4-hb9d3cd8_0.conda sha256: 58e8fc1687534124832d22e102f098b5401173212ac69eb9fd96b16a3e2c8cb2 md5: 303f7a0e9e0cd7d250bb6b952cecda90 @@ -5335,15 +2914,6 @@ packages: license_family: MIT size: 12302 timestamp: 1734168591429 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxshmfence-1.3.3-h86ecc28_0.conda - sha256: 9057e4a85a093d733719228d44aa09924e879ee769a9bb79ef7035cd0f260c8e - md5: c11f706a5072c34a559848f27d6c28c3 - depends: - - libgcc >=13 - license: MIT - license_family: MIT - size: 13805 - timestamp: 1734168631514 - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.6-hb9d3cd8_0.conda sha256: 8a4e2ee642f884e6b78c20c0892b85dd9b2a6e64a6044e903297e616be6ca35b md5: 5efa5fa6243a622445fdfd72aee15efa @@ -5356,17 +2926,6 @@ packages: license_family: MIT size: 17819 timestamp: 1734214575628 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda - sha256: 012f0d1fd9fb1d949e0dccc0b28d9dd5a8895a1f3e2a7edc1fa2e1b33fc0f233 - md5: d745faa2d7c15092652e40a22bb261ed - depends: - - libgcc >=13 - - xorg-libx11 >=1.8.10,<2.0a0 - - xorg-libxext >=1.3.6,<2.0a0 - license: MIT - license_family: MIT - size: 18185 - timestamp: 1734214652726 - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad md5: a77f85f77be52ff59391544bfe73390a @@ -5377,25 +2936,6 @@ packages: license_family: MIT size: 85189 timestamp: 1753484064210 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/yaml-0.2.5-h80f16a2_3.conda - sha256: 66265e943f32ce02396ad214e27cb35f5b0490b3bd4f064446390f9d67fa5d88 - md5: 032d8030e4a24fe1f72c74423a46fb88 - depends: - - libgcc >=14 - license: MIT - license_family: MIT - size: 88088 - timestamp: 1753484092643 -- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - sha256: b4533f7d9efc976511a73ef7d4a2473406d7f4c750884be8e8620b0ce70f4dae - md5: 30cd29cb87d819caead4d55184c1d115 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - size: 24194 - timestamp: 1764460141901 - conda: https://conda.anaconda.org/conda-forge/linux-64/zlib-1.3.1-hb9d3cd8_2.conda sha256: 5d7c0e5f0005f74112a34a7425179f4eb6e73c92f5d109e6af4ddeca407c92ab md5: c9f075ab2f33b3bbee9e62d4ad0a6cd8 @@ -5407,16 +2947,6 @@ packages: license_family: Other size: 92286 timestamp: 1727963153079 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.1-h86ecc28_2.conda - sha256: b4f649aa3ecdae384d5dad7074e198bff120edd3dfb816588e31738fc6d627b1 - md5: bc230abb5d21b63ff4799b0e75204783 - depends: - - libgcc >=13 - - libzlib 1.3.1 h86ecc28_2 - license: Zlib - license_family: Other - size: 95582 - timestamp: 1727963203597 - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 @@ -5427,12 +2957,4604 @@ packages: license_family: BSD size: 601375 timestamp: 1764777111296 -- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda - sha256: 569990cf12e46f9df540275146da567d9c618c1e9c7a0bc9d9cfefadaed20b75 - md5: c3655f82dcea2aa179b291e7099c1fcc - depends: +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2 + build_number: 16 + sha256: 3702bef2f0a4d38bd8288bbe54aace623602a1343c2cfbefd3fa188e015bebf0 + md5: 6168d71addc746e8f2b8d57dfd2edcea + depends: + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 + license: BSD-3-Clause + license_family: BSD + size: 23712 + timestamp: 1650670790230 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/alsa-lib-1.2.15.1-he30d5cf_0.conda + sha256: cb8c79ff99e2e36958e088278971cfec4aeed8e44084e968c906b7bbc3cd8de1 + md5: 50a88426e78ae8eb7d52072ba2e8db21 + depends: + - libgcc >=14 + license: LGPL-2.1-or-later + license_family: GPL + size: 615491 + timestamp: 1766156819056 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/aom-3.9.1-hcccb83c_0.conda + sha256: ac438ce5d3d3673a9188b535fc7cda413b479f0d52536aeeac1bd82faa656ea0 + md5: cc744ac4efe5bcaa8cca51ff5b850df0 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: BSD-2-Clause + license_family: BSD + size: 3250813 + timestamp: 1718551360260 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/attr-2.5.1-h4e544f5_1.tar.bz2 + sha256: 2c793b48e835a8fac93f1664c706442972a0206963bf8ca202e83f7f4d29a7d7 + md5: 1ef6c06fec1b6f5ee99ffe2152e53568 + depends: + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + size: 74992 + timestamp: 1660065534958 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/binutils_impl_linux-aarch64-2.45-default_h5f4c503_104.conda + sha256: b7694c53943941a5234406b77b168e28d92227f8e69c697edda3faf436dd26c1 + md5: 8107322440b07ab4234815368d1785a9 + depends: + - ld_impl_linux-aarch64 2.45 default_h1979696_104 + - sysroot_linux-aarch64 + - zstd >=1.5.7,<1.6.0a0 + license: GPL-3.0-only + license_family: GPL + size: 4850743 + timestamp: 1764007931341 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/brotli-python-1.2.0-py314h352cb57_1.conda + sha256: 5a5b0cdcd7ed89c6a8fb830924967f6314a2b71944bc1ebc2c105781ba97aa75 + md5: a1b5c571a0923a205d663d8678df4792 + depends: + - libgcc >=14 + - libstdcxx >=14 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - libbrotlicommon 1.2.0 he30d5cf_1 + license: MIT + license_family: MIT + size: 373193 + timestamp: 1764017486851 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda + sha256: d2a296aa0b5f38ed9c264def6cf775c0ccb0f110ae156fcde322f3eccebf2e01 + md5: 2921ac0b541bf37c69e66bd6d9a43bca + depends: + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + size: 192536 + timestamp: 1757437302703 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/c-ares-1.34.6-he30d5cf_0.conda + sha256: 7ec8a68efe479e2e298558cbc2e79d29430d5c7508254268818c0ae19b206519 + md5: 1dfbec0d08f112103405756181304c16 + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 217215 + timestamp: 1765214743735 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda + sha256: 37cfff940d2d02259afdab75eb2dbac42cf830adadee78d3733d160a1de2cc66 + md5: cd55953a67ec727db5dc32b167201aa6 + depends: + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - freetype >=2.12.1,<3.0a0 + - icu >=75.1,<76.0a0 + - libexpat >=2.6.4,<3.0a0 + - libgcc >=13 + - libglib >=2.82.2,<3.0a0 + - libpng >=1.6.47,<1.7.0a0 + - libstdcxx >=13 + - libxcb >=1.17.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.44.2,<1.0a0 + - xorg-libice >=1.1.2,<2.0a0 + - xorg-libsm >=1.2.5,<2.0a0 + - xorg-libx11 >=1.8.11,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + license: LGPL-2.1-only or MPL-1.1 + size: 966667 + timestamp: 1741554768968 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cffi-2.0.0-py314h0bd77cf_1.conda + sha256: 728e55b32bf538e792010308fbe55d26d02903ddc295fbe101167903a123dd6f + md5: f333c475896dbc8b15efd8f7c61154c7 + depends: + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 318357 + timestamp: 1761203973223 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cmake-4.2.1-hc9d863e_0.conda + sha256: 4e9c507d0a37fd7b05e17e965e60ca4409abbd0008433dd485a07f461605b367 + md5: f3adb6a5d8c22853698ad18217b738d0 + depends: + - bzip2 >=1.0.8,<2.0a0 + - libcurl >=8.17.0,<9.0a0 + - libexpat >=2.7.3,<3.0a0 + - libgcc >=14 + - liblzma >=5.8.1,<6.0a0 + - libstdcxx >=14 + - libuv >=1.51.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - rhash >=1.4.6,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 21474851 + timestamp: 1765229065445 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dav1d-1.2.1-h31becfc_0.conda + sha256: 33fe66d025cf5bac7745196d1a3dd7a437abcf2dbce66043e9745218169f7e17 + md5: 6e5a87182d66b2d1328a96b61ca43a62 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + size: 347363 + timestamp: 1685696690003 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-h70963c4_1.conda + sha256: 3af801577431af47c0b72a82bb93c654f03072dece0a2a6f92df8a6802f52a22 + md5: a4b6b82427d15f0489cef0df2d82f926 + depends: + - libstdcxx >=14 + - libgcc >=14 + - libglib >=2.86.2,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + license: AFL-2.1 OR GPL-2.0-or-later + size: 480416 + timestamp: 1764536098891 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/expat-2.7.3-hfae3067_0.conda + sha256: a113f31d0d2645e9991ad8685ca5a8699a70ddc314f87317702d6e36fbcfeb88 + md5: b3389e27c0cf1f8df60114cf03ed7575 + depends: + - libexpat 2.7.3 hfae3067_0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 137213 + timestamp: 1763549921101 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ffmpeg-8.0.0-gpl_h8d881e6_905.conda + sha256: 4d3ca22b46f8319cbdcc14a15378d0d89520a21a756d338ad5589a343290251f + md5: 466d482f18378eba3c422dd5ed281691 + depends: + - alsa-lib >=1.2.14,<1.3.0a0 + - aom >=3.9.1,<3.10.0a0 + - bzip2 >=1.0.8,<2.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - gmp >=6.3.0,<7.0a0 + - harfbuzz >=11.4.5 + - lame >=3.100,<3.101.0a0 + - libass >=0.17.4,<0.17.5.0a0 + - libexpat >=2.7.1,<3.0a0 + - libfreetype >=2.13.3 + - libfreetype6 >=2.13.3 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libopenvino >=2025.2.0,<2025.2.1.0a0 + - libopenvino-arm-cpu-plugin >=2025.2.0,<2025.2.1.0a0 + - libopenvino-auto-batch-plugin >=2025.2.0,<2025.2.1.0a0 + - libopenvino-auto-plugin >=2025.2.0,<2025.2.1.0a0 + - libopenvino-hetero-plugin >=2025.2.0,<2025.2.1.0a0 + - libopenvino-ir-frontend >=2025.2.0,<2025.2.1.0a0 + - libopenvino-onnx-frontend >=2025.2.0,<2025.2.1.0a0 + - libopenvino-paddle-frontend >=2025.2.0,<2025.2.1.0a0 + - libopenvino-pytorch-frontend >=2025.2.0,<2025.2.1.0a0 + - libopenvino-tensorflow-frontend >=2025.2.0,<2025.2.1.0a0 + - libopenvino-tensorflow-lite-frontend >=2025.2.0,<2025.2.1.0a0 + - libopus >=1.5.2,<2.0a0 + - librsvg >=2.58.4,<3.0a0 + - libstdcxx >=14 + - libvorbis >=1.3.7,<1.4.0a0 + - libvpx >=1.14.1,<1.15.0a0 + - libxcb >=1.17.0,<2.0a0 + - libxml2 >=2.13.8,<2.14.0a0 + - libzlib >=1.3.1,<2.0a0 + - openh264 >=2.6.0,<2.6.1.0a0 + - openssl >=3.5.2,<4.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - sdl2 >=2.32.54,<3.0a0 + - shaderc >=2025.3,<2025.4.0a0 + - svt-av1 >=3.1.2,<3.1.3.0a0 + - x264 >=1!164.3095,<1!165 + - x265 >=3.5,<3.6.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + constrains: + - __cuda >=12.8 + license: GPL-2.0-or-later + license_family: GPL + size: 12010889 + timestamp: 1757195113491 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda + sha256: fe023bb8917c8a3138af86ef537b70c8c5d60c44f93946a87d1e8bb1a6634b55 + md5: 112b71b6af28b47c624bcbeefeea685b + depends: + - freetype >=2.12.1,<3.0a0 + - libexpat >=2.6.3,<3.0a0 + - libgcc >=13 + - libuuid >=2.38.1,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 277832 + timestamp: 1730284967179 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda + sha256: 9f8de35e95ce301cecfe01bc9d539c7cc045146ffba55efe9733ff77ad1cfb21 + md5: 0c8f36ebd3678eed1685f0fc93fc2175 + depends: + - libfreetype 2.14.1 h8af1aa0_0 + - libfreetype6 2.14.1 hdae7a39_0 + license: GPL-2.0-only OR FTL + size: 173174 + timestamp: 1757945489158 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fribidi-1.0.16-he30d5cf_0.conda + sha256: 1bfcd715bcb49a0b22d5d1899a22c6ff884b06f8e141eb746f3949752469a422 + md5: f3ac54914f7d3e1d68cb8d891765e5f9 + depends: + - libgcc >=14 + license: LGPL-2.1-or-later + size: 62909 + timestamp: 1757438620177 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gcc_impl_linux-aarch64-15.2.0-habb1d5c_16.conda + sha256: 9b7e56534fa3029e0caf6dbbf4daa2d567e630672f977f01ad0c356933fb1b0d + md5: af391ca6347927b4e067a8be221d1b3a + depends: + - binutils_impl_linux-aarch64 >=2.45 + - libgcc >=15.2.0 + - libgcc-devel_linux-aarch64 15.2.0 h55c397f_116 + - libgomp >=15.2.0 + - libsanitizer 15.2.0 he19c465_16 + - libstdcxx >=15.2.0 + - libstdcxx-devel_linux-aarch64 15.2.0 ha7b1723_116 + - sysroot_linux-aarch64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 74461928 + timestamp: 1765257095042 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gdk-pixbuf-2.44.4-h90308e0_0.conda + sha256: 78a1d69c3d0da73b4d54a35001abd4e273605180d21365b4f31e9a241d9fb715 + md5: 4c8c0d2f7620467869d41f29304362dc + depends: + - libgcc >=14 + - libglib >=2.86.0,<3.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libpng >=1.6.50,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 580454 + timestamp: 1761083738779 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gettext-0.25.1-h5ad3122_0.conda + sha256: 510e7eba15e6ba71cd5a2ae403128d56b3bb990878c8110f3abc652f823b4af8 + md5: 1e99d353785a5302bce1a5a86d249b2b + depends: + - gettext-tools 0.25.1 h5ad3122_0 + - libasprintf 0.25.1 h5e0f5ae_0 + - libasprintf-devel 0.25.1 h5e0f5ae_0 + - libgcc >=13 + - libgettextpo 0.25.1 h5ad3122_0 + - libgettextpo-devel 0.25.1 h5ad3122_0 + - libstdcxx >=13 + license: LGPL-2.1-or-later AND GPL-3.0-or-later + size: 534760 + timestamp: 1751557634743 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gettext-tools-0.25.1-h5ad3122_0.conda + sha256: 7b03cc531c9c2d567eb81dffe9f5688c83fbcdfa4882eec3a2045ec43218806f + md5: 4215d91c0eaae5274a36a3f211898c91 + depends: + - libgcc >=13 + license: GPL-3.0-or-later + license_family: GPL + size: 3999301 + timestamp: 1751557600737 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-2.86.3-hc66a092_0.conda + sha256: a777c72511a5ed8bd2f9a3ae2850e62e2705b3352fbd4fdb10b35ec6c84bdeba + md5: ec0c021efe7251a9be4e4f5219c54e4c + depends: + - glib-tools 2.86.3 hc87f4d4_0 + - libffi >=3.5.2,<3.6.0a0 + - libglib 2.86.3 hf53f6bf_0 + - packaging + - python * + license: LGPL-2.1-or-later + size: 624186 + timestamp: 1765221848944 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-networking-2.80.0-h27184f6_0.conda + sha256: feb66f3fe9aee4a6a8113bb71df07bd1919e9e33a011865cfafd5a7dc9588805 + md5: 8f96bb956ab9f94ea2f2e2b15202d3b8 + depends: + - gettext + - glib >=2.74.0 + - libgcc-ng >=12 + - libglib >=2.74.1,<3.0a0 + - libzlib >=1.2.13,<2.0.0a0 + - openssl >=3.2.1,<4.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 162731 + timestamp: 1710667824117 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-tools-2.86.3-hc87f4d4_0.conda + sha256: 96f13110da7c93b62e81b97f520c6da392a886917f4cf4fe87a224e5816c07b3 + md5: d8de978ec69147991fc5d9acb96dfb36 + depends: + - libgcc >=14 + - libglib 2.86.3 hf53f6bf_0 + license: LGPL-2.1-or-later + size: 127526 + timestamp: 1765221824012 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glslang-15.4.0-h9cbfd48_0.conda + sha256: b3b2efed30a9406cba0d6d87a0039e8bfc37b323e3f60ef0ff1fcf11c9d41394 + md5: 86c75de5d651ef9fe3dcbf006ce6ebdb + depends: + - libgcc >=13 + - libstdcxx >=13 + - spirv-tools >=2025,<2026.0a0 + license: BSD-3-Clause + license_family: BSD + size: 1314443 + timestamp: 1751107474911 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gmp-6.3.0-h0a1ffab_2.conda + sha256: a5e341cbf797c65d2477b27d99091393edbaa5178c7d69b7463bb105b0488e69 + md5: 7cbfb3a8bb1b78a7f5518654ac6725ad + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: GPL-2.0-or-later OR LGPL-3.0-or-later + size: 417323 + timestamp: 1718980707330 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphite2-1.3.14-hfae3067_2.conda + sha256: c9b1781fe329e0b77c5addd741e58600f50bef39321cae75eba72f2f381374b7 + md5: 4aa540e9541cc9d6581ab23ff2043f13 + depends: + - libgcc >=14 + - libstdcxx >=14 + license: LGPL-2.0-or-later + license_family: LGPL + size: 102400 + timestamp: 1755102000043 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-libav-1.26.6-hf1974e4_1.conda + sha256: 396da6fcef5ef61f2d0794db8edaae15be596413d06756ce7a817ee54161cda2 + md5: ecd48cd09a40b244252960ff210a7c66 + depends: + - expat + - ffmpeg >=8.0.0,<9.0a0 + - gst-plugins-base >=1.26.6,<1.27.0a0 + - gstreamer 1.26.6.* + - gstreamer >=1.26.6,<1.27.0a0 + - libexpat >=2.7.1,<3.0a0 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - liblzma >=5.8.1,<6.0a0 + - liblzma-devel + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 125880 + timestamp: 1762769510324 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-bad-1.26.6-hd7fd44e_0.conda + sha256: 4ba5c63928550c3d1897b332d47e2c1533d8f9aba95155e0e08e32eb315c519a + md5: beaf8c51985967a3c070250c27e2976a + depends: + - gst-plugins-base >=1.26.6,<1.27.0a0 + - gstreamer 1.26.6.* + - gstreamer >=1.26.6,<1.27.0a0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libexpat >=2.7.1,<3.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglib >=2.86.1,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libopus >=1.5.2,<2.0a0 + - libsndfile >=1.2.2,<1.3.0a0 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libxml2 >=2.13.9,<2.14.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxau >=1.0.12,<2.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + - xorg-libxxf86vm >=1.1.6,<2.0a0 + license: LGPL-2.1-or-later + size: 3297993 + timestamp: 1762180459303 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-base-1.26.6-hb189aef_0.conda + sha256: df183b29cac2ad2e76bb5145c3b229220c3ffdde22e943bea95adeefd08b4386 + md5: 8612184919d3ea505c302e418efc831c + depends: + - alsa-lib >=1.2.14,<1.3.0a0 + - gstreamer 1.26.6 hc24f651_0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libexpat >=2.7.1,<3.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglib >=2.86.1,<3.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libopus >=1.5.2,<2.0a0 + - libpng >=1.6.50,<1.7.0a0 + - libstdcxx >=14 + - libvorbis >=1.3.7,<1.4.0a0 + - libxcb >=1.17.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pango >=1.56.4,<2.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxau >=1.0.12,<2.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + - xorg-libxshmfence >=1.3.3,<2.0a0 + - xorg-libxxf86vm >=1.1.6,<2.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + size: 2897369 + timestamp: 1762010868865 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-good-1.26.6-h18b12b6_0.conda + sha256: 9b702a4f30517c515d0cb6db82150e638e7a290e616e6b4eaf7d7f2350d1cc25 + md5: 11eedd12f9ac0769fd3d311562fc45da + depends: + - bzip2 >=1.0.8,<2.0a0 + - glib-networking + - gst-plugins-base 1.26.6 hb189aef_0 + - gstreamer 1.26.6 hc24f651_0 + - lame >=3.100,<3.101.0a0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libexpat >=2.7.1,<3.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglib >=2.86.1,<3.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - libpng >=1.6.50,<1.7.0a0 + - libsoup >=3.4.4,<4.0a0 + - libstdcxx >=14 + - libvpx >=1.14.1,<1.15.0a0 + - libxcb >=1.17.0,<2.0a0 + - libxml2 >=2.13.9,<2.14.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxau >=1.0.12,<2.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + - xorg-libxshmfence >=1.3.3,<2.0a0 + - xorg-libxxf86vm >=1.1.6,<2.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + size: 2547962 + timestamp: 1762013550436 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gst-plugins-ugly-1.26.6-hd087be5_1.conda + sha256: 32ee6d4e2c31704e0be63048b93fd7fc1f635b90f0eb3c3a93933b04fcaf9704 + md5: 4db4926bbfc37758835f6fabcb3a0671 + depends: + - gst-plugins-base >=1.26.6,<1.27.0a0 + - gstreamer 1.26.6.* + - gstreamer >=1.26.6,<1.27.0a0 + - libdrm >=2.4.125,<2.5.0a0 + - libegl >=1.7.0,<2.0a0 + - libexpat >=2.7.1,<3.0a0 + - libgcc >=14 + - libgl >=1.7.0,<2.0a0 + - libglib >=2.86.1,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libxcb >=1.17.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - x264 >=1!164.3095,<1!165 + - xorg-libx11 >=1.8.12,<2.0a0 + - xorg-libxau >=1.0.12,<2.0a0 + - xorg-libxdamage >=1.1.6,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - xorg-libxrender >=0.9.12,<0.10.0a0 + - xorg-libxxf86vm >=1.1.6,<2.0a0 + license: LGPL-2.1-or-later + size: 172777 + timestamp: 1763220396305 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gstreamer-1.26.6-hc24f651_0.conda + sha256: 307fe215f986c9eb1e8f50f5a820cad349a2730c9db1cbb6cde20427a8ec47da + md5: 80d41f95bf79b9dc666eac9bfcd5685a + depends: + - glib >=2.86.1,<3.0a0 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + size: 2076345 + timestamp: 1762008407247 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda + sha256: 5cfd74a3fbce0921af5beff93a3fe7edc5b1344d9b9668b2de1c1be932b54993 + md5: 1437bf9690976948f90175a65407b65f + depends: + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.14,<2.0a0 + - icu >=75.1,<76.0a0 + - libexpat >=2.7.1,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 2156041 + timestamp: 1762376447693 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda + sha256: 813298f2e54ef087dbfc9cc2e56e08ded41de65cff34c639cc8ba4e27e4540c9 + md5: 268203e8b983fddb6412b36f2024e75c + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: MIT + license_family: MIT + size: 12282786 + timestamp: 1720853454991 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda + sha256: 5ce830ca274b67de11a7075430a72020c1fb7d486161a82839be15c2b84e9988 + md5: e7df0aab10b9cbb73ab2a467ebfaf8c7 + depends: + - libgcc >=13 + license: LGPL-2.1-or-later + size: 129048 + timestamp: 1754906002667 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda + sha256: 0ec272afcf7ea7fbf007e07a3b4678384b7da4047348107b2ae02630a570a815 + md5: 29c10432a2ca1472b53f299ffb2ffa37 + depends: + - keyutils >=1.6.1,<2.0a0 + - libedit >=3.1.20191231,<3.2.0a0 + - libedit >=3.1.20191231,<4.0a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + - openssl >=3.3.1,<4.0a0 + license: MIT + license_family: MIT + size: 1474620 + timestamp: 1719463205834 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lame-3.100-h4e544f5_1003.tar.bz2 + sha256: 2502904a42df6d94bd743f7b73915415391dd6d31d5f50cb57c0a54a108e7b0a + md5: ab05bcf82d8509b4243f07e93bada144 + depends: + - libgcc-ng >=12 + license: LGPL-2.0-only + license_family: LGPL + size: 604863 + timestamp: 1664997611416 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45-default_h1979696_104.conda + sha256: 7a13072581fa23f658a04f62f62c4677c57d3c9696fbc01cc954a88fc354b44d + md5: 28035705fe0c977ea33963489cd008ad + depends: + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-aarch64 2.45 + license: GPL-3.0-only + license_family: GPL + size: 875534 + timestamp: 1764007911054 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.0.0-hfdc4d58_1.conda + sha256: f01df5bbf97783fac9b89be602b4d02f94353f5221acfd80c424ec1c9a8d276c + md5: 60dceb7e876f4d74a9cbd42bbbc6b9cf + depends: + - libgcc >=13 + - libstdcxx >=13 + license: Apache-2.0 + license_family: Apache + size: 227184 + timestamp: 1745265544057 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libabseil-20250512.1-cxx17_h201e9ed_0.conda + sha256: 28bb0a5f3177bb3b45a89d309b93bef65645671d1c97ae7bbcfa74481bf33f3c + md5: 4db30fe7ba05e2ce66595ed646064861 + depends: + - libgcc >=13 + - libstdcxx >=13 + constrains: + - abseil-cpp =20250512.1 + - libabseil-static =20250512.1=cxx17* + license: Apache-2.0 + license_family: Apache + size: 1327580 + timestamp: 1750194149128 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libasprintf-0.25.1-h5e0f5ae_0.conda + sha256: 146be90c237cf3d8399e44afe5f5d21ef9a15a7983ccea90e72d4ae0362f9b28 + md5: 1c5813f6be57f087b6659593248daf00 + depends: + - libgcc >=13 + - libstdcxx >=13 + license: LGPL-2.1-or-later + size: 53434 + timestamp: 1751557548397 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libasprintf-devel-0.25.1-h5e0f5ae_0.conda + sha256: cc2bb8ca349ba4dd4af7971a3dba006bc8643353acd9757b4d645a817ec0f899 + md5: 5df92d925fba917586f3ca31c96d8e6d + depends: + - libasprintf 0.25.1 h5e0f5ae_0 + - libgcc >=13 + license: LGPL-2.1-or-later + size: 34824 + timestamp: 1751557562978 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libass-0.17.4-hcfe818d_0.conda + sha256: cb19ad0b8f9cb469c78d26af9c49c790e5f746bb8a348ec10b681a98f05d1dc7 + md5: 8df67d209c9f7e8d40281a4ebf8ffd6d + depends: + - libgcc >=13 + - libiconv >=1.18,<2.0a0 + - harfbuzz >=11.0.1 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.10,<2.0a0 + - libfreetype >=2.13.3 + - libfreetype6 >=2.13.3 + - libzlib >=1.3.1,<2.0a0 + license: ISC + size: 171287 + timestamp: 1749328949722 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlicommon-1.2.0-he30d5cf_1.conda + sha256: 5fa8c163c8d776503aa68cdaf798ff9440c76a0a1c3ea84e0c43dbf1ece8af4d + md5: 8ec1d03f3000108899d1799d9964f281 + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 80030 + timestamp: 1764017273715 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlidec-1.2.0-he30d5cf_1.conda + sha256: 494365e8f58799ea95a6e82334ef696e9c2120aecd6626121694b30a15033301 + md5: 47e5b71b77bb8b47b4ecf9659492977f + depends: + - libbrotlicommon 1.2.0 he30d5cf_1 + - libgcc >=14 + license: MIT + license_family: MIT + size: 33166 + timestamp: 1764017282936 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libbrotlienc-1.2.0-he30d5cf_1.conda + sha256: f998c03257b9aa1f7464446af2cf424862f0e54258a2a588309853e45ae771df + md5: 6553a5d017fe14859ea8a4e6ea5def8f + depends: + - libbrotlicommon 1.2.0 he30d5cf_1 + - libgcc >=14 + license: MIT + license_family: MIT + size: 309304 + timestamp: 1764017292044 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcap-2.77-h68e9139_0.conda + sha256: 154eefd8f94010d89ba76a057949b9b1f75c7379bd0d19d4657c952bedcf5904 + md5: 10fe36ec0a9f7b1caae0331c9ba50f61 + depends: + - attr >=2.5.1,<2.6.0a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 108542 + timestamp: 1762350753349 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcurl-8.17.0-h7bfdcfb_1.conda + sha256: 1976e96cb86f1e9f0993cbba7a0b482e5f5dc9c3a0be23870b70125c95d96ddb + md5: 3b71a8bb2b714aa8d0a34c9a90e0eec2 + depends: + - krb5 >=1.21.3,<1.22.0a0 + - libgcc >=14 + - libnghttp2 >=1.67.0,<2.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + size: 479017 + timestamp: 1765378979432 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdeflate-1.25-h1af38f5_0.conda + sha256: 48814b73bd462da6eed2e697e30c060ae16af21e9fbed30d64feaf0aad9da392 + md5: a9138815598fe6b91a1d6782ca657b0c + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 71117 + timestamp: 1761979776756 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdrm-2.4.125-he30d5cf_1.conda + sha256: 4e6cdb5dd37db794b88bec714b4418a0435b04d14e9f7afc8cc32f2a3ced12f2 + md5: 2079727b538f6dd16f3fa579d4c3c53f + depends: + - libgcc >=14 + - libpciaccess >=0.18,<0.19.0a0 + license: MIT + license_family: MIT + size: 344548 + timestamp: 1757212128414 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libedit-3.1.20250104-pl5321h976ea20_0.conda + sha256: c0b27546aa3a23d47919226b3a1635fccdb4f24b94e72e206a751b33f46fd8d6 + md5: fb640d776fc92b682a14e001980825b1 + depends: + - ncurses + - libgcc >=13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + size: 148125 + timestamp: 1738479808948 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-1.7.0-hd24410f_2.conda + sha256: 8962abf38a58c235611ce356b9899f6caeb0352a8bce631b0bcc59352fda455e + md5: cf105bce884e4ef8c8ccdca9fe6695e7 + depends: + - libglvnd 1.7.0 hd24410f_2 + license: LicenseRef-libglvnd + size: 53551 + timestamp: 1731330990477 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libev-4.33-h31becfc_2.conda + sha256: 973af77e297f1955dd1f69c2cbdc5ab9dfc88388a5576cd152cda178af0fd006 + md5: a9a13cb143bbaa477b1ebaefbe47a302 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + size: 115123 + timestamp: 1702146237623 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.3-hfae3067_0.conda + sha256: cc2581a78315418cc2e0bb2a273d37363203e79cefe78ba6d282fed546262239 + md5: b414e36fbb7ca122030276c75fa9c34a + depends: + - libgcc >=14 + constrains: + - expat 2.7.3.* + license: MIT + license_family: MIT + size: 76201 + timestamp: 1763549910086 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-hd65408f_0.conda + sha256: 6c3332e78a975e092e54f87771611db81dcb5515a3847a3641021621de76caea + md5: 0c5ad486dcfb188885e3cf8ba209b97b + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 55586 + timestamp: 1760295405021 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libflac-1.5.0-he9c94f4_1.conda + sha256: 175cdc1865c3d6becc87e96bf44010a8e14f3021600ddad59417ed36e677b1ea + md5: cbe37f1d15f60b5e5272955b55b65325 + depends: + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libstdcxx >=14 + license: BSD-3-Clause + license_family: BSD + size: 397272 + timestamp: 1764526699497 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda + sha256: 342c07e4be3d09d04b531c889182a11a488e7e9ba4b75f642040e4681c1e9b98 + md5: 1e61fb236ccd3d6ccaf9e91cb2d7e12d + depends: + - libfreetype6 >=2.14.1 + license: GPL-2.0-only OR FTL + size: 7753 + timestamp: 1757945484817 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda + sha256: cedc83d9733363aca353872c3bfed2e188aa7caf57b57842ba0c6d2765652b7c + md5: 9c2f56b6e011c6d8010ff43b796aab2f + depends: + - libgcc >=14 + - libpng >=1.6.50,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - freetype >=2.14.1 + license: GPL-2.0-only OR FTL + size: 423210 + timestamp: 1757945484108 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_16.conda + sha256: 44bfc6fe16236babb271e0c693fe7fd978f336542e23c9c30e700483796ed30b + md5: cf9cd6739a3b694dcf551d898e112331 + depends: + - _openmp_mutex >=4.5 + constrains: + - libgomp 15.2.0 h8acb6b2_16 + - libgcc-ng ==15.2.0=*_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 620637 + timestamp: 1765256938043 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_16.conda + sha256: 22d7e63a00c880bd14fbbc514ec6f553b9325d705f08582e9076c7e73c93a2e1 + md5: 3e54a6d0f2ff0172903c0acfda9efc0e + depends: + - libgcc 15.2.0 h8acb6b2_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 27356 + timestamp: 1765256948637 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgettextpo-0.25.1-h5ad3122_0.conda + sha256: c8e5590166f4931a3ab01e444632f326e1bb00058c98078eb46b6e8968f1b1e9 + md5: ad7b109fbbff1407b1a7eeaa60d7086a + depends: + - libgcc >=13 + license: GPL-3.0-or-later + license_family: GPL + size: 225352 + timestamp: 1751557555903 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgettextpo-devel-0.25.1-h5ad3122_0.conda + sha256: a26e1982d062daba5bdd3a90a2ef77b323803d21d27cf4e941135f07037d6649 + md5: 0d9d56bac6e4249da2bede0588ae1c1b + depends: + - libgcc >=13 + - libgettextpo 0.25.1 h5ad3122_0 + license: GPL-3.0-or-later + license_family: GPL + size: 37460 + timestamp: 1751557569909 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda + sha256: 3e954380f16255d1c8ae5da3bd3044d3576a0e1ac2e3c3ff2fe8f2f1ad2e467a + md5: 0d00176464ebb25af83d40736a2cd3bb + depends: + - libglvnd 1.7.0 hd24410f_2 + - libglx 1.7.0 hd24410f_2 + license: LicenseRef-libglvnd + size: 145442 + timestamp: 1731331005019 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.3-hf53f6bf_0.conda + sha256: 35f4262131e4d42514787fdc3d45c836e060e18fcb2441abd9dd8ecd386214f4 + md5: f226b9798c6c176d2a94eea1350b3b6b + depends: + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - pcre2 >=10.47,<10.48.0a0 + constrains: + - glib 2.86.3 *_0 + license: LGPL-2.1-or-later + size: 4041779 + timestamp: 1765221790843 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglvnd-1.7.0-hd24410f_2.conda + sha256: 57ec3898a923d4bcc064669e90e8abfc4d1d945a13639470ba5f3748bd3090da + md5: 9e115653741810778c9a915a2f8439e7 + license: LicenseRef-libglvnd + size: 152135 + timestamp: 1731330986070 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda + sha256: 6591af640cb05a399fab47646025f8b1e1a06a0d4bbb4d2e320d6629b47a1c61 + md5: 1d4269e233636148696a67e2d30dad2a + depends: + - libglvnd 1.7.0 hd24410f_2 + - xorg-libx11 >=1.8.9,<2.0a0 + license: LicenseRef-libglvnd + size: 77736 + timestamp: 1731330998960 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_16.conda + sha256: 0a9d77c920db691eb42b78c734d70c5a1d00b3110c0867cfff18e9dd69bc3c29 + md5: 4d2f224e8186e7881d53e3aead912f6c + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 587924 + timestamp: 1765256821307 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libhwloc-2.12.1-default_h6f258fa_1000.conda + sha256: d25c10fd894ce6c5d3eba5667bef98be0e82d8e4d2ec20425d89a5baee715304 + md5: eea9ada077bda5f4a32889b9285af9c0 + depends: + - libgcc >=14 + - libstdcxx >=14 + - libxml2 >=2.13.8,<2.14.0a0 + license: BSD-3-Clause + license_family: BSD + size: 2468653 + timestamp: 1752761831524 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h90929bb_2.conda + sha256: 1473451cd282b48d24515795a595801c9b65b567fe399d7e12d50b2d6cdb04d9 + md5: 5a86bf847b9b926f3a4f203339748d78 + depends: + - libgcc >=14 + license: LGPL-2.1-only + size: 791226 + timestamp: 1754910975665 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.2-he30d5cf_0.conda + sha256: 84064c7c53a64291a585d7215fe95ec42df74203a5bf7615d33d49a3b0f08bb6 + md5: 5109d7f837a3dfdf5c60f60e311b041f + depends: + - libgcc >=14 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + size: 691818 + timestamp: 1762094728337 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.1-h86ecc28_2.conda + sha256: 498ea4b29155df69d7f20990a7028d75d91dbea24d04b2eb8a3d6ef328806849 + md5: 7d362346a479256857ab338588190da0 + depends: + - libgcc >=13 + constrains: + - xz 5.8.1.* + license: 0BSD + size: 125103 + timestamp: 1749232230009 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-devel-5.8.1-h86ecc28_2.conda + sha256: 3bd4de89c0cf559a944408525460b3de5495b4c21fb92c831ff0cc96398a7272 + md5: 236d1ebc954a963b3430ce403fbb0896 + depends: + - libgcc >=13 + - liblzma 5.8.1 h86ecc28_2 + license: 0BSD + size: 440873 + timestamp: 1749232400775 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-h86ecc28_0.conda + sha256: ef8697f934c80b347bf9d7ed45650928079e303bad01bd064995b0e3166d6e7a + md5: 78cfed3f76d6f3f279736789d319af76 + depends: + - libgcc >=13 + license: BSD-2-Clause + license_family: BSD + size: 114064 + timestamp: 1748393729243 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libnghttp2-1.67.0-ha888d0e_0.conda + sha256: b03f406fd5c3f865a5e08c89b625245a9c4e026438fd1a445e45e6a0d69c2749 + md5: 981082c1cc262f514a5a2cf37cab9b81 + depends: + - c-ares >=1.34.5,<2.0a0 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.2,<4.0a0 + license: MIT + license_family: MIT + size: 728661 + timestamp: 1756835019535 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libogg-1.3.5-h86ecc28_1.conda + sha256: 2c1b7c59badc2fd6c19b6926eabfce906c996068d38c2972bd1cfbe943c07420 + md5: 319df383ae401c40970ee4e9bc836c7a + depends: + - libgcc >=13 + license: BSD-3-Clause + license_family: BSD + size: 220653 + timestamp: 1745826021156 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-2025.2.0-hcd21e76_1.conda + sha256: f5c7a24d9918b1f637ca11a7c0b5594e14469ccc5b1f3bafcd248df252d2bdfb + md5: 76baf6bb7a63e310210d91595e245d24 + depends: + - libgcc >=14 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2021.13.0 + size: 5535917 + timestamp: 1753203182299 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-arm-cpu-plugin-2025.2.0-hcd21e76_1.conda + sha256: 018a0ea563bc2e91efee8a07f7b2ff769cd66d03d1c466c8bb7407075023ac85 + md5: 794c3f49774bd710aec2b0602ae38313 + depends: + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2021.13.0 + size: 9257629 + timestamp: 1753203203327 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-auto-batch-plugin-2025.2.0-h3890994_1.conda + sha256: 59a159c547fca34e8a0c600fcca428793da2ad4ecef0f47b58f1ea16d756c521 + md5: ad9768777a654205fa46aed8a829bd7e + depends: + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libstdcxx >=14 + - tbb >=2021.13.0 + size: 111599 + timestamp: 1753203233477 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-auto-plugin-2025.2.0-h3890994_1.conda + sha256: 3353f616cf72dad02d974698a74fa89eb5ff1beeaa64cebcdd1f87c52d2a0516 + md5: 4cec7bb2362ece08d0d1799f1ed4fbe7 + depends: + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libstdcxx >=14 + - tbb >=2021.13.0 + size: 235379 + timestamp: 1753203244808 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-hetero-plugin-2025.2.0-he07c6df_1.conda + sha256: 97f6a555d73d96efe26521527ce4e4c6ea49e46d5e5fd07a5e535e7de34bb6b5 + md5: 00d0206cb4358182c856700e1c1dae8b + depends: + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + size: 187747 + timestamp: 1753203256494 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-ir-frontend-2025.2.0-he07c6df_1.conda + sha256: 935341a98e129d3fd792609de5e85b959c3b31661d1a95c2a655771611383a05 + md5: f86c16f077043c9b1e87dbc07bf5ec42 + depends: + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libstdcxx >=14 + - pugixml >=1.15,<1.16.0a0 + size: 195451 + timestamp: 1753203267888 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-onnx-frontend-2025.2.0-h07d5dce_1.conda + sha256: 576c1ba122fb58d1c0ea6540d5480809196a884d3e56c05ab49b97ccc99e2c90 + md5: f8d90a982f95366614c568eac3157a90 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libstdcxx >=14 + size: 1530030 + timestamp: 1753203281815 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-paddle-frontend-2025.2.0-h07d5dce_1.conda + sha256: b080ca352d8d4526b73815bdbdb12ba5caf5de4621c10e9ad41eac73a7a6a713 + md5: 098597aa6f19b2851f295f47c7105658 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libstdcxx >=14 + size: 674194 + timestamp: 1753203295461 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-pytorch-frontend-2025.2.0-hfae3067_1.conda + sha256: 0dddd3e274c156a2b8ced3009444d99c04d75ab50a748968b94d3890b6dfab65 + md5: d00d92fbb31f8f9dc2cfb78f44286925 + depends: + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libstdcxx >=14 + size: 1123835 + timestamp: 1753203307507 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-tensorflow-frontend-2025.2.0-h38473e3_1.conda + sha256: fcdb5623415c9f5d8c8635f579e5706647e2c97b543ebba621b5b31df096de3d + md5: b42a48c1052c5b576170212c2a834614 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libprotobuf >=6.31.1,<6.31.2.0a0 + - libstdcxx >=14 + - snappy >=1.2.2,<1.3.0a0 + size: 1224816 + timestamp: 1753203320621 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopenvino-tensorflow-lite-frontend-2025.2.0-hfae3067_1.conda + sha256: cd4651c37e45fe6779a32ebfb3000fb3e9742409cd9bd0ac141c130b2f8f8d56 + md5: 274b11e7ed763c4964a6b6d2130ec1cb + depends: + - libgcc >=14 + - libopenvino 2025.2.0 hcd21e76_1 + - libstdcxx >=14 + size: 456714 + timestamp: 1753203333676 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libopus-1.5.2-h86ecc28_0.conda + sha256: c887543068308fb0fd50175183a3513f60cd8eb1defc23adc3c89769fde80d48 + md5: 44b2cfec6e1b94723a960f8a5e6206ae + depends: + - libgcc >=13 + license: BSD-3-Clause + license_family: BSD + size: 357115 + timestamp: 1744331282621 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpciaccess-0.18-h86ecc28_0.conda + sha256: 7641dfdfe9bda7069ae94379e9924892f0b6604c1a016a3f76b230433bb280f2 + md5: 5044e160c5306968d956c2a0a2a440d6 + depends: + - libgcc >=13 + license: MIT + license_family: MIT + size: 29512 + timestamp: 1749901899881 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.53-h1abf092_0.conda + sha256: 31c2b22aa4cb2b8d1456ad5aa92d1b95a8db234572cd29772c58e0b0c5be8823 + md5: 7591d867dbcba9eb7fb5e88a5f756591 + depends: + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: zlib-acknowledgement + size: 340043 + timestamp: 1764981067899 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libprotobuf-6.31.1-h2cf3c76_2.conda + sha256: e1bfa4ee03ddfa3a5e347d6796757a373878b2f277ed48dbc32412b05e16e776 + md5: 8eb7b485dcbb81166e340a07ccb40e67 + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 4465754 + timestamp: 1760550264433 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpsl-0.21.5-h8ebb4f9_0.conda + sha256: 0649e5101392e4b8862f855961c19568440c29a94334dc49ded2112e9720be80 + md5: 38c66dcbe4c75a1f872add1668ae97dd + depends: + - icu >=75.1,<76.0a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: MIT + license_family: MIT + size: 79295 + timestamp: 1721115527303 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/librsvg-2.58.4-h9b423fc_2.conda + sha256: 6ce5fb6eb20e8754c025a8f758b5ecaf071f00751fed570063719a8feb792208 + md5: 57122e6d1d085802579a32ec502c6699 + depends: + - cairo >=1.18.2,<2.0a0 + - freetype >=2.12.1,<3.0a0 + - gdk-pixbuf >=2.42.12,<3.0a0 + - harfbuzz >=10.1.0 + - libgcc >=13 + - libglib >=2.82.2,<3.0a0 + - libpng >=1.6.44,<1.7.0a0 + - libxml2 >=2.13.5,<2.14.0a0 + - pango >=1.54.0,<2.0a0 + constrains: + - __glibc >=2.17 + license: LGPL-2.1-or-later + size: 6019802 + timestamp: 1734908318062 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsanitizer-15.2.0-he19c465_16.conda + sha256: 71be6819f928574caf929aa4764a69e3df0429d686a4c5d6a8985b4c2c14b965 + md5: 4e30740acf8527cc06ca6a8d81432536 + depends: + - libgcc >=15.2.0 + - libstdcxx >=15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 7460968 + timestamp: 1765257008136 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsndfile-1.2.2-h30591a0_2.conda + sha256: f0b6844c09cdec608ca504bd97c5d64a5596a25f66ad806381f9d63dfc89e432 + md5: 362bc94148039b77c6a42b1f7e7ef537 + depends: + - lame >=3.100,<3.101.0a0 + - libflac >=1.5.0,<1.6.0a0 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + - libopus >=1.5.2,<2.0a0 + - libstdcxx >=14 + - libvorbis >=1.3.7,<1.4.0a0 + - mpg123 >=1.32.9,<1.33.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 406978 + timestamp: 1765181892661 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsoup-3.6.5-hd76604a_0.conda + sha256: f678d85c08d75b63b8444dd8f01b2fdf74de0fd62510bdeaf307f39743d42e56 + md5: c1ca5c355fba650c5bd63be4e853305b + depends: + - glib-networking + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libgcc >=14 + - libglib >=2.86.3,<3.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - libpsl >=0.21.5,<0.22.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 444161 + timestamp: 1766243520579 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.1-h022381a_0.conda + sha256: e394dd772b71dbcd653d078f3aacf6e26e3478bd6736a687ab86e463a2f153a8 + md5: 233efdd411317d2dc5fde72464b3df7a + depends: + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + size: 939207 + timestamp: 1764359457549 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libssh2-1.11.1-h18c354c_0.conda + sha256: 1e289bcce4ee6a5817a19c66e296f3c644dcfa6e562e5c1cba807270798814e7 + md5: eecc495bcfdd9da8058969656f916cc2 + depends: + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 311396 + timestamp: 1745609845915 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_16.conda + sha256: 4db11a903707068ae37aa6909511c68e9af6a2e97890d1b73b0a8d87cb74aba9 + md5: 52d9df8055af3f1665ba471cce77da48 + depends: + - libgcc 15.2.0 h8acb6b2_16 + constrains: + - libstdcxx-ng ==15.2.0=*_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 5541149 + timestamp: 1765256980783 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hdbbeba8_16.conda + sha256: dd5c813ae5a4dac6fa946352674e0c21b1847994a717ef67bd6cc77bc15920be + md5: 20b7f96f58ccbe8931c3a20778fb3b32 + depends: + - libstdcxx 15.2.0 hef695bb_16 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 27376 + timestamp: 1765257033344 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsystemd0-257.10-hf9559e3_3.conda + sha256: 57fe7a9f0c289e4f2fdf5200271848adc9f102921056d5904173942628b472cd + md5: 254474a19793a5f06de7cf3e3e2359fb + depends: + - libcap >=2.77,<2.78.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + size: 517687 + timestamp: 1765552618501 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda + sha256: 7ff79470db39e803e21b8185bc8f19c460666d5557b1378d1b1e857d929c6b39 + md5: 8c6fd84f9c87ac00636007c6131e457d + depends: + - lerc >=4.0.0,<5.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libgcc >=14 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libstdcxx >=14 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + size: 488407 + timestamp: 1762022048105 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libudev1-257.10-hf9559e3_3.conda + sha256: 39bdad22998d1ef5b366d9c557b5ca8a2ee2bea1f05eab9e1b20fbfef9d6d7a4 + md5: 8da19c1b9138b2f0a57012c31e3ad81d + depends: + - libcap >=2.77,<2.78.0a0 + - libgcc >=14 + license: LGPL-2.1-or-later + size: 156695 + timestamp: 1765552629955 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libunwind-1.8.3-h6470e1d_0.conda + sha256: 86c013d522975b76e16a74341bfcb22f6ec2e9b8b87ec3e15380f46c435eaa7b + md5: 5d8191a950e492a06dc29b491dd5f7c5 + depends: + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 94555 + timestamp: 1757032278900 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liburing-2.12-hfefdfc9_0.conda + sha256: 43daf21754c0d8618c2fcc1ac1cad8740f9a107358cc31d8619554463f366609 + md5: 63a654dceff75b84fe8ff32ddb66b7fe + depends: + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 129619 + timestamp: 1756126369793 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libusb-1.0.29-h06eaf92_0.conda + sha256: a60aae6b529cd7caa7842f9781ef95b93014e618f71fb005e404af434d76a33f + md5: 9a86e7473e16fe25c5c47f6c1376ac82 + depends: + - libgcc >=13 + - libudev1 >=257.4 + license: LGPL-2.1-or-later + size: 93129 + timestamp: 1748856228398 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.2-h1022ec0_1.conda + sha256: 3113c857e36779d94cf9a18236a710ceca0e94230b3bfeba0d134f33ee8c9ecd + md5: 15b2cc72b9b05bcb141810b1bada654f + depends: + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 43415 + timestamp: 1764790752623 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuv-1.51.0-he30d5cf_1.conda + sha256: 7a0fb5638582efc887a18b7d270b0c4a6f6e681bf401cab25ebafa2482569e90 + md5: 8e62bf5af966325ee416f19c6f14ffa3 + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 629238 + timestamp: 1753948296190 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libvorbis-1.3.7-h7ac5ae9_2.conda + sha256: 066708ca7179a1c6e5639d015de7ed6e432b93ad50525843db67d57eb1ba1faf + md5: 9d099329070afe52d797462ca7bf35f3 + depends: + - libogg + - libstdcxx >=14 + - libgcc >=14 + - libogg >=1.3.5,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 289391 + timestamp: 1753879417231 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libvpx-1.14.1-h0a1ffab_0.conda + sha256: 918493354f78cb3bb2c3d91264afbcb312b2afe287237e7d1c85ee7e96d15b47 + md5: 3cb63f822a49e4c406639ebf8b5d87d7 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: BSD-3-Clause + license_family: BSD + size: 1211700 + timestamp: 1717859955539 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libvulkan-loader-1.4.328.1-h8b8848b_0.conda + sha256: f1b32481c65008087c64dec21cc141dec9b80921ff2a3f5571c24c8f531b18ea + md5: e5a3ff3a266b68398bd28ed1d4363e65 + depends: + - libstdcxx >=14 + - libgcc >=14 + - xorg-libxrandr >=1.5.4,<2.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + constrains: + - libvulkan-headers 1.4.328.1.* + license: Apache-2.0 + license_family: APACHE + size: 214593 + timestamp: 1759972148472 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libwebp-base-1.6.0-ha2e29f5_0.conda + sha256: b03700a1f741554e8e5712f9b06dd67e76f5301292958cd3cb1ac8c6fdd9ed25 + md5: 24e92d0942c799db387f5c9d7b81f1af + depends: + - libgcc >=14 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + size: 359496 + timestamp: 1752160685488 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcb-1.17.0-h262b8f6_0.conda + sha256: 461cab3d5650ac6db73a367de5c8eca50363966e862dcf60181d693236b1ae7b + md5: cd14ee5cca2464a425b1dbfc24d90db2 + depends: + - libgcc >=13 + - pthread-stubs + - xorg-libxau >=1.0.11,<2.0a0 + - xorg-libxdmcp + license: MIT + license_family: MIT + size: 397493 + timestamp: 1727280745441 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.11.0-h95ca766_0.conda + sha256: b23355766092c62b32a7fc8d5729f40d693d2d8491f52e12f3a2f184ec552f6a + md5: 21efa5fee8795bc04bd79bfc02f05c65 + depends: + - libgcc >=14 + - libstdcxx >=14 + - libxcb >=1.17.0,<2.0a0 + - libxml2 >=2.13.8,<2.14.0a0 + - xkeyboard-config + - xorg-libxau >=1.0.12,<2.0a0 + license: MIT/X11 Derivative + license_family: MIT + size: 811243 + timestamp: 1754703942072 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.13.9-he58860d_0.conda + sha256: e7a1c9cf56046b85383f99d0931a3b8a603419c830d45cf1c8691f13aae3f655 + md5: 1e22b9412f9cb2eb7e5a65dd9475534a + depends: + - icu >=75.1,<76.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 737147 + timestamp: 1761766137531 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda + sha256: 5a2c1eeef69342e88a98d1d95bff1603727ab1ff4ee0e421522acd8813439b84 + md5: 08aad7cbe9f5a6b460d0976076b6ae64 + depends: + - libgcc >=13 + constrains: + - zlib 1.3.1 *_2 + license: Zlib + license_family: Other + size: 66657 + timestamp: 1727963199518 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/mpg123-1.32.9-h65af167_0.conda + sha256: d65d5a00278544639ba4f99887154be00a1f57afb0b34d80b08e5cba40a17072 + md5: cdf140c7690ab0132106d3bc48bce47d + depends: + - libgcc >=13 + - libstdcxx >=13 + license: LGPL-2.1-only + license_family: LGPL + size: 558708 + timestamp: 1730581372400 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + sha256: 91cfb655a68b0353b2833521dc919188db3d8a7f4c64bea2c6a7557b24747468 + md5: 182afabe009dc78d8b73100255ee6868 + depends: + - libgcc >=13 + license: X11 AND BSD-3-Clause + size: 926034 + timestamp: 1738196018799 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/nodejs-25.2.1-h244045a_1.conda + sha256: 062377e6f2681fab3c5804ea04ccd02b7ec622209496ea82b5ae9a428231e92a + md5: 235c5f49d2974eddfc5fa3f21160adff + depends: + - __glibc >=2.28,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libuv >=1.51.0,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - icu >=75.1,<76.0a0 + - c-ares >=1.34.6,<2.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libabseil >=20250512.1,<20250513.0a0 + - libabseil * cxx17* + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - openssl >=3.5.4,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + size: 23169197 + timestamp: 1765444723899 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openh264-2.6.0-h0564a2a_0.conda + sha256: 3b7a519e3b7d7721a0536f6cba7f1909b878c71962ee67f02242958314748341 + md5: 0abed5d78c07a64e85c54f705ba14d30 + depends: + - libgcc >=13 + - libstdcxx >=13 + license: BSD-2-Clause + license_family: BSD + size: 774512 + timestamp: 1739400731652 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.0-h8e36d6e_0.conda + sha256: 8dd3b4c31fe176a3e51c5729b2c7f4c836a2ce3bd5c82082dc2a503ba9ee0af3 + md5: 7624c6e01aecba942e9115e0f5a2af9d + depends: + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + size: 3705625 + timestamp: 1762841024958 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pango-1.56.4-he55ef5b_0.conda + sha256: dd36cd5b6bc1c2988291a6db9fa4eb8acade9b487f6f1da4eaa65a1eebb0a12d + md5: a22cc88bf6059c9bcc158c94c9aab5b8 + depends: + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.10,<2.0a0 + - harfbuzz >=11.0.1 + - libexpat >=2.7.0,<3.0a0 + - libfreetype >=2.13.3 + - libfreetype6 >=2.13.3 + - libgcc >=13 + - libglib >=2.84.2,<3.0a0 + - libpng >=1.6.49,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + license: LGPL-2.1-or-later + size: 468811 + timestamp: 1751293869070 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.47-hf841c20_0.conda + sha256: 04df2cee95feba440387f33f878e9f655521e69f4be33a0cd637f07d3d81f0f9 + md5: 1a30c42e32ca0ea216bd0bfe6f842f0b + depends: + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 1166552 + timestamp: 1763655534263 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda + sha256: e6b0846a998f2263629cfeac7bca73565c35af13251969f45d385db537a514e4 + md5: 1587081d537bd4ae77d1c0635d465ba5 + depends: + - libgcc >=14 + - libstdcxx >=14 + - libgcc >=14 + license: MIT + license_family: MIT + size: 357913 + timestamp: 1754665583353 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pkg-config-0.29.2-hce167ba_1009.conda + sha256: 6468cbfaf1d3140be46dd315ec383d373dbbafd770ce2efe77c3f0cdbc4576c1 + md5: 05eda637f6465f7e8c5ab7e341341ea9 + depends: + - libgcc-ng >=12 + - libglib >=2.80.3,<3.0a0 + license: GPL-2.0-or-later + license_family: GPL + size: 54834 + timestamp: 1720806008171 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/process-compose-1.64.1-hb5cd7dd_0.conda + sha256: 39df9a47c7bcd0ad3cfafed5f083d18be363237f34eedee0ff56c356908e3225 + md5: 17887ab4a067b595db691d1d8f3715a2 + license: Apache-2.0 + license_family: APACHE + size: 7683265 + timestamp: 1746946096767 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/protobuf-6.31.1-py314h0cf174a_2.conda + sha256: 57df564f7231ef64ab844c3db34ec5d527d332f57f675c3da626a64dc5b52af7 + md5: c214bbe8181c78539850bb90f2a7fdfa + depends: + - libabseil * cxx17* + - libabseil >=20250512.1,<20250513.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - libprotobuf 6.31.1 + license: BSD-3-Clause + license_family: BSD + size: 497884 + timestamp: 1760393469054 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda + sha256: 977dfb0cb3935d748521dd80262fe7169ab82920afd38ed14b7fee2ea5ec01ba + md5: bb5a90c93e3bac3d5690acf76b4a6386 + depends: + - libgcc >=13 + license: MIT + license_family: MIT + size: 8342 + timestamp: 1726803319942 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pugixml-1.15-h6ef32b0_0.conda + sha256: adc17205a87e064508d809fe5542b7cf49f9b9a458418f8448e2fc895fcd04f3 + md5: 53e14f45d38558aa2b9a15b07416e472 + depends: + - libgcc >=13 + - libstdcxx >=13 + license: MIT + license_family: MIT + size: 113424 + timestamp: 1737355438448 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pulseaudio-client-17.0-hcf98165_3.conda + sha256: bb55db0dfe120f6063ad3ac74524b37c0bf92c6002cc059c31a5506f96a67f22 + md5: 8d73cfc699cd0a5ed2ea04bfb73eee0a + depends: + - dbus >=1.16.2,<2.0a0 + - libgcc >=14 + - libglib >=2.86.1,<3.0a0 + - libiconv >=1.18,<2.0a0 + - libsndfile >=1.2.2,<1.3.0a0 + - libsystemd0 >=257.10 + - libxcb >=1.17.0,<2.0a0 + constrains: + - pulseaudio 17.0 *_3 + license: LGPL-2.1-or-later + license_family: LGPL + size: 760306 + timestamp: 1763148231117 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.2-hb06a95a_100_cp314.conda + build_number: 100 + sha256: 41adf6ee7a953ef4f35551a4a910a196b0a75e1ded458df5e73ef321863cb3f2 + md5: 432459e6961a5bc4cfe7cd080aee721a + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-aarch64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.1,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libuuid >=2.41.2,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.4,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 37217543 + timestamp: 1765020325291 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + sha256: fe695f9d215e9a2e3dd0ca7f56435ab4df24f5504b83865e3d295df36e88d216 + md5: 3d49cad61f829f4f0e0611547a9cda12 + depends: + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 357597 + timestamp: 1765815673644 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/rhash-1.4.6-h86ecc28_1.conda + sha256: 0fe6f40213f2d8af4fcb7388eeb782a4e496c8bab32c189c3a34b37e8004e5a4 + md5: 745d02c0c22ea2f28fbda2cb5dbec189 + depends: + - libgcc >=13 + license: MIT + license_family: MIT + size: 207475 + timestamp: 1748644952027 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/rust-1.92.0-h6cf38e9_0.conda + sha256: 981c225ced01310ff0f136ad2ddbd1eefe786448facb860edbbc8a9642549d82 + md5: 61f46f8777fa1f836657e334372a977e + depends: + - gcc_impl_linux-aarch64 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + - rust-std-aarch64-unknown-linux-gnu 1.92.0 hbe8e118_0 + - sysroot_linux-aarch64 >=2.17 + license: MIT + license_family: MIT + size: 195814654 + timestamp: 1765824047879 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl2-2.32.56-h7ac5ae9_0.conda + sha256: 47f4ef4cd2313906840f146b18fee95c2a3a4fa9bd0afdb2d519e6c0aa8ca2ed + md5: 54747a3f3c468c5f446c78974c8c1234 + depends: + - libstdcxx >=14 + - libgcc >=14 + - sdl3 >=3.2.22,<4.0a0 + - libgl >=1.7.0,<2.0a0 + - libegl >=1.7.0,<2.0a0 + license: Zlib + size: 597756 + timestamp: 1757842928996 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/sdl3-3.2.24-h506f210_0.conda + sha256: fb8915f5cb1aab477b6ba7b6176f2f324d4e50884502909aa0cf2c94c9f25205 + md5: e165931e7fdf10278063adfdafe02ae6 + depends: + - libstdcxx >=14 + - libgcc >=14 + - libusb >=1.0.29,<2.0a0 + - dbus >=1.16.2,<2.0a0 + - xorg-libxfixes >=6.0.2,<7.0a0 + - xorg-libx11 >=1.8.12,<2.0a0 + - libxkbcommon >=1.11.0,<2.0a0 + - libegl >=1.7.0,<2.0a0 + - xorg-libxcursor >=1.2.3,<2.0a0 + - libunwind >=1.8.3,<1.9.0a0 + - libgl >=1.7.0,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - pulseaudio-client >=17.0,<17.1.0a0 + - libvulkan-loader >=1.4.313.0,<2.0a0 + - liburing >=2.12,<2.13.0a0 + - libudev1 >=257.9 + - wayland >=1.24.0,<2.0a0 + - libdrm >=2.4.125,<2.5.0a0 + license: Zlib + size: 1929704 + timestamp: 1759445835424 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/shaderc-2025.3-h8c88b8f_1.conda + sha256: 460f1b433f8da3e64809b931b57ab875b119fab0d25af1bf58c6da65e14da554 + md5: 721cb8b53a40a0ea73618d8e321179a3 + depends: + - glslang >=15,<16.0a0 + - libgcc >=14 + - libstdcxx >=14 + - spirv-tools >=2025,<2026.0a0 + license: Apache-2.0 + license_family: Apache + size: 114964 + timestamp: 1756649548867 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/snappy-1.2.2-he774c54_1.conda + sha256: a8a79c53852fb07286407907402caa5a96b6e22b518c4f010be40647f9ee3726 + md5: 3dec912091fb88614afa0af2712c1362 + depends: + - libgcc >=14 + - libstdcxx >=14 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + size: 47096 + timestamp: 1762948094646 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/spirv-tools-2025.4-hfefdfc9_0.conda + sha256: 8132f3e06572896a4d9f672c5cb989c08bda2855e45eac95eed7012cfc5e5428 + md5: 6cbec31663722c23b6b4b217f6846e3c + depends: + - libgcc >=14 + - libstdcxx >=14 + constrains: + - spirv-headers >=1.4.328.0,<1.4.328.1.0a0 + license: Apache-2.0 + license_family: APACHE + size: 2511309 + timestamp: 1759805874123 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/svt-av1-3.1.2-hfae3067_0.conda + sha256: e4b482062da7cf259f21465274a0f3613d1dbd8ea649aca6072625f5038ac40d + md5: 7602d3004ed53b3f8e5e0e04e5de4de7 + depends: + - libgcc >=14 + - libstdcxx >=14 + license: BSD-2-Clause + license_family: BSD + size: 2106252 + timestamp: 1756090698097 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tbb-2022.3.0-h0eac15c_1.conda + sha256: 3fd3d1ba6b81c5edee8d8fa0d2757f7ba3bf4d4a8ecc68f515c90e737eaa02e4 + md5: eda1e9439d903e3fdd7ff9e086da2018 + depends: + - libgcc >=14 + - libhwloc >=2.12.1,<2.12.2.0a0 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + size: 144223 + timestamp: 1762511489745 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h561c983_103.conda + sha256: 154e73f6269f92ad5257aa2039278b083998fd19d371e150f307483fb93c07ae + md5: 631db4799bc2bfe4daccf80bb3cbc433 + depends: + - libgcc >=13 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + size: 3333495 + timestamp: 1763059192223 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ukkonen-1.0.1-py314hd7d8586_6.conda + sha256: 0e323578e0def2dda684dad27f619dadea6ffa7364641c0ff6610d10aa285464 + md5: 64e3941607577d1fe2deb09f45a8c90d + depends: + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 15565 + timestamp: 1761595014547 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/watchdog-6.0.0-py314ha42fa4b_2.conda + sha256: 3a49352d016ecbd0cf1dac23e7b890787f626133794d76f0dccf12c6f363027a + md5: 55928bb865d89e6dcbe32add5e5a4b71 + depends: + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - pyyaml >=3.10 + license: Apache-2.0 + license_family: APACHE + size: 152046 + timestamp: 1763022885329 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda + sha256: d94af8f287db764327ac7b48f6c0cd5c40da6ea2606afd34ac30671b7c85d8ee + md5: f6966cb1f000c230359ae98c29e37d87 + depends: + - libexpat >=2.7.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 331480 + timestamp: 1761174368396 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/x264-1!164.3095-h4e544f5_2.tar.bz2 + sha256: b48f150db8c052c197691c9d76f59e252d3a7f01de123753d51ebf2eed1cf057 + md5: 0efaf807a0b5844ce5f605bd9b668281 + depends: + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + size: 1000661 + timestamp: 1660324722559 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/x265-3.5-hdd96247_3.tar.bz2 + sha256: cb2227f2441499900bdc0168eb423d7b2056c8fd5a3541df4e2d05509a88c668 + md5: 786853760099c74a1d4f0da98dd67aea + depends: + - libgcc-ng >=10.3.0 + - libstdcxx-ng >=10.3.0 + license: GPL-2.0-or-later + license_family: GPL + size: 1018181 + timestamp: 1646610147365 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.46-he30d5cf_0.conda + sha256: c440a757d210e84c7f315ac3b034266980a8b4c986600649d296b9198b5b4f5e + md5: 9524f30d9dea7dd5d6ead43a8823b6c2 + depends: + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + size: 396706 + timestamp: 1759543850920 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libice-1.1.2-h86ecc28_0.conda + sha256: a2ba1864403c7eb4194dacbfe2777acf3d596feae43aada8d1b478617ce45031 + md5: c8d8ec3e00cd0fd8a231789b91a7c5b7 + depends: + - libgcc >=13 + license: MIT + license_family: MIT + size: 60433 + timestamp: 1734229908988 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libsm-1.2.6-h0808dbd_0.conda + sha256: b86a819cd16f90c01d9d81892155126d01555a20dabd5f3091da59d6309afd0a + md5: 2d1409c50882819cb1af2de82e2b7208 + depends: + - libgcc >=13 + - libuuid >=2.38.1,<3.0a0 + - xorg-libice >=1.1.2,<2.0a0 + license: MIT + license_family: MIT + size: 28701 + timestamp: 1741897678254 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.12-hca56bd8_0.conda + sha256: 452977d8ad96f04ec668ba74f46e70a53e00f99c0e0307956aeca75894c8131d + md5: 3df132f0048b9639bc091ef22937c111 + depends: + - libgcc >=13 + - libxcb >=1.17.0,<2.0a0 + license: MIT + license_family: MIT + size: 864850 + timestamp: 1741901264068 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxau-1.0.12-he30d5cf_1.conda + sha256: e9f6e931feeb2f40e1fdbafe41d3b665f1ab6cb39c5880a1fcf9f79a3f3c84a5 + md5: 1c246e1105000c3660558459e2fd6d43 + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 16317 + timestamp: 1762977521691 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcursor-1.2.3-h86ecc28_0.conda + sha256: c5d3692520762322a9598e7448492309f5ee9d8f3aff72d787cf06e77c42507f + md5: f2054759c2203d12d0007005e1f1296d + depends: + - libgcc >=13 + - xorg-libx11 >=1.8.9,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + - xorg-libxrender >=0.9.11,<0.10.0a0 + license: MIT + license_family: MIT + size: 34596 + timestamp: 1730908388714 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdamage-1.1.6-h86ecc28_0.conda + sha256: 3afaa2f43eb4cb679fc0c3d9d7c50f0f2c80cc5d3df01d5d5fd60655d0bfa9be + md5: d5773c4e4d64428d7ddaa01f6f845dc7 + depends: + - libgcc >=13 + - xorg-libx11 >=1.8.9,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxfixes >=6.0.1,<7.0a0 + license: MIT + license_family: MIT + size: 13794 + timestamp: 1727891406431 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdmcp-1.1.5-he30d5cf_1.conda + sha256: 128d72f36bcc8d2b4cdbec07507542e437c7d67f677b7d77b71ed9eeac7d6df1 + md5: bff06dcde4a707339d66d45d96ceb2e2 + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 21039 + timestamp: 1762979038025 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.6-h57736b2_0.conda + sha256: 8e216b024f52e367463b4173f237af97cf7053c77d9ce3e958bc62473a053f71 + md5: bd1e86dd8aa3afd78a4bfdb4ef918165 + depends: + - libgcc >=13 + - xorg-libx11 >=1.8.9,<2.0a0 + license: MIT + license_family: MIT + size: 50746 + timestamp: 1727754268156 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda + sha256: 8cb9c88e25c57e47419e98f04f9ef3154ad96b9f858c88c570c7b91216a64d0e + md5: e8b4056544341daf1d415eaeae7a040c + depends: + - libgcc >=14 + - xorg-libx11 >=1.8.12,<2.0a0 + license: MIT + license_family: MIT + size: 20704 + timestamp: 1759284028146 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.4-h86ecc28_0.conda + sha256: b2588a2b101d1b0a4e852532c8b9c92c59ef584fc762dd700567bdbf8cd00650 + md5: dd3e74283a082381aa3860312e3c721e + depends: + - libgcc >=13 + - xorg-libx11 >=1.8.9,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + - xorg-libxrender >=0.9.11,<0.10.0a0 + license: MIT + license_family: MIT + size: 30197 + timestamp: 1727794957221 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda + sha256: ffd77ee860c9635a28cfda46163dcfe9224dc6248c62404c544ae6b564a0be1f + md5: ae2c2dd0e2d38d249887727db2af960e + depends: + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + license: MIT + license_family: MIT + size: 33649 + timestamp: 1734229123157 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxshmfence-1.3.3-h86ecc28_0.conda + sha256: 9057e4a85a093d733719228d44aa09924e879ee769a9bb79ef7035cd0f260c8e + md5: c11f706a5072c34a559848f27d6c28c3 + depends: + - libgcc >=13 + license: MIT + license_family: MIT + size: 13805 + timestamp: 1734168631514 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda + sha256: 012f0d1fd9fb1d949e0dccc0b28d9dd5a8895a1f3e2a7edc1fa2e1b33fc0f233 + md5: d745faa2d7c15092652e40a22bb261ed + depends: + - libgcc >=13 + - xorg-libx11 >=1.8.10,<2.0a0 + - xorg-libxext >=1.3.6,<2.0a0 + license: MIT + license_family: MIT + size: 18185 + timestamp: 1734214652726 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/yaml-0.2.5-h80f16a2_3.conda + sha256: 66265e943f32ce02396ad214e27cb35f5b0490b3bd4f064446390f9d67fa5d88 + md5: 032d8030e4a24fe1f72c74423a46fb88 + depends: + - libgcc >=14 + license: MIT + license_family: MIT + size: 88088 + timestamp: 1753484092643 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zlib-1.3.1-h86ecc28_2.conda + sha256: b4f649aa3ecdae384d5dad7074e198bff120edd3dfb816588e31738fc6d627b1 + md5: bc230abb5d21b63ff4799b0e75204783 + depends: + - libgcc >=13 + - libzlib 1.3.1 h86ecc28_2 + license: Zlib + license_family: Other + size: 95582 + timestamp: 1727963203597 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + sha256: 569990cf12e46f9df540275146da567d9c618c1e9c7a0bc9d9cfefadaed20b75 + md5: c3655f82dcea2aa179b291e7099c1fcc + depends: + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 614429 + timestamp: 1764777145593 +- conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda + sha256: 1c656a35800b7f57f7371605bc6507c8d3ad60fbaaec65876fce7f73df1fc8ac + md5: 0a01c169f0ab0f91b26e77a3301fbfe4 + depends: + - python >=3.9 + - pytz >=2015.7 + license: BSD-3-Clause + license_family: BSD + size: 6938256 + timestamp: 1738490268466 +- conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda + sha256: a14a9ad02101aab25570543a59c5193043b73dc311a25650134ed9e6cb691770 + md5: f1976ce927373500cc19d3c0b2c85177 + depends: + - python >=3.10 + - python + constrains: + - pytz >=2015.7 + license: BSD-3-Clause + license_family: BSD + size: 7684321 + timestamp: 1772555330347 +- conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.2.0-py314h680f03e_0.conda + noarch: generic + sha256: de90f762aecfa4b8680ae7299398bd4a1634870a01db8351e5e22affc6bbf313 + md5: 25e227ee028a17c2f2ef6eaf97e86734 + depends: + - python >=3.14 + license: BSD-3-Clause AND MIT AND EPL-2.0 + size: 7512 + timestamp: 1765057691766 +- conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda + noarch: generic + sha256: 709cac7434d1c5a8828105036212a2a36022a07d807e89e2e99cac939c2d2526 + md5: 40d89d8546ad6e139e73ec8f6d56068b + depends: + - python >=3.14 + license: BSD-3-Clause AND MIT AND EPL-2.0 + size: 7526 + timestamp: 1781450817767 +- conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda + sha256: 3a0af23d357a07154645c41d035a4efbd15b7a642db397fa9ea0193fd58ae282 + md5: b16e2595d3a9042aa9d570375978835f + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 143810 + timestamp: 1740887689966 +- conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-7.0-pyhcf101f3_0.conda + sha256: 4e32871acb663d8d64342c486d960b3e64e24b7c715247a1e6a6f46d1b428802 + md5: 970a19304a794630a882b9dcd9e1beb4 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 158181 + timestamp: 1777493261325 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + sha256: b986ba796d42c9d3265602bc038f6f5264095702dd546c14bc684e60c385e773 + md5: f0991f0f84902f6b6009b4d2350a83aa + depends: + - __unix + license: ISC + size: 152432 + timestamp: 1762967197890 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda + sha256: 9812a303a1395e1dafbd92e5bc8a1ff6013bcbba0a09c7f03a8d23e43560aa9b + md5: 489b8e97e666c93f68fdb35c3c9b957f + depends: + - __unix + license: ISC + size: 129868 + timestamp: 1779289852439 +- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.11.12-pyhd8ed1ab_0.conda + sha256: 083a2bdad892ccf02b352ecab38ee86c3e610ba9a4b11b073ea769d55a115d32 + md5: 96a02a5c1a65470a7e4eedb644c872fd + depends: + - python >=3.10 + license: ISC + size: 157131 + timestamp: 1762976260320 +- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda + sha256: 645655a3510e38e625da136595f3f16f2130c3263630cc3bc8f60f619ddbe490 + md5: 9fefff2f745ea1cc2ef15211a20c054a + depends: + - python >=3.10 + license: ISC + size: 134201 + timestamp: 1779285131141 +- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 + md5: 381bd45fb7aa032691f3063aff47e3a1 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 13589 + timestamp: 1763607964133 +- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda + sha256: b32f8362e885f1b8417bac2b3da4db7323faa12d5db62b7fd6691c02d60d6f59 + md5: a22d1fd9bf98827e280a02875d9a007a + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 50965 + timestamp: 1760437331772 +- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + sha256: 3f9483d62ce24ecd063f8a5a714448445dc8d9e201147c46699fc0033e824457 + md5: a9167b9571f3baa9d448faa2139d1089 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 58872 + timestamp: 1775127203018 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.2.1-pyh707e725_0.conda + sha256: 8aee789c82d8fdd997840c952a586db63c6890b00e88c4fb6e80a38edd5f51c0 + md5: 94b550b8d3a614dbd326af798c7dfb40 + depends: + - __unix + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 87749 + timestamp: 1747811451319 +- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 27011 + timestamp: 1733218222191 +- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.0-pyhd8ed1ab_0.conda + sha256: 6d977f0b2fc24fee21a9554389ab83070db341af6d6f09285360b2e09ef8b26e + md5: 003b8ba0a94e2f1e117d0bd46aebc901 + depends: + - python >=3.9 + license: Apache-2.0 + license_family: APACHE + size: 275642 + timestamp: 1752823081585 +- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + sha256: e2753997b8bd34205f42be01b8bab8037423dc30c02a1ec12de23e5b4c0b0a2e + md5: 58638f77697c4f6726753eb8be34818b + depends: + - python >=3.10 + - python + license: Apache-2.0 + license_family: APACHE + size: 303705 + timestamp: 1781320269259 +- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.20.1-pyhd8ed1ab_0.conda + sha256: 8028582d956ab76424f6845fa1bdf5cb3e629477dd44157ca30d45e06d8a9c7c + md5: 81a651287d3000eb12f0860ade0a1b41 + depends: + - python >=3.10 + license: Unlicense + size: 18609 + timestamp: 1765846639623 +- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda + sha256: feb5c13cc8f256212a979783a7645abd7e27925c51ee5431babbc0efc661cdfd + md5: 66f138d7a6dffb5c959cc4bf6dc2b797 + depends: + - python >=3.10 + license: Unlicense + size: 36989 + timestamp: 1781381078337 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2 + sha256: 58d7f40d2940dd0a8aa28651239adbf5613254df0f75789919c4e6762054403b + md5: 0c96522c6bdaed4b1566d11387caaf45 + license: BSD-3-Clause + license_family: BSD + size: 397370 + timestamp: 1566932522327 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2 + sha256: c52a29fdac682c20d252facc50f01e7c2e7ceac52aa9817aaf0bb83f7559ec5c + md5: 34893075a5c9e55cdafac56607368fc6 + license: OFL-1.1 + license_family: Other + size: 96530 + timestamp: 1620479909603 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2 + sha256: 00925c8c055a2275614b4d983e1df637245e19058d79fc7dd1a93b8d9fb4b139 + md5: 4d59c254e01d9cde7957100457e2d5fb + license: OFL-1.1 + license_family: Other + size: 700814 + timestamp: 1620479612257 +- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda + sha256: 2821ec1dc454bd8b9a31d0ed22a7ce22422c0aef163c59f49dfdf915d0f0ca14 + md5: 49023d73832ef61042f6a237cb2687e7 + license: LicenseRef-Ubuntu-Font-Licence-Version-1.0 + license_family: Other + size: 1620504 + timestamp: 1727511233259 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 + sha256: a997f2f1921bb9c9d76e6fa2f6b408b7fa549edd349a77639c9fe7a23ea93e61 + md5: fee5683a3f04bd15cbd8318b096a27ab + depends: + - fonts-conda-forge + license: BSD-3-Clause + license_family: BSD + size: 3667 + timestamp: 1566974674465 +- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda + sha256: 54eea8469786bc2291cc40bca5f46438d3e062a399e8f53f013b6a9f50e98333 + md5: a7970cd949a077b7cb9696379d338681 + depends: + - font-ttf-ubuntu + - font-ttf-inconsolata + - font-ttf-dejavu-sans-mono + - font-ttf-source-code-pro + license: BSD-3-Clause + license_family: BSD + size: 4059 + timestamp: 1762351264405 +- conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda + sha256: 40fdf5a9d5cc7a3503cd0c33e1b90b1e6eab251aaaa74e6b965417d089809a15 + md5: 93f742fe078a7b34c29a182958d4d765 + depends: + - python >=3.9 + - python-dateutil >=2.8.1 + license: Apache-2.0 + license_family: APACHE + size: 16538 + timestamp: 1734344477841 +- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda + sha256: 84c64443368f84b600bfecc529a1194a3b14c3656ee2e832d15a20e0329b6da3 + md5: 164fc43f0b53b6e3a7bc7dce5e4f1dc9 + depends: + - python >=3.10 + - hyperframe >=6.1,<7 + - hpack >=4.1,<5 + - python + license: MIT + license_family: MIT + size: 95967 + timestamp: 1756364871835 +- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda + sha256: 6ad78a180576c706aabeb5b4c8ceb97c0cb25f1e112d76495bff23e3779948ba + md5: 0a802cb9888dd14eeefc611f05c40b6e + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 30731 + timestamp: 1737618390337 +- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda + sha256: 77af6f5fe8b62ca07d09ac60127a30d9069fdc3c68d6b256754d0ffb1f7779f8 + md5: 8e6923fc12f1fe8f8c4e5c9f343256ac + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 17397 + timestamp: 1737618427549 +- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.15-pyhd8ed1ab_0.conda + sha256: 32d5007d12e5731867908cbf5345f5cd44a6c8755a2e8e63e15a184826a51f82 + md5: 25f954b7dae6dd7b0dc004dab74f1ce9 + depends: + - python >=3.10 + - ukkonen + license: MIT + license_family: MIT + size: 79151 + timestamp: 1759437561529 +- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + sha256: 381cedccf0866babfc135d65ee40b778bd20e927d2a5ec810f750c5860a7c5b8 + md5: 84a3233b709a289a4ddd7a2fd27dd988 + depends: + - python >=3.10 + - ukkonen + license: MIT + license_family: MIT + size: 79757 + timestamp: 1776455344188 +- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda + sha256: ae89d0299ada2a3162c2614a9d26557a92aa6a77120ce142f8e0109bbf0342b0 + md5: 53abe63df7e10a6ba605dc5f9f961d36 + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + size: 50721 + timestamp: 1760286526795 +- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.17-pyhcf101f3_0.conda + sha256: f9fe1f9e539c544405ccb7ba632d4ba79edf243c05554d76ace073158a80b691 + md5: c75e517ebd7a5c5272fe111e8b162228 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 56858 + timestamp: 1779999227630 +- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda + sha256: c18ab120a0613ada4391b15981d86ff777b5690ca461ea7e9e49531e8f374745 + md5: 63ccfdc3a3ce25b027b8767eb722fca8 + depends: + - python >=3.9 + - zipp >=3.20 + - python + license: Apache-2.0 + license_family: APACHE + size: 34641 + timestamp: 1747934053147 +- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + sha256: 43e2a5497cad1598ff88a3e69f69bc88b7b8f141fa63c60eab5db296317318b8 + md5: ffc17e785d64e12fc311af9184221839 + depends: + - python >=3.10 + - zipp >=3.20 + - python + license: Apache-2.0 + license_family: APACHE + size: 34766 + timestamp: 1779714582554 +- conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda + sha256: fc9ca7348a4f25fed2079f2153ecdcf5f9cf2a0bc36c4172420ca09e1849df7b + md5: 04558c96691bed63104678757beb4f8d + depends: + - markupsafe >=2.0 + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 120685 + timestamp: 1764517220861 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a + md5: 86d9cba083cd041bfbf242a01a7a1999 + constrains: + - sysroot_linux-64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + size: 1278712 + timestamp: 1765578681495 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-aarch64-4.18.0-h05a177a_9.conda + sha256: 5d224bf4df9bac24e69de41897c53756108c5271a0e5d2d2f66fd4e2fbc1d84b + md5: bb3b7cad9005f2cbf9d169fb30263f3e + constrains: + - sysroot_linux-aarch64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + size: 1248134 + timestamp: 1765578613607 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_116.conda + sha256: 48d7d8dded34100d9065d1c0df86a11ab2cd8ddfd1590512b304527ed25b6d93 + md5: e67832fdbf2382757205bb4b38800643 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 3094906 + timestamp: 1765256682321 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-aarch64-15.2.0-h55c397f_116.conda + sha256: 594e4f22a4b6aae1bca5e22ea3a075c070642ca4c27c53e0c0973926ca711e09 + md5: 8ba6e9b5866b6a5429ca5d9fa12bc964 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 2343262 + timestamp: 1765256811670 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_116.conda + sha256: cb331c51739cc68257c7d7eef0e29c355b46b2d72f630854506dbc99240057c1 + md5: 2730e07e576ffbd7bf13f8de34835d41 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 20763949 + timestamp: 1765256724565 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-aarch64-15.2.0-ha7b1723_116.conda + sha256: 06be0d20cb3784e1d625f316f26962085dd14f74e166bd668ee9c089b5fa3efa + md5: 48cfd02ec4f1308109e5daaccb99aa30 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 17639950 + timestamp: 1765256847600 +- conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10-pyhcf101f3_1.conda + sha256: 32af5d32e3193b7c0ea02c33cc8753bfc0965d07e1aa58418a851d0bb94a7792 + md5: 934afb77580165027b869d4104ee002f + depends: + - importlib-metadata >=4.4 + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 85401 + timestamp: 1762856570927 +- conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10.2-pyhcf101f3_0.conda + sha256: 20e0892592a3e7c683e3d66df704a9425d731486a97c34fc56af4da1106b2b6b + md5: ba0a9221ce1063f31692c07370d062f3 + depends: + - importlib-metadata >=4.4 + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 85893 + timestamp: 1770694658918 +- conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda + sha256: e0cbfea51a19b3055ca19428bd9233a25adca956c208abb9d00b21e7259c7e03 + md5: fab1be106a50e20f10fe5228fd1d1651 + depends: + - python >=3.10 + constrains: + - jinja2 >=3.0.0 + track_features: + - markupsafe_no_compile + license: BSD-3-Clause + license_family: BSD + size: 15499 + timestamp: 1759055275624 +- conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda + sha256: e5b555fd638334a253d83df14e3c913ef8ce10100090e17fd6fb8e752d36f95d + md5: d9a8fc1f01deae61735c88ec242e855c + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 11676 + timestamp: 1734157119152 +- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda + sha256: 902d2e251f9a7ffa7d86a3e62be5b2395e28614bd4dbe5f50acf921fd64a8c35 + md5: 14661160be39d78f2b210f2cc2766059 + depends: + - click >=7.0,<8.3.0a0 + - colorama >=0.4 + - ghp-import >=1.0 + - importlib-metadata >=4.4 + - jinja2 >=2.11.1 + - markdown >=3.3.6 + - markupsafe >=2.0.1 + - mergedeep >=1.3.4 + - mkdocs-get-deps >=0.2.0 + - packaging >=20.5 + - pathspec >=0.11.1 + - python >=3.9 + - pyyaml >=5.1 + - pyyaml-env-tag >=0.1 + - watchdog >=2.0 + constrains: + - babel >=2.9.0 + license: BSD-2-Clause + license_family: BSD + size: 3524754 + timestamp: 1734344673481 +- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda + sha256: e0b501b96f7e393757fb2a61d042015966f6c5e9ac825925e43f9a6eafa907b6 + md5: 84382acddb26c27c70f2de8d4c830830 + depends: + - importlib-metadata >=4.3 + - mergedeep >=1.3.4 + - platformdirs >=2.2.0 + - python >=3.9 + - pyyaml >=5.1 + license: MIT + license_family: MIT + size: 14757 + timestamp: 1734353035244 +- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.2-pyhd8ed1ab_0.conda + sha256: fad4463b52def6214f64dfb918ad77ec4feb4c5d1072838dcf693dab860fde52 + md5: 4e396e96b60a1f616da1f0e5fceae543 + depends: + - importlib-metadata >=4.3 + - mergedeep >=1.3.4 + - platformdirs >=2.2.0 + - python >=3.10 + - pyyaml >=5.1 + license: MIT + license_family: MIT + size: 15572 + timestamp: 1773570672864 +- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda + sha256: e3c9ad7beece49540a4de5a9a3136081af64ceae0745336819a8c40a9e25f336 + md5: ab5cf0f1cd513e87bbd5736bdc13a399 + depends: + - python >=3.10 + - jinja2 >=3.0,<4.dev0 + - markdown >=3.2,<4.dev0 + - mkdocs >=1.6,<2.dev0 + - mkdocs-material-extensions >=1.3,<2.dev0 + - pygments >=2.16,<3.dev0 + - pymdown-extensions >=10.2,<11.dev0 + - babel >=2.10,<3.dev0 + - colorama >=0.4,<1.dev0 + - paginate >=0.5,<1.dev0 + - backrefs >=5.7.post1,<6.dev0 + - requests >=2.26,<3.dev0 + - python + license: MIT + size: 4795211 + timestamp: 1766061978730 +- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.6-pyhcf101f3_1.conda + sha256: 75cc95f2f9d29bc1373fd5c34b334b6b7a6bd9c745ca03105361b21b43f12b32 + md5: 13add6b0be6d719b4e2d1821ad10fcce + depends: + - python >=3.10 + - jinja2 >=3.1 + - markdown >=3.2 + - mkdocs >=1.6,<2 + - mkdocs-material-extensions >=1.3 + - pygments >=2.16 + - pymdown-extensions >=10.2 + - babel >=2.10 + - colorama >=0.4 + - paginate >=0.5 + - backrefs >=5.7.post1 + - requests >=2.30 + - python + license: MIT + license_family: MIT + size: 4800629 + timestamp: 1774620149583 +- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda + sha256: f62955d40926770ab65cc54f7db5fde6c073a3ba36a0787a7a5767017da50aa3 + md5: de8af4000a4872e16fb784c649679c8e + depends: + - python >=3.9 + constrains: + - mkdocs-material >=5.0.0 + license: MIT + license_family: MIT + size: 16122 + timestamp: 1734641109286 +- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee + md5: eb52d14a901e23c39e9e7b4a1a5c015f + depends: + - python >=3.10 + - setuptools + license: BSD-3-Clause + license_family: BSD + size: 40866 + timestamp: 1766261270149 +- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.9.1-pyhd8ed1ab_1.conda + sha256: 3636eec0e60466a00069b47ce94b6d88b01419b6577d8e393da44bb5bc8d3468 + md5: 7ba3f09fceae6a120d664217e58fe686 + depends: + - python >=3.9 + - setuptools + license: BSD-3-Clause + license_family: BSD + size: 34574 + timestamp: 1734112236147 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-25.0-pyh29332c3_1.conda + sha256: 289861ed0c13a15d7bbb408796af4de72c2fe67e2bcb0de98f4c3fce259d7991 + md5: 58335b26c38bf4a20f399384c33cbcf9 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + size: 62477 + timestamp: 1745345660407 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + sha256: 3906abfb6511a3bb309e39b9b1b7bc38f50a723971de2395489fd1f379255890 + md5: 4c06a92e74452cfa53623a81592e8934 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + size: 91574 + timestamp: 1777103621679 +- conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda + sha256: f6fef1b43b0d3d92476e1870c08d7b9c229aebab9a0556b073a5e1641cf453bd + md5: c3f35453097faf911fd3f6023fc2ab24 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 18865 + timestamp: 1734618649164 +- conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_1.conda + sha256: 9f64009cdf5b8e529995f18e03665b03f5d07c0b17445b8badef45bde76249ee + md5: 617f15191456cc6a13db418a275435e5 + depends: + - python >=3.9 + license: MPL-2.0 + license_family: MOZILLA + size: 41075 + timestamp: 1733233471940 +- conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + sha256: 6eaee417d33f298db79bc7185ab1208604c0e6cf51dade34cd513c6f9db9c6f3 + md5: 11adc78451c998c0fd162584abfa3559 + depends: + - python >=3.10 + license: MPL-2.0 + license_family: MOZILLA + size: 56559 + timestamp: 1777271601895 +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + sha256: 9e5e1fd3506ccfc4d444fc4d2d39b0ed097d5d0e3bd3d4bdf6bcc81aaf66860d + md5: 2c5ef45db85d34799771629bd5860fd7 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 26308 + timestamp: 1779972894916 +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda + sha256: 04c64fb78c520e5c396b6e07bc9082735a5cc28175dbe23138201d0a9441800b + md5: 1bd2e65c8c7ef24f4639ae6e850dacc2 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 23922 + timestamp: 1764950726246 +- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda + sha256: 5b81b7516d4baf43d0c185896b245fa7384b25dc5615e7baa504b7fa4e07b706 + md5: 7f3ac694319c7eaf81a0325d6405e974 + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.10 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + size: 200827 + timestamp: 1765937577534 +- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + sha256: 716960bf0a9eb334458a26b3bdcb17b8d0786062138a4f48c7f335c8418c5d0b + md5: 7859736b4f8ebe6c8481bf48d91c9a1e + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.10 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + size: 201606 + timestamp: 1776858157327 +- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda + sha256: 79db7928d13fab2d892592223d7570f5061c192f27b9febd1a418427b719acc6 + md5: 12c566707c80111f9799308d9e265aef + depends: + - python >=3.9 + - python + license: BSD-3-Clause + license_family: BSD + size: 110100 + timestamp: 1733195786147 +- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + sha256: e27e0473fc6723311a0bd48b89b616fa1b996a2f7a2b555338cbbcfb9c640568 + md5: 9c5491066224083c41b6d5635ed7107b + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 55886 + timestamp: 1779293633166 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda + sha256: 5577623b9f6685ece2697c6eb7511b4c9ac5fb607c9babc2646c811b428fd46a + md5: 6b6ece66ebcae2d5f326c77ef2c5a066 + depends: + - python >=3.9 + license: BSD-2-Clause + license_family: BSD + size: 889287 + timestamp: 1750615908735 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + sha256: cf70b2f5ad9ae472b71235e5c8a736c9316df3705746de419b59d442e8348e86 + md5: 16c18772b340887160c79a6acc022db0 + depends: + - python >=3.10 + license: BSD-2-Clause + license_family: BSD + size: 893031 + timestamp: 1774796815820 +- conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.19.1-pyhd8ed1ab_0.conda + sha256: 91cef23b12e050411432920e370c52c36a603aee65d7cdedf61a8a9d138db53e + md5: f6b5b95cde9c86578b4d45ce9aa1501e + depends: + - markdown >=3.6 + - python >=3.10 + - pyyaml + license: MIT + license_family: MIT + size: 170266 + timestamp: 1765736761772 +- conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.21.3-pyhd8ed1ab_0.conda + sha256: 644f7661c3589684b04c1cdda315af0db5839c4f6c609f460d12375fb694debe + md5: 2a324ab0d62115c96875f33b55852784 + depends: + - markdown >=3.6 + - python >=3.10 + - pyyaml + license: MIT + license_family: MIT + size: 172181 + timestamp: 1778686891401 +- conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda + sha256: ba3b032fa52709ce0d9fd388f63d330a026754587a2f461117cac9ab73d8d0d8 + md5: 461219d1a5bd61342293efa2c0c90eac + depends: + - __unix + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + size: 21085 + timestamp: 1733217331982 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda + sha256: d6a17ece93bbd5139e02d2bd7dbfa80bee1a4261dced63f65f679121686bf664 + md5: 5b8d21249ff20967101ffa321cab24e8 + depends: + - python >=3.9 + - six >=1.5 + - python + license: Apache-2.0 + license_family: APACHE + size: 233310 + timestamp: 1751104122689 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + sha256: 6914da740f6e3ec44ffb2f687dbc9c33abf084e42f34e3a8bb8235e475850619 + md5: 7a9095c9300d1b50b1785ca9bc4cadae + depends: + - python >=3.10 + - filelock >=3.15.4 + - platformdirs <5,>=4.3.6 + - python + license: MIT + license_family: MIT + size: 35514 + timestamp: 1781257630962 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + size: 6989 + timestamp: 1752805904792 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda + sha256: 8d2a8bf110cc1fc3df6904091dead158ba3e614d8402a83e51ed3a8aa93cdeb0 + md5: bc8e3267d44011051f2eb14d22fb0960 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 189015 + timestamp: 1742920947249 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + sha256: 828af2fd7bb66afc9ab1c564c2046be391aaf66c0215f05afaf6d7a9a270fe2a + md5: b12f41c0d7fb5ab81709fcc86579688f + depends: + - python >=3.10.* + - yaml + track_features: + - pyyaml_no_compile + license: MIT + license_family: MIT + size: 45223 + timestamp: 1758891992558 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda + sha256: 69ab63bd45587406ae911811fc4d4c1bf972d643fa57a009de7c01ac978c4edd + md5: e8e53c4150a1bba3b160eacf9d53a51b + depends: + - python >=3.9 + - pyyaml + license: MIT + license_family: MIT + size: 11137 + timestamp: 1747237061448 +- conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhd8ed1ab_0.conda + sha256: 8dc54e94721e9ab545d7234aa5192b74102263d3e704e6d0c8aa7008f2da2a7b + md5: db0c6b99149880c8ba515cf4abe93ee4 + depends: + - certifi >=2017.4.17 + - charset-normalizer >=2,<4 + - idna >=2.5,<4 + - python >=3.9 + - urllib3 >=1.21.1,<3 + constrains: + - chardet >=3.0.2,<6 + license: Apache-2.0 + license_family: APACHE + size: 59263 + timestamp: 1755614348400 +- conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda + sha256: 1715246b19c9f85ee022933b4845f2fc14ac9184981b7b7d9b728bec8e9588da + md5: 4a85203c1d80c1059086ae860836ffb9 + depends: + - python >=3.10 + - certifi >=2023.5.7 + - charset-normalizer >=2,<4 + - idna >=2.5,<4 + - urllib3 >=1.26,<3 + - python + constrains: + - chardet >=3.0.2,<8 + license: Apache-2.0 + license_family: APACHE + size: 68709 + timestamp: 1778851103479 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.96.0-hf6ec828_0.conda + sha256: d897ede88a854758ddf5a2b03068892dcfff52bb3ece156afeff85490cf4da58 + md5: cd82a9da3245e87386fd8fa9421d3ca9 + depends: + - __unix + constrains: + - rust >=1.96.0,<1.96.1.0a0 + license: MIT + license_family: MIT + size: 32841379 + timestamp: 1780045774956 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-unknown-linux-gnu-1.92.0-hbe8e118_0.conda + sha256: f8c7d9d4e6d92fe8b27d039c12b233f7dbda77c3b4c45a2cd0e3b74926111ce0 + md5: a03ac1fb6befa71e59c1e067d2ffa8b6 + depends: + - __unix + constrains: + - rust >=1.92.0,<1.92.1.0a0 + license: MIT + license_family: MIT + size: 37826357 + timestamp: 1765822105181 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.92.0-h2c6d0dc_0.conda + sha256: 19570f26206e2635f78d987233ba8960c684576f8571298a6108eed4967e7c9a + md5: ee54789987e177271d9f95ef7fd7fa31 + depends: + - __unix + constrains: + - rust >=1.92.0,<1.92.1.0a0 + license: MIT + license_family: MIT + size: 38587633 + timestamp: 1765820881154 +- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda + sha256: 972560fcf9657058e3e1f97186cc94389144b46dbdf58c807ce62e83f977e863 + md5: 4de79c071274a53dcaf2a8c749d1499e + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 748788 + timestamp: 1748804951958 +- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 + md5: 8e194e7b992f99a5015edbd4ebd38efd + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 639697 + timestamp: 1773074868565 +- conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d + md5: 3339e3b65d58accf4ca4fb8748ab16b3 + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + size: 18455 + timestamp: 1753199211006 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 + md5: 13dc3adbc692664cd3beabd216434749 + depends: + - __glibc >=2.28 + - kernel-headers_linux-64 4.18.0 he073ed8_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + size: 24008591 + timestamp: 1765578833462 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-aarch64-2.28-h585391f_9.conda + sha256: 1bd2db6b2e451247bab103e4a0128cf6c7595dd72cb26d70f7fadd9edd1d1bc3 + md5: fdf07ab944a222ff28c754914fdb0740 + depends: + - __glibc >=2.28 + - kernel-headers_linux-aarch64 4.18.0 h05a177a_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + size: 23644746 + timestamp: 1765578629426 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + size: 51692 + timestamp: 1756220668932 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-h8577fbf_0.conda + sha256: 50fad5db6734d1bb73df1cf5db73215e326413d4b2137933f70708aa1840e25b + md5: 338201218b54cadff2e774ac27733990 + license: LicenseRef-Public-Domain + size: 119204 + timestamp: 1765745742795 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.2-pyhd8ed1ab_0.conda + sha256: f4302a80ee9b76279ad061df05003abc2a29cc89751ffab2fd2919b43455dac0 + md5: 4949ca7b83065cfe94ebe320aece8c72 + depends: + - backports.zstd >=1.0.0 + - brotli-python >=1.2.0 + - h2 >=4,<5 + - pysocks >=1.5.6,<2.0,!=1.5.7 + - python >=3.10 + license: MIT + license_family: MIT + size: 102842 + timestamp: 1765719817255 +- conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda + sha256: feff959a816f7988a0893201aa9727bbb7ee1e9cec2c4f0428269b489eb93fb4 + md5: cbb88288f74dbe6ada1c6c7d0a97223e + depends: + - backports.zstd >=1.0.0 + - brotli-python >=1.2.0 + - h2 >=4,<5 + - pysocks >=1.5.6,<2.0,!=1.5.7 + - python >=3.10 + license: MIT + license_family: MIT + size: 103560 + timestamp: 1778188657149 +- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.35.4-pyhd8ed1ab_0.conda + sha256: 77193c99c6626c58446168d3700f9643d8c0dab1f6deb6b9dd039e6872781bfb + md5: cfccfd4e8d9de82ed75c8e2c91cab375 + depends: + - distlib >=0.3.7,<1 + - filelock >=3.12.2,<4 + - platformdirs >=3.9.1,<5 + - python >=3.10 + - typing_extensions >=4.13.2 + license: MIT + license_family: MIT + size: 4401341 + timestamp: 1761726489722 +- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.0-pyhcf101f3_0.conda + sha256: 7d2656432734025a1337aa0fc35b047743eafea8b54c18dda8bda0dea4c0c28d + md5: ae6c3161f863cba63c9dbd18efd819ad + depends: + - python >=3.10 + - distlib >=0.3.7,<1 + - filelock <4,>=3.24.2 + - importlib-metadata >=6.6 + - platformdirs >=3.9.1,<5 + - python-discovery >=1.4.2 + - typing_extensions >=4.13.2 + - python + license: MIT + size: 3114788 + timestamp: 1781427532844 +- conda: https://conda.anaconda.org/conda-forge/noarch/wayland-protocols-1.47-hd8ed1ab_0.conda + sha256: 9ab2c12053ea8984228dd573114ffc6d63df42c501d59fda3bf3aeb1eaa1d23e + md5: 7da1571f560d4ba3343f7f4c48a79c76 + license: MIT + license_family: MIT + size: 140476 + timestamp: 1765821981856 +- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda + sha256: b4533f7d9efc976511a73ef7d4a2473406d7f4c750884be8e8620b0ce70f4dae + md5: 30cd29cb87d819caead4d55184c1d115 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 24194 + timestamp: 1764460141901 +- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + sha256: 210bd31c22bb88f5e2a167df24c95bb5f152b2ada7502f9b8c49d1f5366db423 + md5: ba3dcdc8584155c97c648ae9c044b7a3 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 24190 + timestamp: 1779159948016 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/aom-3.14.1-pl5321h513545f_1.conda + sha256: 58ea99a3fe5fed86bfa40acc801010eb2a0a04dcf0180f68b4e2bf7b0ba7ec1f + md5: 506b0327a51de9871ce2725022c0c955 + depends: + - libcxx >=19 + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + size: 2669709 + timestamp: 1780752523088 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda + sha256: 5c2e471fd262fcc3c5a9d5ea4dae5917b885e0e9b02763dbd0f0d9635ed4cb99 + md5: f9501812fe7c66b6548c7fcaa1c1f252 + depends: + - __osx >=11.0 + - libcxx >=19 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 359854 + timestamp: 1764018178608 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df + md5: 620b85a3f45526a8bc4d23fd78fc22f0 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + size: 124834 + timestamp: 1771350416561 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda + sha256: 2995f2aed4e53725e5efbc28199b46bf311c3cab2648fc4f10c2227d6d5fa196 + md5: bcb3cba70cf1eec964a03b4ba7775f01 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 180327 + timestamp: 1765215064054 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-he0f2337_1.conda + sha256: cde9b79ee206fe3ba6ca2dc5906593fb7a1350515f85b2a1135a4ce8ec1539e3 + md5: 36200ecfbbfbcb82063c87725434161f + depends: + - __osx >=11.0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - icu >=78.1,<79.0a0 + - libcxx >=19 + - libexpat >=2.7.3,<3.0a0 + - libfreetype >=2.14.1 + - libfreetype6 >=2.14.1 + - libglib >=2.86.3,<3.0a0 + - libpng >=1.6.53,<1.7.0a0 + - libzlib >=1.3.1,<2.0a0 + - pixman >=0.46.4,<1.0a0 + license: LGPL-2.1-only or MPL-1.1 + size: 900035 + timestamp: 1766416416791 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py314h44086f9_1.conda + sha256: 5b5ee5de01eb4e4fd2576add5ec9edfc654fbaf9293e7b7ad2f893a67780aa98 + md5: 10dd19e4c797b8f8bdb1ec1fbb6821d7 + depends: + - __osx >=11.0 + - libffi >=3.5.2,<3.6.0a0 + - pycparser + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 292983 + timestamp: 1761203354051 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmake-4.3.3-h8cb302d_0.conda + sha256: 09eab0e2876eeee3f619223e151b788e157771b7150038ee07fa768e8aff8e9e + md5: 7a6a2812529d5c8fa77c2653e303ba4f + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libcurl >=8.20.0,<9.0a0 + - libcxx >=19 + - libexpat >=2.8.1,<3.0a0 + - liblzma >=5.8.3,<6.0a0 + - libuv >=1.51.0,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - rhash >=1.4.6,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + size: 18285327 + timestamp: 1779399044060 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/dav1d-1.2.1-hb547adb_0.conda + sha256: 93e077b880a85baec8227e8c72199220c7f87849ad32d02c14fb3807368260b8 + md5: 5a74cdee497e6b65173e10d94582fae6 + license: BSD-2-Clause + license_family: BSD + size: 316394 + timestamp: 1685695959391 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/dbus-1.16.2-h3ff7a7c_1.conda + sha256: a8207751ed261764061866880da38e4d3063e167178bfe85b6db9501432462ba + md5: 5a3506971d2d53023c1c4450e908a8da + depends: + - libcxx >=19 + - __osx >=11.0 + - libglib >=2.86.2,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + license: AFL-2.1 OR GPL-2.0-or-later + size: 393811 + timestamp: 1764536084131 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/expat-2.8.1-hf6b4638_1.conda + sha256: 0f1f9c2f72a18f41c2096bd245425a7c43bc82d1aac24025ea308055352639b9 + md5: 79cef10347b58a6d3c10cc78614efda6 + depends: + - __osx >=11.0 + - libexpat 2.8.1 hf6b4638_1 + license: MIT + license_family: MIT + size: 136056 + timestamp: 1781203642860 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ffmpeg-8.1.1-gpl_he97032f_104.conda + sha256: 0e228b07ee9719dc83f9e1db6ee2d64a7f8f5e11a7af65c38bd1dd6479f813e5 + md5: 6f5909cec06893dddcbd0a6a6736cac8 + depends: + - __osx >=11.0 + - aom >=3.14.1,<3.15.0a0 + - bzip2 >=1.0.8,<2.0a0 + - dav1d >=1.2.1,<1.2.2.0a0 + - fontconfig >=2.18.1,<3.0a0 + - fonts-conda-ecosystem + - gmp >=6.3.0,<7.0a0 + - harfbuzz >=14.2.1 + - lame >=3.100,<3.101.0a0 + - libass >=0.17.4,<0.17.5.0a0 + - libcxx >=19 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libiconv >=1.18,<2.0a0 + - libjxl >=0.11,<1.0a0 + - liblzma >=5.8.3,<6.0a0 + - libopenvino >=2026.2.0,<2026.2.1.0a0 + - libopenvino-arm-cpu-plugin >=2026.2.0,<2026.2.1.0a0 + - libopenvino-auto-batch-plugin >=2026.2.0,<2026.2.1.0a0 + - libopenvino-auto-plugin >=2026.2.0,<2026.2.1.0a0 + - libopenvino-hetero-plugin >=2026.2.0,<2026.2.1.0a0 + - libopenvino-ir-frontend >=2026.2.0,<2026.2.1.0a0 + - libopenvino-onnx-frontend >=2026.2.0,<2026.2.1.0a0 + - libopenvino-paddle-frontend >=2026.2.0,<2026.2.1.0a0 + - libopenvino-pytorch-frontend >=2026.2.0,<2026.2.1.0a0 + - libopenvino-tensorflow-frontend >=2026.2.0,<2026.2.1.0a0 + - libopenvino-tensorflow-lite-frontend >=2026.2.0,<2026.2.1.0a0 + - libopus >=1.6.1,<2.0a0 + - libplacebo >=7.360.1,<7.361.0a0 + - librsvg >=2.62.3,<3.0a0 + - libvorbis >=1.3.7,<1.4.0a0 + - libvpx >=1.15.2,<1.16.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - openh264 >=2.6.0,<2.6.1.0a0 + - openssl >=3.5.6,<4.0a0 + - sdl2 >=2.32.56,<3.0a0 + - shaderc >=2026.2,<2026.3.0a0 + - svt-av1 >=4.0.1,<4.0.2.0a0 + - x264 >=1!164.3095,<1!165 + - x265 >=3.5,<3.6.0a0 + license: GPL-2.0-or-later + license_family: GPL + size: 9660776 + timestamp: 1780669502795 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.18.1-h2b252f5_0.conda + sha256: 8607d8d0b32f9f6fc61ea8c06b537486b78428a04516658222fa4d1d521af765 + md5: 9d928e6a62192141fb6540a3125b1345 + depends: + - __osx >=11.0 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libintl >=0.25.1,<1.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + size: 248677 + timestamp: 1780450500773 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-hc919400_0.conda + sha256: d856dc6744ecfba78c5f7df3378f03a75c911aadac803fa2b41a583667b4b600 + md5: 04bdce8d93a4ed181d1d726163c2d447 + depends: + - __osx >=11.0 + license: LGPL-2.1-or-later + size: 59391 + timestamp: 1757438897523 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.6-h4e57454_0.conda + sha256: 07cbba4e12430de35ea608eb3006cf1f7f63832c4f89a081cd6f3872944c1aa6 + md5: e67ebd2f639f46e52af8531622fa6051 + depends: + - __osx >=11.0 + - libglib >=2.86.4,<3.0a0 + - libintl >=0.25.1,<1.0a0 + - libjpeg-turbo >=3.1.2,<4.0a0 + - liblzma >=5.8.2,<6.0a0 + - libpng >=1.6.56,<1.7.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 548309 + timestamp: 1774986047281 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-0.25.1-h3dcc1bd_0.conda + sha256: 129a81e9da9f60ae6955b49938447e7faeb7e1be815b2db99e76956dddf8c392 + md5: 7059ba83fd98707b2cd9a5f06f589dd4 + depends: + - __osx >=11.0 + - gettext-tools 0.25.1 h493aca8_0 + - libasprintf 0.25.1 h493aca8_0 + - libasprintf-devel 0.25.1 h493aca8_0 + - libcxx >=18 + - libgettextpo 0.25.1 h493aca8_0 + - libgettextpo-devel 0.25.1 h493aca8_0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + - libintl-devel 0.25.1 h493aca8_0 + license: LGPL-2.1-or-later AND GPL-3.0-or-later + size: 543276 + timestamp: 1751558682952 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gettext-tools-0.25.1-h493aca8_0.conda + sha256: e8dd68706676d5b6f6ee09240936a0ecd1ae12b87dbb37e4c4be263e332ab125 + md5: 817042c017930497931da6aa04a47f09 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: GPL-3.0-or-later + license_family: GPL + size: 3748044 + timestamp: 1751558602508 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-2.88.1-h92d5a4f_2.conda + sha256: e6804164a3d771d369d97fd08a2db739321d452c9ffa6803f9bbf309aa1f4dfe + md5: 937bab93d8e05d664f45f1aed40291ba + depends: + - python * + - packaging + - libglib ==2.88.1 ha08bb59_2 + - glib-tools ==2.88.1 h37541a8_2 + - libintl-devel + - libintl >=0.25.1,<1.0a0 + license: LGPL-2.1-or-later + size: 90974 + timestamp: 1778508895255 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-networking-2.80.0-h8ad88b3_0.conda + sha256: 8bf688aac7e7757b7cb80ed2d1a5637cb055b2a8edad777693d3ebe1c68940a2 + md5: 94006cdd1530d1b87e49ac0e20eb6267 + depends: + - gettext >=0.21.1,<1.0a0 + - glib >=2.74.0 + - libglib >=2.74.1,<3.0a0 + - libzlib >=1.2.13,<2.0.0a0 + - openssl >=3.2.1,<4.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 153507 + timestamp: 1710665985736 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.88.1-h37541a8_2.conda + sha256: 414bdf86a8096d5706293d163359def2e61b8ffd3fe106bbf2028d79e58e6a97 + md5: 8d4580a91948a6c3383a7c2fbfe5311c + depends: + - libglib ==2.88.1 ha08bb59_2 + - libffi + - __osx >=11.0 + - libintl >=0.25.1,<1.0a0 + license: LGPL-2.1-or-later + size: 204902 + timestamp: 1778508895255 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glslang-16.3.0-h7cb4797_0.conda + sha256: d5bb8e2373cb39d1404ef7dc8019e764b27180c8a0f88ba234a595dc330caabb + md5: 85d9c709161737695252660b174b36f2 + depends: + - __osx >=11.0 + - libcxx >=19 + - spirv-tools >=2026,<2027.0a0 + license: BSD-3-Clause + license_family: BSD + size: 875961 + timestamp: 1777747792638 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gmp-6.3.0-h7bae524_2.conda + sha256: 76e222e072d61c840f64a44e0580c2503562b009090f55aa45053bf1ccb385dd + md5: eed7278dfbab727b56f2c0b64330814b + depends: + - __osx >=11.0 + - libcxx >=16 + license: GPL-2.0-or-later OR LGPL-3.0-or-later + size: 365188 + timestamp: 1718981343258 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.15-hf6b4638_0.conda + sha256: c0a060d7b7a05669043ef3f68c7a1025c8594e1ab73735afb64c35e8baa41da5 + md5: 0d576cff278a2e60456d5b2c0a1ffda3 + depends: + - __osx >=11.0 + - libcxx >=19 + license: LGPL-2.0-or-later + license_family: LGPL + size: 82245 + timestamp: 1780454628763 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-libav-1.28.4-hb6f6a77_0.conda + sha256: 57dbf4765554834958e4ae271d5c433f070bd54429e0a82b13eeed6af122d082 + md5: ee84eff4ab56d4423ab7606f76352bcc + depends: + - gstreamer ==1.28.4 + - expat + - libcxx >=19 + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + - gstreamer >=1.28.4,<1.29.0a0 + - ffmpeg >=8.1.1,<9.0a0 + - libexpat >=2.8.1,<3.0a0 + - libglib >=2.88.1,<3.0a0 + - liblzma >=5.8.3,<6.0a0 + - gst-plugins-base >=1.28.4,<1.29.0a0 + license: LGPL-2.1-or-later + size: 134612 + timestamp: 1781380994064 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-bad-1.28.4-pl5321h6e07036_0.conda + sha256: 6b88c1b33456e615d71f9270d326c765904509f3c2c759bf6c509ab85e9014dc + md5: 03321b3d7154f9a073baf06467e338e2 + depends: + - gstreamer ==1.28.4 + - libcxx >=19 + - __osx >=11.0 + - libsndfile >=1.2.2,<1.3.0a0 + - openssl >=3.5.7,<4.0a0 + - libiconv >=1.18,<2.0a0 + - gst-plugins-base >=1.28.4,<1.29.0a0 + - gstreamer >=1.28.4,<1.29.0a0 + - libzlib >=1.3.2,<2.0a0 + - libintl >=0.25.1,<1.0a0 + - libopus >=1.6.1,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libglib >=2.88.1,<3.0a0 + - libexpat >=2.8.1,<3.0a0 + license: LGPL-2.1-or-later + size: 3147306 + timestamp: 1781380260737 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-base-1.28.4-h6b9204b_0.conda + sha256: 23ec598316a723053f91fed89d01d6eef69e52ce6482084f7978fb1d7c8234b8 + md5: d2026cef077e3a78d713821ff4b19cdd + depends: + - gstreamer ==1.28.4 h087694b_0 + - libcxx >=19 + - __osx >=11.0 + - libvorbis >=1.3.7,<1.4.0a0 + - gstreamer >=1.28.4,<1.29.0a0 + - pango >=1.56.4,<2.0a0 + - libintl >=0.25.1,<1.0a0 + - libopus >=1.6.1,<2.0a0 + - libglib >=2.88.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libexpat >=2.8.1,<3.0a0 + - libpng >=1.6.58,<1.7.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + size: 2757554 + timestamp: 1781370583896 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-good-1.28.4-hb52ed23_0.conda + sha256: 61db5d83c4c228ab4f99bea2860b6f5f420d4167941bb47bc06047f48c195f25 + md5: d8bf1fb00bfac7ab2e7367d9d6ebaf38 + depends: + - gstreamer ==1.28.4 h087694b_0 + - gst-plugins-base ==1.28.4 h6b9204b_0 + - libsoup + - glib-networking + - __osx >=11.0 + - libcxx >=19 + - libglib >=2.88.1,<3.0a0 + - gstreamer >=1.28.4,<1.29.0a0 + - libsoup >=3.6.6,<4.0a0 + - libintl >=0.25.1,<1.0a0 + - libpng >=1.6.58,<1.7.0a0 + - libvpx >=1.15.2,<1.16.0a0 + - mpg123 >=1.32.9,<1.33.0a0 + - libzlib >=1.3.2,<2.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - lame >=3.100,<3.101.0a0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - openssl >=3.5.7,<4.0a0 + - gst-plugins-base >=1.28.4,<1.29.0a0 + - libexpat >=2.8.1,<3.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + size: 2235385 + timestamp: 1781370583896 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gst-plugins-ugly-1.28.4-hb9cbea2_0.conda + sha256: 44f12ef092af6e137ab356ed6fc3b8bbb06a2c090b27d34d71caeb0048561cf8 + md5: 90e438c039c88d2e5482d63f66c09064 + depends: + - gstreamer 1.28.4.* + - __osx >=11.0 + - x264 >=1!164.3095,<1!165 + - libzlib >=1.3.2,<2.0a0 + - gst-plugins-base >=1.28.4,<1.29.0a0 + - libiconv >=1.18,<2.0a0 + - libintl >=0.25.1,<1.0a0 + - gstreamer >=1.28.4,<1.29.0a0 + - libglib >=2.88.1,<3.0a0 + license: LGPL-2.1-or-later + size: 172914 + timestamp: 1781381672316 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gstreamer-1.28.4-h087694b_0.conda + sha256: 6ef987ad349b03dadf9e5261264bcc8c1d6cddac4f30ae8d27a5c38f33d4ba19 + md5: 7e006c3331e640e53a06dbdff8b18c08 + depends: + - glib >=2.88.1,<3.0a0 + - libcxx >=19 + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - libintl >=0.25.1,<1.0a0 + - libzlib >=1.3.2,<2.0a0 + - libglib >=2.88.1,<3.0a0 + license: LGPL-2.0-or-later + license_family: LGPL + size: 2072860 + timestamp: 1781370583896 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-14.2.1-h3103d1b_0.conda + sha256: 5593f4aad6580707eb268e8dbb4c562a736d87bea03f5e1551becaebfe1a6620 + md5: 389b1c7cb4738fa74f8a142336807a13 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - graphite2 >=1.3.14,<2.0a0 + - icu >=78.3,<79.0a0 + - libcxx >=19 + - libexpat >=2.8.1,<3.0a0 + - libfreetype >=2.14.3 + - libfreetype6 >=2.14.3 + - libglib >=2.88.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + size: 1721040 + timestamp: 1780451752518 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 + md5: f1182c91c0de31a7abd40cedf6a5ebef + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 12361647 + timestamp: 1773822915649 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h385eeb1_0.conda + sha256: c0a0bf028fe7f3defcdcaa464e536cf1b202d07451e18ad83fdd169d15bef6ed + md5: e446e1822f4da8e5080a9de93474184d + depends: + - __osx >=11.0 + - libcxx >=19 + - libedit >=3.1.20250104,<3.2.0a0 + - libedit >=3.1.20250104,<4.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + size: 1160828 + timestamp: 1769770119811 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lame-3.100-h1a8c8d9_1003.tar.bz2 + sha256: f40ce7324b2cf5338b766d4cdb8e0453e4156a4f83c2f31bbfff750785de304c + md5: bff0e851d66725f78dc2fd8b032ddb7e + license: LGPL-2.0-only + license_family: LGPL + size: 528805 + timestamp: 1664996399305 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lcms2-2.19.1-hdfa7624_1.conda + sha256: ccb5598fad3694e79bf54f0eb812e3b3c3dd63d1497e631f5978800eadb9bcc4 + md5: d2f2c7c10e2957647d45589b7701a453 + depends: + - __osx >=11.0 + - libjpeg-turbo >=3.1.4.1,<4.0a0 + - libtiff >=4.7.1,<4.8.0a0 + license: MIT + license_family: MIT + size: 213747 + timestamp: 1780212240694 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.1.0-h1eee2c3_0.conda + sha256: 66e5ffd301a44da696f3efc2f25d6d94f42a9adc0db06c44ad753ab844148c51 + md5: 095e5749868adab9cae42d4b460e5443 + depends: + - __osx >=11.0 + - libcxx >=19 + license: Apache-2.0 + license_family: Apache + size: 164222 + timestamp: 1773114244984 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260107.1-cxx17_h2062a1b_0.conda + sha256: 756611fbb8d2957a5b4635d9772bd8432cb6ddac05580a6284cca6fdc9b07fca + md5: bb65152e0d7c7178c0f1ee25692c9fd1 + depends: + - __osx >=11.0 + - libcxx >=19 + constrains: + - abseil-cpp =20260107.1 + - libabseil-static =20260107.1=cxx17* + license: Apache-2.0 + license_family: Apache + size: 1229639 + timestamp: 1770863511331 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-0.25.1-h493aca8_0.conda + sha256: 7265547424e978ea596f51cc8e7b81638fb1c660b743e98cc4deb690d9d524ab + md5: 0deb80a2d6097c5fb98b495370b2435b + depends: + - __osx >=11.0 + - libcxx >=18 + license: LGPL-2.1-or-later + size: 52316 + timestamp: 1751558366611 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libasprintf-devel-0.25.1-h493aca8_0.conda + sha256: fc76b07620eabde52928c69bcdcb5497da3fdad3331a76f9d4bffeb27e0bdd8f + md5: c18067d2d5864e77f84456d97c1c17cc + depends: + - __osx >=11.0 + - libasprintf 0.25.1 h493aca8_0 + license: LGPL-2.1-or-later + size: 35256 + timestamp: 1751558418167 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libass-0.17.4-hcbd7ca7_0.conda + sha256: 079f5fdf7aace970a0db91cd2cc493c754dfdc4520d422ecec43d2561021167a + md5: 0977f4a79496437ff3a2c97d13c4c223 + depends: + - __osx >=11.0 + - fontconfig >=2.15.0,<3.0a0 + - fonts-conda-ecosystem + - libzlib >=1.3.1,<2.0a0 + - fribidi >=1.0.10,<2.0a0 + - libiconv >=1.18,<2.0a0 + - harfbuzz >=11.0.1 + - libfreetype >=2.13.3 + - libfreetype6 >=2.13.3 + license: ISC + size: 138339 + timestamp: 1749328988096 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + sha256: a7cb9e660531cf6fbd4148cff608c85738d0b76f0975c5fc3e7d5e92840b7229 + md5: 006e7ddd8a110771134fcc4e1e3a6ffa + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 79443 + timestamp: 1764017945924 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + sha256: 2eae444039826db0454b19b52a3390f63bfe24f6b3e63089778dd5a5bf48b6bf + md5: 079e88933963f3f149054eec2c487bc2 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 29452 + timestamp: 1764017979099 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + sha256: 01436c32bb41f9cb4bcf07dda647ce4e5deb8307abfc3abdc8da5317db8189d1 + md5: b2b7c8288ca1a2d71ff97a8e6a1e8883 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + size: 290754 + timestamp: 1764018009077 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcurl-8.20.0-hd5a2499_0.conda + sha256: 38c0bc634b61e542776e97cfd15d5d41edd304d4e47c333004d2d622439b2381 + md5: 2f57b7d0c6adda88957586b7afd78438 + depends: + - __osx >=11.0 + - krb5 >=1.22.2,<1.23.0a0 + - libnghttp2 >=1.68.1,<2.0a0 + - libssh2 >=1.11.1,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.6,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: curl + license_family: MIT + size: 400568 + timestamp: 1777462251987 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.7-h55c6f16_0.conda + sha256: cceb668dc1b71f054b1036dd83eca2e02c0c3a4b2ba3ad28c74a982d819597a3 + md5: 0325fbe13eb6dd39234eb305ac1b3cb8 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 568252 + timestamp: 1780441702930 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda + sha256: 5e0b6961be3304a5f027a8c00bd0967fc46ae162cffb7553ff45c70f51b8314c + md5: a6130c709305cd9828b4e1bd9ba0000c + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 55420 + timestamp: 1761980066242 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdovi-3.3.2-h78f8ca3_4.conda + sha256: 0eff0b03662d30b14b6f95a930fedf19948d45b05653389f59ae964ddf92ba9c + md5: 6ece15d35513fb9543cf45310cda72e1 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 278373 + timestamp: 1777839138867 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + sha256: 66aa216a403de0bb0c1340a88d1a06adaff66bae2cfd196731aa24db9859d631 + md5: 44083d2d2c2025afca315c7a172eab2b + depends: + - ncurses + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + size: 107691 + timestamp: 1738479560845 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda + sha256: 95cecb3902fbe0399c3a7e67a5bed1db813e5ab0e22f4023a5e0f722f2cc214f + md5: 36d33e440c31857372a72137f78bacf5 + license: BSD-2-Clause + license_family: BSD + size: 107458 + timestamp: 1702146414478 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + size: 69362 + timestamp: 1781203631990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 40979 + timestamp: 1769456747661 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libflac-1.5.0-h6824b09_1.conda + sha256: 565e5c38e80eb5752bfa52f6b771fa2fd75c0551a1f61ea84a12a2fb4454900c + md5: b14b19359aed60c200b1ca58b53f5ca3 + depends: + - __osx >=11.0 + - libcxx >=19 + - libiconv >=1.18,<2.0a0 + - libogg >=1.3.5,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 321872 + timestamp: 1764527007890 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.3-hce30654_1.conda + sha256: d5637b01941c0fc8f5cbb1f170c238f4ee153b3c1708b9d50f4f1305438ff051 + md5: 0582e67cd14cfed773be2f3b1aba08e0 + depends: + - libfreetype6 >=2.14.3 + license: GPL-2.0-only OR FTL + size: 8365 + timestamp: 1780933612390 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.3-hdfa99f5_1.conda + sha256: abbfffd8a8c776bb8b59a10c8247fc3aa6b17ba0051e9f6d199dca38479f214f + md5: a0bb0678f67c464938d3693fa96f6884 + depends: + - __osx >=11.0 + - libpng >=1.6.58,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - freetype >=2.14.3 + license: GPL-2.0-only OR FTL + size: 338442 + timestamp: 1780933611662 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-0.25.1-h493aca8_0.conda + sha256: 3ba35ff26b3b9573b5df5b9bbec5c61476157ec3a9f12c698e2a9350cd4338fd + md5: 98acd9989d0d8d5914ccc86dceb6c6c2 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: GPL-3.0-or-later + license_family: GPL + size: 183091 + timestamp: 1751558452316 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgettextpo-devel-0.25.1-h493aca8_0.conda + sha256: 976941e18f879e5c1e67553f9657f7bb9d3935c89014ebfeafe89dcfba2de9e7 + md5: 91c2fdde1cb4a61b5cb7afa682af359e + depends: + - __osx >=11.0 + - libgettextpo 0.25.1 h493aca8_0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: GPL-3.0-or-later + license_family: GPL + size: 37894 + timestamp: 1751558502415 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.1-ha08bb59_2.conda + sha256: 3b32a7a710132d509f2ea38b2f0384414c863533e0fc7ac71b6a0763e4c67424 + md5: 62d6f3b832d7d79ae0c0aa1bb3c325fa + depends: + - __osx >=11.0 + - libintl >=0.25.1,<1.0a0 + - libffi >=3.5.2,<3.6.0a0 + - pcre2 >=10.47,<10.48.0a0 + - libiconv >=1.18,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - glib >2.66 + license: LGPL-2.1-or-later + size: 4439458 + timestamp: 1778508895255 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwloc-2.13.0-default_ha97f43a_1000.conda + sha256: d47c3c030671d196ff1cdd343e93eb2ae0d7b665cb79f8164cc91488796db437 + md5: fed55ddd65a830cb62e78f07cfffcd41 + depends: + - __osx >=11.0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + license: BSD-3-Clause + license_family: BSD + size: 2339152 + timestamp: 1770953916323 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-ha332bbd_0.conda + sha256: 4fcad3cbec60da940312e883b7866816517acc5f9baecfe9a778de57327a1b1b + md5: 7394850583ca88325244b68b532c7a39 + depends: + - __osx >=11.0 + - libcxx >=19 + license: Apache-2.0 OR BSD-3-Clause + size: 609931 + timestamp: 1776990524407 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + sha256: de0336e800b2af9a40bdd694b03870ac4a848161b35c8a2325704f123f185f03 + md5: 4d5a7445f0b25b6a3ddbb56e790f5251 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + size: 750379 + timestamp: 1754909073836 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + sha256: 99d2cebcd8f84961b86784451b010f5f0a795ed1c08f1e7c76fbb3c22abf021a + md5: 5103f6a6b210a3912faf8d7db516918c + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + license: LGPL-2.1-or-later + size: 90957 + timestamp: 1751558394144 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-devel-0.25.1-h493aca8_0.conda + sha256: 5a446cb0501d87e0816da0bce524c60a053a4cf23c94dfd3e2b32a8499009e36 + md5: 5f9888e1cdbbbef52c8cf8b567393535 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - libintl 0.25.1 h493aca8_0 + license: LGPL-2.1-or-later + size: 40340 + timestamp: 1751558481257 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.4.1-h84a0fba_0.conda + sha256: 17e035ae6a520ff6a6bb5dd93a4a7c3895891f4f9743bcb8c6ef607445a31cd0 + md5: b8a7544c83a67258b0e8592ec6a5d322 + depends: + - __osx >=11.0 + constrains: + - jpeg <0.0.0a + license: IJG AND BSD-3-Clause AND Zlib + size: 555681 + timestamp: 1775962975624 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjxl-0.11.2-h934fa54_1.conda + sha256: 948cf1370abb58e06a7c9554838c68672ef1d78e01c3fc4e62ccfc3072579645 + md5: 05bead8980f5ae6a070117dacec38b5b + depends: + - libcxx >=19 + - __osx >=11.0 + - libhwy >=1.4.0,<1.5.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + size: 1032419 + timestamp: 1777065264956 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e + md5: b1fd823b5ae54fbec272cea0811bd8a9 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + size: 92472 + timestamp: 1775825802659 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + size: 73690 + timestamp: 1769482560514 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + sha256: 2bc7bc3978066f2c274ebcbf711850cc9ab92e023e433b9631958a098d11e10a + md5: 6ea18834adbc3b33df9bd9fb45eaf95b + depends: + - __osx >=11.0 + - c-ares >=1.34.6,<2.0a0 + - libcxx >=19 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + size: 576526 + timestamp: 1773854624224 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libogg-1.3.5-h48c0fde_1.conda + sha256: 28bd1fe20fe43da105da41b95ac201e95a1616126f287985df8e86ddebd1c3d8 + md5: 29b8b11f6d7e6bd0e76c029dcf9dd024 + depends: + - __osx >=11.0 license: BSD-3-Clause license_family: BSD - size: 614429 - timestamp: 1764777145593 + size: 216719 + timestamp: 1745826006052 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-2026.2.0-h3e6d54f_0.conda + sha256: 6566ed811af8ffac6abc796469653600bd220d5c62901570b4c4168e28d7f899 + md5: 7d1d2f279930e0fba1e9dd9de2390996 + depends: + - __osx >=11.0 + - libcxx >=19 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2022.3.0 + license: Apache-2.0 + license_family: APACHE + size: 4689949 + timestamp: 1780330059355 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-arm-cpu-plugin-2026.2.0-h3e6d54f_0.conda + sha256: bc163b339b63f649a948aae4702c5e941d4e08bfcc276a7a1ca07df46915c5a7 + md5: 53082736d4093b3eb8cfb4b195f3c05b + depends: + - __osx >=11.0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - pugixml >=1.15,<1.16.0a0 + - tbb >=2022.3.0 + license: Apache-2.0 + license_family: APACHE + size: 8629712 + timestamp: 1780330146460 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-batch-plugin-2026.2.0-h2406d2e_0.conda + sha256: 4910e58cec66fe832d117990eb6199c4ffb1982150ae08049caeb16ea956b306 + md5: 74e0d4f569b58d72b81e8d41632b0db0 + depends: + - __osx >=11.0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - tbb >=2022.3.0 + license: Apache-2.0 + license_family: APACHE + size: 107197 + timestamp: 1780330196908 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-auto-plugin-2026.2.0-h2406d2e_0.conda + sha256: df8a306eea3c7047e0d16871e3d7fa9fd5fd731fc66ef4ba4004b75fdaa73a58 + md5: 0eab52af2d85da0abe0a55706b2d8659 + depends: + - __osx >=11.0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - tbb >=2022.3.0 + license: Apache-2.0 + license_family: APACHE + size: 216301 + timestamp: 1780330216376 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-hetero-plugin-2026.2.0-h85cbfa6_0.conda + sha256: e1d9153f314b92f06047c2c2170d4d82d20774254577c63801c1857c44b8f731 + md5: 1a397b1b1505e5dba638f34b5e00cb1d + depends: + - __osx >=11.0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + size: 187730 + timestamp: 1780330236147 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-ir-frontend-2026.2.0-h85cbfa6_0.conda + sha256: 42edff0c56e336308d04c78ad7caec8265e4b346ae92aa34863e5c92b5bec99f + md5: aae4e9508e2cf83eb707b8abe88ea410 + depends: + - __osx >=11.0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - pugixml >=1.15,<1.16.0a0 + license: Apache-2.0 + license_family: APACHE + size: 181528 + timestamp: 1780330255501 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-onnx-frontend-2026.2.0-h41365f2_0.conda + sha256: 6795a454e9d5e3f5d2f4842f86605ae60be28e2a934fb5019c90428af48b4823 + md5: 8b4bc2f8b5f9d5bb224cd54d8877f31d + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20260107.1,<20260108.0a0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - libprotobuf >=6.33.5,<6.33.6.0a0 + license: Apache-2.0 + license_family: APACHE + size: 1483711 + timestamp: 1780330278474 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-paddle-frontend-2026.2.0-h41365f2_0.conda + sha256: 88ab10b48ea3704eab00936692d069a7d65ecd24c5b59c312395014e541b85d0 + md5: 3ecb3d3ac14e84e8ca7c60e82804342d + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20260107.1,<20260108.0a0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - libprotobuf >=6.33.5,<6.33.6.0a0 + license: Apache-2.0 + license_family: APACHE + size: 442697 + timestamp: 1780330301363 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-pytorch-frontend-2026.2.0-hf6b4638_0.conda + sha256: 5e3b1e146bf1840a1c6e88a70d5ca3455356f0a39f59f64a35baa36cf8c66bc4 + md5: 1e3e31675d9c5f2165b4102c438b7c0d + depends: + - __osx >=11.0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + license: Apache-2.0 + license_family: APACHE + size: 850068 + timestamp: 1780330324413 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-frontend-2026.2.0-hc295da0_0.conda + sha256: 9557684b2b053407b23dbdafea14475fa7f54a88d8ce7c90ae1fe6676b3763b9 + md5: 188283c0fa4c4e3396fb8c101aff974c + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20260107.1,<20260108.0a0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + - libprotobuf >=6.33.5,<6.33.6.0a0 + - snappy >=1.2.2,<1.3.0a0 + license: Apache-2.0 + license_family: APACHE + size: 931313 + timestamp: 1780330352611 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenvino-tensorflow-lite-frontend-2026.2.0-hf6b4638_0.conda + sha256: 205f95f430809889394021a07486381b0c7a4ed31af61926229e1fbb62b5c931 + md5: 74e9aedbdbbf9c02676d8e996be91470 + depends: + - __osx >=11.0 + - libcxx >=19 + - libopenvino 2026.2.0 h3e6d54f_0 + license: Apache-2.0 + license_family: APACHE + size: 412463 + timestamp: 1780330375694 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopus-1.6.1-h1a92334_0.conda + sha256: 5c95a5f7712f543c59083e62fc3a95efec8b7f3773fbf4542ad1fb87fbf51ff4 + md5: 7f414dd3fd1cb7a76e51fec074a9c49e + depends: + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + size: 308000 + timestamp: 1768497248058 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libplacebo-7.360.1-h176d363_0.conda + sha256: e9b39572e2feaef496167ba9f4ab75ed3afa4c16c3aeb0bb8c71adc515a74536 + md5: 7402fdef0c155dcd18c0ff4c5853c4b2 + depends: + - __osx >=11.0 + - libcxx >=19 + - libdovi >=3.3.2,<4.0a0 + - shaderc >=2026.2,<2026.3.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + - lcms2 >=2.19,<3.0a0 + license: LGPL-2.1-or-later + size: 529463 + timestamp: 1777836126438 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.58-h132b30e_0.conda + sha256: 66eae34546df1f098a67064970c92aa14ae7a7505091889e00468294d2882c36 + md5: 2259ae0949dbe20c0665850365109b27 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: zlib-acknowledgement + size: 289546 + timestamp: 1776315246750 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libprotobuf-6.33.5-h2d4b707_1.conda + sha256: 416c2244999d678dc9a4d8c3472336f8f754676125605399cf6e43956fa3d18b + md5: 300fdae9d7ad150a90755f55b0a8a7a8 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20260107.1,<20260108.0a0 + - libcxx >=19 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 2768714 + timestamp: 1780004273744 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpsl-0.21.5-hb427e8f_1.conda + sha256: e9c7baa76ed1a57f7b47e05a87636f6545bd23dd119919318d8ed08894b73daa + md5: e8c7f78a1dcb52a322bd8c88dbb9e1ad + depends: + - __osx >=11.0 + - icu >=78.2,<79.0a0 + - libcxx >=19 + license: MIT + license_family: MIT + size: 76026 + timestamp: 1768987433234 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.62.3-he8aa2a2_0.conda + sha256: f5b4fb7b6f13bbfca59613bff2e70b5a398e80727b9d0f814837ffcbc34185e1 + md5: 6973724fadafe66ac6e4f1c55c191407 + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.18.0,<3.0a0 + - fonts-conda-ecosystem + - gdk-pixbuf >=2.44.6,<3.0a0 + - harfbuzz >=14.2.0 + - libglib >=2.88.1,<3.0a0 + - libxml2-16 >=2.14.6 + - pango >=1.56.4,<2.0a0 + constrains: + - __osx >=11.0 + license: LGPL-2.1-or-later + size: 2397567 + timestamp: 1780452232118 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsndfile-1.2.2-hf95f74e_2.conda + sha256: f549aafe57439ca7c362884968f24fcc35a26e4e1eb66bb6985aa9e553c25521 + md5: 27e1a5a9249833f98a739249c7d1518b + depends: + - __osx >=11.0 + - lame >=3.100,<3.101.0a0 + - libcxx >=19 + - libflac >=1.5.0,<1.6.0a0 + - libogg >=1.3.5,<1.4.0a0 + - libopus >=1.5.2,<2.0a0 + - libvorbis >=1.3.7,<1.4.0a0 + - mpg123 >=1.32.9,<1.33.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 321233 + timestamp: 1765182495193 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsoup-3.6.6-hcf8573c_0.conda + sha256: a0ac6bdfab851e220f0f9b0bc2b3c5dd866d8715e496eb11acb825c36600fd7e + md5: 22c13dc0ae50ff5b8f85000e5c9b2bca + depends: + - __osx >=11.0 + - glib-networking + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libglib >=2.86.4,<3.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - libpsl >=0.21.5,<0.22.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + license: LGPL-2.1-or-later + license_family: LGPL + size: 391905 + timestamp: 1771431689668 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.2-h1ae2325_0.conda + sha256: 862463917e8ef5ac3ebdaf8f19914634b457609cc27ba678b7197124cefeb1f7 + md5: 1ebde5c677f00765233a17e278571177 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + size: 927724 + timestamp: 1780575223548 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libssh2-1.11.1-h1590b86_0.conda + sha256: 8bfe837221390ffc6f111ecca24fa12d4a6325da0c8d131333d63d6c37f27e0a + md5: b68e8f66b94b44aaa8de4583d3d4cc40 + depends: + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.0,<4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 279193 + timestamp: 1745608793272 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h4030677_1.conda + sha256: e9248077b3fa63db94caca42c8dbc6949c6f32f94d1cafad127f9005d9b1507f + md5: e2a72ab2fa54ecb6abab2b26cde93500 + depends: + - __osx >=11.0 + - lerc >=4.0.0,<5.0a0 + - libcxx >=19 + - libdeflate >=1.25,<1.26.0a0 + - libjpeg-turbo >=3.1.0,<4.0a0 + - liblzma >=5.8.1,<6.0a0 + - libwebp-base >=1.6.0,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: HPND + size: 373892 + timestamp: 1762022345545 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libusb-1.0.29-hbc156a2_0.conda + sha256: 5eee9a2bf359e474d4548874bcfc8d29ebad0d9ba015314439c256904e40aaad + md5: f6654e9e96e9d973981b3b2f898a5bfa + depends: + - __osx >=11.0 + license: LGPL-2.1-or-later + size: 83849 + timestamp: 1748856224950 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda + sha256: e23176af832f637693ebbb9bbe7d29c0f4cba662dabd001081d2aa6fc9f7f661 + md5: fa9fef7d9f33724b7c3899c883c25a3e + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 122732 + timestamp: 1779396113397 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvorbis-1.3.7-h81086ad_2.conda + sha256: 95768e4eceaffb973081fd986d03da15d93aa10609ed202e6fd5ca1e490a3dce + md5: 719e7653178a09f5ca0aa05f349b41f7 + depends: + - libogg + - libcxx >=19 + - __osx >=11.0 + - libogg >=1.3.5,<1.4.0a0 + license: BSD-3-Clause + license_family: BSD + size: 259122 + timestamp: 1753879389702 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvpx-1.15.2-ha759d40_0.conda + sha256: d21729b04fe101d1b2f8cdd607faacf1070abba3702db699787a3fe026eeaca6 + md5: 0d2febd301e25a48e00447b300d68f9c + depends: + - __osx >=11.0 + - libcxx >=19 + license: BSD-3-Clause + license_family: BSD + size: 1192913 + timestamp: 1762010603501 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libvulkan-loader-1.4.341.0-h3feff0a_0.conda + sha256: d2790dafc9149b1acd45b9033d02cfa3f3e9ee5af97bd61e0a5718c414a0a135 + md5: 6b4c9a5b130759136a0dde0c373cb0ea + depends: + - __osx >=11.0 + - libcxx >=19 + constrains: + - libvulkan-headers 1.4.341.0.* + license: Apache-2.0 + license_family: APACHE + size: 180304 + timestamp: 1770077143460 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda + sha256: a4de3f371bb7ada325e1f27a4ef7bcc81b2b6a330e46fac9c2f78ac0755ea3dd + md5: e5e7d467f80da752be17796b87fe6385 + depends: + - __osx >=11.0 + constrains: + - libwebp 1.6.0 + license: BSD-3-Clause + license_family: BSD + size: 294974 + timestamp: 1752159906788 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + sha256: ff75b84cdb9e8d123db2fa694a8ac2c2059516b6cbc98ac21fb68e235d0fd354 + md5: 19edaa53885fc8205614b03da2482282 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + size: 466360 + timestamp: 1776377102261 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda + sha256: 2fe1d8de0854342ae9cabe408b476935f82f5636e153b3b497456264dc8ff3a1 + md5: 8e037d73747d6fe34e12d7bcac10cf21 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 h5ef1a60_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + size: 41102 + timestamp: 1776377119495 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 + md5: bc5a5721b6439f2f62a84f2548136082 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 47759 + timestamp: 1774072956767 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.3-py314h6e9b3f0_1.conda + sha256: 411153d14ee0d98be6e3751cf5cc0502db17bce2deebebb8779e33d29d0e525f + md5: d33c0a15882b70255abdd54711b06a45 + depends: + - __osx >=11.0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - jinja2 >=3.0.0 + license: BSD-3-Clause + license_family: BSD + size: 27256 + timestamp: 1772445397216 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/mpg123-1.32.9-hf642e45_0.conda + sha256: 070bbbbb96856c325c0b6637638ce535afdc49adbaff306e2238c6032d28dddf + md5: d2b4857bdc3b76c36e23236172d09840 + depends: + - __osx >=11.0 + - libcxx >=18 + license: LGPL-2.1-only + license_family: LGPL + size: 360712 + timestamp: 1730581491116 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d + md5: 343d10ed5b44030a2f67193905aea159 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + size: 805509 + timestamp: 1777423252320 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.3.0-h7039424_0.conda + sha256: 0a2c7c1d2d0f99686c183cc132b265130b92d371fd95b14f383509a0e1966485 + md5: acf34c628b82bbcff7ec879c93135034 + depends: + - libcxx >=19 + - __osx >=11.0 + - openssl >=3.5.6,<4.0a0 + - libsqlite >=3.53.1,<4.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - zstd >=1.5.7,<1.6.0a0 + - libabseil >=20260107.1,<20260108.0a0 + - libabseil * cxx17* + - icu >=78.3,<79.0a0 + - libnghttp2 >=1.68.1,<2.0a0 + - libuv >=1.52.1,<2.0a0 + - c-ares >=1.34.6,<2.0a0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + size: 18027748 + timestamp: 1780383044420 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openh264-2.6.0-hb5b2745_0.conda + sha256: fbea05722a8e8abfb41c989e2cec7ba6597eabe27cb6b88ff0b6443a5abb9069 + md5: 6ff0890a94972aca7cc7f8f8ef1ff142 + depends: + - __osx >=11.0 + - libcxx >=18 + license: BSD-2-Clause + license_family: BSD + size: 601538 + timestamp: 1739400923874 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + sha256: b3e3ca895c336d4eb91c5d2f244a312bdb59a0de8cfa0cc4c179225ab2f6bbfb + md5: 8187a86242741725bfa74785fe812979 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + size: 3102584 + timestamp: 1781069820667 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.56.4-hf80efc4_1.conda + sha256: b57c59cf5abb06d407b3a79017b990ca5bfb10c15a10c62fc29e113f2b12d9a9 + md5: 4b433508ebb295c05dd3d03daf27f7bb + depends: + - __osx >=11.0 + - cairo >=1.18.4,<2.0a0 + - fontconfig >=2.17.1,<3.0a0 + - fonts-conda-ecosystem + - fribidi >=1.0.16,<2.0a0 + - harfbuzz >=13.2.1 + - libexpat >=2.7.4,<3.0a0 + - libfreetype >=2.14.2 + - libfreetype6 >=2.14.2 + - libglib >=2.86.4,<3.0a0 + - libpng >=1.6.55,<1.7.0a0 + - libzlib >=1.3.2,<2.0a0 + license: LGPL-2.1-or-later + size: 425743 + timestamp: 1774282709773 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + sha256: 5e2e443f796f2fd92adf7978286a525fb768c34e12b1ee9ded4000a41b2894ba + md5: 9b4190c4055435ca3502070186eba53a + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 850231 + timestamp: 1763655726735 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda + sha256: 29c9b08a9b8b7810f9d4f159aecfd205fce051633169040005c0b7efad4bc718 + md5: 17c3d745db6ea72ae2fce17e7338547f + depends: + - __osx >=11.0 + - libcxx >=19 + license: MIT + license_family: MIT + size: 248045 + timestamp: 1754665282033 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hde07d2e_1009.conda + sha256: d82f4655b2d67fe12eefe1a3eea4cd27d33fa41dbc5e9aeab5fd6d3d2c26f18a + md5: b4f41e19a8c20184eec3aaf0f0953293 + depends: + - __osx >=11.0 + - libglib >=2.80.3,<3.0a0 + - libiconv >=1.17,<2.0a0 + license: GPL-2.0-or-later + license_family: GPL + size: 49724 + timestamp: 1720806128118 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/process-compose-1.103.0-hfb368cc_0.conda + sha256: 6b45a5b977bbb326f3bf12a45a148ff41831547a6ccdfad591b83d0ed51bb6d9 + md5: 40c536e4798385b3054d29665307009f + depends: + - __osx >=11.0 + license: Apache-2.0 + license_family: APACHE + size: 11449774 + timestamp: 1775243329728 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/protobuf-6.33.5-py314he407d35_2.conda + sha256: 1c9dc0ad0ecb8b346b8321c6c6a57cd436a0a852d02e42ce05c1ec12964cb8dd + md5: a667bd9984b0d39ec5d6ea5bf7f052c4 + depends: + - __osx >=11.0 + - libabseil * cxx17* + - libabseil >=20260107.1,<20260108.0a0 + - libcxx >=19 + - libzlib >=1.3.1,<2.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + constrains: + - libprotobuf 6.33.5 + license: BSD-3-Clause + license_family: BSD + size: 473978 + timestamp: 1773265431848 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pugixml-1.15-hd3d436d_0.conda + sha256: 5ad8d036040b095f85d23c70624d3e5e1e4c00bc5cea97831542f2dcae294ec9 + md5: b9a4004e46de7aeb005304a13b35cb94 + depends: + - __osx >=11.0 + - libcxx >=18 + license: MIT + license_family: MIT + size: 91283 + timestamp: 1736601509593 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda + build_number: 100 + sha256: 984081c9fae3a3944c6f2707bbbbc70e8b961f02cdb7c640d9745e2636235632 + md5: 4841be3d0cf616a860efc6e60af66f8b + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + size: 14059371 + timestamp: 1781254578985 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.3-py314h6e9b3f0_1.conda + sha256: 95f385f9606e30137cf0b5295f63855fd22223a4cf024d306cf9098ea1c4a252 + md5: dcf51e564317816cb8d546891019b3ab + depends: + - __osx >=11.0 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + size: 189475 + timestamp: 1770223788648 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 313930 + timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rhash-1.4.6-h5505292_1.conda + sha256: f4957c05f4fbcd99577de8838ca4b5b1ae4b400a44be647a0159c14f85b9bfc0 + md5: 029e812c8ae4e0d4cf6ff4f7d8dc9366 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 185448 + timestamp: 1748645057503 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.96.0-h4ff7c5d_0.conda + sha256: 91fe26674ea4680e2c7847fdafdc76ba0956f1026eff306aa7bfc089318720c3 + md5: 7e6a23a17d56b0960ec7799402e10273 + depends: + - rust-std-aarch64-apple-darwin 1.96.0 hf6ec828_0 + license: MIT + license_family: MIT + size: 179967376 + timestamp: 1780046072905 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl2-2.32.56-h248ca61_0.conda + sha256: 704c5cae4bc839a18c70cbf3387d7789f1902828c79c6ddabcd34daf594f4103 + md5: 092c5b693dc6adf5f409d12f33295a2a + depends: + - libcxx >=19 + - __osx >=11.0 + - sdl3 >=3.2.22,<4.0a0 + license: Zlib + size: 542508 + timestamp: 1757842919681 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sdl3-3.4.10-h6fa9c73_0.conda + sha256: d957212def592e0d6d26354a602a313e7ccc8238c939832f0794a83d6e53e109 + md5: 3301738d307bba9ec51f9ce9cf23b842 + depends: + - libcxx >=19 + - __osx >=11.0 + - libusb >=1.0.29,<2.0a0 + - dbus >=1.16.2,<2.0a0 + - libvulkan-loader >=1.4.341.0,<2.0a0 + license: Zlib + size: 1564281 + timestamp: 1780262867528 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/shaderc-2026.2-hf31e910_0.conda + sha256: 97870b15002b9e78a169681655a148049cd1763d4062114155268e84b3ef8793 + md5: 6e50dd641e624d5921f25a82aea39ae9 + depends: + - __osx >=11.0 + - glslang >=16,<17.0a0 + - libcxx >=19 + - spirv-tools >=2026,<2027.0a0 + license: Apache-2.0 + license_family: Apache + size: 112111 + timestamp: 1777361061717 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/snappy-1.2.2-hada39a4_1.conda + sha256: cb9305ede19584115f43baecdf09a3866bfcd5bcca0d9e527bd76d9a1dbe2d8d + md5: fca4a2222994acd7f691e57f94b750c5 + depends: + - libcxx >=19 + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + size: 38883 + timestamp: 1762948066818 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/spirv-tools-2026.2-h4ddebb9_0.conda + sha256: cfcaa9b8fcc9d0e3a463ed6c8c102d93486794257908ca0b9fd98749bb67328c + md5: b08f1917b02b1877a13119de24ead63f + depends: + - __osx >=11.0 + - libcxx >=19 + constrains: + - spirv-headers >=1.4.350.0,<1.4.350.1.0a0 + license: Apache-2.0 + license_family: APACHE + size: 1648693 + timestamp: 1780140149076 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/svt-av1-4.0.1-h0cb729a_0.conda + sha256: bdef3c1c4d2a396ad4f7dc64c5e9a02d4c5a21ff93ed07a33e49574de5d2d18d + md5: 8badc3bf16b62272aa2458f138223821 + depends: + - __osx >=11.0 + - libcxx >=19 + license: BSD-2-Clause + license_family: BSD + size: 1456245 + timestamp: 1769664727051 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tbb-2023.0.0-he0260a5_2.conda + sha256: 6f72a2984052444b9381020fa329b83dace95f335573dc21199f1b1d1a5f5473 + md5: 440c0a36cc20db1f28877a69afbb5e88 + depends: + - __osx >=11.0 + - libcxx >=19 + - libhwloc >=2.13.0,<2.13.1.0a0 + license: Apache-2.0 + license_family: APACHE + size: 122303 + timestamp: 1778675142610 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 + md5: a9d86bc62f39b94c4661716624eb21b0 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + size: 3127137 + timestamp: 1769460817696 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py314h6cfcd04_0.conda + sha256: 033dbf9859fe58fb85350cf6395be6b1346792e1766d2d5acab538a6eb3659fb + md5: e229f444fbdb28d8c4f40e247154d993 + depends: + - __osx >=11.0 + - cffi + - libcxx >=19 + - python >=3.14,<3.15.0a0 + - python >=3.14,<3.15.0a0 *_cp314 + - python_abi 3.14.* *_cp314 + license: MIT + license_family: MIT + size: 14884 + timestamp: 1769439056290 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchdog-6.0.0-py314ha14b1ff_3.conda + sha256: dfdd3d45401568c164cc54d12c455bb740c84ad08cee04c13aa22855d91242f0 + md5: 2519c58dc35157f1b1a044041934815a + depends: + - python + - pyyaml >=3.10 + - python 3.14.* *_cp314 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: Apache-2.0 + license_family: APACHE + size: 176720 + timestamp: 1772608060730 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/x264-1!164.3095-h57fd34a_2.tar.bz2 + sha256: debdf60bbcfa6a60201b12a1d53f36736821db281a28223a09e0685edcce105a + md5: b1f6dccde5d3a1f911960b6e567113ff + license: GPL-2.0-or-later + license_family: GPL + size: 717038 + timestamp: 1660323292329 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/x265-3.5-hbc6ce65_3.tar.bz2 + sha256: 2fed6987dba7dee07bd9adc1a6f8e6c699efb851431bcb6ebad7de196e87841d + md5: b1f7f2780feffe310b068c021e8ff9b2 + depends: + - libcxx >=12.0.1 + license: GPL-2.0-or-later + license_family: GPL + size: 1832744 + timestamp: 1646609481185 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda + sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac + md5: 78a0fe9e9c50d2c381e8ee47e3ea437d + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 83386 + timestamp: 1753484079473 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_2.conda + sha256: 8dd2ac25f0ba714263aac5832d46985648f4bfb9b305b5021d702079badc08d2 + md5: f1c0bce276210bed45a04949cfe8dc20 + depends: + - __osx >=11.0 + - libzlib 1.3.2 h8088a28_2 + license: Zlib + license_family: Other + size: 81123 + timestamp: 1774072974535 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 + md5: ab136e4c34e97f34fb621d2592a393d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 433413 + timestamp: 1764777166076 diff --git a/pixi.toml b/pixi.toml index e5fed165..951dc25d 100644 --- a/pixi.toml +++ b/pixi.toml @@ -4,7 +4,7 @@ version = "0.0.1" description = "Physical AI orchestration built on Zenoh" authors = ["Edgar Riba "] channels = ["conda-forge"] -platforms = ["linux-64", "linux-aarch64"] +platforms = ["linux-64", "linux-aarch64", "osx-arm64"] [activation] scripts = ["scripts/activate.sh"] From 53511ec20fed3f44bfba344e55231f77e1ab9b65 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Sun, 14 Jun 2026 22:54:25 +0530 Subject: [PATCH 03/19] feat(storage): add reconcile diff-matrix healing primitive (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `storage::reconcile` per spec §3.5 — the user-facing healing primitive that diffs the three sources of truth for a recording (local manifest, local chunk files, remote listing) and converges them. - Applies the §3.5.2 diff matrix row-by-row: uploads missing-remote chunks, re-uploads on remote content mismatch, marks `uploaded_at_ns` when the remote already has a chunk, flags local corruption and chunks missing everywhere, and reports (never touches) orphans. - Optional `restore` re-downloads remote-only chunks, verifying SHA-256 on receipt before writing (§8). - Never deletes anything (§3.5.4) — idempotent and safe to re-run. - Backend-agnostic (`&dyn StorageBackend`) and clock-free (caller passes `now_ns`), so it's fully unit-testable against `LocalFs` as a stand-in remote. Re-uploads the manifest at the end (§3.5.3 step 6). 11 new unit tests (68 storage tests total); clippy-clean within storage. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/storage/mod.rs | 9 +- crates/bubbaloop/src/storage/reconcile.rs | 899 ++++++++++++++++++++++ 2 files changed, 905 insertions(+), 3 deletions(-) create mode 100644 crates/bubbaloop/src/storage/reconcile.rs diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index 92307fe9..e3eb5a82 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -10,15 +10,17 @@ //! - [`profile`] — `~/.bubbaloop/profiles/*.yaml` with the v1 validator + canonical hash. //! - [`secrets`] — opaque, zeroize-on-drop credentials in `secrets.toml` (chmod 0600). //! - [`backend`] — the `StorageBackend` trait + `LocalFs` (S3Compat lands in PR2). +//! - [`reconcile`] — the diff-matrix healing primitive (§3.5), backend-agnostic. //! -//! Still to come (later PRs from the spec): `discover`, `sync`, `reconcile`, -//! `replay`, `ring_buffer`, the `mcap-recorder` node, the CLI/MCP surfaces, and -//! the dashboard tab. +//! Still to come (later PRs from the spec): `discover`, `sync`, the S3Compat +//! backend, `replay`, `ring_buffer`, the `mcap-recorder` node, the CLI/MCP +//! surfaces, and the dashboard tab. pub mod backend; pub mod integrity; pub mod manifest; pub mod profile; +pub mod reconcile; pub mod recording; pub mod secrets; @@ -27,6 +29,7 @@ use std::path::{Path, PathBuf}; // Re-export the most commonly used types at the subsystem root. pub use backend::{local::LocalFs, BackendError, ObjectMeta, PutResult, StorageBackend}; pub use profile::Profile; +pub use reconcile::{reconcile, ReconcileError, ReconcileOptions, ReconcileReport}; pub use recording::{Channel, Chunk, Lifecycle, Recording, RecordingMode, Selection, Trigger}; /// Base bubbaloop directory: `~/.bubbaloop`. diff --git a/crates/bubbaloop/src/storage/reconcile.rs b/crates/bubbaloop/src/storage/reconcile.rs new file mode 100644 index 00000000..d9f2cca5 --- /dev/null +++ b/crates/bubbaloop/src/storage/reconcile.rs @@ -0,0 +1,899 @@ +//! Reconcile — the user-facing healing primitive (spec §3.5). +//! +//! `reconcile` diffs the three sources of truth for one recording and converges +//! them: +//! +//! 1. the **local manifest** (`manifest.json`) — what *should* exist + each +//! chunk's SHA-256, +//! 2. the **local chunk files** on disk — what is actually present locally, +//! 3. the **remote listing** (via [`StorageBackend`]) — what is actually in R2. +//! +//! It applies the §3.5.2 diff matrix row-by-row, then re-uploads the manifest so +//! the post-reconcile state is captured remotely (§3.5.3 step 6). +//! +//! **Safety invariant (§3.5.4):** reconcile *never deletes* anything, local or +//! remote. It only uploads missing/mismatched chunks, optionally re-downloads +//! (with `restore`), and *reports* orphans. That keeps it idempotent and safe to +//! re-run — running it five times produces the same result as running it once. +//! +//! The function is backend-agnostic: it drives a `&dyn StorageBackend`, so the +//! same code path serves R2 in production and [`LocalFs`](super::backend::local) +//! as a stand-in remote in tests. It is also clock-free — the caller passes +//! `now_ns` for any `uploaded_at_ns` it stamps — so it stays deterministic and +//! unit-testable. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use super::backend::{BackendError, StorageBackend}; +use super::integrity::{self, Sha256Digest}; +use super::manifest::{self, ManifestError}; +use super::recording::Chunk; +use super::{object_key_chunk, object_key_manifest}; + +/// Options controlling reconcile behaviour. +#[derive(Debug, Clone, Default)] +pub struct ReconcileOptions { + /// Re-download chunks that are present + verified in the remote but missing + /// locally (e.g. swept by `retention_days_local`). Without this, such chunks + /// are only reported, never fetched (spec §3.5.2, the `--restore` row). + pub restore: bool, +} + +/// What happened to a single chunk during reconcile (one row of the §3.5.2 +/// matrix). Carried in [`ReconcileReport::chunk_outcomes`] for reporting. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChunkOutcome { + /// Local + remote both present and matching; nothing to do (the + /// `uploaded_at_ns` was already set). + VerifiedOk, + /// Remote was confirmed present; the manifest's null `uploaded_at_ns` was + /// filled in from this reconcile (`now_ns`). + MarkedUploaded, + /// Local chunk was uploaded because the remote object was missing. + Uploaded, + /// Local chunk was re-uploaded because the remote object's content did not + /// match the manifest digest (stale/divergent remote at our key). + Reuploaded, + /// Remote chunk was re-downloaded to local (`restore` was set). + Redownloaded, + /// Local file is gone but the remote copy is intact; left as-is because + /// `restore` was not set. + PresentRemoteOnly, +} + +/// A non-fatal per-chunk problem. Reconcile records these and keeps going (it +/// stays idempotent and safe to re-run); the caller decides the exit status from +/// whether `errors` is non-empty. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChunkError { + /// Chunk index this error concerns. + pub index: u32, + /// Machine-readable failure category. + pub kind: ChunkErrorKind, + /// Human-readable detail for logs / CLI output. + pub detail: String, +} + +/// Category of a [`ChunkError`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChunkErrorKind { + /// The local file exists but its content does not match the manifest digest + /// — local corruption. Reconcile refuses to upload it (§3.5.2 row 4). + LocalCorrupt, + /// The chunk is absent both locally and remotely — the manifest is broken + /// and the recording cannot be made whole (§3.5.2 row 6). + MissingEverywhere, + /// The remote object exists but its content mismatches the manifest, and + /// there is no good local copy to heal from. + RemoteMismatchNoLocal, + /// A backend or filesystem error occurred while handling this chunk. + Io, +} + +/// An object/file present in exactly one place but not declared by the manifest. +/// Reported, never touched (§3.5.2 rows 7–8, §3.5.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Orphan { + /// Local path (orphan local file) or remote object key (orphan remote + /// object). + pub location: String, + /// Parsed chunk index, when the name was recognisable. + pub index: Option, +} + +/// Summary of one reconcile pass (spec §3.5.3 step 7). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReconcileReport { + /// Number of manifest chunks examined. + pub chunks_diffed: u32, + /// Chunks uploaded (missing remote). + pub chunks_uploaded: u32, + /// Chunks re-uploaded (remote content mismatched manifest). + pub chunks_reuploaded: u32, + /// Chunks re-downloaded (`restore`). + pub chunks_redownloaded: u32, + /// Chunks already present + verified on both sides. + pub chunks_verified_ok: u32, + /// Chunks whose null `uploaded_at_ns` was filled in from the remote. + pub chunks_marked_uploaded: u32, + /// Per-chunk outcomes in manifest order. + pub chunk_outcomes: Vec<(u32, ChunkOutcome)>, + /// Local files not declared by the manifest. + pub orphans_local: Vec, + /// Remote objects not declared by the manifest (includes stale objects at a + /// manifest chunk's index but a different content hash). + pub orphans_remote: Vec, + /// Non-fatal per-chunk errors. + pub errors: Vec, +} + +impl ReconcileReport { + /// Whether the recording is fully healed (every chunk converged, no errors). + pub fn is_clean(&self) -> bool { + self.errors.is_empty() + } +} + +/// Errors that abort a reconcile before it can produce a report. +#[derive(Debug, thiserror::Error)] +pub enum ReconcileError { + /// No manifest at the recording directory — there is nothing to reconcile + /// against (recovering a lost manifest is a deferred `storage repair` job, + /// §3.5.3 step 1). + #[error("no manifest to reconcile at {0}")] + ManifestMissing(String), + /// The manifest exists but failed to load or validate. + #[error("manifest error: {0}")] + Manifest(#[from] ManifestError), + /// A manifest chunk carried an unparseable SHA-256 (should have been caught + /// by manifest validation, but reconcile re-checks before trusting it). + #[error("chunk {index} has an invalid sha256: {sha256}")] + BadManifestDigest { index: u32, sha256: String }, + /// A hard backend error while listing the remote prefix (per-chunk backend + /// errors are non-fatal and collected in the report instead). + #[error("backend error: {0}")] + Backend(#[from] BackendError), +} + +/// Reconcile recording `` rooted at `recording_dir` against `backend`. +/// +/// `now_ns` is the timestamp stamped into any `uploaded_at_ns` that reconcile +/// fills in. The function loads the manifest from `recording_dir/manifest.json`, +/// derives the remote prefix from the manifest's own `machine_id`/`name`, applies +/// the diff matrix, persists the (possibly mutated) manifest locally, and +/// re-uploads it. +pub async fn reconcile( + recording_dir: impl AsRef, + backend: &dyn StorageBackend, + opts: &ReconcileOptions, + now_ns: u64, +) -> Result { + let recording_dir = recording_dir.as_ref(); + let manifest_path = manifest::manifest_path(recording_dir); + if !manifest_path.exists() { + return Err(ReconcileError::ManifestMissing( + manifest_path.display().to_string(), + )); + } + + // §3.5.3 step 1: read the local manifest (validated on load). + let mut manifest = manifest::load_dir(recording_dir)?; + let machine_id = manifest.machine_id.clone(); + let name = manifest.name.clone(); + + // §3.5.3 step 2: enumerate local chunk files, keyed by parsed index. + let chunks_dir = super::chunks_path(recording_dir); + let local_files = scan_local_chunks(&chunks_dir)?; + + // §3.5.3 step 3: list remote objects under the recording's chunk prefix. + let remote_prefix = format!("{machine_id}/{name}/chunks/"); + let remote_objects = scan_remote_chunks(backend, &remote_prefix).await?; + + let mut report = ReconcileReport::default(); + + // Track which local files / remote objects a manifest chunk claims, so the + // leftovers can be reported as orphans afterwards. + let mut claimed_local: Vec = Vec::new(); + let mut claimed_remote: Vec = Vec::new(); + let mut manifest_dirty = false; + + // §3.5.3 step 4: apply the diff matrix to each manifest chunk. + for i in 0..manifest.chunks.len() { + let index = manifest.chunks[i].index; + let sha_hex = manifest.chunks[i].sha256.clone(); + report.chunks_diffed += 1; + + let expected_digest = match integrity::from_hex(&sha_hex) { + Ok(d) => d, + Err(_) => { + return Err(ReconcileError::BadManifestDigest { + index, + sha256: sha_hex, + }) + } + }; + let expected_key = object_key_chunk(&machine_id, &name, index, &sha_hex); + + // -- Local side ------------------------------------------------------- + let local_path = local_files.get(&index).cloned(); + let local_state = match &local_path { + Some(path) => { + claimed_local.push(path.clone()); + match integrity::sha256_file(path) { + Ok(actual) if actual == expected_digest => LocalState::Present, + Ok(_) => LocalState::Corrupt, + Err(e) => { + report.errors.push(ChunkError { + index, + kind: ChunkErrorKind::Io, + detail: format!("hashing {}: {e}", path.display()), + }); + continue; + } + } + } + None => LocalState::Absent, + }; + + // -- Remote side ------------------------------------------------------ + // HEAD the deterministic key. Its embedded sha-prefix means a hit is + // already content-addressed to this chunk; we additionally verify the + // full digest when the backend supplies one (R2 returns the + // x-amz-checksum-sha256 we set; LocalFs returns the real hash). + let remote_state = match backend.head(&expected_key).await { + Ok(Some(meta)) => { + claimed_remote.push(expected_key.clone()); + match meta.sha256 { + Some(actual) if actual == expected_digest => RemoteState::Present, + Some(_) => RemoteState::Mismatch, + None => RemoteState::Present, // backend can't verify; trust the key + } + } + Ok(None) => RemoteState::Absent, + Err(e) => { + report.errors.push(ChunkError { + index, + kind: ChunkErrorKind::Io, + detail: format!("HEAD {expected_key}: {e}"), + }); + continue; + } + }; + + // -- Diff matrix (§3.5.2) -------------------------------------------- + match (local_state, remote_state) { + // Row 4: local file present but corrupt — never upload, surface it. + (LocalState::Corrupt, _) => { + report.errors.push(ChunkError { + index, + kind: ChunkErrorKind::LocalCorrupt, + detail: format!("local chunk {index} does not match manifest sha256 {sha_hex}"), + }); + } + + // Row 1: both present + matching. + (LocalState::Present, RemoteState::Present) => { + if manifest.chunks[i].uploaded_at_ns.is_none() { + manifest.chunks[i].uploaded_at_ns = Some(now_ns); + manifest_dirty = true; + report.chunks_marked_uploaded += 1; + record(&mut report, index, ChunkOutcome::MarkedUploaded); + } else { + report.chunks_verified_ok += 1; + record(&mut report, index, ChunkOutcome::VerifiedOk); + } + } + + // Row 2: local good, remote missing — upload. + (LocalState::Present, RemoteState::Absent) => { + match upload_chunk( + backend, + &expected_key, + local_path.as_ref().unwrap(), + &expected_digest, + ) + .await + { + Ok(etag) => { + mark_uploaded(&mut manifest.chunks[i], now_ns, etag); + manifest_dirty = true; + report.chunks_uploaded += 1; + record(&mut report, index, ChunkOutcome::Uploaded); + } + Err(e) => push_upload_error(&mut report, index, &expected_key, e), + } + } + + // Row 3: remote present but content mismatches — re-upload local + // (deterministic key makes the overwrite safe). + (LocalState::Present, RemoteState::Mismatch) => { + log::warn!( + "reconcile {name}: remote chunk {index} content mismatched manifest; re-uploading local copy" + ); + match upload_chunk( + backend, + &expected_key, + local_path.as_ref().unwrap(), + &expected_digest, + ) + .await + { + Ok(etag) => { + mark_uploaded(&mut manifest.chunks[i], now_ns, etag); + manifest_dirty = true; + report.chunks_reuploaded += 1; + record(&mut report, index, ChunkOutcome::Reuploaded); + } + Err(e) => push_upload_error(&mut report, index, &expected_key, e), + } + } + + // Row 5: local missing, remote good. + (LocalState::Absent, RemoteState::Present) => { + if opts.restore { + match restore_chunk( + backend, + &expected_key, + &chunks_dir, + &manifest.chunks[i], + &expected_digest, + ) + .await + { + Ok(()) => { + if manifest.chunks[i].uploaded_at_ns.is_none() { + manifest.chunks[i].uploaded_at_ns = Some(now_ns); + manifest_dirty = true; + } + report.chunks_redownloaded += 1; + record(&mut report, index, ChunkOutcome::Redownloaded); + } + Err(e) => { + report.errors.push(ChunkError { + index, + kind: ChunkErrorKind::Io, + detail: format!("restore {expected_key}: {e}"), + }); + } + } + } else { + if manifest.chunks[i].uploaded_at_ns.is_none() { + manifest.chunks[i].uploaded_at_ns = Some(now_ns); + manifest_dirty = true; + } + record(&mut report, index, ChunkOutcome::PresentRemoteOnly); + } + } + + // Remote present but mismatched, with no good local copy to heal + // from — can't safely converge, surface it. + (LocalState::Absent, RemoteState::Mismatch) => { + report.errors.push(ChunkError { + index, + kind: ChunkErrorKind::RemoteMismatchNoLocal, + detail: format!( + "remote chunk {index} mismatches manifest and no local copy exists" + ), + }); + } + + // Row 6: gone in both places — the manifest is broken. + (LocalState::Absent, RemoteState::Absent) => { + report.errors.push(ChunkError { + index, + kind: ChunkErrorKind::MissingEverywhere, + detail: format!("chunk {index} is missing both locally and remotely"), + }); + } + } + } + + // §3.5.2 rows 7–8: report (never touch) orphans. + collect_local_orphans(&local_files, &claimed_local, &mut report); + collect_remote_orphans(&remote_objects, &claimed_remote, &mut report); + + // §3.5.3 step 6: persist the (possibly mutated) manifest, then re-upload it + // so the post-reconcile state is captured remotely. + if manifest_dirty { + manifest::save_dir(recording_dir, &manifest)?; + } + if let Err(e) = upload_manifest(backend, &machine_id, &name, recording_dir).await { + log::warn!("reconcile {name}: failed to re-upload manifest: {e}"); + report.errors.push(ChunkError { + // Manifest is not a chunk; use a sentinel index for reporting. + index: u32::MAX, + kind: ChunkErrorKind::Io, + detail: format!("manifest re-upload failed: {e}"), + }); + } + + Ok(report) +} + +/// Local presence of a chunk relative to the manifest digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalState { + /// File present and content matches the manifest. + Present, + /// File present but content does not match the manifest. + Corrupt, + /// No local file for this index. + Absent, +} + +/// Remote presence of a chunk at its deterministic key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteState { + /// Object present and (where verifiable) content matches the manifest. + Present, + /// Object present at the key but content mismatches the manifest. + Mismatch, + /// No object at the deterministic key. + Absent, +} + +fn record(report: &mut ReconcileReport, index: u32, outcome: ChunkOutcome) { + report.chunk_outcomes.push((index, outcome)); +} + +fn mark_uploaded(chunk: &mut Chunk, now_ns: u64, etag: Option) { + chunk.uploaded_at_ns = Some(now_ns); + if etag.is_some() { + chunk.remote_etag = etag; + } +} + +fn push_upload_error(report: &mut ReconcileReport, index: u32, key: &str, e: BackendError) { + let kind = match e { + BackendError::BadDigest { .. } => ChunkErrorKind::LocalCorrupt, + _ => ChunkErrorKind::Io, + }; + report.errors.push(ChunkError { + index, + kind, + detail: format!("upload {key}: {e}"), + }); +} + +/// Read the local file and PUT it at `key` with the manifest digest as the +/// integrity check, returning the backend ETag. +async fn upload_chunk( + backend: &dyn StorageBackend, + key: &str, + path: &Path, + digest: &Sha256Digest, +) -> Result, BackendError> { + let bytes = tokio::fs::read(path).await.map_err(|e| BackendError::Io { + key: key.to_string(), + detail: format!("reading {}: {e}", path.display()), + })?; + let result = backend.put(key, &bytes, Some(digest)).await?; + Ok(result.etag) +} + +/// Download a chunk from the backend, verify it against the manifest digest, and +/// write it to the local chunks directory under its canonical name. Refuses to +/// write a file that fails verification (spec §8, "download verifies on receipt +/// and refuses to write a corrupted file"). +async fn restore_chunk( + backend: &dyn StorageBackend, + key: &str, + chunks_dir: &Path, + chunk: &Chunk, + digest: &Sha256Digest, +) -> Result<(), BackendError> { + let bytes = backend.get(key).await?; + let actual = integrity::sha256(&bytes); + if &actual != digest { + return Err(BackendError::BadDigest { + key: key.to_string(), + expected: integrity::to_hex(digest), + actual: integrity::to_hex(&actual), + }); + } + tokio::fs::create_dir_all(chunks_dir) + .await + .map_err(|e| BackendError::Io { + key: key.to_string(), + detail: format!("creating {}: {e}", chunks_dir.display()), + })?; + let dest = chunks_dir.join(&chunk.name); + tokio::fs::write(&dest, &bytes) + .await + .map_err(|e| BackendError::Io { + key: key.to_string(), + detail: format!("writing {}: {e}", dest.display()), + })?; + Ok(()) +} + +/// Re-upload the manifest.json so the remote reflects the post-reconcile state. +async fn upload_manifest( + backend: &dyn StorageBackend, + machine_id: &str, + name: &str, + recording_dir: &Path, +) -> Result<(), BackendError> { + let path = manifest::manifest_path(recording_dir); + let bytes = tokio::fs::read(&path).await.map_err(|e| BackendError::Io { + key: path.display().to_string(), + detail: e.to_string(), + })?; + let digest = integrity::sha256(&bytes); + let key = object_key_manifest(machine_id, name); + backend.put(&key, &bytes, Some(&digest)).await?; + Ok(()) +} + +/// Enumerate local chunk files by parsed index. Non-chunk files are skipped; if +/// two files share an index, the lexicographically last wins (deterministic) and +/// the others fall out as orphans. +fn scan_local_chunks(chunks_dir: &Path) -> Result, ReconcileError> { + let mut map = BTreeMap::new(); + let read = match std::fs::read_dir(chunks_dir) { + Ok(rd) => rd, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(map), + Err(e) => { + return Err(ReconcileError::Manifest(ManifestError::Io { + path: chunks_dir.display().to_string(), + detail: e.to_string(), + })) + } + }; + let mut entries: Vec = Vec::new(); + for entry in read.flatten() { + if entry.path().is_file() { + entries.push(entry.path()); + } + } + entries.sort(); + for path in entries { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if let Some((index, _)) = parse_chunk_filename(name) { + map.insert(index, path); + } + } + } + Ok(map) +} + +/// List remote chunk objects under `prefix`, keyed by parsed index. Objects whose +/// names don't parse, or that collide on index, keep the lexicographically last +/// (matching the local scan); the rest surface as remote orphans. +async fn scan_remote_chunks( + backend: &dyn StorageBackend, + prefix: &str, +) -> Result, ReconcileError> { + let mut map = BTreeMap::new(); + let mut objects = backend.list(prefix).await?; + objects.sort_by(|a, b| a.key.cmp(&b.key)); + for obj in objects { + let filename = obj.key.rsplit('/').next().unwrap_or(&obj.key); + if let Some((index, _)) = parse_chunk_filename(filename) { + map.insert(index, obj.key); + } + } + Ok(map) +} + +/// Parse `chunk-{index:06}-{prefix}.mcap` into `(index, prefix)`. +fn parse_chunk_filename(name: &str) -> Option<(u32, String)> { + let stem = name.strip_suffix(".mcap")?; + let rest = stem.strip_prefix("chunk-")?; + let (idx_str, prefix) = rest.split_once('-')?; + let index = idx_str.parse::().ok()?; + if prefix.is_empty() { + return None; + } + Some((index, prefix.to_string())) +} + +fn collect_local_orphans( + local_files: &BTreeMap, + claimed: &[PathBuf], + report: &mut ReconcileReport, +) { + for (index, path) in local_files { + if !claimed.contains(path) { + log::warn!( + "reconcile: orphan local file {} (chunk index {index} not claimed by manifest)", + path.display() + ); + report.orphans_local.push(Orphan { + location: path.display().to_string(), + index: Some(*index), + }); + } + } +} + +fn collect_remote_orphans( + remote_objects: &BTreeMap, + claimed: &[String], + report: &mut ReconcileReport, +) { + for (index, key) in remote_objects { + if !claimed.contains(key) { + log::warn!("reconcile: orphan remote object {key} (chunk index {index} not claimed by manifest)"); + report.orphans_remote.push(Orphan { + location: key.clone(), + index: Some(*index), + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::backend::local::LocalFs; + use crate::storage::recording::{Chunk, Recording}; + use std::path::Path; + + const NOW: u64 = 1_700_000_000_000_000_000; + + /// Deterministic chunk content for index `i`. + fn chunk_bytes(i: u32) -> Vec { + format!("chunk-content-{i}").into_bytes() + } + + /// Build a chunk entry from real bytes (so sha256/name are consistent). + fn chunk_for(i: u32, bytes: &[u8], uploaded: bool) -> Chunk { + let digest = integrity::sha256(bytes); + let sha = integrity::to_hex(&digest); + Chunk { + name: Chunk::canonical_name(i, &sha), + index: i, + size_bytes: bytes.len() as u64, + sha256: sha, + log_time_first_ns: None, + log_time_last_ns: None, + uploaded_at_ns: if uploaded { Some(NOW - 1) } else { None }, + remote_etag: None, + extra: Default::default(), + } + } + + /// Lay out a recording dir + manifest with `n` chunks, writing the given + /// indices to local disk. Returns (tempdir, recording_dir, manifest). + fn setup( + n: u32, + write_local: &[u32], + uploaded: &[u32], + ) -> (tempfile::TempDir, PathBuf, Recording) { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let chunks_dir = rec_dir.join("chunks"); + std::fs::create_dir_all(&chunks_dir).unwrap(); + + let mut rec = Recording::new("rec_a", "jetson_alpha", 1_000); + for i in 0..n { + let bytes = chunk_bytes(i); + let chunk = chunk_for(i, &bytes, uploaded.contains(&i)); + if write_local.contains(&i) { + std::fs::write(chunks_dir.join(&chunk.name), &bytes).unwrap(); + } + rec.chunks.push(chunk); + } + manifest::save_dir(&rec_dir, &rec).unwrap(); + (dir, rec_dir, rec) + } + + /// A LocalFs "remote" rooted in its own tempdir. + fn remote() -> (tempfile::TempDir, LocalFs) { + let dir = tempfile::tempdir().unwrap(); + let fs = LocalFs::new(dir.path()); + (dir, fs) + } + + /// Seed the remote with chunk `i`'s canonical object (matching content). + async fn seed_remote(fs: &LocalFs, rec: &Recording, i: u32) { + let c = &rec.chunks[i as usize]; + let key = object_key_chunk(&rec.machine_id, &rec.name, i, &c.sha256); + fs.put(&key, &chunk_bytes(i), None).await.unwrap(); + } + + #[tokio::test] + async fn uploads_missing_remote_chunks() { + let (_d, rec_dir, _rec) = setup(3, &[0, 1, 2], &[]); + let (_rd, fs) = remote(); + + let report = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + + assert_eq!(report.chunks_diffed, 3); + assert_eq!(report.chunks_uploaded, 3); + assert!(report.is_clean()); + + // Manifest now records all chunks as uploaded. + let reloaded = manifest::load_dir(&rec_dir).unwrap(); + assert!(reloaded.is_fully_uploaded()); + for c in &reloaded.chunks { + assert_eq!(c.uploaded_at_ns, Some(NOW)); + assert!(c.remote_etag.is_some()); + } + + // And the manifest itself was uploaded. + let mkey = object_key_manifest("jetson_alpha", "rec_a"); + assert!(fs.head(&mkey).await.unwrap().is_some()); + } + + #[tokio::test] + async fn idempotent_second_run_is_a_noop() { + let (_d, rec_dir, _rec) = setup(2, &[0, 1], &[]); + let (_rd, fs) = remote(); + + let first = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + assert_eq!(first.chunks_uploaded, 2); + + let second = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW + 1) + .await + .unwrap(); + assert_eq!(second.chunks_uploaded, 0); + assert_eq!(second.chunks_verified_ok, 2); + assert!(second.is_clean()); + } + + #[tokio::test] + async fn marks_uploaded_when_remote_present_but_manifest_null() { + let (_d, rec_dir, rec) = setup(1, &[0], &[]); + let (_rd, fs) = remote(); + seed_remote(&fs, &rec, 0).await; + + let report = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + assert_eq!(report.chunks_marked_uploaded, 1); + assert_eq!(report.chunks_uploaded, 0); + + let reloaded = manifest::load_dir(&rec_dir).unwrap(); + assert_eq!(reloaded.chunks[0].uploaded_at_ns, Some(NOW)); + } + + #[tokio::test] + async fn detects_local_corruption() { + let (_d, rec_dir, rec) = setup(1, &[], &[]); + let (_rd, fs) = remote(); + // Write a local file whose content does NOT match the manifest digest. + let chunks_dir = rec_dir.join("chunks"); + std::fs::write(chunks_dir.join(&rec.chunks[0].name), b"corrupted").unwrap(); + + let report = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + assert_eq!(report.chunks_uploaded, 0); + assert_eq!(report.errors.len(), 1); + assert_eq!(report.errors[0].kind, ChunkErrorKind::LocalCorrupt); + assert!(!report.is_clean()); + } + + #[tokio::test] + async fn missing_everywhere_is_an_error() { + let (_d, rec_dir, _rec) = setup(1, &[], &[]); + let (_rd, fs) = remote(); + + let report = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + assert_eq!(report.errors.len(), 1); + assert_eq!(report.errors[0].kind, ChunkErrorKind::MissingEverywhere); + } + + #[tokio::test] + async fn restore_redownloads_missing_local() { + let (_d, rec_dir, rec) = setup(1, &[], &[]); + let (_rd, fs) = remote(); + seed_remote(&fs, &rec, 0).await; + + // Without restore: only reported, not fetched. + let no_restore = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + assert_eq!(no_restore.chunks_redownloaded, 0); + assert!(!rec_dir.join("chunks").join(&rec.chunks[0].name).exists()); + + // With restore: the local file reappears and verifies. + let restored = reconcile(&rec_dir, &fs, &ReconcileOptions { restore: true }, NOW) + .await + .unwrap(); + assert_eq!(restored.chunks_redownloaded, 1); + let local = rec_dir.join("chunks").join(&rec.chunks[0].name); + assert!(local.exists()); + assert_eq!(std::fs::read(&local).unwrap(), chunk_bytes(0)); + } + + #[tokio::test] + async fn reuploads_on_remote_content_mismatch() { + let (_d, rec_dir, rec) = setup(1, &[0], &[]); + let (_rd, fs) = remote(); + // Put DIFFERENT content at the expected deterministic key. + let key = object_key_chunk(&rec.machine_id, &rec.name, 0, &rec.chunks[0].sha256); + fs.put(&key, b"stale different content", None) + .await + .unwrap(); + + let report = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + assert_eq!(report.chunks_reuploaded, 1); + assert!(report.is_clean()); + + // Remote now holds the correct content. + let got = fs.get(&key).await.unwrap(); + assert_eq!(got, chunk_bytes(0)); + } + + #[tokio::test] + async fn reports_orphans_without_touching_them() { + let (_d, rec_dir, _rec) = setup(1, &[0], &[]); + let (_rd, fs) = remote(); + let chunks_dir = rec_dir.join("chunks"); + + // An orphan local file at an index the manifest doesn't declare. + let orphan_name = Chunk::canonical_name(9, &"ff".repeat(32)); + std::fs::write(chunks_dir.join(&orphan_name), b"orphan").unwrap(); + + // An orphan remote object likewise. + fs.put( + "jetson_alpha/rec_a/chunks/chunk-000009-ffffffff.mcap", + b"orphan-remote", + None, + ) + .await + .unwrap(); + + let report = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + + assert_eq!(report.orphans_local.len(), 1); + assert_eq!(report.orphans_local[0].index, Some(9)); + assert_eq!(report.orphans_remote.len(), 1); + assert_eq!(report.orphans_remote[0].index, Some(9)); + + // Orphans are untouched (never deleted, §3.5.4). + assert!(chunks_dir.join(&orphan_name).exists()); + assert!(fs + .head("jetson_alpha/rec_a/chunks/chunk-000009-ffffffff.mcap") + .await + .unwrap() + .is_some()); + } + + #[tokio::test] + async fn missing_manifest_is_a_fatal_error() { + let dir = tempfile::tempdir().unwrap(); + let (_rd, fs) = remote(); + let err = reconcile(dir.path(), &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap_err(); + assert!(matches!(err, ReconcileError::ManifestMissing(_))); + } + + #[test] + fn chunk_filename_parsing() { + assert_eq!( + parse_chunk_filename("chunk-000007-a1b2c3d4.mcap"), + Some((7, "a1b2c3d4".to_string())) + ); + assert_eq!( + parse_chunk_filename("chunk-000000-aa.mcap"), + Some((0, "aa".to_string())) + ); + assert_eq!(parse_chunk_filename("not-a-chunk.mcap"), None); + assert_eq!(parse_chunk_filename("chunk-000000-.mcap"), None); + assert_eq!(parse_chunk_filename("chunk-000000-aa.txt"), None); + assert_eq!(parse_chunk_filename("manifest.json"), None); + } + + // Touch `Path` import in a trivial assertion to keep it used across cfgs. + #[test] + fn chunks_dir_is_relative_join() { + let p = Path::new("/tmp/rec"); + assert_eq!(super::super::chunks_path(p), Path::new("/tmp/rec/chunks")); + } +} From 030f00143ce00a115ac9c04460b3c0f1686c1aa6 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Sun, 14 Jun 2026 22:59:54 +0530 Subject: [PATCH 04/19] =?UTF-8?q?feat(storage):=20add=20sync=20upload=20co?= =?UTF-8?q?re=20=E2=80=94=20backoff,=20dead-letter,=20upload=20sequence=20?= =?UTF-8?q?(PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the deterministic core of `storage::sync` per spec §3.4 that the daemon's background queue driver is built from: - BackoffSchedule + SharedBackoff: the shared exponential backoff (§3.4.3) — immediate, then 5s→10s→20s→40s→60s clamp, dead-letter after max_elapsed_time. Backoff is shared across jobs and resets on the first success. - classify(): retryable / terminal / pause-sync error mapping (§3.4.3). Adds Forbidden + NoSuchBucket terminal variants to BackendError for the future S3 backend. - upload_one(): the per-chunk upload sequence (§3.4.4) — re-verify local SHA-256, HEAD-before-PUT idempotency (AlreadyPresent / Conflict / PUT), PUT with the checksum header. - pending_jobs() + mark_chunk_uploaded(): the startup-scan enqueue (§3.4.1 trigger 3) and the atomic manifest mutation recording uploaded_at_ns (§3.4.5). - DeadLetterList: the persisted ~/.bubbaloop/storage/dead_letter.json (§3.4.6) that survives restarts and that reconcile flushes; dedup add + remove + atomic save. The async queue driver (bounded VecDeque + semaphore + the in-process MPSC of ChunkFinalized events) is intentionally deferred — it consumes recorder events that land in a later PR. The decision logic it needs lives here. 12 new unit tests (80 storage tests total); clippy-clean within storage. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/storage/backend.rs | 9 + crates/bubbaloop/src/storage/mod.rs | 13 +- crates/bubbaloop/src/storage/sync.rs | 741 ++++++++++++++++++++++++ 3 files changed, 760 insertions(+), 3 deletions(-) create mode 100644 crates/bubbaloop/src/storage/sync.rs diff --git a/crates/bubbaloop/src/storage/backend.rs b/crates/bubbaloop/src/storage/backend.rs index be2e339d..c9336206 100644 --- a/crates/bubbaloop/src/storage/backend.rs +++ b/crates/bubbaloop/src/storage/backend.rs @@ -80,6 +80,15 @@ pub enum BackendError { /// The key is invalid (e.g. path traversal, empty). #[error("invalid object key: {0}")] InvalidKey(String), + /// Authentication/authorization was rejected (terminal — credentials won't + /// fix themselves; sync dead-letters immediately, §3.4.3). Produced by cloud + /// backends; `LocalFs` never returns it. + #[error("forbidden for {key}")] + Forbidden { key: String }, + /// The configured bucket does not exist (terminal misconfiguration — sync + /// pauses entirely until reconfigured, §3.4.3). Produced by cloud backends. + #[error("no such bucket: {bucket}")] + NoSuchBucket { bucket: String }, /// Filesystem / transport error (generally retryable). #[error("backend io error for {key}: {detail}")] Io { key: String, detail: String }, diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index e3eb5a82..35e1baa5 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -11,10 +11,12 @@ //! - [`secrets`] — opaque, zeroize-on-drop credentials in `secrets.toml` (chmod 0600). //! - [`backend`] — the `StorageBackend` trait + `LocalFs` (S3Compat lands in PR2). //! - [`reconcile`] — the diff-matrix healing primitive (§3.5), backend-agnostic. +//! - [`sync`] — the background-upload core (§3.4): backoff, dead-letter, +//! per-chunk upload sequence (the async queue driver lands with the recorder). //! -//! Still to come (later PRs from the spec): `discover`, `sync`, the S3Compat -//! backend, `replay`, `ring_buffer`, the `mcap-recorder` node, the CLI/MCP -//! surfaces, and the dashboard tab. +//! Still to come (later PRs from the spec): `discover`, the S3Compat backend, the +//! sync queue driver, `replay`, `ring_buffer`, the `mcap-recorder` node, the +//! CLI/MCP surfaces, and the dashboard tab. pub mod backend; pub mod integrity; @@ -23,6 +25,7 @@ pub mod profile; pub mod reconcile; pub mod recording; pub mod secrets; +pub mod sync; use std::path::{Path, PathBuf}; @@ -31,6 +34,10 @@ pub use backend::{local::LocalFs, BackendError, ObjectMeta, PutResult, StorageBa pub use profile::Profile; pub use reconcile::{reconcile, ReconcileError, ReconcileOptions, ReconcileReport}; pub use recording::{Channel, Chunk, Lifecycle, Recording, RecordingMode, Selection, Trigger}; +pub use sync::{ + classify, BackoffSchedule, DeadLetterEntry, DeadLetterList, ErrorClass, SharedBackoff, + SyncError, UploadJob, UploadOutcome, +}; /// Base bubbaloop directory: `~/.bubbaloop`. pub fn bubbaloop_dir() -> Result { diff --git a/crates/bubbaloop/src/storage/sync.rs b/crates/bubbaloop/src/storage/sync.rs new file mode 100644 index 00000000..aa027a7b --- /dev/null +++ b/crates/bubbaloop/src/storage/sync.rs @@ -0,0 +1,741 @@ +//! Sync — background upload of finalized chunks (spec §3.4). +//! +//! Sync replaces the standalone `uploader/` node. In production it runs as one +//! background task in the daemon, driven by three triggers (§3.4.1): a +//! chunk-finalize event from the recorder, a manifest-finalize event on `record +//! stop`, and a startup scan that re-enqueues anything left un-uploaded. +//! +//! This module is the **deterministic core** the driver task is built from: +//! +//! - [`BackoffSchedule`] / [`SharedBackoff`] — the shared exponential backoff +//! (§3.4.3): immediate, then 5s→10s→20s→40s→60s clamp, dead-letter after +//! `max_elapsed_time`. Backoff is shared across jobs (a down R2 fails them all) +//! and resets on the first success. +//! - [`classify`] — retryable vs terminal vs pause-sync error mapping (§3.4.3). +//! - [`DeadLetterList`] — the persisted `~/.bubbaloop/storage/dead_letter.json` +//! that survives daemon restarts and that `reconcile` picks back up (§3.4.6). +//! - [`upload_one`] — the per-chunk upload sequence (§3.4.4): re-verify the local +//! SHA-256, HEAD-before-PUT for idempotency, PUT with the checksum header. +//! - [`pending_jobs`] / [`mark_chunk_uploaded`] — the startup-scan enqueue and +//! the manifest mutation that records `uploaded_at_ns`. +//! +//! The async **queue driver** (the bounded `VecDeque`, the concurrency +//! semaphore, and the in-process MPSC of `ChunkFinalized` events) is intentionally +//! *not* here: it consumes events the recorder emits, and the recorder lands in a +//! later PR. Everything the driver needs to make decisions lives in this module +//! and is unit-tested against [`LocalFs`](super::backend::local) as a stand-in +//! remote, with no clock or network dependency (callers pass `now_ns`). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::backend::{BackendError, StorageBackend}; +use super::integrity; +use super::profile::RetryOnFailure; +use super::recording::{Chunk, Recording}; +use super::{manifest, object_key_chunk, StoragePathError}; + +// --------------------------------------------------------------------------- +// Error classification (§3.4.3) +// --------------------------------------------------------------------------- + +/// How sync should treat a backend failure (spec §3.4.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorClass { + /// Transient — retry with the shared backoff (network, 5xx, 429, DNS). + Retryable, + /// Permanent for this job — dead-letter immediately, do not keep retrying + /// (bad auth, local corruption, invalid key). + Terminal, + /// Misconfiguration affecting *every* job — pause sync entirely until the + /// operator fixes it (`NoSuchBucket`). + PauseSync, +} + +/// Classify a [`BackendError`] for the retry policy. +pub fn classify(err: &BackendError) -> ErrorClass { + match err { + BackendError::NoSuchBucket { .. } => ErrorClass::PauseSync, + BackendError::BadDigest { .. } + | BackendError::InvalidKey(_) + | BackendError::Forbidden { .. } => ErrorClass::Terminal, + // NotFound isn't expected on the upload path; treat transport/IO and any + // other transient condition as retryable. + BackendError::NotFound { .. } | BackendError::Io { .. } => ErrorClass::Retryable, + } +} + +// --------------------------------------------------------------------------- +// Backoff (§3.4.3) +// --------------------------------------------------------------------------- + +/// The exponential backoff parameters, derived from a profile's +/// `retry_on_failure` block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackoffSchedule { + /// First non-zero delay (attempt 2). Default 5s. + pub initial: Duration, + /// Upper clamp on the delay. Default 60s. + pub max: Duration, + /// Total failure streak duration after which a job is dead-lettered. + /// Default 5m. + pub max_elapsed: Duration, +} + +impl Default for BackoffSchedule { + fn default() -> Self { + Self { + initial: Duration::from_secs(5), + max: Duration::from_secs(60), + max_elapsed: Duration::from_secs(300), + } + } +} + +impl BackoffSchedule { + /// Build from a profile's `retry_on_failure` block. + pub fn from_profile(r: &RetryOnFailure) -> Self { + Self { + initial: r.initial_interval.as_duration(), + max: r.max_interval.as_duration(), + max_elapsed: r.max_elapsed_time.as_duration(), + } + } + + /// Delay before `attempt` (1-based). Attempt 1 is immediate; attempt 2 is + /// `initial`; each subsequent attempt doubles, clamped to `max` (§3.4.3): + /// `0, 5s, 10s, 20s, 40s, 60s, 60s, …`. + pub fn delay_for_attempt(&self, attempt: u32) -> Duration { + if attempt <= 1 { + return Duration::ZERO; + } + // attempt 2 → 2^0 × initial, attempt 3 → 2^1 × initial, … + let shifts = (attempt - 2).min(32); + let initial_ms = self.initial.as_millis(); + let scaled = initial_ms.saturating_mul(1u128 << shifts); + let clamped = scaled.min(self.max.as_millis()); + Duration::from_millis(clamped.min(u64::MAX as u128) as u64) + } +} + +/// The shared backoff state. One instance is shared by the whole queue: a single +/// R2 outage fails every job, so per-job backoff would just burn the queue +/// retrying hopeless work (§3.4.3). Any job's success resets it for everyone. +#[derive(Debug, Clone)] +pub struct SharedBackoff { + schedule: BackoffSchedule, + /// Number of consecutive failures in the current streak. + failures: u32, + /// When the current failure streak started (nanoseconds since epoch). + streak_started_ns: Option, +} + +/// The decision returned after recording a failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackoffDecision { + /// How long to wait before the next attempt. + pub delay: Duration, + /// Whether the failure streak has exceeded `max_elapsed` and the job should + /// be dead-lettered instead of retried. + pub dead_letter: bool, + /// Failure count in the current streak (1-based). + pub failures: u32, +} + +impl SharedBackoff { + /// New backoff in the rested state (no failures). + pub fn new(schedule: BackoffSchedule) -> Self { + Self { + schedule, + failures: 0, + streak_started_ns: None, + } + } + + /// Current consecutive-failure count. + pub fn failures(&self) -> u32 { + self.failures + } + + /// Reset after a successful upload (§3.4.3: any success resets the shared + /// backoff to attempt 1 for everyone). + pub fn on_success(&mut self) { + self.failures = 0; + self.streak_started_ns = None; + } + + /// Record a failure observed at `now_ns`. Returns the delay before the next + /// attempt and whether the streak has run past `max_elapsed` (dead-letter). + pub fn on_failure(&mut self, now_ns: u64) -> BackoffDecision { + let started = *self.streak_started_ns.get_or_insert(now_ns); + self.failures += 1; + let elapsed_ns = now_ns.saturating_sub(started) as u128; + let dead_letter = elapsed_ns >= self.schedule.max_elapsed.as_nanos(); + // The next attempt number is failures + 1 (after one failure, attempt 2). + let delay = self.schedule.delay_for_attempt(self.failures + 1); + BackoffDecision { + delay, + dead_letter, + failures: self.failures, + } + } +} + +// --------------------------------------------------------------------------- +// Upload job + per-chunk sequence (§3.4.4) +// --------------------------------------------------------------------------- + +/// A single chunk waiting to be uploaded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UploadJob { + /// Recording this chunk belongs to. + pub recording_name: String, + /// Zero-based chunk index. + pub chunk_index: u32, + /// Local file to upload. + pub local_path: PathBuf, + /// Manifest SHA-256 (lowercase hex). + pub sha256: String, + /// Deterministic remote object key. + pub object_key: String, +} + +impl UploadJob { + /// Build the job for `chunk` of recording `recording_name` on `machine_id`, + /// whose files live under `recording_dir`. + pub fn for_chunk( + machine_id: &str, + recording_dir: &Path, + recording_name: &str, + chunk: &Chunk, + ) -> Self { + Self { + recording_name: recording_name.to_string(), + chunk_index: chunk.index, + local_path: super::chunks_path(recording_dir).join(&chunk.name), + sha256: chunk.sha256.clone(), + object_key: object_key_chunk(machine_id, recording_name, chunk.index, &chunk.sha256), + } + } +} + +/// Result of [`upload_one`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UploadOutcome { + /// The object was freshly PUT. + Uploaded { etag: Option }, + /// The object already existed with matching content (HEAD short-circuit). + AlreadyPresent { etag: Option }, + /// An object exists at our key but its content differs — a possible bucket + /// collision. Sync dead-letters this for human review (§3.4.4 step 3). + Conflict, +} + +/// Upload one chunk following the §3.4.4 sequence: +/// +/// 1. re-verify the local file's SHA-256 against the manifest digest (never +/// trusted — a mismatch is local corruption → terminal [`BackendError::BadDigest`]), +/// 2. HEAD the deterministic key: matching content ⇒ [`UploadOutcome::AlreadyPresent`]; +/// differing content ⇒ [`UploadOutcome::Conflict`]; absent ⇒ proceed, +/// 3. PUT with the SHA-256 checksum so the backend validates server-side. +/// +/// Returns a [`BackendError`] for the caller to [`classify`]; does **not** mutate +/// the manifest (see [`mark_chunk_uploaded`]). +pub async fn upload_one( + backend: &dyn StorageBackend, + job: &UploadJob, +) -> Result { + let digest = integrity::from_hex(&job.sha256).map_err(|_| BackendError::BadDigest { + key: job.object_key.clone(), + expected: job.sha256.clone(), + actual: "".to_string(), + })?; + + // Step 2: re-hash the local file; mismatch = local corruption (terminal). + let actual = integrity::sha256_file(&job.local_path).map_err(|e| BackendError::Io { + key: job.object_key.clone(), + detail: format!("hashing {}: {e}", job.local_path.display()), + })?; + if actual != digest { + return Err(BackendError::BadDigest { + key: job.object_key.clone(), + expected: job.sha256.clone(), + actual: integrity::to_hex(&actual), + }); + } + + // Step 3: HEAD-before-PUT idempotency. + if let Some(meta) = backend.head(&job.object_key).await? { + match meta.sha256 { + Some(remote) if remote == digest => { + return Ok(UploadOutcome::AlreadyPresent { etag: meta.etag }) + } + Some(_) => return Ok(UploadOutcome::Conflict), + // Backend can't supply a hash; the key already encodes the content + // address, so a hit means the same content. + None => return Ok(UploadOutcome::AlreadyPresent { etag: meta.etag }), + } + } + + // Step 4: PUT with the checksum header. + let bytes = tokio::fs::read(&job.local_path) + .await + .map_err(|e| BackendError::Io { + key: job.object_key.clone(), + detail: format!("reading {}: {e}", job.local_path.display()), + })?; + let result = backend.put(&job.object_key, &bytes, Some(&digest)).await?; + Ok(UploadOutcome::Uploaded { etag: result.etag }) +} + +// --------------------------------------------------------------------------- +// Startup scan + manifest mutation (§3.4.1 trigger 3, §3.4.5) +// --------------------------------------------------------------------------- + +/// Build upload jobs for every chunk in `recording` that has no `uploaded_at_ns` +/// — the startup-scan enqueue (§3.4.1 trigger 3). `recording_dir` is where the +/// recording's files live; the machine id and name come from the manifest. +pub fn pending_jobs(recording_dir: &Path, recording: &Recording) -> Vec { + recording + .chunks + .iter() + .filter(|c| !c.is_uploaded()) + .map(|c| UploadJob::for_chunk(&recording.machine_id, recording_dir, &recording.name, c)) + .collect() +} + +/// Record that chunk `chunk_index` of the recording at `recording_dir` was +/// uploaded at `now_ns` (optionally stamping the remote ETag), persisting the +/// manifest atomically (§3.4.5). Returns `false` if the chunk index is unknown. +/// +/// The daemon serializes this behind the per-recording `ManifestHandle` mutex so +/// the recorder's append path and sync's mark path don't race; this function +/// performs the atomic file mutation that sits under that lock. +pub fn mark_chunk_uploaded( + recording_dir: &Path, + chunk_index: u32, + now_ns: u64, + etag: Option, +) -> Result { + let mut recording = manifest::load_dir(recording_dir)?; + let Some(chunk) = recording.chunks.iter_mut().find(|c| c.index == chunk_index) else { + return Ok(false); + }; + chunk.uploaded_at_ns = Some(now_ns); + if etag.is_some() { + chunk.remote_etag = etag; + } + manifest::save_dir(recording_dir, &recording)?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// Dead-letter list (§3.4.6) +// --------------------------------------------------------------------------- + +/// One dead-lettered chunk: an upload that exhausted retries or hit a terminal +/// error. Persisted so it survives daemon restarts; `reconcile` flushes it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeadLetterEntry { + /// Recording the chunk belongs to. + pub recording_name: String, + /// Zero-based chunk index. + pub chunk_index: u32, + /// Deterministic remote object key the upload targeted. + pub object_key: String, + /// Manifest SHA-256 (lowercase hex). + pub sha256: String, + /// Human-readable failure reason (classification + backend detail). + pub reason: String, + /// When the chunk was dead-lettered (nanoseconds since epoch). + pub failed_at_ns: u64, + /// How many attempts had been made. + pub attempts: u32, +} + +/// The persisted dead-letter list (`~/.bubbaloop/storage/dead_letter.json`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DeadLetterList { + entries: Vec, +} + +impl DeadLetterList { + /// Default path: `~/.bubbaloop/storage/dead_letter.json`. + pub fn default_path() -> Result { + Ok(super::storage_state_dir()?.join("dead_letter.json")) + } + + /// Load from `path`. A missing file is an empty list (not an error). + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + match std::fs::read_to_string(path) { + Ok(s) => serde_json::from_str(&s).map_err(|e| SyncError::Parse(e.to_string())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(SyncError::Io { + path: path.display().to_string(), + detail: e.to_string(), + }), + } + } + + /// Atomically persist to `path` (write-tmp-then-rename, §3.4.6). + pub fn save_atomic(&self, path: impl AsRef) -> Result<(), SyncError> { + let path = path.as_ref(); + let bytes = + serde_json::to_vec_pretty(self).map_err(|e| SyncError::Serialize(e.to_string()))?; + let parent = path.parent().ok_or_else(|| SyncError::Io { + path: path.display().to_string(), + detail: "dead-letter path has no parent".to_string(), + })?; + std::fs::create_dir_all(parent).map_err(|e| SyncError::Io { + path: parent.display().to_string(), + detail: e.to_string(), + })?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &bytes).map_err(|e| SyncError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + std::fs::rename(&tmp, path).map_err(|e| SyncError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + Ok(()) + } + + /// Current entries. + pub fn entries(&self) -> &[DeadLetterEntry] { + &self.entries + } + + /// Number of dead-lettered chunks. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the list is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Add (or replace) an entry, keyed by `(recording_name, chunk_index)` so a + /// chunk is never dead-lettered twice. + pub fn add(&mut self, entry: DeadLetterEntry) { + if let Some(existing) = self.entries.iter_mut().find(|e| { + e.recording_name == entry.recording_name && e.chunk_index == entry.chunk_index + }) { + *existing = entry; + } else { + self.entries.push(entry); + } + } + + /// Remove the entry for `(recording_name, chunk_index)`, returning whether one + /// was present (e.g. after `reconcile` heals it). + pub fn remove(&mut self, recording_name: &str, chunk_index: u32) -> bool { + let before = self.entries.len(); + self.entries + .retain(|e| !(e.recording_name == recording_name && e.chunk_index == chunk_index)); + self.entries.len() != before + } + + /// All dead-letter entries for one recording. + pub fn for_recording<'a>(&'a self, recording_name: &str) -> Vec<&'a DeadLetterEntry> { + self.entries + .iter() + .filter(|e| e.recording_name == recording_name) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Errors from the sync core (manifest mutation, dead-letter persistence). +#[derive(Debug, thiserror::Error)] +pub enum SyncError { + /// Filesystem error. + #[error("sync io error at {path}: {detail}")] + Io { path: String, detail: String }, + /// JSON failed to parse. + #[error("dead-letter parse error: {0}")] + Parse(String), + /// JSON failed to serialize. + #[error("dead-letter serialize error: {0}")] + Serialize(String), + /// Manifest load/save failed. + #[error("manifest error: {0}")] + Manifest(#[from] manifest::ManifestError), + /// Storage path could not be resolved. + #[error("storage path error: {0}")] + Path(#[from] StoragePathError), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::backend::local::LocalFs; + use crate::storage::object_key_manifest; + use crate::storage::recording::Recording; + + const NOW: u64 = 1_700_000_000_000_000_000; + const SEC: u64 = 1_000_000_000; + + // ---- error classification ---------------------------------------------- + + #[test] + fn error_classification_matches_spec() { + assert_eq!( + classify(&BackendError::NoSuchBucket { bucket: "b".into() }), + ErrorClass::PauseSync + ); + assert_eq!( + classify(&BackendError::Forbidden { key: "k".into() }), + ErrorClass::Terminal + ); + assert_eq!( + classify(&BackendError::BadDigest { + key: "k".into(), + expected: "a".into(), + actual: "b".into() + }), + ErrorClass::Terminal + ); + assert_eq!( + classify(&BackendError::Io { + key: "k".into(), + detail: "timeout".into() + }), + ErrorClass::Retryable + ); + } + + // ---- backoff schedule --------------------------------------------------- + + #[test] + fn backoff_schedule_follows_spec_curve() { + let s = BackoffSchedule::default(); + assert_eq!(s.delay_for_attempt(1), Duration::ZERO); + assert_eq!(s.delay_for_attempt(2), Duration::from_secs(5)); + assert_eq!(s.delay_for_attempt(3), Duration::from_secs(10)); + assert_eq!(s.delay_for_attempt(4), Duration::from_secs(20)); + assert_eq!(s.delay_for_attempt(5), Duration::from_secs(40)); + assert_eq!(s.delay_for_attempt(6), Duration::from_secs(60)); // clamp (80→60) + assert_eq!(s.delay_for_attempt(7), Duration::from_secs(60)); + assert_eq!(s.delay_for_attempt(100), Duration::from_secs(60)); + } + + #[test] + fn shared_backoff_streak_and_reset() { + let mut b = SharedBackoff::new(BackoffSchedule::default()); + assert_eq!(b.failures(), 0); + + let d1 = b.on_failure(NOW); + assert_eq!(d1.failures, 1); + assert_eq!(d1.delay, Duration::from_secs(5)); // next attempt = 2 + assert!(!d1.dead_letter); + + let d2 = b.on_failure(NOW + SEC); + assert_eq!(d2.failures, 2); + assert_eq!(d2.delay, Duration::from_secs(10)); + assert!(!d2.dead_letter); + + // A success resets the shared streak for everyone. + b.on_success(); + assert_eq!(b.failures(), 0); + let d3 = b.on_failure(NOW + 2 * SEC); + assert_eq!(d3.failures, 1); + assert_eq!(d3.delay, Duration::from_secs(5)); + } + + #[test] + fn dead_letter_after_max_elapsed() { + let mut b = SharedBackoff::new(BackoffSchedule::default()); // max_elapsed 5m + let first = b.on_failure(NOW); + assert!(!first.dead_letter); + // 5 minutes later, still failing → dead-letter. + let later = b.on_failure(NOW + 300 * SEC); + assert!(later.dead_letter); + } + + // ---- upload sequence ---------------------------------------------------- + + fn write_chunk(rec_dir: &Path, rec: &mut Recording, i: u32, bytes: &[u8]) { + let digest = integrity::sha256(bytes); + let sha = integrity::to_hex(&digest); + let name = Chunk::canonical_name(i, &sha); + let chunks = super::super::chunks_path(rec_dir); + std::fs::create_dir_all(&chunks).unwrap(); + std::fs::write(chunks.join(&name), bytes).unwrap(); + rec.chunks.push(Chunk { + name, + index: i, + size_bytes: bytes.len() as u64, + sha256: sha, + log_time_first_ns: None, + log_time_last_ns: None, + uploaded_at_ns: None, + remote_etag: None, + extra: Default::default(), + }); + } + + fn remote() -> (tempfile::TempDir, LocalFs) { + let dir = tempfile::tempdir().unwrap(); + let fs = LocalFs::new(dir.path()); + (dir, fs) + } + + #[tokio::test] + async fn upload_one_puts_then_short_circuits() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let mut rec = Recording::new("rec_a", "jetson_alpha", 1_000); + write_chunk(&rec_dir, &mut rec, 0, b"chunk zero"); + let job = UploadJob::for_chunk("jetson_alpha", &rec_dir, "rec_a", &rec.chunks[0]); + let (_rd, fs) = remote(); + + match upload_one(&fs, &job).await.unwrap() { + UploadOutcome::Uploaded { etag } => assert!(etag.is_some()), + other => panic!("expected Uploaded, got {other:?}"), + } + // Second time: HEAD short-circuits to AlreadyPresent. + assert!(matches!( + upload_one(&fs, &job).await.unwrap(), + UploadOutcome::AlreadyPresent { .. } + )); + } + + #[tokio::test] + async fn upload_one_detects_local_corruption() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let mut rec = Recording::new("rec_a", "m", 1); + write_chunk(&rec_dir, &mut rec, 0, b"good bytes"); + let job = UploadJob::for_chunk("m", &rec_dir, "rec_a", &rec.chunks[0]); + // Corrupt the local file after the manifest digest was computed. + std::fs::write(&job.local_path, b"tampered").unwrap(); + let (_rd, fs) = remote(); + + let err = upload_one(&fs, &job).await.unwrap_err(); + assert!(matches!(err, BackendError::BadDigest { .. })); + assert_eq!(classify(&err), ErrorClass::Terminal); + } + + #[tokio::test] + async fn upload_one_reports_conflict_on_remote_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let mut rec = Recording::new("rec_a", "m", 1); + write_chunk(&rec_dir, &mut rec, 0, b"local content"); + let job = UploadJob::for_chunk("m", &rec_dir, "rec_a", &rec.chunks[0]); + let (_rd, fs) = remote(); + // Different content already sitting at our deterministic key. + fs.put(&job.object_key, b"someone elses content", None) + .await + .unwrap(); + + assert_eq!( + upload_one(&fs, &job).await.unwrap(), + UploadOutcome::Conflict + ); + } + + // ---- startup scan + manifest mutation ----------------------------------- + + #[test] + fn pending_jobs_skips_uploaded_chunks() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let mut rec = Recording::new("rec_a", "m", 1); + write_chunk(&rec_dir, &mut rec, 0, b"a"); + write_chunk(&rec_dir, &mut rec, 1, b"bb"); + write_chunk(&rec_dir, &mut rec, 2, b"ccc"); + rec.chunks[1].uploaded_at_ns = Some(NOW); // already done + + let jobs = pending_jobs(&rec_dir, &rec); + let idxs: Vec = jobs.iter().map(|j| j.chunk_index).collect(); + assert_eq!(idxs, vec![0, 2]); + assert_eq!(jobs[0].recording_name, "rec_a"); + assert!(jobs[0].object_key.starts_with("m/rec_a/chunks/")); + } + + #[test] + fn mark_chunk_uploaded_persists_atomically() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec_a"); + let mut rec = Recording::new("rec_a", "m", 1); + write_chunk(&rec_dir, &mut rec, 0, b"a"); + manifest::save_dir(&rec_dir, &rec).unwrap(); + + let marked = mark_chunk_uploaded(&rec_dir, 0, NOW, Some("\"etag123\"".into())).unwrap(); + assert!(marked); + let reloaded = manifest::load_dir(&rec_dir).unwrap(); + assert_eq!(reloaded.chunks[0].uploaded_at_ns, Some(NOW)); + assert_eq!( + reloaded.chunks[0].remote_etag.as_deref(), + Some("\"etag123\"") + ); + + // Unknown index → false, no change. + assert!(!mark_chunk_uploaded(&rec_dir, 99, NOW, None).unwrap()); + } + + // ---- dead-letter list --------------------------------------------------- + + fn entry(rec: &str, idx: u32) -> DeadLetterEntry { + DeadLetterEntry { + recording_name: rec.into(), + chunk_index: idx, + object_key: format!("m/{rec}/chunks/chunk-{idx:06}-deadbeef.mcap"), + sha256: "de".repeat(32), + reason: "Forbidden".into(), + failed_at_ns: NOW, + attempts: 7, + } + } + + #[test] + fn dead_letter_dedup_add_and_remove() { + let mut dl = DeadLetterList::default(); + assert!(dl.is_empty()); + dl.add(entry("rec_a", 0)); + dl.add(entry("rec_a", 1)); + dl.add(entry("rec_a", 0)); // replace, not duplicate + assert_eq!(dl.len(), 2); + + assert_eq!(dl.for_recording("rec_a").len(), 2); + assert!(dl.remove("rec_a", 0)); + assert!(!dl.remove("rec_a", 0)); // already gone + assert_eq!(dl.len(), 1); + } + + #[test] + fn dead_letter_roundtrips_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("storage").join("dead_letter.json"); + + // Missing file loads as empty. + assert!(DeadLetterList::load(&path).unwrap().is_empty()); + + let mut dl = DeadLetterList::default(); + dl.add(entry("rec_a", 0)); + dl.add(entry("rec_b", 3)); + dl.save_atomic(&path).unwrap(); + + let reloaded = DeadLetterList::load(&path).unwrap(); + assert_eq!(reloaded, dl); + assert_eq!(reloaded.for_recording("rec_b")[0].chunk_index, 3); + } + + // Touch object_key_manifest import in an assertion so the helper stays wired + // for the eventual manifest-finalize trigger. + #[test] + fn manifest_key_shape() { + assert_eq!(object_key_manifest("m", "rec_a"), "m/rec_a/manifest.json"); + } +} From c4899c3015e88152bda8eae87f53214dd029de4b Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Sun, 14 Jun 2026 23:13:25 +0530 Subject: [PATCH 05/19] feat(storage): add `bubbaloop storage` CLI + config/backend factory (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the data-plane CLI verbs (spec §5) over the StorageBackend trait, so the whole flow runs end-to-end against the `local` backend without R2: - `storage::config` — parses the `[storage]` table of ~/.bubbaloop/config.toml (§4.1) and a backend factory. `local` builds a LocalFs object store; the cloud backends (r2/s3/gcs/minio) parse but return BackendNotImplemented until the S3-compatible backend lands. Secrets are never read here (§4.2). - `cli/storage.rs` — argh subcommands (NOT clap), output via `log` (never println!): - `list [--remote] [--machine] [--tag] [--since]` — scan local manifests (or remote objects) into a summarised table. - `info ` — pretty-print manifest.json. - `upload ` — flush un-uploaded chunks via sync::upload_one (idempotent). - `download [--to] [--machine]` — pull + SHA-256-verify each chunk. - `reconcile [--restore]` — drive storage::reconcile. - `rm [--remote] [--machine]` — delete local (+ remote objects). - Registered `Command::Storage` in the binary; pure-local so no Zenoh/daemon. Recording-control verbs (configure/topics/profile/record/replay) and the cloud backends are later slices. Collection/formatting logic lives in pure helpers (`collect_local`, `format_table`, `parse_since`) with 13 new unit tests (93 storage tests total). Smoke-tested end-to-end against a temp HOME: list→upload→ list --remote→download→reconcile --restore→info→rm. clippy-clean within storage. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/bin/bubbaloop.rs | 8 +- crates/bubbaloop/src/cli/mod.rs | 2 + crates/bubbaloop/src/cli/storage.rs | 773 +++++++++++++++++++++++++ crates/bubbaloop/src/storage/config.rs | 234 ++++++++ crates/bubbaloop/src/storage/mod.rs | 2 + 5 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 crates/bubbaloop/src/cli/storage.rs create mode 100644 crates/bubbaloop/src/storage/config.rs diff --git a/crates/bubbaloop/src/bin/bubbaloop.rs b/crates/bubbaloop/src/bin/bubbaloop.rs index e971de1e..d50ee10f 100644 --- a/crates/bubbaloop/src/bin/bubbaloop.rs +++ b/crates/bubbaloop/src/bin/bubbaloop.rs @@ -21,7 +21,7 @@ use argh::FromArgs; use bubbaloop::cli::launch::LaunchCommand; use bubbaloop::cli::{ AgentCommand, DaemonCommand, DataflowCommand, DebugCommand, LoginCommand, LogoutCommand, - MarketplaceCommand, NodeCommand, UpCommand, + MarketplaceCommand, NodeCommand, StorageCommand, UpCommand, }; /// Bubbaloop - AI-native orchestration for Physical AI @@ -51,6 +51,7 @@ enum Command { Debug(DebugCommand), Up(UpCommand), Dataflow(DataflowCommand), + Storage(StorageCommand), InitTls(InitTlsArgs), } @@ -437,6 +438,11 @@ async fn main() -> Result<(), Box> { init_logger("warn,zenoh=warn"); cmd.run().await?; } + Some(Command::Storage(cmd)) => { + // Pure-local data-plane verbs — no Zenoh/daemon needed. + init_logger("info"); + cmd.run().await?; + } Some(Command::InitTls(args)) => { let cert_dir = args.output_dir.unwrap_or_else(|| { let home = diff --git a/crates/bubbaloop/src/cli/mod.rs b/crates/bubbaloop/src/cli/mod.rs index 161ee50f..eef3fbdf 100644 --- a/crates/bubbaloop/src/cli/mod.rs +++ b/crates/bubbaloop/src/cli/mod.rs @@ -13,6 +13,7 @@ pub mod login; pub mod marketplace; pub mod node; pub mod status; +pub mod storage; pub mod system_utils; pub mod up; pub mod zenoh_session; @@ -24,4 +25,5 @@ pub use debug::{DebugCommand, DebugError}; pub use login::{LoginCommand, LogoutCommand}; pub use marketplace::MarketplaceCommand; pub use node::{NodeCommand, NodeError}; +pub use storage::StorageCommand; pub use up::UpCommand; diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs new file mode 100644 index 00000000..2c2b54f0 --- /dev/null +++ b/crates/bubbaloop/src/cli/storage.rs @@ -0,0 +1,773 @@ +//! `bubbaloop storage` CLI verbs (spec §5). +//! +//! This slice wires the data-plane verbs that work against any +//! [`StorageBackend`](crate::storage::StorageBackend) — including the `local` +//! backend, so the whole flow is exercisable end-to-end without R2: +//! +//! - `list` — scan local recordings (or `--remote` objects) and summarise them, +//! - `info` — pretty-print one recording's `manifest.json`, +//! - `upload` — flush un-uploaded chunks to the backend (idempotent, §3.4.4), +//! - `download` — pull + SHA-256-verify each chunk, +//! - `reconcile` — heal local/remote divergence (§3.5), +//! - `rm` — delete a recording locally (and `--remote`). +//! +//! The recording-control verbs (`configure`, `topics`, `profile`, `record`, +//! `replay`) and the cloud backends land in later slices. Per CLAUDE.md this uses +//! `argh` (not clap) and emits all user-facing output via `log` (never +//! `println!`). + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use argh::FromArgs; + +use crate::storage::{ + self, integrity, manifest, object_key_chunk, object_key_manifest, reconcile::ReconcileReport, + recording::Recording, ReconcileOptions, StorageBackend, StorageConfig, UploadJob, + UploadOutcome, +}; + +/// Manage fleet recordings (list/info/upload/download/reconcile/rm). +#[derive(FromArgs)] +#[argh(subcommand, name = "storage")] +pub struct StorageCommand { + #[argh(subcommand)] + action: StorageAction, +} + +#[derive(FromArgs)] +#[argh(subcommand)] +enum StorageAction { + List(ListArgs), + Info(InfoArgs), + Upload(UploadArgs), + Download(DownloadArgs), + Reconcile(ReconcileArgs), + Rm(RmArgs), +} + +/// List recordings. +#[derive(FromArgs)] +#[argh(subcommand, name = "list")] +struct ListArgs { + /// list recordings in the configured remote backend instead of locally + #[argh(switch)] + remote: bool, + /// only show recordings from this machine id + #[argh(option)] + machine: Option, + /// only show recordings carrying this tag + #[argh(option)] + tag: Option, + /// only show recordings started on/after this date (YYYY-MM-DD) + #[argh(option)] + since: Option, +} + +/// Print a recording's manifest.json. +#[derive(FromArgs)] +#[argh(subcommand, name = "info")] +struct InfoArgs { + /// recording name + #[argh(positional)] + name: String, +} + +/// Upload a recording's un-uploaded chunks to the backend (idempotent). +#[derive(FromArgs)] +#[argh(subcommand, name = "upload")] +struct UploadArgs { + /// recording name + #[argh(positional)] + name: String, +} + +/// Download + verify a recording from the backend. +#[derive(FromArgs)] +#[argh(subcommand, name = "download")] +struct DownloadArgs { + /// recording name + #[argh(positional)] + name: String, + /// destination directory (default: the local recording directory) + #[argh(option)] + to: Option, + /// machine id to use when there is no local manifest to read it from + #[argh(option)] + machine: Option, +} + +/// Reconcile a recording's local manifest, local files, and remote objects. +#[derive(FromArgs)] +#[argh(subcommand, name = "reconcile")] +struct ReconcileArgs { + /// recording name + #[argh(positional)] + name: String, + /// also re-download chunks present remotely but missing locally + #[argh(switch)] + restore: bool, +} + +/// Delete a recording locally (and optionally remotely). +#[derive(FromArgs)] +#[argh(subcommand, name = "rm")] +struct RmArgs { + /// recording name + #[argh(positional)] + name: String, + /// also delete the recording's objects from the remote backend + #[argh(switch)] + remote: bool, + /// machine id for remote deletion when there is no local manifest + #[argh(option)] + machine: Option, +} + +impl StorageCommand { + /// Dispatch the chosen verb. + pub async fn run(self) -> Result<()> { + match self.action { + StorageAction::List(a) => run_list(a).await, + StorageAction::Info(a) => run_info(a), + StorageAction::Upload(a) => run_upload(a).await, + StorageAction::Download(a) => run_download(a).await, + StorageAction::Reconcile(a) => run_reconcile(a).await, + StorageAction::Rm(a) => run_rm(a).await, + } + } +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +/// A one-line summary of a recording, for `list`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordingSummary { + pub name: String, + pub machine_id: String, + pub mode: String, + pub chunk_count: usize, + pub uploaded_count: usize, + pub size_bytes: u64, + pub started_at_ns: u64, + pub ended_at_ns: Option, + pub tags: Vec, +} + +impl RecordingSummary { + fn from_recording(r: &Recording) -> Self { + Self { + name: r.name.clone(), + machine_id: r.machine_id.clone(), + mode: format!("{:?}", r.mode).to_lowercase(), + chunk_count: r.chunks.len(), + uploaded_count: r.uploaded_chunk_count(), + size_bytes: r.size_bytes, + started_at_ns: r.started_at_ns, + ended_at_ns: r.ended_at_ns, + tags: r.tags.clone(), + } + } + + /// `synced` once every chunk is uploaded, else `local`. + fn sync_state(&self) -> &'static str { + if self.chunk_count > 0 && self.uploaded_count == self.chunk_count { + "synced" + } else { + "local" + } + } + + /// `complete` once the manifest is closed, else `open`. + fn status(&self) -> &'static str { + if self.ended_at_ns.is_some() { + "complete" + } else { + "open" + } + } +} + +/// Filters applied by `list`. +#[derive(Debug, Default, Clone)] +pub struct ListFilter { + pub machine: Option, + pub tag: Option, + pub since_ns: Option, +} + +impl ListFilter { + fn accepts(&self, s: &RecordingSummary) -> bool { + if let Some(m) = &self.machine { + if &s.machine_id != m { + return false; + } + } + if let Some(t) = &self.tag { + if !s.tags.iter().any(|x| x == t) { + return false; + } + } + if let Some(since) = self.since_ns { + if s.started_at_ns < since { + return false; + } + } + true + } +} + +/// Scan a recordings root directory and return summaries passing `filter`, +/// sorted by start time. Unreadable manifests are skipped with a warning. Pure +/// and testable — no global state. +pub fn collect_local(recordings_dir: &Path, filter: &ListFilter) -> Vec { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(recordings_dir) { + Ok(e) => e, + Err(_) => return out, // missing dir → nothing recorded yet + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + match manifest::load_dir(&path) { + Ok(r) => { + let summary = RecordingSummary::from_recording(&r); + if filter.accepts(&summary) { + out.push(summary); + } + } + Err(e) => log::warn!("skipping {}: {e}", path.display()), + } + } + out.sort_by(|a, b| { + a.started_at_ns + .cmp(&b.started_at_ns) + .then_with(|| a.name.cmp(&b.name)) + }); + out +} + +async fn run_list(args: ListArgs) -> Result<()> { + let filter = ListFilter { + machine: args.machine, + tag: args.tag, + since_ns: args + .since + .as_deref() + .map(parse_since) + .transpose() + .context("parsing --since")?, + }; + + let summaries = if args.remote { + collect_remote(&filter).await? + } else { + let dir = storage::recordings_dir()?; + collect_local(&dir, &filter) + }; + + if summaries.is_empty() { + log::info!("No recordings found."); + return Ok(()); + } + for line in format_table(&summaries) { + log::info!("{line}"); + } + Ok(()) +} + +/// Gather summaries from the configured backend by listing + fetching manifests. +async fn collect_remote(filter: &ListFilter) -> Result> { + let backend = StorageConfig::load()?.build_backend()?; + let objects = backend.list("").await.context("listing remote objects")?; + let mut out = Vec::new(); + for obj in objects { + if !obj.key.ends_with("/manifest.json") { + continue; + } + let bytes = backend.get(&obj.key).await.context("fetching manifest")?; + match manifest::parse(&String::from_utf8_lossy(&bytes)) { + Ok(r) => { + let s = RecordingSummary::from_recording(&r); + if filter.accepts(&s) { + out.push(s); + } + } + Err(e) => log::warn!("skipping remote {}: {e}", obj.key), + } + } + out.sort_by_key(|s| s.started_at_ns); + Ok(out) +} + +/// Render summaries as aligned text rows (header + one line per recording). +pub fn format_table(summaries: &[RecordingSummary]) -> Vec { + let mut lines = Vec::new(); + lines.push(format!( + "{:<28} {:<14} {:<11} {:>7} {:<8} {:<9} {:<19}", + "NAME", "MACHINE", "MODE", "CHUNKS", "SYNC", "STATUS", "STARTED" + )); + for s in summaries { + lines.push(format!( + "{:<28} {:<14} {:<11} {:>7} {:<8} {:<9} {:<19}", + truncate(&s.name, 28), + truncate(&s.machine_id, 14), + truncate(&s.mode, 11), + format!("{}/{}", s.uploaded_count, s.chunk_count), + s.sync_state(), + s.status(), + format_time(s.started_at_ns), + )); + } + lines +} + +// --------------------------------------------------------------------------- +// info +// --------------------------------------------------------------------------- + +fn run_info(args: InfoArgs) -> Result<()> { + let dir = storage::recording_dir(&args.name)?; + let recording = manifest::load_dir(&dir) + .with_context(|| format!("no recording named '{}' at {}", args.name, dir.display()))?; + let pretty = serde_json::to_string_pretty(&recording).context("serializing manifest")?; + log::info!("{pretty}"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// upload +// --------------------------------------------------------------------------- + +async fn run_upload(args: UploadArgs) -> Result<()> { + let dir = storage::recording_dir(&args.name)?; + let recording = + manifest::load_dir(&dir).with_context(|| format!("no recording named '{}'", args.name))?; + let backend = StorageConfig::load()?.build_backend()?; + + let pending: Vec<_> = recording + .chunks + .iter() + .filter(|c| !c.is_uploaded()) + .cloned() + .collect(); + if pending.is_empty() { + log::info!( + "'{}' is already fully uploaded ({} chunks).", + args.name, + recording.chunks.len() + ); + return Ok(()); + } + + let mut uploaded = 0usize; + let mut conflicts = 0usize; + let mut failed = 0usize; + for chunk in &pending { + let job = UploadJob::for_chunk(&recording.machine_id, &dir, &recording.name, chunk); + match storage::sync::upload_one(&*backend, &job).await { + Ok(UploadOutcome::Uploaded { etag }) | Ok(UploadOutcome::AlreadyPresent { etag }) => { + storage::sync::mark_chunk_uploaded(&dir, chunk.index, now_ns(), etag) + .with_context(|| format!("recording chunk {} as uploaded", chunk.index))?; + uploaded += 1; + } + Ok(UploadOutcome::Conflict) => { + log::warn!( + "chunk {} conflicts with different remote content at {} — skipping (run reconcile)", + chunk.index, + job.object_key + ); + conflicts += 1; + } + Err(e) => { + log::error!( + "chunk {} upload failed: {e} ({:?})", + chunk.index, + storage::classify(&e) + ); + failed += 1; + } + } + } + + // Push the manifest so remote state reflects the upload (§3.4.4 step 5). + upload_manifest(&*backend, &dir, &recording.machine_id, &recording.name).await?; + + log::info!( + "Uploaded {uploaded} chunk(s); {conflicts} conflict(s); {failed} failure(s) for '{}'.", + args.name + ); + if failed > 0 || conflicts > 0 { + bail!("upload incomplete: {conflicts} conflict(s), {failed} failure(s)"); + } + Ok(()) +} + +async fn upload_manifest( + backend: &dyn StorageBackend, + recording_dir: &Path, + machine_id: &str, + name: &str, +) -> Result<()> { + let path = manifest::manifest_path(recording_dir); + let bytes = std::fs::read(&path).context("reading manifest for upload")?; + let digest = integrity::sha256(&bytes); + let key = object_key_manifest(machine_id, name); + backend + .put(&key, &bytes, Some(&digest)) + .await + .context("uploading manifest")?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// download +// --------------------------------------------------------------------------- + +async fn run_download(args: DownloadArgs) -> Result<()> { + let local_dir = storage::recording_dir(&args.name)?; + let dest: PathBuf = match &args.to { + Some(p) => PathBuf::from(p), + None => local_dir.clone(), + }; + let backend = StorageConfig::load()?.build_backend()?; + + // Prefer a local manifest; otherwise fetch it from the backend (needs the + // machine id, since the object key is machine-scoped). + let recording = match manifest::load_dir(&local_dir) { + Ok(r) => r, + Err(_) => { + let machine = args.machine.clone().context( + "no local manifest; pass --machine so the remote manifest key can be built", + )?; + let key = object_key_manifest(&machine, &args.name); + let bytes = backend + .get(&key) + .await + .with_context(|| format!("fetching remote manifest {key}"))?; + manifest::parse(&String::from_utf8_lossy(&bytes))? + } + }; + + let chunks_dir = dest.join("chunks"); + std::fs::create_dir_all(&chunks_dir) + .with_context(|| format!("creating {}", chunks_dir.display()))?; + + let mut verified = 0usize; + for chunk in &recording.chunks { + let key = object_key_chunk( + &recording.machine_id, + &recording.name, + chunk.index, + &chunk.sha256, + ); + let bytes = backend + .get(&key) + .await + .with_context(|| format!("downloading {key}"))?; + // Verify on receipt; refuse to write a corrupted file (§8). + let expected = integrity::from_hex(&chunk.sha256) + .with_context(|| format!("chunk {} has an invalid manifest sha256", chunk.index))?; + let actual = integrity::sha256(&bytes); + if actual != expected { + bail!( + "chunk {} failed verification (expected {}, got {}) — not written", + chunk.index, + chunk.sha256, + integrity::to_hex(&actual) + ); + } + std::fs::write(chunks_dir.join(&chunk.name), &bytes) + .with_context(|| format!("writing chunk {}", chunk.index))?; + verified += 1; + } + + // Persist the manifest alongside the chunks. + manifest::save_dir(&dest, &recording).context("writing manifest")?; + + log::info!( + "Downloaded and verified {verified} chunk(s) for '{}' to {}.", + args.name, + dest.display() + ); + Ok(()) +} + +// --------------------------------------------------------------------------- +// reconcile +// --------------------------------------------------------------------------- + +async fn run_reconcile(args: ReconcileArgs) -> Result<()> { + let dir = storage::recording_dir(&args.name)?; + let backend = StorageConfig::load()?.build_backend()?; + let report = storage::reconcile( + &dir, + &*backend, + &ReconcileOptions { + restore: args.restore, + }, + now_ns(), + ) + .await + .with_context(|| format!("reconciling '{}'", args.name))?; + + for line in format_reconcile(&report) { + log::info!("{line}"); + } + if !report.is_clean() { + bail!("reconcile finished with {} error(s)", report.errors.len()); + } + Ok(()) +} + +/// Human-readable reconcile report lines. +fn format_reconcile(r: &ReconcileReport) -> Vec { + let mut lines = vec![format!( + "diffed {} | uploaded {} | re-uploaded {} | re-downloaded {} | verified {} | marked {}", + r.chunks_diffed, + r.chunks_uploaded, + r.chunks_reuploaded, + r.chunks_redownloaded, + r.chunks_verified_ok, + r.chunks_marked_uploaded, + )]; + if !r.orphans_local.is_empty() { + lines.push(format!("orphan local files: {}", r.orphans_local.len())); + } + if !r.orphans_remote.is_empty() { + lines.push(format!("orphan remote objects: {}", r.orphans_remote.len())); + } + for e in &r.errors { + lines.push(format!( + "ERROR chunk {}: {:?} — {}", + e.index, e.kind, e.detail + )); + } + lines +} + +// --------------------------------------------------------------------------- +// rm +// --------------------------------------------------------------------------- + +async fn run_rm(args: RmArgs) -> Result<()> { + let dir = storage::recording_dir(&args.name)?; + + if args.remote { + // Determine the machine id (object keys are machine-scoped). + let machine = match manifest::load_dir(&dir) { + Ok(r) => r.machine_id, + Err(_) => args + .machine + .clone() + .context("no local manifest; pass --machine to delete remote objects")?, + }; + let backend = StorageConfig::load()?.build_backend()?; + let prefix = format!("{machine}/{}/", args.name); + let objects = backend + .list(&prefix) + .await + .context("listing remote objects")?; + let count = objects.len(); + for obj in objects { + backend + .delete(&obj.key) + .await + .with_context(|| format!("deleting {}", obj.key))?; + } + log::info!("Deleted {count} remote object(s) for '{}'.", args.name); + } + + if dir.exists() { + std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?; + log::info!("Deleted local recording '{}'.", args.name); + } else if !args.remote { + bail!("no local recording named '{}'", args.name); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn now_ns() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) +} + +/// Parse a `YYYY-MM-DD` date into nanoseconds since the Unix epoch (UTC midnight). +fn parse_since(s: &str) -> Result { + use chrono::{NaiveDate, NaiveTime}; + let date = NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d") + .with_context(|| format!("expected YYYY-MM-DD, got '{s}'"))?; + let dt = date.and_time(NaiveTime::MIN).and_utc(); + Ok(dt.timestamp_nanos_opt().unwrap_or(0).max(0) as u64) +} + +/// Format nanoseconds since the epoch as `YYYY-MM-DD HH:MM:SS` (UTC). +fn format_time(ns: u64) -> String { + use chrono::DateTime; + let secs = (ns / 1_000_000_000) as i64; + let nsec = (ns % 1_000_000_000) as u32; + match DateTime::from_timestamp(secs, nsec) { + Some(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(), + None => "-".to_string(), + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let keep = max.saturating_sub(1); + format!("{}…", s.chars().take(keep).collect::()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::recording::{Chunk, Recording}; + + fn make_recording(name: &str, machine: &str, started: u64) -> Recording { + let mut r = Recording::new(name, machine, started); + r.size_bytes = 1024; + r + } + + fn push_chunk(r: &mut Recording, i: u32, uploaded: bool) { + let sha = format!("{i:02x}").repeat(32); + r.chunks.push(Chunk { + name: Chunk::canonical_name(i, &sha), + index: i, + size_bytes: 100, + sha256: sha, + log_time_first_ns: None, + log_time_last_ns: None, + uploaded_at_ns: if uploaded { Some(1) } else { None }, + remote_etag: None, + extra: Default::default(), + }); + } + + fn write_recording(root: &Path, r: &Recording) { + let dir = root.join(&r.name); + std::fs::create_dir_all(dir.join("chunks")).unwrap(); + manifest::save_dir(&dir, r).unwrap(); + } + + #[test] + fn collect_local_filters_and_sorts() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + let mut a = make_recording("rec_a", "jetson_alpha", 2_000); + push_chunk(&mut a, 0, true); + a.tags = vec!["outdoor".into()]; + let mut b = make_recording("rec_b", "jetson_beta", 1_000); + push_chunk(&mut b, 0, false); + write_recording(root, &a); + write_recording(root, &b); + + // No filter: both, sorted by start time (b before a). + let all = collect_local(root, &ListFilter::default()); + assert_eq!( + all.iter().map(|s| s.name.as_str()).collect::>(), + vec!["rec_b", "rec_a"] + ); + + // Machine filter. + let only_alpha = collect_local( + root, + &ListFilter { + machine: Some("jetson_alpha".into()), + ..Default::default() + }, + ); + assert_eq!(only_alpha.len(), 1); + assert_eq!(only_alpha[0].name, "rec_a"); + assert_eq!(only_alpha[0].sync_state(), "synced"); + + // Tag filter. + let tagged = collect_local( + root, + &ListFilter { + tag: Some("outdoor".into()), + ..Default::default() + }, + ); + assert_eq!(tagged.len(), 1); + + // Since filter (started >= 1_500 excludes rec_b at 1_000). + let recent = collect_local( + root, + &ListFilter { + since_ns: Some(1_500), + ..Default::default() + }, + ); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].name, "rec_a"); + } + + #[test] + fn collect_local_missing_dir_is_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(collect_local(&dir.path().join("nope"), &ListFilter::default()).is_empty()); + } + + #[test] + fn sync_state_and_status_derivation() { + let mut r = make_recording("r", "m", 1); + push_chunk(&mut r, 0, true); + push_chunk(&mut r, 1, false); + let s = RecordingSummary::from_recording(&r); + assert_eq!(s.sync_state(), "local"); // not all uploaded + assert_eq!(s.status(), "open"); // no ended_at_ns + assert_eq!(s.chunk_count, 2); + assert_eq!(s.uploaded_count, 1); + } + + #[test] + fn format_table_has_header_and_rows() { + let mut r = make_recording("rec_a", "m", 1_700_000_000_000_000_000); + push_chunk(&mut r, 0, true); + let rows = format_table(&[RecordingSummary::from_recording(&r)]); + assert_eq!(rows.len(), 2); + assert!(rows[0].contains("NAME")); + assert!(rows[1].contains("rec_a")); + assert!(rows[1].contains("synced")); + } + + #[test] + fn parse_since_accepts_iso_date() { + let ns = parse_since("2026-01-01").unwrap(); + // 2026-01-01T00:00:00Z is well after the epoch. + assert!(ns > 1_700_000_000_000_000_000); + assert!(parse_since("not-a-date").is_err()); + } + + #[test] + fn truncate_adds_ellipsis() { + assert_eq!(truncate("short", 10), "short"); + assert_eq!(truncate("a_very_long_recording_name", 6), "a_ver…"); + } + + #[test] + fn format_time_is_stable() { + // 2023-11-14T22:13:20Z + assert_eq!( + format_time(1_700_000_000_000_000_000), + "2023-11-14 22:13:20" + ); + assert_eq!(format_time(0), "1970-01-01 00:00:00"); + } +} diff --git a/crates/bubbaloop/src/storage/config.rs b/crates/bubbaloop/src/storage/config.rs new file mode 100644 index 00000000..8a453133 --- /dev/null +++ b/crates/bubbaloop/src/storage/config.rs @@ -0,0 +1,234 @@ +//! Storage configuration — the `[storage]` table of `~/.bubbaloop/config.toml` +//! (spec §4.1) and the backend factory that turns it into a live +//! [`StorageBackend`]. +//! +//! Secrets are **never** read from here — credentials live only in +//! `~/.bubbaloop/secrets.toml` (chmod 0600, see [`super::secrets`]). This struct +//! holds the non-secret connection shape (backend kind, endpoint, region, +//! bucket) plus local-store knobs. +//! +//! In this slice only the `local` backend is constructible; the cloud backends +//! (`r2`/`s3`/`gcs`/`minio`) parse fine but [`StorageConfig::build_backend`] +//! returns [`ConfigError::BackendNotImplemented`] until the S3-compatible backend +//! lands. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::backend::local::LocalFs; +use super::{bubbaloop_dir, StorageBackend, StoragePathError}; + +/// The `[storage]` configuration table. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageConfig { + /// Backend kind: `r2` | `s3` | `gcs` | `minio` | `local`. + #[serde(default = "default_backend")] + pub backend: String, + /// S3-compatible endpoint URL (cloud backends). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + /// Region (R2 uses `auto`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + /// Bucket name — the sharing unit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bucket: Option, + /// Whether the daemon background-syncs finalized chunks. + #[serde(default = "default_true")] + pub auto_upload: bool, + /// Auto-delete local recordings this many days after successful upload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention_days_local: Option, + /// Soft cap on the local recordings directory (GiB). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk_quota_gb: Option, + /// Which recorder instance to command, when several are installed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recorder_instance: Option, + /// Root directory for the `local` backend's object store. Defaults to + /// `~/.bubbaloop/storage/objects`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_path: Option, +} + +impl Default for StorageConfig { + fn default() -> Self { + Self { + backend: default_backend(), + endpoint: None, + region: None, + bucket: None, + auto_upload: true, + retention_days_local: None, + disk_quota_gb: None, + recorder_instance: None, + local_path: None, + } + } +} + +/// Whole-file shape, so we can pluck `[storage]` out of a `config.toml` that also +/// holds unrelated tables. Unknown tables/keys are ignored (forward-compat). +#[derive(Debug, Default, Deserialize)] +struct ConfigFile { + #[serde(default)] + storage: Option, +} + +impl StorageConfig { + /// Load the `[storage]` table from `~/.bubbaloop/config.toml`. A missing file + /// or missing `[storage]` table yields [`StorageConfig::default`] (the + /// `local` backend), so the data-plane verbs work out of the box. + pub fn load() -> Result { + let path = bubbaloop_dir()?.join("config.toml"); + Self::load_from(&path) + } + + /// Load from an explicit path (testable). Missing file → default. + pub fn load_from(path: &std::path::Path) -> Result { + match std::fs::read_to_string(path) { + Ok(s) => Self::parse(&s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(ConfigError::Io { + path: path.display().to_string(), + detail: e.to_string(), + }), + } + } + + /// Parse a `config.toml` body and return its `[storage]` table (or default). + pub fn parse(toml_str: &str) -> Result { + let file: ConfigFile = + toml::from_str(toml_str).map_err(|e| ConfigError::Parse(e.to_string()))?; + Ok(file.storage.unwrap_or_default()) + } + + /// Resolve the `local` backend's object-store root. + pub fn local_root(&self) -> Result { + match &self.local_path { + Some(p) => Ok(p.clone()), + None => Ok(bubbaloop_dir()?.join("storage").join("objects")), + } + } + + /// Build a live [`StorageBackend`] from this config. + pub fn build_backend(&self) -> Result, ConfigError> { + match self.backend.as_str() { + "local" => Ok(Box::new(LocalFs::new(self.local_root()?))), + "r2" | "s3" | "gcs" | "minio" => { + Err(ConfigError::BackendNotImplemented(self.backend.clone())) + } + other => Err(ConfigError::UnknownBackend(other.to_string())), + } + } +} + +fn default_backend() -> String { + "local".to_string() +} +fn default_true() -> bool { + true +} + +/// Errors loading storage config or building a backend. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// Filesystem error reading the config file. + #[error("config io error at {path}: {detail}")] + Io { path: String, detail: String }, + /// TOML failed to parse. + #[error("config parse error: {0}")] + Parse(String), + /// Storage path could not be resolved. + #[error("storage path error: {0}")] + Path(#[from] StoragePathError), + /// The backend kind is recognised but not yet implemented in this build. + #[error("storage backend '{0}' is not yet implemented (only 'local' is available so far)")] + BackendNotImplemented(String), + /// The backend kind is not one of r2/s3/gcs/minio/local. + #[error("unknown storage backend '{0}' (expected one of: r2, s3, gcs, minio, local)")] + UnknownBackend(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_yields_default_local_backend() { + let dir = tempfile::tempdir().unwrap(); + let cfg = StorageConfig::load_from(&dir.path().join("nope.toml")).unwrap(); + assert_eq!(cfg.backend, "local"); + assert!(cfg.auto_upload); + } + + #[test] + fn parses_spec_example_storage_table() { + let toml = r#" + [storage] + backend = "r2" + endpoint = "https://acct.r2.cloudflarestorage.com" + region = "auto" + bucket = "kornia-recordings" + auto_upload = true + retention_days_local = 7 + disk_quota_gb = 50 + recorder_instance = "mcap_recorder" + + [agent] + model = "claude" + "#; + let cfg = StorageConfig::parse(toml).unwrap(); + assert_eq!(cfg.backend, "r2"); + assert_eq!(cfg.bucket.as_deref(), Some("kornia-recordings")); + assert_eq!(cfg.region.as_deref(), Some("auto")); + assert_eq!(cfg.retention_days_local, Some(7)); + assert_eq!(cfg.recorder_instance.as_deref(), Some("mcap_recorder")); + } + + #[test] + fn unrelated_config_without_storage_is_default() { + let cfg = StorageConfig::parse("[agent]\nmodel = \"x\"\n").unwrap(); + assert_eq!(cfg.backend, "local"); + } + + #[test] + fn build_local_backend_uses_configured_path() { + let dir = tempfile::tempdir().unwrap(); + let cfg = StorageConfig { + backend: "local".into(), + local_path: Some(dir.path().join("objs")), + ..Default::default() + }; + // Just exercising the factory: it must succeed and not panic. + assert!(cfg.build_backend().is_ok()); + assert_eq!(cfg.local_root().unwrap(), dir.path().join("objs")); + } + + #[test] + fn cloud_backends_are_not_yet_implemented() { + for backend in ["r2", "s3", "gcs", "minio"] { + let cfg = StorageConfig { + backend: backend.into(), + ..Default::default() + }; + assert!(matches!( + cfg.build_backend(), + Err(ConfigError::BackendNotImplemented(_)) + )); + } + } + + #[test] + fn unknown_backend_is_rejected() { + let cfg = StorageConfig { + backend: "ftp".into(), + ..Default::default() + }; + assert!(matches!( + cfg.build_backend(), + Err(ConfigError::UnknownBackend(_)) + )); + } +} diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index 35e1baa5..c75aa889 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -19,6 +19,7 @@ //! CLI/MCP surfaces, and the dashboard tab. pub mod backend; +pub mod config; pub mod integrity; pub mod manifest; pub mod profile; @@ -31,6 +32,7 @@ use std::path::{Path, PathBuf}; // Re-export the most commonly used types at the subsystem root. pub use backend::{local::LocalFs, BackendError, ObjectMeta, PutResult, StorageBackend}; +pub use config::{ConfigError, StorageConfig}; pub use profile::Profile; pub use reconcile::{reconcile, ReconcileError, ReconcileOptions, ReconcileReport}; pub use recording::{Channel, Chunk, Lifecycle, Recording, RecordingMode, Selection, Trigger}; From c791f5973935d78c1432477565e8f574be8d6d5b Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Sun, 14 Jun 2026 23:48:52 +0530 Subject: [PATCH 06/19] fix(storage): address code-review findings (security, correctness, efficiency) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Validate recording names before they become filesystem paths or object keys. New storage::validate_recording_name (charset [A-Za-z0-9._-], no ./.. /NUL) is enforced inside recording_dir/chunks_dir, closing a path traversal where `storage rm '../../x'` would remove_dir_all an arbitrary directory (and info/download/reconcile reads/writes escaping the sandbox). - Enforce canonical chunk filenames in manifest::validate, so a tampered remote manifest can't smuggle a `../evil` chunk.name into a download write; download also derives the on-disk name from (index, sha256) as defense. Correctness: - `--remote` (list/rm) now refuses a non-remote backend instead of silently operating on the local object store (StorageConfig::is_remote). - run_upload only persists + re-uploads the manifest when ≥1 chunk changed, so an all-failed/all-conflict run can't clobber newer remote manifest state. - reconcile's "present remote, absent local" branch now counts the chunk (marked/verified) instead of mutating the manifest with no report signal. - reconcile marks the recording durably `corrupt` (new Recording.corrupt field, surfaced in `list` status) when a chunk is missing both locally and remotely, instead of only emitting a transient error. - reconcile fetches+hashes the full digest when the backend HEAD returns no checksum, instead of trusting the 8-char key prefix (§8 integrity). - SharedBackoff::on_failure re-anchors on a backward clock step so a hopeless job still dead-letters instead of retrying forever (no saturating_sub=0 trap). - `rm` errors when a name matched nothing (local or remote) instead of a silent exit-0 success on a typo with --remote. Efficiency: - run_upload mutates the manifest in memory and saves once, replacing the per-chunk load+save (O(N) full-manifest rewrites). - collect_remote lists the per-machine prefix when --machine is given rather than enumerating the whole bucket. Adds 5 unit tests (name validation, canonical-name rejection, backward-clock dead-letter, is_remote) and tightens reconcile tests; 98 storage tests pass, clippy-clean. Smoke-tested: traversal/typo/--remote paths rejected, valid local flow intact. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 103 +++++++++++++++++----- crates/bubbaloop/src/storage/config.rs | 19 ++++ crates/bubbaloop/src/storage/manifest.rs | 27 +++++- crates/bubbaloop/src/storage/mod.rs | 57 ++++++++++++ crates/bubbaloop/src/storage/reconcile.rs | 55 +++++++++++- crates/bubbaloop/src/storage/recording.rs | 11 +++ crates/bubbaloop/src/storage/sync.rs | 29 +++++- 7 files changed, 274 insertions(+), 27 deletions(-) diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index 2c2b54f0..069c2f51 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -22,11 +22,26 @@ use anyhow::{bail, Context, Result}; use argh::FromArgs; use crate::storage::{ - self, integrity, manifest, object_key_chunk, object_key_manifest, reconcile::ReconcileReport, - recording::Recording, ReconcileOptions, StorageBackend, StorageConfig, UploadJob, - UploadOutcome, + self, integrity, manifest, object_key_chunk, object_key_manifest, + reconcile::ReconcileReport, + recording::{Chunk, Recording}, + ReconcileOptions, StorageBackend, StorageConfig, UploadJob, UploadOutcome, }; +/// Build the configured backend for a `--remote` operation, refusing to run +/// against a non-remote (local) backend so `--remote` can't silently target the +/// local object store. +fn require_remote_backend() -> Result> { + let cfg = StorageConfig::load()?; + if !cfg.is_remote() { + bail!( + "no remote backend configured ([storage].backend = {:?}); set it to r2/s3/gcs/minio to use --remote", + cfg.backend + ); + } + Ok(cfg.build_backend()?) +} + /// Manage fleet recordings (list/info/upload/download/reconcile/rm). #[derive(FromArgs)] #[argh(subcommand, name = "storage")] @@ -154,6 +169,7 @@ pub struct RecordingSummary { pub started_at_ns: u64, pub ended_at_ns: Option, pub tags: Vec, + pub corrupt: bool, } impl RecordingSummary { @@ -168,6 +184,7 @@ impl RecordingSummary { started_at_ns: r.started_at_ns, ended_at_ns: r.ended_at_ns, tags: r.tags.clone(), + corrupt: r.corrupt, } } @@ -180,9 +197,12 @@ impl RecordingSummary { } } - /// `complete` once the manifest is closed, else `open`. + /// `corrupt` if reconcile flagged it, else `complete` once the manifest is + /// closed, else `open`. fn status(&self) -> &'static str { - if self.ended_at_ns.is_some() { + if self.corrupt { + "corrupt" + } else if self.ended_at_ns.is_some() { "complete" } else { "open" @@ -282,8 +302,18 @@ async fn run_list(args: ListArgs) -> Result<()> { /// Gather summaries from the configured backend by listing + fetching manifests. async fn collect_remote(filter: &ListFilter) -> Result> { - let backend = StorageConfig::load()?.build_backend()?; - let objects = backend.list("").await.context("listing remote objects")?; + let backend = require_remote_backend()?; + // Scope the listing to one machine's prefix when --machine is given, instead + // of enumerating the whole (cross-machine) bucket and filtering after. + let prefix = filter + .machine + .as_deref() + .map(|m| format!("{m}/")) + .unwrap_or_default(); + let objects = backend + .list(&prefix) + .await + .context("listing remote objects")?; let mut out = Vec::new(); for obj in objects { if !obj.key.ends_with("/manifest.json") { @@ -345,15 +375,16 @@ fn run_info(args: InfoArgs) -> Result<()> { async fn run_upload(args: UploadArgs) -> Result<()> { let dir = storage::recording_dir(&args.name)?; - let recording = + let mut recording = manifest::load_dir(&dir).with_context(|| format!("no recording named '{}'", args.name))?; let backend = StorageConfig::load()?.build_backend()?; - let pending: Vec<_> = recording + let pending: Vec = recording .chunks .iter() - .filter(|c| !c.is_uploaded()) - .cloned() + .enumerate() + .filter(|(_, c)| !c.is_uploaded()) + .map(|(i, _)| i) .collect(); if pending.is_empty() { log::info!( @@ -364,15 +395,21 @@ async fn run_upload(args: UploadArgs) -> Result<()> { return Ok(()); } + let ts = now_ns(); let mut uploaded = 0usize; let mut conflicts = 0usize; let mut failed = 0usize; - for chunk in &pending { - let job = UploadJob::for_chunk(&recording.machine_id, &dir, &recording.name, chunk); + for i in pending { + let chunk = recording.chunks[i].clone(); + let job = UploadJob::for_chunk(&recording.machine_id, &dir, &recording.name, &chunk); match storage::sync::upload_one(&*backend, &job).await { Ok(UploadOutcome::Uploaded { etag }) | Ok(UploadOutcome::AlreadyPresent { etag }) => { - storage::sync::mark_chunk_uploaded(&dir, chunk.index, now_ns(), etag) - .with_context(|| format!("recording chunk {} as uploaded", chunk.index))?; + // Mutate in memory; persist the manifest once after the loop + // rather than reloading + rewriting it per chunk. + recording.chunks[i].uploaded_at_ns = Some(ts); + if etag.is_some() { + recording.chunks[i].remote_etag = etag; + } uploaded += 1; } Ok(UploadOutcome::Conflict) => { @@ -394,8 +431,12 @@ async fn run_upload(args: UploadArgs) -> Result<()> { } } - // Push the manifest so remote state reflects the upload (§3.4.4 step 5). - upload_manifest(&*backend, &dir, &recording.machine_id, &recording.name).await?; + // Only persist + re-upload the manifest when something actually changed, so + // an all-failed/all-conflict run never clobbers newer remote manifest state. + if uploaded > 0 { + manifest::save_dir(&dir, &recording).context("saving updated manifest")?; + upload_manifest(&*backend, &dir, &recording.machine_id, &recording.name).await?; + } log::info!( "Uploaded {uploaded} chunk(s); {conflicts} conflict(s); {failed} failure(s) for '{}'.", @@ -481,7 +522,11 @@ async fn run_download(args: DownloadArgs) -> Result<()> { integrity::to_hex(&actual) ); } - std::fs::write(chunks_dir.join(&chunk.name), &bytes) + // Derive the on-disk name from (index, sha256) rather than trusting the + // manifest's `name` field as a path segment. (Manifest validation already + // requires it to be canonical, but deriving here removes all doubt.) + let safe_name = Chunk::canonical_name(chunk.index, &chunk.sha256); + std::fs::write(chunks_dir.join(&safe_name), &bytes) .with_context(|| format!("writing chunk {}", chunk.index))?; verified += 1; } @@ -555,9 +600,13 @@ fn format_reconcile(r: &ReconcileReport) -> Vec { // --------------------------------------------------------------------------- async fn run_rm(args: RmArgs) -> Result<()> { + // recording_dir validates the name, so a traversal name can't reach + // remove_dir_all or a remote prefix. let dir = storage::recording_dir(&args.name)?; + let mut removed_anything = false; if args.remote { + let backend = require_remote_backend()?; // Determine the machine id (object keys are machine-scoped). let machine = match manifest::load_dir(&dir) { Ok(r) => r.machine_id, @@ -566,7 +615,6 @@ async fn run_rm(args: RmArgs) -> Result<()> { .clone() .context("no local manifest; pass --machine to delete remote objects")?, }; - let backend = StorageConfig::load()?.build_backend()?; let prefix = format!("{machine}/{}/", args.name); let objects = backend .list(&prefix) @@ -579,14 +627,27 @@ async fn run_rm(args: RmArgs) -> Result<()> { .await .with_context(|| format!("deleting {}", obj.key))?; } + if count > 0 { + removed_anything = true; + } log::info!("Deleted {count} remote object(s) for '{}'.", args.name); } if dir.exists() { std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?; + removed_anything = true; log::info!("Deleted local recording '{}'.", args.name); - } else if !args.remote { - bail!("no local recording named '{}'", args.name); + } + + // A name that matched nothing (local or remote) is an error, not a silent + // success — otherwise a typo with --remote exits 0 having done nothing. + if !removed_anything { + let scope = if args.remote { + "no local directory and no matching remote objects" + } else { + "no local directory" + }; + bail!("no recording named '{}' found ({scope})", args.name); } Ok(()) } diff --git a/crates/bubbaloop/src/storage/config.rs b/crates/bubbaloop/src/storage/config.rs index 8a453133..969c7b80 100644 --- a/crates/bubbaloop/src/storage/config.rs +++ b/crates/bubbaloop/src/storage/config.rs @@ -104,6 +104,13 @@ impl StorageConfig { Ok(file.storage.unwrap_or_default()) } + /// Whether the configured backend is an actual remote (i.e. not the local + /// object store). CLI `--remote` flags require this so they can't silently + /// operate on the local backend. + pub fn is_remote(&self) -> bool { + self.backend != "local" + } + /// Resolve the `local` backend's object-store root. pub fn local_root(&self) -> Result { match &self.local_path { @@ -220,6 +227,18 @@ mod tests { } } + #[test] + fn is_remote_only_for_cloud_backends() { + assert!(!StorageConfig::default().is_remote()); // default = local + for backend in ["r2", "s3", "gcs", "minio"] { + let cfg = StorageConfig { + backend: backend.into(), + ..Default::default() + }; + assert!(cfg.is_remote()); + } + } + #[test] fn unknown_backend_is_rejected() { let cfg = StorageConfig { diff --git a/crates/bubbaloop/src/storage/manifest.rs b/crates/bubbaloop/src/storage/manifest.rs index f6470ebc..a8095885 100644 --- a/crates/bubbaloop/src/storage/manifest.rs +++ b/crates/bubbaloop/src/storage/manifest.rs @@ -12,7 +12,7 @@ //! digests), so a future writer's extra fields never trip an older reader. use super::integrity; -use super::recording::{Recording, MANIFEST_SCHEMA_VERSION}; +use super::recording::{Chunk, Recording, MANIFEST_SCHEMA_VERSION}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -127,6 +127,17 @@ pub fn validate(r: &Recording) -> Result<(), ManifestError> { chunk.index, chunk.sha256 )) })?; + // The filename must be the canonical `chunk-{idx:06}-{prefix8}.mcap` + // (§3.3.6). Enforcing this here means no consumer can be tricked into + // treating a tampered/non-canonical `name` (e.g. one fetched from an + // untrusted remote manifest) as a path segment for a write. + let expected_name = Chunk::canonical_name(chunk.index, &chunk.sha256); + if chunk.name != expected_name { + return Err(ManifestError::Invalid(format!( + "chunk {} name {:?} is not canonical (expected {:?})", + chunk.index, chunk.name, expected_name + ))); + } } Ok(()) } @@ -221,6 +232,20 @@ mod tests { assert!(matches!(err, ManifestError::Invalid(_))); } + #[test] + fn rejects_non_canonical_chunk_name() { + // A manifest whose chunk name has been tampered into a path-traversal + // string must fail validation (it would otherwise be trusted as a write + // target on the download path). + let mut r = valid_recording(); + r.chunks[0].name = "../../evil.mcap".to_string(); + let err = validate(&r).unwrap_err(); + assert!(matches!(err, ManifestError::Invalid(_))); + // Parsing also rejects it (download fetches remote manifests via parse). + let json = serde_json::to_string(&r).unwrap(); + assert!(parse(&json).is_err()); + } + #[test] fn rejects_bad_sha256() { let mut r = valid_recording(); diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index c75aa889..e327c48d 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -52,8 +52,38 @@ pub fn recordings_dir() -> Result { Ok(bubbaloop_dir()?.join("recordings")) } +/// Maximum length of a recording name. +pub const MAX_RECORDING_NAME_LEN: usize = 128; + +/// Validate a caller-supplied recording name before it is used as a filesystem +/// path segment or remote object-key component. +/// +/// Allowed: 1..=[`MAX_RECORDING_NAME_LEN`] characters of `[A-Za-z0-9._-]`, and +/// not the traversal segments `.` / `..`. This is the single guard that every +/// path builder funnels through, so no caller can smuggle a `..`, `/`, or NUL +/// into [`recording_dir`]/[`chunks_dir`] (and thence into `remove_dir_all`, +/// reads, or a remote prefix). Mirrors the node-name rules in CLAUDE.md. +pub fn validate_recording_name(name: &str) -> Result<(), StoragePathError> { + let valid_chars = name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + if name.is_empty() + || name.len() > MAX_RECORDING_NAME_LEN + || !valid_chars + || name == "." + || name == ".." + { + return Err(StoragePathError::InvalidName(name.to_string())); + } + Ok(()) +} + /// Directory for one recording: `~/.bubbaloop/recordings/{name}`. +/// +/// Rejects names that fail [`validate_recording_name`] so the returned path can +/// never escape the recordings root. pub fn recording_dir(name: &str) -> Result { + validate_recording_name(name)?; Ok(recordings_dir()?.join(name)) } @@ -101,6 +131,10 @@ pub enum StoragePathError { /// No home directory could be determined. #[error("could not determine home directory")] NoHomeDir, + /// The recording name is empty, too long, or contains illegal characters + /// (allowed: `[A-Za-z0-9._-]`, and not `.`/`..`). + #[error("invalid recording name: {0:?}")] + InvalidName(String), } #[cfg(test)] @@ -124,6 +158,29 @@ mod tests { ); } + #[test] + fn recording_name_validation_blocks_traversal() { + assert!(validate_recording_name("rec_2026-05-26T12.34.56_jetson").is_ok()); + assert!(validate_recording_name("a").is_ok()); + // Traversal and separators must be rejected. + assert!(validate_recording_name("..").is_err()); + assert!(validate_recording_name(".").is_err()); + assert!(validate_recording_name("../../etc").is_err()); + assert!(validate_recording_name("a/b").is_err()); + assert!(validate_recording_name("/abs").is_err()); + assert!(validate_recording_name("a\0b").is_err()); + assert!(validate_recording_name("").is_err()); + assert!(validate_recording_name(&"x".repeat(MAX_RECORDING_NAME_LEN + 1)).is_err()); + // A `..` substring inside a single segment is fine (not a traversal). + assert!(validate_recording_name("a..b").is_ok()); + } + + #[test] + fn recording_dir_rejects_traversal_names() { + assert!(recording_dir("../../escape").is_err()); + assert!(chunks_dir("..").is_err()); + } + #[test] fn chunk_key_is_a_valid_backend_key() { let hex = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; diff --git a/crates/bubbaloop/src/storage/reconcile.rs b/crates/bubbaloop/src/storage/reconcile.rs index d9f2cca5..23b9d525 100644 --- a/crates/bubbaloop/src/storage/reconcile.rs +++ b/crates/bubbaloop/src/storage/reconcile.rs @@ -197,6 +197,7 @@ pub async fn reconcile( let mut claimed_local: Vec = Vec::new(); let mut claimed_remote: Vec = Vec::new(); let mut manifest_dirty = false; + let mut found_missing_everywhere = false; // §3.5.3 step 4: apply the diff matrix to each manifest chunk. for i in 0..manifest.chunks.len() { @@ -247,7 +248,27 @@ pub async fn reconcile( match meta.sha256 { Some(actual) if actual == expected_digest => RemoteState::Present, Some(_) => RemoteState::Mismatch, - None => RemoteState::Present, // backend can't verify; trust the key + None => { + // The backend gave no checksum (e.g. a pre-existing object, + // or a backend that doesn't surface x-amz-checksum-sha256). + // The key only embeds the first 8 hex chars, so we must not + // mark the chunk synced on the key alone — fetch and verify + // the full digest (§8 mandatory integrity). + match backend.get(&expected_key).await { + Ok(bytes) if integrity::sha256(&bytes) == expected_digest => { + RemoteState::Present + } + Ok(_) => RemoteState::Mismatch, + Err(e) => { + report.errors.push(ChunkError { + index, + kind: ChunkErrorKind::Io, + detail: format!("verifying {expected_key}: {e}"), + }); + continue; + } + } + } } } Ok(None) => RemoteState::Absent, @@ -358,9 +379,14 @@ pub async fn reconcile( } } } else { + // Confirmed present remotely; record it in the report so the + // manifest mutation isn't silent. if manifest.chunks[i].uploaded_at_ns.is_none() { manifest.chunks[i].uploaded_at_ns = Some(now_ns); manifest_dirty = true; + report.chunks_marked_uploaded += 1; + } else { + report.chunks_verified_ok += 1; } record(&mut report, index, ChunkOutcome::PresentRemoteOnly); } @@ -378,8 +404,9 @@ pub async fn reconcile( }); } - // Row 6: gone in both places — the manifest is broken. + // Row 6: gone in both places — the recording can't be made whole. (LocalState::Absent, RemoteState::Absent) => { + found_missing_everywhere = true; report.errors.push(ChunkError { index, kind: ChunkErrorKind::MissingEverywhere, @@ -393,6 +420,17 @@ pub async fn reconcile( collect_local_orphans(&local_files, &claimed_local, &mut report); collect_remote_orphans(&remote_objects, &claimed_remote, &mut report); + // §3.5.2 row 6: a chunk gone in both places means the recording can never be + // made whole. Record that durably so `list`/`info` surface it instead of + // advertising the recording as healthy. + if found_missing_everywhere && !manifest.corrupt { + manifest.corrupt = true; + manifest_dirty = true; + log::warn!( + "reconcile {name}: marking recording corrupt (chunk missing both locally and remotely)" + ); + } + // §3.5.3 step 6: persist the (possibly mutated) manifest, then re-upload it // so the post-reconcile state is captured remotely. if manifest_dirty { @@ -781,6 +819,9 @@ mod tests { .unwrap(); assert_eq!(report.errors.len(), 1); assert_eq!(report.errors[0].kind, ChunkErrorKind::MissingEverywhere); + // The recording is durably marked corrupt so it isn't advertised healthy. + let reloaded = manifest::load_dir(&rec_dir).unwrap(); + assert!(reloaded.corrupt); } #[tokio::test] @@ -789,12 +830,20 @@ mod tests { let (_rd, fs) = remote(); seed_remote(&fs, &rec, 0).await; - // Without restore: only reported, not fetched. + // Without restore: only reported, not fetched — but the manifest's null + // uploaded_at_ns is filled in from the remote and that is counted. let no_restore = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) .await .unwrap(); assert_eq!(no_restore.chunks_redownloaded, 0); + assert_eq!(no_restore.chunks_marked_uploaded, 1); assert!(!rec_dir.join("chunks").join(&rec.chunks[0].name).exists()); + // The mutation was persisted, so a second run sees it as verified. + let second = reconcile(&rec_dir, &fs, &ReconcileOptions::default(), NOW) + .await + .unwrap(); + assert_eq!(second.chunks_verified_ok, 1); + assert_eq!(second.chunks_marked_uploaded, 0); // With restore: the local file reappears and verifies. let restored = reconcile(&rec_dir, &fs, &ReconcileOptions { restore: true }, NOW) diff --git a/crates/bubbaloop/src/storage/recording.rs b/crates/bubbaloop/src/storage/recording.rs index adebf525..7b2ff72c 100644 --- a/crates/bubbaloop/src/storage/recording.rs +++ b/crates/bubbaloop/src/storage/recording.rs @@ -69,11 +69,21 @@ pub struct Recording { /// Free-form tags (Phase 3 search; carried through verbatim in v1). #[serde(default)] pub tags: Vec, + /// Set when reconcile finds a chunk missing both locally and remotely + /// (§3.5.2 row 6): the recording can no longer be made whole. Durable so + /// `list`/`info` can surface it instead of advertising a broken recording as + /// healthy. Defaults to `false` and is omitted from the manifest when unset. + #[serde(default, skip_serializing_if = "is_false")] + pub corrupt: bool, /// Unknown fields from newer schema versions, preserved on round-trip. #[serde(flatten)] pub extra: BTreeMap, } +fn is_false(b: &bool) -> bool { + !*b +} + /// Capture mode (`mode` in the manifest). Distinct from the command-envelope /// `SessionMode`, which carries the window inline; the manifest keeps `mode` and /// `window_secs` as separate fields per §4.4. @@ -228,6 +238,7 @@ impl Recording { profile_sha256: None, recorder_version: String::new(), tags: Vec::new(), + corrupt: false, extra: BTreeMap::new(), } } diff --git a/crates/bubbaloop/src/storage/sync.rs b/crates/bubbaloop/src/storage/sync.rs index aa027a7b..66ec6a13 100644 --- a/crates/bubbaloop/src/storage/sync.rs +++ b/crates/bubbaloop/src/storage/sync.rs @@ -169,9 +169,21 @@ impl SharedBackoff { /// Record a failure observed at `now_ns`. Returns the delay before the next /// attempt and whether the streak has run past `max_elapsed` (dead-letter). pub fn on_failure(&mut self, now_ns: u64) -> BackoffDecision { - let started = *self.streak_started_ns.get_or_insert(now_ns); + // Anchor the streak on the first failure, and *re-anchor* if the wall + // clock has jumped backward (now < start). Without this, a backward NTP + // step / suspend-resume would make `now - start` saturate to 0 forever, + // so `elapsed >= max_elapsed` could never fire and a hopeless job would + // retry indefinitely instead of dead-lettering. Re-anchoring restarts the + // dead-letter timer from the corrected clock, which always moves forward. + let started = match self.streak_started_ns { + Some(s) if s <= now_ns => s, + _ => { + self.streak_started_ns = Some(now_ns); + now_ns + } + }; self.failures += 1; - let elapsed_ns = now_ns.saturating_sub(started) as u128; + let elapsed_ns = (now_ns - started) as u128; let dead_letter = elapsed_ns >= self.schedule.max_elapsed.as_nanos(); // The next attempt number is failures + 1 (after one failure, attempt 2). let delay = self.schedule.delay_for_attempt(self.failures + 1); @@ -562,6 +574,19 @@ mod tests { assert!(later.dead_letter); } + #[test] + fn backward_clock_jump_still_dead_letters() { + let mut b = SharedBackoff::new(BackoffSchedule::default()); + b.on_failure(NOW); + // Clock steps backward: must not underflow, must not be "dead" yet, and + // must re-anchor so the timer makes forward progress again. + let back = b.on_failure(NOW - 100 * SEC); + assert!(!back.dead_letter); + // From the re-anchored start, 5 more minutes of forward time dead-letters. + let forward = b.on_failure(NOW - 100 * SEC + 300 * SEC); + assert!(forward.dead_letter); + } + // ---- upload sequence ---------------------------------------------------- fn write_chunk(rec_dir: &Path, rec: &mut Recording, i: u32, bytes: &[u8]) { From 40403d0d7903a8a2de6dc74205ea2c6819acfbcc Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Sun, 14 Jun 2026 23:59:45 +0530 Subject: [PATCH 07/19] feat(storage): add `storage profile` CLI verbs (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds create/list/show/rm over storage::profile (§5): - create builds a Profile from flags and validates it against the v1 schema via profile::save (rejects e.g. ring_buffer without window_secs); --force to overwrite; profile names are run through validate_recording_name. - list scans ~/.bubbaloop/profiles/*.yaml with name/sha256/description. - show prints the on-disk YAML after confirming it parses. - rm deletes the profile (errors if absent). Pure-local, no backend/Zenoh. +1 unit test (split_csv); 99 storage tests pass, clippy-clean. Smoke-tested create/list/show/rm + validation + name guard. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 245 ++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index 069c2f51..da517c94 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -59,6 +59,7 @@ enum StorageAction { Download(DownloadArgs), Reconcile(ReconcileArgs), Rm(RmArgs), + Profile(ProfileCommand), } /// List recordings. @@ -149,6 +150,7 @@ impl StorageCommand { StorageAction::Download(a) => run_download(a).await, StorageAction::Reconcile(a) => run_reconcile(a).await, StorageAction::Rm(a) => run_rm(a).await, + StorageAction::Profile(c) => run_profile(c), } } } @@ -652,10 +654,243 @@ async fn run_rm(args: RmArgs) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// profile create/list/show/rm +// --------------------------------------------------------------------------- + +/// Manage recording profiles (`~/.bubbaloop/profiles/*.yaml`). +#[derive(FromArgs)] +#[argh(subcommand, name = "profile")] +pub struct ProfileCommand { + #[argh(subcommand)] + action: ProfileAction, +} + +#[derive(FromArgs)] +#[argh(subcommand)] +enum ProfileAction { + Create(ProfileCreateArgs), + List(ProfileListArgs), + Show(ProfileShowArgs), + Rm(ProfileRmArgs), +} + +/// Create a profile from flags, validating it against the v1 schema. +#[derive(FromArgs)] +#[argh(subcommand, name = "create")] +struct ProfileCreateArgs { + /// profile name + #[argh(positional)] + name: String, + /// human description + #[argh(option)] + description: Option, + /// additive include patterns (comma-separated) + #[argh(option)] + topics: Option, + /// additive regex include + #[argh(option)] + regex: Option, + /// subtractive exclude patterns (comma-separated) + #[argh(option)] + exclude: Option, + /// also include SHM-only bubbaloop/local/** topics + #[argh(switch)] + include_local: bool, + /// target chunk size in bytes (default 786432) + #[argh(option)] + chunk_size_bytes: Option, + /// compression codec: zstd | lz4 | none (default zstd) + #[argh(option)] + compression: Option, + /// capture mode: streaming | ring_buffer (default streaming) + #[argh(option)] + mode: Option, + /// ring-buffer window length in seconds (required with --mode ring_buffer) + #[argh(option)] + window_secs: Option, + /// ring-buffer trigger: manual | on-event (required with --mode ring_buffer) + #[argh(option)] + trigger: Option, + /// overwrite an existing profile of the same name + #[argh(switch)] + force: bool, +} + +/// List profiles. +#[derive(FromArgs)] +#[argh(subcommand, name = "list")] +struct ProfileListArgs {} + +/// Print a profile's YAML. +#[derive(FromArgs)] +#[argh(subcommand, name = "show")] +struct ProfileShowArgs { + /// profile name + #[argh(positional)] + name: String, +} + +/// Delete a profile. +#[derive(FromArgs)] +#[argh(subcommand, name = "rm")] +struct ProfileRmArgs { + /// profile name + #[argh(positional)] + name: String, +} + +fn run_profile(cmd: ProfileCommand) -> Result<()> { + match cmd.action { + ProfileAction::Create(a) => run_profile_create(a), + ProfileAction::List(_) => run_profile_list(), + ProfileAction::Show(a) => run_profile_show(a), + ProfileAction::Rm(a) => run_profile_rm(a), + } +} + +/// Resolve a validated profile name to its `.yaml` path under the profiles dir. +fn profile_path(name: &str) -> Result { + // Reuse the recording-name guard so a profile name can't traverse the path. + storage::validate_recording_name(name) + .with_context(|| format!("invalid profile name {name:?}"))?; + Ok(storage::profile::profiles_dir()?.join(format!("{name}.yaml"))) +} + +fn run_profile_create(args: ProfileCreateArgs) -> Result<()> { + use storage::profile::{CompressionKind, Profile, DEFAULT_CHUNK_SIZE_BYTES}; + use storage::recording::{RecordingMode, Trigger}; + + let path = profile_path(&args.name)?; + if path.exists() && !args.force { + bail!( + "profile '{}' already exists (use --force to overwrite)", + args.name + ); + } + + let compression = match args.compression.as_deref() { + None | Some("zstd") => CompressionKind::Zstd, + Some("lz4") => CompressionKind::Lz4, + Some("none") => CompressionKind::None, + Some(other) => bail!("unknown --compression {other:?} (expected zstd|lz4|none)"), + }; + let mode = match args.mode.as_deref() { + None | Some("streaming") => RecordingMode::Streaming, + Some("ring_buffer") => RecordingMode::RingBuffer, + Some(other) => bail!("unknown --mode {other:?} (expected streaming|ring_buffer)"), + }; + let trigger = match args.trigger.as_deref() { + None => None, + Some("manual") => Some(Trigger::Manual), + Some("on-event") => Some(Trigger::OnEvent), + Some(other) => bail!("unknown --trigger {other:?} (expected manual|on-event)"), + }; + + let profile = Profile { + name: args.name.clone(), + description: args.description, + topics: split_csv(args.topics.as_deref()), + regex: args.regex, + exclude: split_csv(args.exclude.as_deref()), + include_local: args.include_local, + chunk_size_bytes: args.chunk_size_bytes.unwrap_or(DEFAULT_CHUNK_SIZE_BYTES), + compression, + compression_level: Default::default(), + chunk_crc: true, + mode, + window_secs: args.window_secs, + trigger, + sending_queue: Default::default(), + retry_on_failure: Default::default(), + }; + + // save() validates against the v1 schema before writing. + storage::profile::save(&path, &profile) + .with_context(|| format!("saving profile '{}'", args.name))?; + log::info!( + "Created profile '{}' at {} (sha256 {}).", + args.name, + path.display(), + storage::profile::canonical_sha256(&profile) + ); + Ok(()) +} + +fn run_profile_list() -> Result<()> { + let dir = storage::profile::profiles_dir()?; + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => { + log::info!("No profiles found."); + return Ok(()); + } + }; + let mut names: Vec = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("yaml") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + names.push(stem.to_string()); + } + } + } + names.sort(); + if names.is_empty() { + log::info!("No profiles found."); + return Ok(()); + } + log::info!("{:<24} {:<12} {}", "NAME", "SHA256", "DESCRIPTION"); + for name in names { + match storage::profile::load_named(&name) { + Ok(p) => log::info!( + "{:<24} {:<12} {}", + truncate(&name, 24), + &storage::profile::canonical_sha256(&p)[..12], + p.description.as_deref().unwrap_or("") + ), + Err(e) => log::warn!("{name}: unreadable ({e})"), + } + } + Ok(()) +} + +fn run_profile_show(args: ProfileShowArgs) -> Result<()> { + let path = profile_path(&args.name)?; + // Validate it parses, then print the on-disk YAML verbatim. + storage::profile::load(&path).with_context(|| format!("no profile named '{}'", args.name))?; + let yaml = std::fs::read_to_string(&path).context("reading profile")?; + log::info!("{yaml}"); + Ok(()) +} + +fn run_profile_rm(args: ProfileRmArgs) -> Result<()> { + let path = profile_path(&args.name)?; + if !path.exists() { + bail!("no profile named '{}'", args.name); + } + std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; + log::info!("Deleted profile '{}'.", args.name); + Ok(()) +} + // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- +/// Split a comma-separated flag value into trimmed, non-empty items. +fn split_csv(value: Option<&str>) -> Vec { + value + .map(|s| { + s.split(',') + .map(|x| x.trim()) + .filter(|x| !x.is_empty()) + .map(|x| x.to_string()) + .collect() + }) + .unwrap_or_default() +} + fn now_ns() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() @@ -816,6 +1051,16 @@ mod tests { assert!(parse_since("not-a-date").is_err()); } + #[test] + fn split_csv_trims_and_drops_empties() { + assert_eq!(split_csv(None), Vec::::new()); + assert_eq!(split_csv(Some("")), Vec::::new()); + assert_eq!( + split_csv(Some("cam_*/compressed, lidar/* ,, ")), + vec!["cam_*/compressed".to_string(), "lidar/*".to_string()] + ); + } + #[test] fn truncate_adds_ellipsis() { assert_eq!(truncate("short", 10), "short"); From 538e069b853646eff22188355fa13f7c7e1e346e Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 00:02:38 +0530 Subject: [PATCH 08/19] feat(storage): add `storage configure` CLI + config writer (PR2) - StorageConfig::save/save_to writes the [storage] table into config.toml, merging via toml::Table so unrelated tables (e.g. [agent]) survive; atomic tmp+rename. - `storage configure --backend ... [--endpoint --region --bucket --retention-days-local --disk-quota-gb --local-path --no-auto-upload]` writes the config; `--access-key-id`/`--secret-access-key` (both required together) write secrets.toml via storage::secrets::save_r2 (chmod 0600). Secrets never touch config.toml. Validates backend kind and requires --bucket for cloud backends. Flag-driven/non-interactive (the wizard will call the same handler). +1 config round-trip test; clippy-clean. Smoke-tested: local/r2 config, secrets split + 0600, [agent] preservation, validation paths. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 99 ++++++++++++++++++++++++++ crates/bubbaloop/src/storage/config.rs | 71 ++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index da517c94..e830bd08 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -60,6 +60,7 @@ enum StorageAction { Reconcile(ReconcileArgs), Rm(RmArgs), Profile(ProfileCommand), + Configure(ConfigureArgs), } /// List recordings. @@ -151,6 +152,7 @@ impl StorageCommand { StorageAction::Reconcile(a) => run_reconcile(a).await, StorageAction::Rm(a) => run_rm(a).await, StorageAction::Profile(c) => run_profile(c), + StorageAction::Configure(a) => run_configure(a), } } } @@ -654,6 +656,103 @@ async fn run_rm(args: RmArgs) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// configure +// --------------------------------------------------------------------------- + +/// Configure the storage backend (writes the `[storage]` table of config.toml, +/// and `secrets.toml` when credentials are supplied). +/// +/// Flag-driven and non-interactive so it is scriptable and testable; the +/// interactive wizard (a later slice) collects the same fields and calls this +/// same handler (spec §6.2). +#[derive(FromArgs)] +#[argh(subcommand, name = "configure")] +pub struct ConfigureArgs { + /// backend: r2 | s3 | gcs | minio | local + #[argh(option)] + backend: String, + /// S3-compatible endpoint URL (cloud backends) + #[argh(option)] + endpoint: Option, + /// region (R2 uses "auto") + #[argh(option)] + region: Option, + /// bucket name (the sharing unit; required for cloud backends) + #[argh(option)] + bucket: Option, + /// disable background sync of finalized chunks + #[argh(switch)] + no_auto_upload: bool, + /// auto-delete local recordings N days after successful upload + #[argh(option)] + retention_days_local: Option, + /// soft cap on the local recordings directory (GiB) + #[argh(option)] + disk_quota_gb: Option, + /// object-store root for the local backend + #[argh(option)] + local_path: Option, + /// access key id (written to secrets.toml, chmod 0600) + #[argh(option)] + access_key_id: Option, + /// secret access key (written to secrets.toml, chmod 0600) + #[argh(option)] + secret_access_key: Option, +} + +fn run_configure(args: ConfigureArgs) -> Result<()> { + const BACKENDS: [&str; 5] = ["r2", "s3", "gcs", "minio", "local"]; + if !BACKENDS.contains(&args.backend.as_str()) { + bail!( + "unknown backend {:?} (expected one of: {})", + args.backend, + BACKENDS.join(", ") + ); + } + let is_remote = args.backend != "local"; + if is_remote && args.bucket.is_none() { + bail!("--bucket is required for the {:?} backend", args.backend); + } + + let cfg = StorageConfig { + backend: args.backend.clone(), + endpoint: args.endpoint, + region: args.region, + bucket: args.bucket, + auto_upload: !args.no_auto_upload, + retention_days_local: args.retention_days_local, + disk_quota_gb: args.disk_quota_gb, + recorder_instance: None, + local_path: args.local_path.map(PathBuf::from), + }; + cfg.save().context("writing config.toml")?; + log::info!("Wrote [storage] config (backend = {}).", cfg.backend); + + // Credentials: write secrets.toml only when both halves are supplied. + match (args.access_key_id, args.secret_access_key) { + (Some(id), Some(secret)) => { + use storage::secrets::{R2Credentials, Secret}; + let creds = R2Credentials { + access_key_id: Secret::new(id), + secret_access_key: Secret::new(secret), + }; + let path = storage::secrets::default_path()?; + storage::secrets::save_r2(&path, &creds).context("writing secrets.toml")?; + log::info!("Wrote credentials to {} (chmod 0600).", path.display()); + } + (None, None) => { + if is_remote { + log::warn!( + "no credentials supplied; set --access-key-id/--secret-access-key (or edit ~/.bubbaloop/secrets.toml) before uploading" + ); + } + } + _ => bail!("--access-key-id and --secret-access-key must be provided together"), + } + Ok(()) +} + // --------------------------------------------------------------------------- // profile create/list/show/rm // --------------------------------------------------------------------------- diff --git a/crates/bubbaloop/src/storage/config.rs b/crates/bubbaloop/src/storage/config.rs index 969c7b80..10a85bc0 100644 --- a/crates/bubbaloop/src/storage/config.rs +++ b/crates/bubbaloop/src/storage/config.rs @@ -111,6 +111,50 @@ impl StorageConfig { self.backend != "local" } + /// Write this config as the `[storage]` table of `~/.bubbaloop/config.toml`, + /// preserving any other tables already in the file. Atomic (tmp + rename). + pub fn save(&self) -> Result<(), ConfigError> { + self.save_to(&bubbaloop_dir()?.join("config.toml")) + } + + /// As [`save`](Self::save) but to an explicit path (testable). + pub fn save_to(&self, path: &std::path::Path) -> Result<(), ConfigError> { + // Read the existing document (so unrelated tables survive), replace just + // the `[storage]` table. + let mut doc: toml::Table = match std::fs::read_to_string(path) { + Ok(s) => toml::from_str(&s).map_err(|e| ConfigError::Parse(e.to_string()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => toml::Table::new(), + Err(e) => { + return Err(ConfigError::Io { + path: path.display().to_string(), + detail: e.to_string(), + }) + } + }; + let storage = toml::Value::try_from(self) + .map_err(|e| ConfigError::Parse(format!("serializing [storage]: {e}")))?; + doc.insert("storage".to_string(), storage); + let body = toml::to_string(&doc) + .map_err(|e| ConfigError::Parse(format!("encoding config: {e}")))?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io { + path: parent.display().to_string(), + detail: e.to_string(), + })?; + } + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, body.as_bytes()).map_err(|e| ConfigError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + std::fs::rename(&tmp, path).map_err(|e| ConfigError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + Ok(()) + } + /// Resolve the `local` backend's object-store root. pub fn local_root(&self) -> Result { match &self.local_path { @@ -239,6 +283,33 @@ mod tests { } } + #[test] + fn save_roundtrips_and_preserves_other_tables() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + // Pre-existing unrelated table must survive the write. + std::fs::write(&path, "[agent]\nmodel = \"claude\"\n").unwrap(); + + let cfg = StorageConfig { + backend: "r2".into(), + endpoint: Some("https://acct.r2.cloudflarestorage.com".into()), + region: Some("auto".into()), + bucket: Some("kornia-recordings".into()), + retention_days_local: Some(7), + ..Default::default() + }; + cfg.save_to(&path).unwrap(); + + let reloaded = StorageConfig::load_from(&path).unwrap(); + assert_eq!(reloaded.backend, "r2"); + assert_eq!(reloaded.bucket.as_deref(), Some("kornia-recordings")); + assert_eq!(reloaded.retention_days_local, Some(7)); + // The unrelated [agent] table is still present. + let raw = std::fs::read_to_string(&path).unwrap(); + assert!(raw.contains("[agent]")); + assert!(raw.contains("model = \"claude\"")); + } + #[test] fn unknown_backend_is_rejected() { let cfg = StorageConfig { From 0b27545353d3e2d1cd42895dbfd0af382f6de9d0 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 00:06:30 +0530 Subject: [PATCH 09/19] feat(storage): add storage::discover + `storage topics` CLI (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage::discover queries `bubbaloop/**/manifest`, decodes the CBOR node manifests, and groups output topics by machine_id → instance → topic with per-topic liveness (live/idle from still_live && ever_fired), mirroring the `bubbaloop dataflow` query pattern. Zenoh I/O (query_nodes) is thin; the grouping/filtering (group) is pure with --machine / --grep filters. - `storage topics [--machine] [--grep] [-z endpoint] [--timeout-secs]` prints the grouped tree. 4 unit tests on the pure grouping (sort order, machine filter, case-insensitive grep dropping empty nodes). The Zenoh path is compile-checked; it needs a live router + nodes to exercise. 103 storage tests pass, clippy-clean. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 53 ++++ crates/bubbaloop/src/storage/discover.rs | 302 +++++++++++++++++++++++ crates/bubbaloop/src/storage/mod.rs | 1 + 3 files changed, 356 insertions(+) create mode 100644 crates/bubbaloop/src/storage/discover.rs diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index e830bd08..29f03f1c 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -61,6 +61,7 @@ enum StorageAction { Rm(RmArgs), Profile(ProfileCommand), Configure(ConfigureArgs), + Topics(TopicsArgs), } /// List recordings. @@ -153,6 +154,7 @@ impl StorageCommand { StorageAction::Rm(a) => run_rm(a).await, StorageAction::Profile(c) => run_profile(c), StorageAction::Configure(a) => run_configure(a), + StorageAction::Topics(a) => run_topics(a).await, } } } @@ -656,6 +658,57 @@ async fn run_rm(args: RmArgs) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// topics +// --------------------------------------------------------------------------- + +/// List live topics across the fleet (via the node manifest queryables). +#[derive(FromArgs)] +#[argh(subcommand, name = "topics")] +pub struct TopicsArgs { + /// only show topics from this machine id + #[argh(option)] + machine: Option, + /// only show topics whose key contains this substring (case-insensitive) + #[argh(option)] + grep: Option, + /// zenoh endpoint (default: env BUBBALOOP_ZENOH_ENDPOINT or tcp/127.0.0.1:7447) + #[argh(option, short = 'z')] + zenoh_endpoint: Option, + /// query timeout in seconds (default 2) + #[argh(option, default = "2")] + timeout_secs: u64, +} + +async fn run_topics(args: TopicsArgs) -> Result<()> { + let session = crate::cli::zenoh_session::create_zenoh_session(args.zenoh_endpoint.as_deref()) + .await + .context("connecting to zenoh")?; + let machines = storage::discover::discover( + &session, + std::time::Duration::from_secs(args.timeout_secs), + args.grep.as_deref(), + args.machine.as_deref(), + ) + .await?; + + if machines.is_empty() { + log::info!("No live topics found."); + return Ok(()); + } + for m in &machines { + log::info!("{}", m.machine_id); + for node in &m.nodes { + log::info!(" {} ({})", node.instance_name, node.role); + for t in &node.topics { + let mark = if t.is_live() { "live" } else { "idle" }; + log::info!(" [{mark}] {}", t.topic); + } + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // configure // --------------------------------------------------------------------------- diff --git a/crates/bubbaloop/src/storage/discover.rs b/crates/bubbaloop/src/storage/discover.rs new file mode 100644 index 00000000..ec8cea19 --- /dev/null +++ b/crates/bubbaloop/src/storage/discover.rs @@ -0,0 +1,302 @@ +//! Topic discovery for recording (spec §3.2, §5 `storage topics`). +//! +//! Every Bubbaloop node publishes a CBOR manifest queryable at +//! `bubbaloop/global/{machine_id}/{instance}/manifest` listing its live +//! `inputs`/`outputs` with per-topic liveness (`ever_fired`, `still_live`). This +//! module queries `bubbaloop/**/manifest`, decodes the replies, and groups the +//! *output* topics (the ones a recorder would capture) by +//! `machine_id → node_instance → topic` so the user can see what's available to +//! record before starting a session. +//! +//! The Zenoh I/O ([`query_nodes`]) is kept thin; the grouping/filtering +//! ([`group`]) is pure and unit-tested. + +use std::time::Duration; + +use serde::Deserialize; + +/// Per-topic liveness, mirrored from the node manifest's IO entries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TopicInfo { + /// Full Zenoh topic key. + pub topic: String, + /// Whether the topic has ever published a sample. + pub ever_fired: bool, + /// Whether the producing node still considers it live. + pub still_live: bool, +} + +impl TopicInfo { + /// A topic is recordable-live when it's still declared and has actually + /// fired at least once (matches `bubbaloop dataflow`'s default edge rule). + pub fn is_live(&self) -> bool { + self.still_live && self.ever_fired + } +} + +/// One node's published topics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredNode { + /// Machine the node runs on. + pub machine_id: String, + /// Node instance name. + pub instance_name: String, + /// Node role (`source`/`processor`/`sink`/…). + pub role: String, + /// Output topics with liveness. + pub topics: Vec, +} + +/// Topics grouped under one node, for display. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeTopics { + pub instance_name: String, + pub role: String, + pub topics: Vec, +} + +/// All recordable topics on one machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MachineTopics { + pub machine_id: String, + pub nodes: Vec, +} + +/// Errors from topic discovery. +#[derive(Debug, thiserror::Error)] +pub enum DiscoverError { + /// The Zenoh query failed. + #[error("zenoh query failed: {0}")] + Zenoh(String), +} + +// --------------------------------------------------------------------------- +// Pure grouping/filtering (unit-tested without Zenoh) +// --------------------------------------------------------------------------- + +/// Group discovered nodes by `machine_id → instance → topic`, applying optional +/// `machine` (exact match) and `grep` (case-insensitive topic substring) +/// filters. Output is sorted by machine, then instance, then topic; nodes whose +/// topics are all filtered out are dropped, as are machines left empty. +pub fn group( + nodes: Vec, + grep: Option<&str>, + machine: Option<&str>, +) -> Vec { + let grep_lc = grep.map(|g| g.to_lowercase()); + let mut by_machine: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + + for node in nodes { + if let Some(m) = machine { + if node.machine_id != m { + continue; + } + } + let mut topics: Vec = node + .topics + .into_iter() + .filter(|t| { + grep_lc + .as_ref() + .map(|g| t.topic.to_lowercase().contains(g)) + .unwrap_or(true) + }) + .collect(); + if topics.is_empty() { + continue; + } + topics.sort_by(|a, b| a.topic.cmp(&b.topic)); + by_machine + .entry(node.machine_id) + .or_default() + .push(NodeTopics { + instance_name: node.instance_name, + role: node.role, + topics, + }); + } + + by_machine + .into_iter() + .map(|(machine_id, mut nodes)| { + nodes.sort_by(|a, b| a.instance_name.cmp(&b.instance_name)); + MachineTopics { machine_id, nodes } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Zenoh query (thin I/O) +// --------------------------------------------------------------------------- + +/// Wire shape of a node manifest reply (subset we need: identity + outputs). +#[derive(Debug, Deserialize)] +struct WireManifest { + instance_name: String, + machine_id: String, + #[serde(default = "unknown_role")] + role: String, + #[serde(default)] + outputs: Vec, +} + +#[derive(Debug, Deserialize)] +struct WireIo { + topic: String, + #[serde(default)] + ever_fired: bool, + #[serde(default = "wire_default_true")] + still_live: bool, +} + +fn unknown_role() -> String { + "unknown".to_string() +} +fn wire_default_true() -> bool { + true +} + +/// Query `bubbaloop/**/manifest` and return one [`DiscoveredNode`] per live node +/// (deduplicated by `machine_id`+`instance`). Undecodable replies are skipped. +pub async fn query_nodes( + session: &zenoh::Session, + timeout: Duration, +) -> Result, DiscoverError> { + use zenoh::query::{ConsolidationMode, QueryTarget}; + + let replies = session + .get("bubbaloop/**/manifest") + .target(QueryTarget::All) + .consolidation(ConsolidationMode::None) + .timeout(timeout) + .await + .map_err(|e| DiscoverError::Zenoh(e.to_string()))?; + + let mut seen = std::collections::BTreeSet::new(); + let mut nodes = Vec::new(); + while let Ok(reply) = replies.recv_async().await { + if let Ok(sample) = reply.result() { + let bytes = sample.payload().to_bytes(); + match ciborium::from_reader::(&bytes[..]) { + Ok(m) => { + if seen.insert((m.machine_id.clone(), m.instance_name.clone())) { + nodes.push(DiscoveredNode { + machine_id: m.machine_id, + instance_name: m.instance_name, + role: m.role, + topics: m + .outputs + .into_iter() + .map(|io| TopicInfo { + topic: io.topic, + ever_fired: io.ever_fired, + still_live: io.still_live, + }) + .collect(), + }); + } + } + Err(e) => log::debug!("undecodable manifest: {e}"), + } + } + } + Ok(nodes) +} + +/// Discover and group recordable topics across the fleet. +pub async fn discover( + session: &zenoh::Session, + timeout: Duration, + grep: Option<&str>, + machine: Option<&str>, +) -> Result, DiscoverError> { + let nodes = query_nodes(session, timeout).await?; + Ok(group(nodes, grep, machine)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn topic(t: &str, fired: bool, live: bool) -> TopicInfo { + TopicInfo { + topic: t.to_string(), + ever_fired: fired, + still_live: live, + } + } + + fn node(machine: &str, instance: &str, topics: Vec) -> DiscoveredNode { + DiscoveredNode { + machine_id: machine.to_string(), + instance_name: instance.to_string(), + role: "source".to_string(), + topics, + } + } + + #[test] + fn groups_by_machine_then_instance_then_topic() { + let nodes = vec![ + node( + "beta", + "lidar", + vec![topic("bubbaloop/global/beta/lidar/points", true, true)], + ), + node( + "alpha", + "cam_front", + vec![ + topic("bubbaloop/global/alpha/cam_front/compressed", true, true), + topic("bubbaloop/global/alpha/cam_front/info", false, true), + ], + ), + ]; + let grouped = group(nodes, None, None); + assert_eq!(grouped.len(), 2); + // machines sorted: alpha before beta + assert_eq!(grouped[0].machine_id, "alpha"); + assert_eq!(grouped[1].machine_id, "beta"); + // topics sorted within node: compressed before info + assert!(grouped[0].nodes[0].topics[0].topic.contains("compressed")); + assert!(grouped[0].nodes[0].topics[0].is_live()); + assert!(!grouped[0].nodes[0].topics[1].is_live()); // never fired + } + + #[test] + fn machine_filter_excludes_others() { + let nodes = vec![ + node("alpha", "a", vec![topic("t1", true, true)]), + node("beta", "b", vec![topic("t2", true, true)]), + ]; + let grouped = group(nodes, None, Some("alpha")); + assert_eq!(grouped.len(), 1); + assert_eq!(grouped[0].machine_id, "alpha"); + } + + #[test] + fn grep_filters_topics_case_insensitively_and_drops_empty_nodes() { + let nodes = vec![ + node( + "m", + "cam", + vec![ + topic("bubbaloop/global/m/cam/Compressed", true, true), + topic("bubbaloop/global/m/cam/health", true, true), + ], + ), + node( + "m", + "imu", + vec![topic("bubbaloop/global/m/imu/data", true, true)], + ), + ]; + let grouped = group(nodes, Some("compressed"), None); + // Only the cam node survives, with its one matching topic. + assert_eq!(grouped.len(), 1); + assert_eq!(grouped[0].nodes.len(), 1); + assert_eq!(grouped[0].nodes[0].instance_name, "cam"); + assert_eq!(grouped[0].nodes[0].topics.len(), 1); + } +} diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index e327c48d..2cd413e3 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -20,6 +20,7 @@ pub mod backend; pub mod config; +pub mod discover; pub mod integrity; pub mod manifest; pub mod profile; From 690487c86cab8a568056cfc3fe8765b48517b440 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 00:15:00 +0530 Subject: [PATCH 10/19] feat(storage): add S3-compatible backend behind `s3` feature (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements StorageBackend over aws-sdk-s3 for R2/AWS/GCS/MinIO (spec §8): - Explicit static Credentials (never the ambient AWS chain), region (auto for R2), explicit endpoint_url, force_path_style for MinIO. TLS via rustls+ring (aws-smithy-http-client) so the build needs no aws-lc/cmake. - Per-chunk x-amz-checksum-sha256 (base64) on PUT for server-side validation; checksum requested on HEAD/GET so reconcile can compare the full digest. - HEAD→None on not-found; ListObjectsV2 paginated; DeleteObject idempotent. - Error mapping to BackendError per §3.4.3: NoSuchBucket→terminal-pause, AccessDenied/Forbidden→terminal, BadDigest→terminal, NoSuchKey→NotFound, everything else→retryable Io. Optional `s3` cargo feature keeps aws-sdk-s3 out of default builds; the config factory builds S3Compat under the feature (reading creds from secrets.toml) and returns BackendNotImplemented without it. So `bubbaloop storage configure --backend r2 ...` + the existing upload/download/reconcile/rm verbs now reach real R2 when built with --features s3. Verified: `cargo check/test/clippy --features s3` clean (rustls-ring, no cmake); default build unchanged (103 storage tests). Functional R2 round-trips need live credentials (CI/minio) — compile + unit-verified here. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 625 ++++++++++++++++-- crates/bubbaloop/Cargo.toml | 20 + crates/bubbaloop/src/storage/backend.rs | 9 +- .../src/storage/backend/s3_compat.rs | 282 ++++++++ crates/bubbaloop/src/storage/config.rs | 75 ++- 5 files changed, 941 insertions(+), 70 deletions(-) create mode 100644 crates/bubbaloop/src/storage/backend/s3_compat.rs diff --git a/Cargo.lock b/Cargo.lock index b58576b9..29207cad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -249,6 +249,329 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-runtime" +version = "1.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.136.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd03e531a7d981fba45114c5813a7565dd470a1bd3ef1188ca98c98ebdfc668" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru 0.16.4", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e8e65f4f81fcccdeb6c3eca2af17ac21d421a1786a26a394aecf421d616d3a" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.8" @@ -260,8 +583,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -275,7 +598,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite 0.28.0", @@ -293,8 +616,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -310,6 +633,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -340,6 +673,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -356,6 +698,10 @@ dependencies = [ "anyhow", "argh", "async-trait", + "aws-sdk-s3", + "aws-smithy-http-client", + "aws-smithy-runtime-api", + "aws-smithy-types", "axum", "base64", "chrono", @@ -386,7 +732,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2", + "sha2 0.10.9", "sysinfo", "tempfile", "thiserror 2.0.18", @@ -423,6 +769,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -515,10 +871,16 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.4" @@ -564,6 +926,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const_format" version = "0.2.35" @@ -618,6 +986,25 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.0", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -736,6 +1123,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -747,6 +1143,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.21.3" @@ -848,7 +1253,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -883,12 +1288,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1355,7 +1772,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.13.0", "slab", "tokio", @@ -1445,7 +1862,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -1468,6 +1894,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1478,6 +1915,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1485,7 +1933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1496,8 +1944,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1519,6 +1967,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1530,8 +1987,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1548,10 +2005,11 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1582,8 +2040,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -2010,7 +2468,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2168,6 +2626,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2198,6 +2665,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2519,6 +2996,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2631,7 +3114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3097,7 +3580,7 @@ dependencies = [ "indoc", "instability", "itertools 0.13.0", - "lru", + "lru 0.12.5", "paste", "strum", "unicode-segmentation", @@ -3220,6 +3703,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3236,8 +3725,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -3309,8 +3798,8 @@ dependencies = [ "bytes", "chrono", "futures", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "pastey", "pin-project-lite", @@ -3373,8 +3862,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -3442,7 +3931,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -3842,8 +4331,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3853,8 +4353,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3869,7 +4380,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "digest", + "digest 0.10.7", "keccak", ] @@ -3934,7 +4445,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4024,7 +4535,7 @@ checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -4490,8 +5001,8 @@ dependencies = [ "base64", "bytes", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", @@ -4536,8 +5047,8 @@ dependencies = [ "bitflags 2.11.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -4566,7 +5077,7 @@ dependencies = [ "axum", "forwarded-header-value", "governor", - "http", + "http 1.4.0", "pin-project", "thiserror 2.0.18", "tonic", @@ -4664,11 +5175,11 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.8.5", - "sha1", + "sha1 0.10.6", "thiserror 1.0.69", "utf-8", ] @@ -4681,11 +5192,11 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "utf-8", ] @@ -4698,11 +5209,11 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "utf-8", ] @@ -4920,6 +5431,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -5628,6 +6145,12 @@ dependencies = [ "time", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yasna" version = "0.5.2" @@ -5841,7 +6364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44b80a042fc71419fc4952a90c9cbcfb323c0ced048125d8b44fd362f184045f" dependencies = [ "aes", - "hmac", + "hmac 0.12.1", "rand 0.8.5", "rand_chacha 0.3.1", "sha3", diff --git a/crates/bubbaloop/Cargo.toml b/crates/bubbaloop/Cargo.toml index a939ab9e..827766bc 100644 --- a/crates/bubbaloop/Cargo.toml +++ b/crates/bubbaloop/Cargo.toml @@ -91,6 +91,18 @@ base64 = "0.22" sha2 = "0.10" zeroize = { version = "1", features = ["derive"] } async-trait = "0.1" +# Storage: S3-compatible backend (R2/AWS/GCS/MinIO), behind the optional `s3` +# feature so default builds stay lean. Routed through rustls+ring (no aws-lc / +# cmake); behavior-version is set explicitly in code. +aws-sdk-s3 = { version = "1", default-features = false, features = [ + "rt-tokio", + "behavior-version-latest", +], optional = true } +aws-smithy-http-client = { version = "1", default-features = false, features = [ + "rustls-ring", +], optional = true } +aws-smithy-types = { version = "1", optional = true } +aws-smithy-runtime-api = { version = "1", optional = true } # TUI for agent chat REPL ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } @@ -107,6 +119,14 @@ tokio-util = { version = "0.7", default-features = true } [features] default = [] dashboard = ["dep:rust-embed", "dep:mime_guess", "dep:tokio-tungstenite", "axum/ws"] +# S3-compatible storage backend (R2/AWS/GCS/MinIO). Optional because aws-sdk-s3 +# is a large dependency tree; enable for cloud uploads. +s3 = [ + "dep:aws-sdk-s3", + "dep:aws-smithy-http-client", + "dep:aws-smithy-types", + "dep:aws-smithy-runtime-api", +] test-harness = ["rmcp/client"] [dev-dependencies] diff --git a/crates/bubbaloop/src/storage/backend.rs b/crates/bubbaloop/src/storage/backend.rs index c9336206..4d761cbb 100644 --- a/crates/bubbaloop/src/storage/backend.rs +++ b/crates/bubbaloop/src/storage/backend.rs @@ -1,15 +1,16 @@ //! Storage backend abstraction (spec §3.2, §3.5). //! //! A [`StorageBackend`] is the put/get/list/delete/head surface that `sync` and -//! `reconcile` drive. Two implementations are planned: [`local::LocalFs`] (this -//! slice) and an `S3Compat` backend over `aws-sdk-s3` for R2/AWS/GCS/MinIO -//! (deferred to PR2 — it pulls a heavy SDK and needs network/credential plumbing -//! that belongs with the sync work). +//! `reconcile` drive. Two implementations: [`local::LocalFs`] (always available) +//! and [`s3_compat::S3Compat`] over `aws-sdk-s3` for R2/AWS/GCS/MinIO (behind the +//! optional `s3` feature). //! //! The trait is async and object-safe (via `async-trait`) so the daemon can hold //! a `Box` chosen at runtime from `[storage].backend`. pub mod local; +#[cfg(feature = "s3")] +pub mod s3_compat; use async_trait::async_trait; diff --git a/crates/bubbaloop/src/storage/backend/s3_compat.rs b/crates/bubbaloop/src/storage/backend/s3_compat.rs new file mode 100644 index 00000000..92bfb308 --- /dev/null +++ b/crates/bubbaloop/src/storage/backend/s3_compat.rs @@ -0,0 +1,282 @@ +//! S3-compatible backend over `aws-sdk-s3` (spec §8) — R2 / AWS S3 / GCS / MinIO. +//! +//! Behind the optional `s3` feature (the SDK is a large dependency tree). The +//! client is built with explicit static credentials (never the ambient AWS +//! credential chain, §8), `region("auto")` for R2, an explicit `endpoint_url`, +//! and a rustls+ring TLS stack (so the build needs no `aws-lc`/cmake). +//! +//! Per-chunk integrity uses `x-amz-checksum-sha256` (base64 of the SHA-256): set +//! on PUT so the server validates server-side, and requested on HEAD/GET so +//! [`reconcile`](crate::storage::reconcile) can compare the full digest. Errors +//! are mapped to the [`BackendError`] variants the sync retry policy understands +//! (§3.4.3): `Forbidden`/`NoSuchBucket` terminal, `BadDigest` terminal, the rest +//! retryable `Io`. + +use async_trait::async_trait; + +use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region}; +use aws_sdk_s3::error::ProvideErrorMetadata; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode}; +use aws_sdk_s3::Client; +use aws_smithy_runtime_api::client::result::SdkError; + +use super::{validate_key, BackendError, ObjectMeta, PutResult, StorageBackend}; +use crate::storage::integrity::{self, Sha256Digest}; + +/// An S3-compatible [`StorageBackend`]. +#[derive(Debug, Clone)] +pub struct S3Compat { + client: Client, + bucket: String, +} + +impl S3Compat { + /// Build a client for `bucket` at `endpoint` with explicit credentials. + /// + /// `path_style` should be `true` for MinIO-style deployments and `false` for + /// R2/AWS (virtual-hosted, the documented default, §8). + pub fn new( + endpoint: &str, + region: &str, + bucket: &str, + access_key_id: &str, + secret_access_key: &str, + path_style: bool, + ) -> Self { + // rustls + ring HTTP stack — no aws-lc / cmake build dependency. + let http_client = aws_smithy_http_client::Builder::new() + .tls_provider(aws_smithy_http_client::tls::Provider::Rustls( + aws_smithy_http_client::tls::rustls_provider::CryptoMode::Ring, + )) + .build_https(); + + let creds = Credentials::new( + access_key_id, + secret_access_key, + None, + None, + "bubbaloop-storage", + ); + + let conf = aws_sdk_s3::Config::builder() + .behavior_version(BehaviorVersion::latest()) + .region(Region::new(region.to_string())) + .endpoint_url(endpoint) + .credentials_provider(creds) + .http_client(http_client) + .force_path_style(path_style) + .build(); + + Self { + client: Client::from_conf(conf), + bucket: bucket.to_string(), + } + } +} + +#[async_trait] +impl StorageBackend for S3Compat { + async fn put( + &self, + key: &str, + bytes: &[u8], + checksum_sha256: Option<&Sha256Digest>, + ) -> Result { + validate_key(key)?; + let mut req = self + .client + .put_object() + .bucket(&self.bucket) + .key(key) + .body(ByteStream::from(bytes.to_vec())); + if let Some(digest) = checksum_sha256 { + // R2/S3 validates this server-side and returns BadDigest on mismatch. + req = req + .checksum_algorithm(ChecksumAlgorithm::Sha256) + .checksum_sha256(integrity::to_base64(digest)); + } + let resp = req.send().await.map_err(|e| map_err(key, e))?; + Ok(PutResult { + etag: resp.e_tag().map(|s| s.to_string()), + }) + } + + async fn get(&self, key: &str) -> Result, BackendError> { + validate_key(key)?; + let resp = self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .checksum_mode(ChecksumMode::Enabled) + .send() + .await + .map_err(|e| map_err(key, e))?; + let data = resp + .body + .collect() + .await + .map_err(|e| BackendError::Io { + key: key.to_string(), + detail: format!("reading body: {e}"), + })? + .into_bytes(); + Ok(data.to_vec()) + } + + async fn head(&self, key: &str) -> Result, BackendError> { + validate_key(key)?; + match self + .client + .head_object() + .bucket(&self.bucket) + .key(key) + .checksum_mode(ChecksumMode::Enabled) + .send() + .await + { + Ok(resp) => Ok(Some(ObjectMeta { + key: key.to_string(), + size_bytes: resp.content_length().unwrap_or(0).max(0) as u64, + etag: resp.e_tag().map(|s| s.to_string()), + sha256: resp.checksum_sha256().and_then(decode_b64_digest), + })), + Err(e) => { + let mapped = map_err(key, e); + if matches!(mapped, BackendError::NotFound { .. }) { + Ok(None) + } else { + Err(mapped) + } + } + } + } + + async fn list(&self, prefix: &str) -> Result, BackendError> { + let mut out = Vec::new(); + let mut continuation: Option = None; + loop { + let mut req = self + .client + .list_objects_v2() + .bucket(&self.bucket) + .prefix(prefix); + if let Some(token) = &continuation { + req = req.continuation_token(token); + } + let resp = req.send().await.map_err(|e| map_err(prefix, e))?; + for obj in resp.contents() { + if let Some(key) = obj.key() { + out.push(ObjectMeta { + key: key.to_string(), + size_bytes: obj.size().unwrap_or(0).max(0) as u64, + etag: obj.e_tag().map(|s| s.to_string()), + // ListObjectsV2 does not return checksums; HEAD does. + sha256: None, + }); + } + } + if resp.is_truncated().unwrap_or(false) { + continuation = resp.next_continuation_token().map(|s| s.to_string()); + if continuation.is_none() { + break; + } + } else { + break; + } + } + out.sort_by(|a, b| a.key.cmp(&b.key)); + Ok(out) + } + + async fn delete(&self, key: &str) -> Result<(), BackendError> { + validate_key(key)?; + // S3 DeleteObject is idempotent (204 even if the key is absent). + self.client + .delete_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + .map_err(|e| map_err(key, e))?; + Ok(()) + } +} + +/// Decode a base64 `x-amz-checksum-sha256` header into a 32-byte digest. +fn decode_b64_digest(b64: &str) -> Option { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + bytes.try_into().ok() +} + +/// Map an SDK error to a [`BackendError`], honoring the retryable/terminal split +/// (§3.4.3). Service errors are classified by their S3 error code; transport / +/// timeout / construction failures are retryable `Io`. +fn map_err(key: &str, err: SdkError) -> BackendError +where + E: ProvideErrorMetadata + std::fmt::Debug, + R: std::fmt::Debug, +{ + let code = err.code().unwrap_or_default().to_string(); + match code.as_str() { + "NoSuchBucket" => BackendError::NoSuchBucket { + bucket: key.to_string(), + }, + "AccessDenied" | "InvalidAccessKeyId" | "SignatureDoesNotMatch" | "Forbidden" => { + BackendError::Forbidden { + key: key.to_string(), + } + } + "NoSuchKey" | "NotFound" => BackendError::NotFound { + key: key.to_string(), + }, + "BadDigest" + | "InvalidDigest" + | "XAmzContentSHA256Mismatch" + | "BadChecksum" + | "InvalidChecksum" => BackendError::BadDigest { + key: key.to_string(), + expected: "server-validated checksum".to_string(), + actual: "mismatch".to_string(), + }, + // Everything else (5xx, throttling, DNS, timeouts, dispatch) is retryable. + other => BackendError::Io { + key: key.to_string(), + detail: if other.is_empty() { + format!("{err:?}") + } else { + format!("{other}: {err:?}") + }, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn b64_digest_roundtrips() { + let digest: Sha256Digest = [7u8; 32]; + let b64 = integrity::to_base64(&digest); + assert_eq!(decode_b64_digest(&b64), Some(digest)); + assert_eq!(decode_b64_digest("not base64!!!"), None); + // Wrong length decodes but fails the 32-byte conversion. + assert_eq!(decode_b64_digest("YWJj"), None); // "abc" + } + + #[test] + fn client_builds_without_panicking() { + // Construction must not require network; it just configures the client. + let _ = S3Compat::new( + "https://acct.r2.cloudflarestorage.com", + "auto", + "kornia-recordings", + "AKID", + "SECRET", + false, + ); + } +} diff --git a/crates/bubbaloop/src/storage/config.rs b/crates/bubbaloop/src/storage/config.rs index 10a85bc0..1049743a 100644 --- a/crates/bubbaloop/src/storage/config.rs +++ b/crates/bubbaloop/src/storage/config.rs @@ -7,10 +7,11 @@ //! holds the non-secret connection shape (backend kind, endpoint, region, //! bucket) plus local-store knobs. //! -//! In this slice only the `local` backend is constructible; the cloud backends -//! (`r2`/`s3`/`gcs`/`minio`) parse fine but [`StorageConfig::build_backend`] -//! returns [`ConfigError::BackendNotImplemented`] until the S3-compatible backend -//! lands. +//! The `local` backend is always available; the cloud backends +//! (`r2`/`s3`/`gcs`/`minio`) are built via [`backend::s3_compat`] when the `s3` +//! feature is enabled, reading credentials from `secrets.toml`. Without the +//! feature, [`StorageConfig::build_backend`] returns +//! [`ConfigError::BackendNotImplemented`]. use std::path::PathBuf; @@ -163,16 +164,52 @@ impl StorageConfig { } } - /// Build a live [`StorageBackend`] from this config. + /// Build a live [`StorageBackend`] from this config. Cloud backends read + /// credentials from `secrets.toml` (never from this config, §4.2) and require + /// the `s3` feature. pub fn build_backend(&self) -> Result, ConfigError> { match self.backend.as_str() { "local" => Ok(Box::new(LocalFs::new(self.local_root()?))), - "r2" | "s3" | "gcs" | "minio" => { - Err(ConfigError::BackendNotImplemented(self.backend.clone())) - } + "r2" | "s3" | "gcs" | "minio" => self.build_s3_backend(), other => Err(ConfigError::UnknownBackend(other.to_string())), } } + + #[cfg(feature = "s3")] + fn build_s3_backend(&self) -> Result, ConfigError> { + use super::backend::s3_compat::S3Compat; + use super::secrets; + + let endpoint = self + .endpoint + .as_deref() + .ok_or(ConfigError::MissingField("endpoint"))?; + let bucket = self + .bucket + .as_deref() + .ok_or(ConfigError::MissingField("bucket"))?; + let region = self.region.as_deref().unwrap_or("auto"); + + let loaded = secrets::load(secrets::default_path()?)?; + let creds = loaded.r2().ok_or(ConfigError::MissingCredentials)?; + let path_style = self.backend == "minio"; + Ok(Box::new(S3Compat::new( + endpoint, + region, + bucket, + creds.access_key_id.expose(), + creds.secret_access_key.expose(), + path_style, + ))) + } + + #[cfg(not(feature = "s3"))] + fn build_s3_backend(&self) -> Result, ConfigError> { + Err(ConfigError::BackendNotImplemented(format!( + "{} (rebuild with --features s3)", + self.backend + ))) + } } fn default_backend() -> String { @@ -194,12 +231,21 @@ pub enum ConfigError { /// Storage path could not be resolved. #[error("storage path error: {0}")] Path(#[from] StoragePathError), - /// The backend kind is recognised but not yet implemented in this build. - #[error("storage backend '{0}' is not yet implemented (only 'local' is available so far)")] + /// The backend kind is recognised but not built in (the `s3` feature is off). + #[error("storage backend '{0}' is not available in this build")] BackendNotImplemented(String), /// The backend kind is not one of r2/s3/gcs/minio/local. #[error("unknown storage backend '{0}' (expected one of: r2, s3, gcs, minio, local)")] UnknownBackend(String), + /// A required cloud-backend config field is missing. + #[error("missing required [storage] field: {0}")] + MissingField(&'static str), + /// No credentials in secrets.toml for the configured cloud backend. + #[error("no credentials in secrets.toml — run `bubbaloop storage configure` with --access-key-id/--secret-access-key")] + MissingCredentials, + /// Failed to read secrets.toml. + #[error("secrets error: {0}")] + Secrets(#[from] super::secrets::SecretsError), } #[cfg(test)] @@ -258,16 +304,15 @@ mod tests { } #[test] - fn cloud_backends_are_not_yet_implemented() { + fn cloud_backends_need_config_or_feature() { for backend in ["r2", "s3", "gcs", "minio"] { let cfg = StorageConfig { backend: backend.into(), ..Default::default() }; - assert!(matches!( - cfg.build_backend(), - Err(ConfigError::BackendNotImplemented(_)) - )); + // Without the `s3` feature: not built in. With it: a bare config is + // missing endpoint/bucket/credentials. Either way it must not build. + assert!(cfg.build_backend().is_err()); } } From 78d4645c80d8027ff5ec0bace403f5b6b8408c22 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 00:16:04 +0530 Subject: [PATCH 11/19] docs(storage): update storage CLI module doc for the full PR2 verb surface Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index 29f03f1c..0e227a93 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -10,11 +10,14 @@ //! - `download` — pull + SHA-256-verify each chunk, //! - `reconcile` — heal local/remote divergence (§3.5), //! - `rm` — delete a recording locally (and `--remote`). +//! - `profile create/list/show/rm` — manage recording profiles. +//! - `configure` — write the `[storage]` config (+ `secrets.toml`). +//! - `topics` — list live fleet topics via the node manifest queryables (Zenoh). //! -//! The recording-control verbs (`configure`, `topics`, `profile`, `record`, -//! `replay`) and the cloud backends land in later slices. Per CLAUDE.md this uses -//! `argh` (not clap) and emits all user-facing output via `log` (never -//! `println!`). +//! The cloud backend is reached via `configure --backend r2 …` (built with the +//! `s3` feature). The recording-control verbs (`record`, `replay`) land with the +//! recorder in a later PR. Per CLAUDE.md this uses `argh` (not clap) and emits +//! all user-facing output via `log` (never `println!`). use std::path::{Path, PathBuf}; From d1adc50a2b73a626942b572f6f99fa781c331f48 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 00:53:08 +0530 Subject: [PATCH 12/19] feat(storage): add replay + ring_buffer + CLI replay (PR3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the in-tree half of spec §17 PR3: the circular MCAP writer and sliding-window ring buffer (§3.3.5), and MCAP-based replay into Zenoh (§4.5). storage::ring_buffer - `RingBuffer`: pure, IO-free sliding window bounded by BOTH a time window and a byte cap (256 MiB default OOM safety net), FIFO eviction by log_time, never drops the sole sample. snapshot() is non-destructive; drain() clears. - `seal_samples`: the circular MCAP writer — materializes a snapshot into `chunks/chunk-{idx:06}-{prefix8}.mcap` with rosbag2 wire defaults (786 432-byte chunks, zstd/fast, CRC on, §3.3.7), dual timestamps, §4.5 channel metadata (zenoh.encoding / zenoh.topic / bubbaloop.schema_name), streaming SHA-256 with the prefix embedded at finalize (§3.3.6), rolling a new chunk file at the size target. Writes manifest.json atomically and returns the assembled Recording. storage::replay - `read_recording_messages`: recover messages from a recording's MCAP chunks, preserving topic/encoding (from channel metadata) and both timestamps. - `plan` (pure): filter by --topics/--exclude (zenoh key-expr intersection) and --start-time/--end-time trim, order by the selected timeline (publish_time, or log_time with --use-log-time), rate-scale offsets, apply --remap. - `ReplaySink` trait + `run_replay` driver: sleeps between messages to honor the recorded timing, loops with --loop, cancels promptly via CancellationToken. CLI: `bubbaloop storage replay ` with --rate/--loop/--start-time/ --end-time/--topics/--exclude/--remap/--use-log-time, publishing through a Zenoh-backed sink (Ctrl-C cancels; the only way to stop --loop). Deps: add `mcap = "0.24"` (§12). 19 new unit tests (eviction, byte cap, seal→read round-trip with both timestamps, planner trim/filter/remap/rate, driver ordering + loop-until-cancel). 122 storage tests pass; clippy-clean. The `mcap-recorder` marketplace-node relocation (the other half of PR3) lives in the separate bubbaloop-nodes-official repo, not present in this checkout. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 103 +++ crates/bubbaloop/Cargo.toml | 3 + crates/bubbaloop/src/cli/storage.rs | 148 +++++ crates/bubbaloop/src/storage/mod.rs | 16 +- crates/bubbaloop/src/storage/replay.rs | 614 ++++++++++++++++++ crates/bubbaloop/src/storage/ring_buffer.rs | 683 ++++++++++++++++++++ 6 files changed, 1564 insertions(+), 3 deletions(-) create mode 100644 crates/bubbaloop/src/storage/replay.rs create mode 100644 crates/bubbaloop/src/storage/ring_buffer.rs diff --git a/Cargo.lock b/Cargo.lock index 29207cad..3577c794 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -649,6 +649,36 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bimap" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" + +[[package]] +name = "binrw" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1faf7031c34c71da53eec4e070cf90c3b825729e21ca3aab51b20da4a1d1d9" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c5eb3446e2f5ea7fa9a6f2cb594648c73bf2dbc60eccf3b2fa41834e5449150" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -715,6 +745,7 @@ dependencies = [ "hex", "hostname", "log", + "mcap", "mime_guess", "notify", "open", @@ -757,6 +788,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -1389,6 +1426,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -2641,6 +2699,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "lz4_flex" version = "0.10.0" @@ -2665,6 +2742,26 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "mcap" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43908ab970f3a880b02834055a1e04221a3056f442a65ae9111f63e550e7daa5" +dependencies = [ + "bimap", + "binrw", + "byteorder", + "crc32fast", + "enumset", + "log", + "lz4", + "num_cpus", + "paste", + "static_assertions", + "thiserror 1.0.69", + "zstd", +] + [[package]] name = "md-5" version = "0.11.0" @@ -3002,6 +3099,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "parking" version = "2.2.1" diff --git a/crates/bubbaloop/Cargo.toml b/crates/bubbaloop/Cargo.toml index 827766bc..408dee4d 100644 --- a/crates/bubbaloop/Cargo.toml +++ b/crates/bubbaloop/Cargo.toml @@ -91,6 +91,9 @@ base64 = "0.22" sha2 = "0.10" zeroize = { version = "1", features = ["derive"] } async-trait = "0.1" +# Storage: MCAP container read/write (replay + ring-buffer). Matches rosbag2's +# on-disk format; zstd compression for chunks. +mcap = "0.24" # Storage: S3-compatible backend (R2/AWS/GCS/MinIO), behind the optional `s3` # feature so default builds stay lean. Routed through rustls+ring (no aws-lc / # cmake); behavior-version is set explicitly in code. diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index 0e227a93..6fffb581 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -62,6 +62,7 @@ enum StorageAction { Download(DownloadArgs), Reconcile(ReconcileArgs), Rm(RmArgs), + Replay(ReplayArgs), Profile(ProfileCommand), Configure(ConfigureArgs), Topics(TopicsArgs), @@ -145,6 +146,42 @@ struct RmArgs { machine: Option, } +/// Replay a recording back into Zenoh (ros2 bag-style). +#[derive(FromArgs)] +#[argh(subcommand, name = "replay")] +struct ReplayArgs { + /// recording name + #[argh(positional)] + name: String, + /// playback speed multiplier (default 1.0; 2.0 = twice as fast) + #[argh(option, default = "1.0")] + rate: f64, + /// loop the recording until interrupted + #[argh(switch, long = "loop")] + loop_playback: bool, + /// start offset into the recording, in seconds (skip earlier messages) + #[argh(option)] + start_time: Option, + /// end offset into the recording, in seconds (skip later messages) + #[argh(option)] + end_time: Option, + /// only replay topics intersecting these key patterns (comma-separated) + #[argh(option)] + topics: Option, + /// drop topics intersecting these key patterns (comma-separated) + #[argh(option)] + exclude: Option, + /// remap a topic on publish; repeatable, form `from:=to` + #[argh(option)] + remap: Vec, + /// schedule by recorder log_time instead of publish_time + #[argh(switch)] + use_log_time: bool, + /// zenoh router endpoint (defaults to BUBBALOOP_ZENOH_ENDPOINT or localhost) + #[argh(option)] + zenoh_endpoint: Option, +} + impl StorageCommand { /// Dispatch the chosen verb. pub async fn run(self) -> Result<()> { @@ -155,6 +192,7 @@ impl StorageCommand { StorageAction::Download(a) => run_download(a).await, StorageAction::Reconcile(a) => run_reconcile(a).await, StorageAction::Rm(a) => run_rm(a).await, + StorageAction::Replay(a) => run_replay_cmd(a).await, StorageAction::Profile(c) => run_profile(c), StorageAction::Configure(a) => run_configure(a), StorageAction::Topics(a) => run_topics(a).await, @@ -661,6 +699,116 @@ async fn run_rm(args: RmArgs) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// replay +// --------------------------------------------------------------------------- + +/// A [`ReplaySink`] that re-publishes onto a live Zenoh session, preserving the +/// recorded encoding (§4.5). +struct ZenohReplaySink { + session: std::sync::Arc, +} + +#[async_trait::async_trait] +impl storage::ReplaySink for ZenohReplaySink { + async fn publish( + &self, + topic: &str, + encoding: &str, + payload: &[u8], + ) -> std::result::Result<(), storage::ReplayError> { + self.session + .put(topic, payload.to_vec()) + .encoding(zenoh::bytes::Encoding::from(encoding)) + .await + .map_err(|e| storage::ReplayError::Publish { + topic: topic.to_string(), + detail: e.to_string(), + }) + } +} + +async fn run_replay_cmd(args: ReplayArgs) -> Result<()> { + // Load the local recording and recover its messages from the MCAP chunks. + let dir = storage::recording_dir(&args.name)?; + let recording = manifest::load_dir(&dir) + .with_context(|| format!("no recording named '{}' at {}", args.name, dir.display()))?; + let messages = storage::read_recording_messages(&dir, &recording) + .with_context(|| format!("reading MCAP chunks for '{}'", args.name))?; + if messages.is_empty() { + log::warn!("recording '{}' has no messages to replay", args.name); + return Ok(()); + } + + // `--start-time`/`--end-time` are offsets in seconds from the recording's + // first message (on the selected timeline); convert to absolute ns for the + // planner, which trims against the recorded timestamps. + let base = messages + .iter() + .map(|m| { + if args.use_log_time { + m.log_time_ns + } else { + m.publish_time_ns + } + }) + .min() + .unwrap_or(0); + let to_abs = |secs: f64| -> u64 { base.saturating_add((secs.max(0.0) * 1e9) as u64) }; + + let remap = args + .remap + .iter() + .map(|r| storage::replay::parse_remap(r)) + .collect::, _>>() + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let opts = storage::ReplayOptions { + rate: args.rate, + loop_playback: args.loop_playback, + start_time_ns: args.start_time.map(to_abs), + end_time_ns: args.end_time.map(to_abs), + topics: split_csv(args.topics.as_deref()), + exclude: split_csv(args.exclude.as_deref()), + remap, + use_log_time: args.use_log_time, + }; + + let session = crate::cli::zenoh_session::create_zenoh_session(args.zenoh_endpoint.as_deref()) + .await + .context("connecting to zenoh")?; + let sink = ZenohReplaySink { + session: session.clone(), + }; + + // Ctrl-C cancels the replay (and is the only way to stop `--loop`). + let cancel = tokio_util::sync::CancellationToken::new(); + let cancel_handle = cancel.clone(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + cancel_handle.cancel(); + } + }); + + log::info!( + "replaying '{}' ({} messages, rate {}x{}){}", + args.name, + messages.len(), + args.rate, + if args.use_log_time { ", log-time" } else { "" }, + if args.loop_playback { ", looping" } else { "" }, + ); + let stats = storage::run_replay(&messages, &opts, &sink, cancel) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + log::info!( + "replay finished: {} messages published across {} pass(es)", + stats.messages_published, + stats.loops_completed, + ); + Ok(()) +} + // --------------------------------------------------------------------------- // topics // --------------------------------------------------------------------------- diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index 2cd413e3..31fea9a2 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -13,10 +13,13 @@ //! - [`reconcile`] — the diff-matrix healing primitive (§3.5), backend-agnostic. //! - [`sync`] — the background-upload core (§3.4): backoff, dead-letter, //! per-chunk upload sequence (the async queue driver lands with the recorder). +//! - [`ring_buffer`] — the sliding in-memory window (§3.3.5) + the circular MCAP +//! writer (`seal_samples`) that materializes a snapshot into a recording. +//! - [`replay`] — read a recording's MCAP chunks and re-publish into Zenoh, +//! preserving both timestamps (§4.5). //! -//! Still to come (later PRs from the spec): `discover`, the S3Compat backend, the -//! sync queue driver, `replay`, `ring_buffer`, the `mcap-recorder` node, the -//! CLI/MCP surfaces, and the dashboard tab. +//! Still to come (later PRs from the spec): the sync queue driver, the daemon-side +//! `record` path + `mcap-recorder` node, the MCP surface, and the dashboard tab. pub mod backend; pub mod config; @@ -26,6 +29,8 @@ pub mod manifest; pub mod profile; pub mod reconcile; pub mod recording; +pub mod replay; +pub mod ring_buffer; pub mod secrets; pub mod sync; @@ -37,6 +42,11 @@ pub use config::{ConfigError, StorageConfig}; pub use profile::Profile; pub use reconcile::{reconcile, ReconcileError, ReconcileOptions, ReconcileReport}; pub use recording::{Channel, Chunk, Lifecycle, Recording, RecordingMode, Selection, Trigger}; +pub use replay::{ + plan as plan_replay, read_recording_messages, run_replay, ReplayError, ReplayMessage, + ReplayOptions, ReplaySink, ReplayStats, +}; +pub use ring_buffer::{seal_samples, McapWriteConfig, RingBuffer, RingBufferError, Sample}; pub use sync::{ classify, BackoffSchedule, DeadLetterEntry, DeadLetterList, ErrorClass, SharedBackoff, SyncError, UploadJob, UploadOutcome, diff --git a/crates/bubbaloop/src/storage/replay.rs b/crates/bubbaloop/src/storage/replay.rs new file mode 100644 index 00000000..48e6a3d6 --- /dev/null +++ b/crates/bubbaloop/src/storage/replay.rs @@ -0,0 +1,614 @@ +//! Replay — read a recording's MCAP chunks and re-publish into Zenoh (spec §4.5, +//! CLI §5 `storage replay`). +//! +//! Replay mirrors `ros2 bag play`: `--rate`, `--loop`, `--start-time`/`--end-time` +//! trim, `--topics`/`--exclude` filtering, `--remap from:=to`, and a choice of +//! timeline (`publish_time` by default for fidelity, `--use-log-time` for +//! recorder-local ordering). The original Zenoh encoding is recovered from the +//! MCAP channel metadata (`zenoh.encoding`, §4.5) so re-published bytes are +//! byte-identical to what was recorded. +//! +//! The module is split so the time-and-filter logic is pure and unit-testable +//! without a live router: +//! - [`read_recording_messages`] turns a recording's chunk files into +//! [`ReplayMessage`]s (filesystem only, no Zenoh). +//! - [`plan`] applies the [`ReplayOptions`] (trim / filter / remap / rate) and +//! returns a [`PlannedMessage`] schedule with wall-clock offsets — pure. +//! - [`run_replay`] drives that schedule against any [`ReplaySink`], sleeping +//! between messages; the CLI supplies a Zenoh-backed sink. + +use std::path::Path; + +use async_trait::async_trait; +use tokio_util::sync::CancellationToken; + +use crate::storage::recording::Recording; +use crate::storage::ring_buffer::{META_ZENOH_ENCODING, META_ZENOH_TOPIC}; + +/// One message recovered from a recording, ready to be filtered and replayed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReplayMessage { + /// The topic to re-publish on (the recorded Zenoh key). + pub topic: String, + /// The Zenoh encoding string to set on the re-published sample (§4.5). + pub zenoh_encoding: String, + /// Publish-time from the recording (ns). + pub publish_time_ns: u64, + /// Recorder-clock log-time from the recording (ns). + pub log_time_ns: u64, + /// Raw payload bytes. + pub data: Vec, +} + +impl ReplayMessage { + /// The timestamp this message is ordered/scheduled by, per `use_log_time`. + fn timeline(&self, use_log_time: bool) -> u64 { + if use_log_time { + self.log_time_ns + } else { + self.publish_time_ns + } + } +} + +/// A message scheduled for replay: the resolved (possibly remapped) topic, the +/// encoding, the payload, and the wall-clock delay from playback start. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedMessage { + /// Topic to publish on (after `--remap`). + pub topic: String, + /// Zenoh encoding to set. + pub zenoh_encoding: String, + /// Payload bytes. + pub data: Vec, + /// Delay from playback start, after `--rate` scaling (ns). + pub offset_ns: u64, +} + +/// Options controlling a replay (mirror the CLI flags, §5). +#[derive(Debug, Clone)] +pub struct ReplayOptions { + /// Playback speed multiplier (`--rate`). `1.0` = original rate; `2.0` = 2×. + pub rate: f64, + /// Repeat indefinitely (`--loop`). + pub loop_playback: bool, + /// Drop messages with a timeline timestamp below this (`--start-time`, ns). + pub start_time_ns: Option, + /// Drop messages with a timeline timestamp above this (`--end-time`, ns). + pub end_time_ns: Option, + /// Include only topics intersecting one of these key patterns (empty = all). + pub topics: Vec, + /// Drop topics intersecting one of these key patterns (`--exclude`). + pub exclude: Vec, + /// Topic remaps applied to the published key (`--remap from:=to`). + pub remap: Vec<(String, String)>, + /// Order/schedule by `log_time` instead of `publish_time` (`--use-log-time`). + pub use_log_time: bool, +} + +impl Default for ReplayOptions { + fn default() -> Self { + Self { + rate: 1.0, + loop_playback: false, + start_time_ns: None, + end_time_ns: None, + topics: Vec::new(), + exclude: Vec::new(), + remap: Vec::new(), + use_log_time: false, + } + } +} + +/// Errors from planning or driving a replay. +#[derive(Debug, thiserror::Error)] +pub enum ReplayError { + /// A `--topics`/`--exclude` pattern or a recorded topic was not a valid key. + #[error("invalid key expression {pattern:?}: {detail}")] + InvalidPattern { pattern: String, detail: String }, + /// `--rate` was zero or negative. + #[error("replay rate must be > 0 (got {0})")] + InvalidRate(f64), + /// A `--remap` argument was not of the form `from:=to`. + #[error("invalid remap {0:?}: expected the form from:=to")] + InvalidRemap(String), + /// Reading a chunk file failed. + #[error("io error reading {path}: {detail}")] + Io { path: String, detail: String }, + /// An MCAP chunk could not be parsed. + #[error("mcap read error in {path}: {detail}")] + Mcap { path: String, detail: String }, + /// The replay sink failed to publish. + #[error("publish failed for {topic}: {detail}")] + Publish { topic: String, detail: String }, +} + +/// Parse a single `from:=to` remap argument (ros2 bag style). +pub fn parse_remap(arg: &str) -> Result<(String, String), ReplayError> { + let (from, to) = arg + .split_once(":=") + .ok_or_else(|| ReplayError::InvalidRemap(arg.to_string()))?; + if from.is_empty() || to.is_empty() { + return Err(ReplayError::InvalidRemap(arg.to_string())); + } + Ok((from.to_string(), to.to_string())) +} + +/// Whether `topic` is selected by `patterns` (any pattern whose key expression +/// intersects the concrete topic key). An empty pattern list matches everything. +fn matches_any(topic: &str, patterns: &[String]) -> Result { + if patterns.is_empty() { + return Ok(true); + } + let topic_ke = + zenoh::key_expr::KeyExpr::try_from(topic).map_err(|e| ReplayError::InvalidPattern { + pattern: topic.to_string(), + detail: e.to_string(), + })?; + for pat in patterns { + let pat_ke = zenoh::key_expr::KeyExpr::try_from(pat.as_str()).map_err(|e| { + ReplayError::InvalidPattern { + pattern: pat.clone(), + detail: e.to_string(), + } + })?; + if pat_ke.intersects(&topic_ke) { + return Ok(true); + } + } + Ok(false) +} + +/// Apply remaps to a topic (first matching `from` wins). Remaps match the full +/// recorded key exactly; non-matching topics pass through unchanged. +fn apply_remap(topic: &str, remap: &[(String, String)]) -> String { + for (from, to) in remap { + if from == topic { + return to.clone(); + } + } + topic.to_string() +} + +/// Build the replay schedule from recovered messages and options (pure). +/// +/// Steps: filter by `--topics`/`--exclude` and the `--start-time`/`--end-time` +/// trim, sort by the selected timeline, then compute each message's wall-clock +/// offset from the first surviving message scaled by `--rate`, and apply +/// `--remap`. The result is ordered and ready for [`run_replay`]. +pub fn plan( + messages: &[ReplayMessage], + opts: &ReplayOptions, +) -> Result, ReplayError> { + if !opts.rate.is_finite() || opts.rate <= 0.0 { + return Err(ReplayError::InvalidRate(opts.rate)); + } + + // Filter: topic include/exclude and the time-window trim. + let mut kept: Vec<&ReplayMessage> = Vec::new(); + for m in messages { + let ts = m.timeline(opts.use_log_time); + if let Some(start) = opts.start_time_ns { + if ts < start { + continue; + } + } + if let Some(end) = opts.end_time_ns { + if ts > end { + continue; + } + } + if !matches_any(&m.topic, &opts.topics)? { + continue; + } + if !opts.exclude.is_empty() && matches_any(&m.topic, &opts.exclude)? { + continue; + } + kept.push(m); + } + + // Stable sort by the selected timeline so equal timestamps preserve order. + kept.sort_by_key(|m| m.timeline(opts.use_log_time)); + + let Some(base) = kept.first().map(|m| m.timeline(opts.use_log_time)) else { + return Ok(Vec::new()); + }; + + let planned = kept + .into_iter() + .map(|m| { + let raw = m.timeline(opts.use_log_time).saturating_sub(base); + // Scale by rate: higher rate → smaller delay. + let offset_ns = (raw as f64 / opts.rate) as u64; + PlannedMessage { + topic: apply_remap(&m.topic, &opts.remap), + zenoh_encoding: m.zenoh_encoding.clone(), + data: m.data.clone(), + offset_ns, + } + }) + .collect(); + Ok(planned) +} + +/// Read all messages from a recording's chunk files, in chunk-index order. +/// +/// Topic and encoding are recovered from MCAP channel metadata (`zenoh.topic` / +/// `zenoh.encoding`, §4.5), falling back to the MCAP channel `topic` / +/// `message_encoding` when a recording predates those metadata keys. +pub fn read_recording_messages( + recording_dir: &Path, + recording: &Recording, +) -> Result, ReplayError> { + let chunks_dir = recording_dir.join("chunks"); + let mut out = Vec::new(); + for chunk in &recording.chunks { + let path = chunks_dir.join(&chunk.name); + let bytes = std::fs::read(&path).map_err(|e| ReplayError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + let stream = mcap::MessageStream::new(&bytes).map_err(|e| ReplayError::Mcap { + path: path.display().to_string(), + detail: e.to_string(), + })?; + for msg in stream { + let msg = msg.map_err(|e| ReplayError::Mcap { + path: path.display().to_string(), + detail: e.to_string(), + })?; + let topic = msg + .channel + .metadata + .get(META_ZENOH_TOPIC) + .cloned() + .unwrap_or_else(|| msg.channel.topic.clone()); + let zenoh_encoding = msg + .channel + .metadata + .get(META_ZENOH_ENCODING) + .cloned() + .unwrap_or_else(|| msg.channel.message_encoding.clone()); + out.push(ReplayMessage { + topic, + zenoh_encoding, + publish_time_ns: msg.publish_time, + log_time_ns: msg.log_time, + data: msg.data.into_owned(), + }); + } + } + Ok(out) +} + +/// A destination for replayed samples. The CLI implements this over a Zenoh +/// session; tests implement it over an in-memory buffer. +#[async_trait] +pub trait ReplaySink { + /// Publish `payload` on `topic` with the given Zenoh `encoding`. + async fn publish(&self, topic: &str, encoding: &str, payload: &[u8]) + -> Result<(), ReplayError>; +} + +/// Outcome of a replay run. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ReplayStats { + /// Total messages published across all passes. + pub messages_published: u64, + /// Number of full passes completed (a cancelled partial pass is not counted). + pub loops_completed: u64, +} + +/// Drive a planned schedule against a sink, sleeping between messages to honor +/// the recorded timing (already rate-scaled in [`plan`]). Runs once, or forever +/// when `opts.loop_playback`, until `cancel` fires. Cancellation is checked +/// before each sleep and after each publish, so a long inter-message gap or an +/// infinite loop both stop promptly. +pub async fn run_replay( + messages: &[ReplayMessage], + opts: &ReplayOptions, + sink: &S, + cancel: CancellationToken, +) -> Result { + let schedule = plan(messages, opts)?; + let mut stats = ReplayStats::default(); + if schedule.is_empty() { + return Ok(stats); + } + + loop { + let start = tokio::time::Instant::now(); + for pm in &schedule { + if cancel.is_cancelled() { + return Ok(stats); + } + let deadline = start + std::time::Duration::from_nanos(pm.offset_ns); + tokio::select! { + _ = cancel.cancelled() => return Ok(stats), + _ = tokio::time::sleep_until(deadline) => {} + } + sink.publish(&pm.topic, &pm.zenoh_encoding, &pm.data) + .await?; + stats.messages_published += 1; + } + stats.loops_completed += 1; + if !opts.loop_playback || cancel.is_cancelled() { + return Ok(stats); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn msg(topic: &str, pub_ns: u64, log_ns: u64, data: &[u8]) -> ReplayMessage { + ReplayMessage { + topic: topic.to_string(), + zenoh_encoding: "application/cbor".to_string(), + publish_time_ns: pub_ns, + log_time_ns: log_ns, + data: data.to_vec(), + } + } + + #[test] + fn parse_remap_forms() { + assert_eq!( + parse_remap("a/b:=c/d").unwrap(), + ("a/b".to_string(), "c/d".to_string()) + ); + assert!(parse_remap("noseparator").is_err()); + assert!(parse_remap(":=to").is_err()); + assert!(parse_remap("from:=").is_err()); + } + + #[test] + fn plan_orders_and_offsets_by_publish_time() { + let msgs = vec![ + msg("t/a", 2000, 9999, b"second"), + msg("t/a", 1000, 8888, b"first"), + msg("t/a", 4000, 7777, b"third"), + ]; + let planned = plan(&msgs, &ReplayOptions::default()).unwrap(); + assert_eq!(planned.len(), 3); + // Sorted by publish_time; offsets relative to the earliest (1000). + assert_eq!(&planned[0].data, b"first"); + assert_eq!(planned[0].offset_ns, 0); + assert_eq!(&planned[1].data, b"second"); + assert_eq!(planned[1].offset_ns, 1000); + assert_eq!(&planned[2].data, b"third"); + assert_eq!(planned[2].offset_ns, 3000); + } + + #[test] + fn rate_scales_offsets() { + let msgs = vec![msg("t", 0, 0, b"a"), msg("t", 1000, 0, b"b")]; + let opts = ReplayOptions { + rate: 2.0, + ..ReplayOptions::default() + }; + let planned = plan(&msgs, &opts).unwrap(); + assert_eq!(planned[1].offset_ns, 500); // 1000ns / 2x + } + + #[test] + fn use_log_time_changes_ordering() { + // publish_time and log_time disagree on order. + let msgs = vec![ + msg("t", 100, 200, b"pub_first_log_second"), + msg("t", 300, 100, b"pub_second_log_first"), + ]; + let opts = ReplayOptions { + use_log_time: true, + ..ReplayOptions::default() + }; + let planned = plan(&msgs, &opts).unwrap(); + assert_eq!(&planned[0].data, b"pub_second_log_first"); // log_time 100 first + assert_eq!(&planned[1].data, b"pub_first_log_second"); + } + + #[test] + fn start_end_trim_by_timeline() { + let msgs = vec![ + msg("t", 100, 0, b"a"), + msg("t", 200, 0, b"b"), + msg("t", 300, 0, b"c"), + msg("t", 400, 0, b"d"), + ]; + let opts = ReplayOptions { + start_time_ns: Some(200), + end_time_ns: Some(300), + ..ReplayOptions::default() + }; + let planned = plan(&msgs, &opts).unwrap(); + let datas: Vec<&[u8]> = planned.iter().map(|p| p.data.as_slice()).collect(); + assert_eq!(datas, vec![b"b".as_slice(), b"c".as_slice()]); + } + + #[test] + fn topic_include_and_exclude_filter() { + let msgs = vec![ + msg("bubbaloop/global/m/cam/compressed", 1, 0, b"cam"), + msg("bubbaloop/global/m/imu/data", 2, 0, b"imu"), + msg("bubbaloop/global/m/cam/health", 3, 0, b"health"), + ]; + let opts = ReplayOptions { + topics: vec!["bubbaloop/global/m/cam/**".to_string()], + exclude: vec!["**/health".to_string()], + ..ReplayOptions::default() + }; + let planned = plan(&msgs, &opts).unwrap(); + assert_eq!(planned.len(), 1); + assert_eq!(&planned[0].data, b"cam"); + } + + #[test] + fn remap_rewrites_topic() { + let msgs = vec![msg("old/topic", 0, 0, b"x")]; + let opts = ReplayOptions { + remap: vec![("old/topic".to_string(), "new/topic".to_string())], + ..ReplayOptions::default() + }; + let planned = plan(&msgs, &opts).unwrap(); + assert_eq!(planned[0].topic, "new/topic"); + } + + #[test] + fn zero_rate_is_rejected() { + let msgs = vec![msg("t", 0, 0, b"x")]; + let opts = ReplayOptions { + rate: 0.0, + ..ReplayOptions::default() + }; + assert!(matches!( + plan(&msgs, &opts), + Err(ReplayError::InvalidRate(_)) + )); + } + + #[derive(Default)] + struct MockSink { + published: Mutex)>>, + cancel_after: Option<(usize, CancellationToken)>, + } + + #[async_trait] + impl ReplaySink for MockSink { + async fn publish( + &self, + topic: &str, + encoding: &str, + payload: &[u8], + ) -> Result<(), ReplayError> { + let mut g = self.published.lock().unwrap(); + g.push((topic.to_string(), encoding.to_string(), payload.to_vec())); + if let Some((n, token)) = &self.cancel_after { + if g.len() >= *n { + token.cancel(); + } + } + Ok(()) + } + } + + #[tokio::test] + async fn run_replay_publishes_in_order() { + let msgs = vec![ + msg("t/a", 1000, 0, b"first"), + msg("t/b", 3000, 0, b"second"), + ]; + let sink = MockSink::default(); + let stats = run_replay( + &msgs, + &ReplayOptions::default(), + &sink, + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(stats.messages_published, 2); + assert_eq!(stats.loops_completed, 1); + let g = sink.published.lock().unwrap(); + assert_eq!(g[0].0, "t/a"); + assert_eq!(&g[0].2, b"first"); + assert_eq!(g[1].0, "t/b"); + assert_eq!(&g[1].2, b"second"); + } + + #[tokio::test] + async fn run_replay_loops_until_cancelled() { + let msgs = vec![msg("t", 0, 0, b"x"), msg("t", 100, 0, b"y")]; + let token = CancellationToken::new(); + let sink = MockSink { + published: Mutex::new(Vec::new()), + // Cancel once 5 messages have gone out (mid-third pass). + cancel_after: Some((5, token.clone())), + }; + let opts = ReplayOptions { + loop_playback: true, + ..ReplayOptions::default() + }; + let stats = run_replay(&msgs, &opts, &sink, token).await.unwrap(); + assert_eq!(stats.messages_published, 5); + // Two full passes (4 msgs) completed; the third was cancelled mid-way. + assert_eq!(stats.loops_completed, 2); + } + + #[tokio::test] + async fn run_replay_empty_schedule_is_noop() { + let sink = MockSink::default(); + let stats = run_replay( + &[], + &ReplayOptions::default(), + &sink, + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(stats, ReplayStats::default()); + } + + #[test] + fn read_recording_roundtrips_through_real_mcap() { + use crate::storage::recording::{RecordingMode, Selection}; + use crate::storage::ring_buffer::{seal_samples, McapWriteConfig, Sample}; + + // Seal real samples into an on-disk MCAP recording, then read them back + // — proving the §4.5 metadata + dual timestamps survive the container. + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec"); + let samples = vec![ + Sample { + topic: "bubbaloop/global/m/cam/compressed".to_string(), + message_encoding: "protobuf".to_string(), + zenoh_encoding: "application/protobuf".to_string(), + schema_name: Some("bubbaloop.camera.v1.CompressedImage".to_string()), + publish_time_ns: 1000, + log_time_ns: 1100, + data: vec![9, 8, 7, 6], + }, + Sample { + topic: "bubbaloop/global/m/imu/data".to_string(), + message_encoding: "cbor".to_string(), + zenoh_encoding: "application/cbor".to_string(), + schema_name: None, + publish_time_ns: 2000, + log_time_ns: 2100, + data: vec![1, 2], + }, + ]; + let recording = seal_samples( + &rec_dir, + "rec", + "m", + RecordingMode::Streaming, + None, + Selection::default(), + &samples, + &McapWriteConfig::default(), + 0, + ) + .unwrap(); + + let mut recovered = read_recording_messages(&rec_dir, &recording).unwrap(); + recovered.sort_by_key(|m| m.publish_time_ns); + assert_eq!(recovered.len(), 2); + + assert_eq!(recovered[0].topic, "bubbaloop/global/m/cam/compressed"); + assert_eq!(recovered[0].zenoh_encoding, "application/protobuf"); + assert_eq!(recovered[0].publish_time_ns, 1000); + assert_eq!(recovered[0].log_time_ns, 1100); + assert_eq!(&recovered[0].data, &[9, 8, 7, 6]); + + assert_eq!(recovered[1].zenoh_encoding, "application/cbor"); + assert_eq!(recovered[1].log_time_ns, 2100); + + // And the recovered messages plan into a 1000ns-spaced schedule. + let planned = plan(&recovered, &ReplayOptions::default()).unwrap(); + assert_eq!(planned[0].offset_ns, 0); + assert_eq!(planned[1].offset_ns, 1000); + } +} diff --git a/crates/bubbaloop/src/storage/ring_buffer.rs b/crates/bubbaloop/src/storage/ring_buffer.rs new file mode 100644 index 00000000..319bfa8a --- /dev/null +++ b/crates/bubbaloop/src/storage/ring_buffer.rs @@ -0,0 +1,683 @@ +//! Ring-buffer capture + the circular MCAP writer (spec §3.3.5, §3.2). +//! +//! Ring-buffer mode is the v1 differentiator: the recorder keeps the most recent +//! samples in a bounded in-memory ring and, on `flush`, seals the current +//! contents into a brand-new finalized recording (chunks + `manifest.json`) while +//! the ring keeps running. This module provides two cooperating pieces: +//! +//! - [`RingBuffer`] — a pure, IO-free sliding window bounded by **both** a time +//! window (`window_secs`) and a byte cap (`ring_max_bytes`, the OOM safety +//! net, §3.3.5). Eviction is FIFO by arrival (`log_time`). +//! - [`seal_samples`] — the circular MCAP writer: takes a snapshot of samples and +//! writes them to `{dir}/chunks/chunk-{idx:06}-{prefix8}.mcap` with rosbag2 wire +//! defaults (§3.3.7), computing the SHA-256 streaming during the write and +//! embedding the prefix in the filename (§3.3.6), then returns the assembled +//! [`Recording`] manifest for the caller to persist. +//! +//! Both halves are deliberately clock-free and filesystem-narrow so they unit +//! test without Zenoh or a live recorder; the daemon-side `record` path (a later +//! PR) wires sample ingestion and the `{name}-flush-{ISO}` directory naming on +//! top. + +use std::collections::{BTreeMap, VecDeque}; +use std::io::Cursor; +use std::path::Path; + +use mcap::{Channel, Compression, Message, WriteOptions}; + +use crate::storage::integrity; +use crate::storage::profile::{CompressionKind, CompressionLevel, DEFAULT_CHUNK_SIZE_BYTES}; +use crate::storage::recording::{ + Channel as ManifestChannel, Chunk, Recording, RecordingMode, Selection, +}; + +/// Default ring byte cap (§3.3.5): 256 MiB. The window is the primary bound; this +/// is the OOM safety net that evicts even inside the window if memory balloons. +pub const DEFAULT_RING_MAX_BYTES: u64 = 256 * 1024 * 1024; + +/// MCAP channel metadata keys carrying replay fidelity (spec §4.5). +pub const META_ZENOH_ENCODING: &str = "zenoh.encoding"; +pub const META_ZENOH_TOPIC: &str = "zenoh.topic"; +pub const META_SCHEMA_NAME: &str = "bubbaloop.schema_name"; + +/// One captured sample destined for an MCAP recording. +/// +/// Carries both timestamps (§3.3.7 dual timestamps) and the original Zenoh +/// encoding so [`replay`](crate::storage::replay) can be byte-identical (§4.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sample { + /// Full Zenoh topic key the sample was published on. + pub topic: String, + /// MCAP `message_encoding` (`cbor` / `protobuf` / `raw`). + pub message_encoding: String, + /// Original Zenoh sample encoding (e.g. `application/cbor`), stored in + /// channel metadata for byte-identical replay. + pub zenoh_encoding: String, + /// Fully-qualified protobuf schema name, when known. + pub schema_name: Option, + /// Publish-time from the Zenoh sample timestamp / message header (ns). + pub publish_time_ns: u64, + /// Recorder local-clock receipt time (ns) — drives windowing and ordering. + pub log_time_ns: u64, + /// The raw payload bytes (recorded verbatim). + pub data: Vec, +} + +impl Sample { + /// Bytes this sample contributes to the ring's accounting. The payload + /// dominates; the small per-sample overhead is ignored (the byte cap is a + /// safety net, not an exact allocator accounting). + fn ring_bytes(&self) -> u64 { + self.data.len() as u64 + } +} + +/// A bounded in-memory sliding window of the most recent samples (§3.3.5). +/// +/// Bounded by **both** a time window and a byte cap: a sample is evicted (FIFO) +/// when it is older than `window_secs` relative to the newest sample **or** when +/// the total retained bytes exceed `ring_max_bytes`. Purely in-memory — there is +/// no disk spillover in v1 (§3.3.5). +#[derive(Debug)] +pub struct RingBuffer { + window_ns: u64, + max_bytes: u64, + samples: VecDeque, + cur_bytes: u64, +} + +impl RingBuffer { + /// Create a ring with a time window (seconds) and a byte cap. + /// + /// `window_secs == 0` means "byte-cap only" (no time eviction); `max_bytes` + /// of 0 is clamped to [`DEFAULT_RING_MAX_BYTES`] so the ring can never be + /// configured to drop every sample immediately. + pub fn new(window_secs: u64, max_bytes: u64) -> Self { + Self { + window_ns: window_secs.saturating_mul(1_000_000_000), + max_bytes: if max_bytes == 0 { + DEFAULT_RING_MAX_BYTES + } else { + max_bytes + }, + samples: VecDeque::new(), + cur_bytes: 0, + } + } + + /// Append a sample and evict anything that falls outside the window or pushes + /// the ring over its byte cap. + pub fn push(&mut self, sample: Sample) { + let newest = sample.log_time_ns; + self.cur_bytes += sample.ring_bytes(); + self.samples.push_back(sample); + self.evict(newest); + } + + /// Evict from the front while the oldest sample is older than the window + /// (relative to `newest_ns`) or while the ring is over its byte cap. Always + /// keeps at least the newest sample (a single sample larger than the cap is + /// retained rather than dropping the very thing just captured). + fn evict(&mut self, newest_ns: u64) { + while self.samples.len() > 1 { + let front = &self.samples[0]; + let too_old = + self.window_ns != 0 && newest_ns.saturating_sub(front.log_time_ns) > self.window_ns; + let over_bytes = self.cur_bytes > self.max_bytes; + if !(too_old || over_bytes) { + break; + } + let evicted = self.samples.pop_front().expect("len > 1"); + self.cur_bytes -= evicted.ring_bytes(); + } + } + + /// Number of samples currently retained. + pub fn len(&self) -> usize { + self.samples.len() + } + + /// Whether the ring holds no samples. + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } + + /// Total retained payload bytes. + pub fn byte_len(&self) -> u64 { + self.cur_bytes + } + + /// Snapshot the current contents without disturbing the ring (§3.3.5 flush + /// step a — retained samples may belong to the next window too). + pub fn snapshot(&self) -> Vec { + self.samples.iter().cloned().collect() + } + + /// Snapshot the current contents and clear the ring. Use this for a flush + /// that should *not* retain samples into the next window. + pub fn drain(&mut self) -> Vec { + self.cur_bytes = 0; + self.samples.drain(..).collect() + } +} + +/// MCAP wire configuration for a sealed recording (spec §3.3.7 — defaults match +/// rosbag2 verbatim: 786 432-byte chunks, zstd, fast level, CRC on). +#[derive(Debug, Clone, Copy)] +pub struct McapWriteConfig { + /// Target size of one on-disk chunk file before rolling to the next. + pub chunk_size_bytes: u32, + /// Chunk compression codec. + pub compression: CompressionKind, + /// Compression level. + pub compression_level: CompressionLevel, + /// Whether to compute the per-chunk CRC inside the MCAP container. + pub chunk_crc: bool, +} + +impl Default for McapWriteConfig { + fn default() -> Self { + Self { + chunk_size_bytes: DEFAULT_CHUNK_SIZE_BYTES, + compression: CompressionKind::Zstd, + compression_level: CompressionLevel::Fast, + chunk_crc: true, + } + } +} + +impl McapWriteConfig { + /// The mcap-crate compression codec for this config (`None` = uncompressed). + fn mcap_compression(&self) -> Option { + match self.compression { + CompressionKind::Zstd => Some(Compression::Zstd), + CompressionKind::Lz4 => Some(Compression::Lz4), + CompressionKind::None => None, + } + } + + /// The mcap-crate numeric compression level for this config. + fn mcap_level(&self) -> u32 { + match self.compression_level { + CompressionLevel::Fast => 1, + CompressionLevel::Default => 3, + CompressionLevel::High => 19, + } + } +} + +/// Errors from the circular MCAP writer. +#[derive(Debug, thiserror::Error)] +pub enum RingBufferError { + /// Filesystem error creating directories or writing a chunk file. + #[error("io error at {path}: {detail}")] + Io { path: String, detail: String }, + /// The underlying MCAP writer failed. + #[error("mcap write error: {0}")] + Mcap(String), + /// The assembled manifest failed validation before save. + #[error("manifest error: {0}")] + Manifest(#[from] crate::storage::manifest::ManifestError), +} + +/// A chunk file being assembled in memory: the MCAP writer over an in-memory +/// buffer, the accumulated uncompressed payload bytes (for size-based rolling), +/// and the running min/max `log_time` of messages written to it. +type PendingChunk = (mcap::Writer>>, u64, Option, Option); + +/// In-progress accounting for one channel while sealing. +struct ChannelAccum { + channel: std::sync::Arc>, + sequence: u32, + message_count: u64, + publish_first: Option, + publish_last: Option, +} + +/// Seal a snapshot of samples into a finalized recording at `recording_dir` +/// (spec §3.3.5 flush step b; §3.3.6/§3.3.7 for the wire format). +/// +/// Writes one or more `chunks/chunk-{idx:06}-{prefix8}.mcap` files (rolling to a +/// new file once the accumulated payload exceeds `cfg.chunk_size_bytes`), +/// computes each chunk's SHA-256 during the write, persists `manifest.json` +/// atomically, and returns the assembled [`Recording`]. Samples are written in +/// the order given (the ring already holds them in arrival order); MCAP message +/// headers carry both `publish_time` and `log_time` (§3.3.7). +/// +/// `now_ns` is the caller's clock reading used as `ended_at_ns` when the snapshot +/// is empty of timestamps; with samples present the recorder-clock bounds come +/// from the samples themselves. +#[allow(clippy::too_many_arguments)] +pub fn seal_samples( + recording_dir: &Path, + name: &str, + machine_id: &str, + mode: RecordingMode, + window_secs: Option, + selection: Selection, + samples: &[Sample], + cfg: &McapWriteConfig, + now_ns: u64, +) -> Result { + let chunks_dir = recording_dir.join("chunks"); + std::fs::create_dir_all(&chunks_dir).map_err(|e| RingBufferError::Io { + path: chunks_dir.display().to_string(), + detail: e.to_string(), + })?; + + // Stable channel ids in first-seen order, shared across every chunk file. + let mut channel_ids: BTreeMap = BTreeMap::new(); + let mut channels: Vec = Vec::new(); + + // started_at = earliest log_time across the snapshot (or the caller's clock + // when the snapshot is empty). + let started_at = samples + .iter() + .map(|s| s.log_time_ns) + .min() + .unwrap_or(now_ns); + let mut manifest = Recording::new(name, machine_id, started_at); + manifest.mode = mode; + manifest.window_secs = window_secs; + manifest.selection = selection; + + let mut chunk_entries: Vec = Vec::new(); + let mut total_bytes: u64 = 0; + + // A pending chunk writer over an in-memory buffer; flushed to disk on roll. + let mut writer_state: Option = None; + + let target = cfg.chunk_size_bytes as u64; + + // Helper to finalize the active chunk writer to a canonical on-disk file. + let finalize_chunk = |writer_state: &mut Option, + chunk_entries: &mut Vec, + total_bytes: &mut u64| + -> Result<(), RingBufferError> { + let Some((mut writer, _accum, log_first, log_last)) = writer_state.take() else { + return Ok(()); + }; + writer + .finish() + .map_err(|e| RingBufferError::Mcap(e.to_string()))?; + let buf = writer.into_inner().into_inner(); + let digest = integrity::sha256(&buf); + let hex = integrity::to_hex(&digest); + let index = chunk_entries.len() as u32; + let fname = Chunk::canonical_name(index, &hex); + let path = chunks_dir.join(&fname); + atomic_write(&path, &buf)?; + *total_bytes += buf.len() as u64; + chunk_entries.push(Chunk { + name: fname, + index, + size_bytes: buf.len() as u64, + sha256: hex, + log_time_first_ns: log_first, + log_time_last_ns: log_last, + uploaded_at_ns: None, + remote_etag: None, + extra: Default::default(), + }); + Ok(()) + }; + + for sample in samples { + // Register the channel on first sight (stable id across chunk files). + if !channel_ids.contains_key(&sample.topic) { + let id = channels.len() as u16; + channel_ids.insert(sample.topic.clone(), id); + let mut metadata = BTreeMap::new(); + metadata.insert( + META_ZENOH_ENCODING.to_string(), + sample.zenoh_encoding.clone(), + ); + metadata.insert(META_ZENOH_TOPIC.to_string(), sample.topic.clone()); + if let Some(schema) = &sample.schema_name { + metadata.insert(META_SCHEMA_NAME.to_string(), schema.clone()); + } + channels.push(ChannelAccum { + channel: std::sync::Arc::new(Channel { + id, + topic: sample.topic.clone(), + schema: None, + message_encoding: sample.message_encoding.clone(), + metadata, + }), + sequence: 0, + message_count: 0, + publish_first: None, + publish_last: None, + }); + } + let cid = channel_ids[&sample.topic]; + let accum = &mut channels[cid as usize]; + + // Roll to a new chunk file once the accumulated payload exceeds target. + if let Some((_, ref bytes, _, _)) = writer_state { + if *bytes >= target && target > 0 { + finalize_chunk(&mut writer_state, &mut chunk_entries, &mut total_bytes)?; + } + } + if writer_state.is_none() { + let mut opts = WriteOptions::new() + .chunk_size(Some(target.max(1))) + .compression(cfg.mcap_compression()) + .calculate_chunk_crcs(cfg.chunk_crc); + if cfg.mcap_compression().is_some() { + opts = opts.compression_level(cfg.mcap_level()); + } + let writer = opts + .create(Cursor::new(Vec::new())) + .map_err(|e| RingBufferError::Mcap(e.to_string()))?; + writer_state = Some((writer, 0, None, None)); + } + + let (writer, acc_bytes, log_first, log_last) = writer_state.as_mut().expect("just set"); + let msg = Message { + channel: accum.channel.clone(), + sequence: accum.sequence, + log_time: sample.log_time_ns, + publish_time: sample.publish_time_ns, + data: std::borrow::Cow::Borrowed(&sample.data), + }; + writer + .write(&msg) + .map_err(|e| RingBufferError::Mcap(e.to_string()))?; + + accum.sequence = accum.sequence.wrapping_add(1); + accum.message_count += 1; + accum.publish_first = Some( + accum + .publish_first + .map_or(sample.publish_time_ns, |v| v.min(sample.publish_time_ns)), + ); + accum.publish_last = Some( + accum + .publish_last + .map_or(sample.publish_time_ns, |v| v.max(sample.publish_time_ns)), + ); + *acc_bytes += sample.data.len() as u64; + *log_first = Some(log_first.map_or(sample.log_time_ns, |v| v.min(sample.log_time_ns))); + *log_last = Some(log_last.map_or(sample.log_time_ns, |v| v.max(sample.log_time_ns))); + } + + finalize_chunk(&mut writer_state, &mut chunk_entries, &mut total_bytes)?; + + // Assemble the manifest channels in id order. + manifest.channels = channels + .iter() + .enumerate() + .map(|(id, acc)| ManifestChannel { + topic: acc.channel.topic.clone(), + channel_id: id as u16, + message_count: acc.message_count, + message_encoding: acc.channel.message_encoding.clone(), + zenoh_encoding: acc + .channel + .metadata + .get(META_ZENOH_ENCODING) + .cloned() + .unwrap_or_default(), + schema_name: acc.channel.metadata.get(META_SCHEMA_NAME).cloned(), + publish_time_first_ns: acc.publish_first, + publish_time_last_ns: acc.publish_last, + extra: Default::default(), + }) + .collect(); + manifest.chunks = chunk_entries; + manifest.size_bytes = total_bytes; + + let ended = samples + .iter() + .map(|s| s.log_time_ns) + .max() + .unwrap_or(now_ns); + manifest.ended_at_ns = Some(ended); + manifest.duration_ns = Some(ended.saturating_sub(manifest.started_at_ns)); + + crate::storage::manifest::save_dir(recording_dir, &manifest)?; + Ok(manifest) +} + +/// Atomically write `bytes` to `path` via a sibling temp file + rename. +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), RingBufferError> { + use std::io::Write; + let tmp = path.with_extension("mcap.tmp"); + { + let mut f = std::fs::File::create(&tmp).map_err(|e| RingBufferError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + f.write_all(bytes).map_err(|e| RingBufferError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + f.sync_all().map_err(|e| RingBufferError::Io { + path: tmp.display().to_string(), + detail: e.to_string(), + })?; + } + std::fs::rename(&tmp, path).map_err(|e| RingBufferError::Io { + path: path.display().to_string(), + detail: e.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(topic: &str, log_ns: u64, bytes: usize) -> Sample { + Sample { + topic: topic.to_string(), + message_encoding: "cbor".to_string(), + zenoh_encoding: "application/cbor".to_string(), + schema_name: None, + publish_time_ns: log_ns, + log_time_ns: log_ns, + data: vec![0xAB; bytes], + } + } + + const S: u64 = 1_000_000_000; + + #[test] + fn window_evicts_old_samples() { + // 5-second window, generous byte cap. + let mut ring = RingBuffer::new(5, 10_000_000); + for i in 0..=10u64 { + ring.push(sample("t", i * S, 10)); // one per second, t=0..10s + } + // Newest is 10s; window is 5s → keep samples with log_time > 5s, i.e. + // 6,7,8,9,10 plus the boundary handling (strictly older-than-window + // evicted). 5s is exactly window away (10-5=5, not > 5) so 5s is kept. + let kept: Vec = ring.snapshot().iter().map(|s| s.log_time_ns / S).collect(); + assert_eq!(kept, vec![5, 6, 7, 8, 9, 10]); + } + + #[test] + fn byte_cap_evicts_even_inside_window() { + // Huge time window, tight byte cap (100 bytes), samples of 30 bytes. + let mut ring = RingBuffer::new(3600, 100); + for i in 0..10u64 { + ring.push(sample("t", i * S, 30)); + } + // 100 / 30 → at most 3 samples fit (90 bytes); a 4th would be 120 > 100. + assert!(ring.byte_len() <= 100); + assert_eq!(ring.len(), 3); + let kept: Vec = ring.snapshot().iter().map(|s| s.log_time_ns / S).collect(); + assert_eq!(kept, vec![7, 8, 9]); + } + + #[test] + fn never_drops_the_only_sample_even_if_oversized() { + let mut ring = RingBuffer::new(1, 10); // 10-byte cap + ring.push(sample("t", 0, 1000)); // single 1000-byte sample + assert_eq!(ring.len(), 1); + assert!(!ring.is_empty()); + } + + #[test] + fn snapshot_preserves_ring_drain_clears_it() { + let mut ring = RingBuffer::new(3600, 1_000_000); + ring.push(sample("t", 0, 10)); + ring.push(sample("t", S, 10)); + assert_eq!(ring.snapshot().len(), 2); + assert_eq!(ring.len(), 2); // snapshot is non-destructive + let drained = ring.drain(); + assert_eq!(drained.len(), 2); + assert!(ring.is_empty()); + assert_eq!(ring.byte_len(), 0); + } + + #[test] + fn seal_writes_valid_mcap_and_manifest() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec"); + let samples = vec![ + Sample { + topic: "bubbaloop/global/m/cam/compressed".to_string(), + message_encoding: "protobuf".to_string(), + zenoh_encoding: "application/protobuf".to_string(), + schema_name: Some("bubbaloop.camera.v1.CompressedImage".to_string()), + publish_time_ns: 100, + log_time_ns: 110, + data: vec![1, 2, 3, 4], + }, + Sample { + topic: "bubbaloop/global/m/imu/data".to_string(), + message_encoding: "cbor".to_string(), + zenoh_encoding: "application/cbor".to_string(), + schema_name: None, + publish_time_ns: 200, + log_time_ns: 210, + data: vec![5, 6, 7], + }, + ]; + let manifest = seal_samples( + &rec_dir, + "rec", + "m", + RecordingMode::RingBuffer, + Some(60), + Selection::default(), + &samples, + &McapWriteConfig::default(), + 999, + ) + .unwrap(); + + // Manifest is internally consistent and passes the strict validator. + crate::storage::manifest::validate(&manifest).unwrap(); + assert_eq!(manifest.channels.len(), 2); + assert_eq!(manifest.chunks.len(), 1); + assert!(manifest.size_bytes > 0); + assert_eq!(manifest.started_at_ns, 110); + assert_eq!(manifest.ended_at_ns, Some(210)); + + // Channel metadata carried the zenoh encoding and schema name. + let cam = manifest + .channels + .iter() + .find(|c| c.topic.ends_with("/compressed")) + .unwrap(); + assert_eq!(cam.zenoh_encoding, "application/protobuf"); + assert_eq!( + cam.schema_name.as_deref(), + Some("bubbaloop.camera.v1.CompressedImage") + ); + + // The chunk file exists, hashes to the recorded digest, and parses as MCAP + // with both timestamps preserved. + let chunk = &manifest.chunks[0]; + let path = rec_dir.join("chunks").join(&chunk.name); + assert!(path.exists()); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(integrity::to_hex(&integrity::sha256(&bytes)), chunk.sha256); + assert!(chunk.name_is_canonical()); + + let mut seen = 0; + for msg in mcap::MessageStream::new(&bytes).unwrap() { + let msg = msg.unwrap(); + seen += 1; + if msg.channel.topic.ends_with("/compressed") { + assert_eq!(msg.publish_time, 100); + assert_eq!(msg.log_time, 110); + assert_eq!(&*msg.data, &[1, 2, 3, 4]); + assert_eq!( + msg.channel + .metadata + .get(META_ZENOH_ENCODING) + .map(|s| s.as_str()), + Some("application/protobuf") + ); + } + } + assert_eq!(seen, 2); + } + + #[test] + fn seal_rolls_multiple_chunks_by_size() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec"); + // Tiny target so each ~500-byte sample forces a new chunk file. + let cfg = McapWriteConfig { + chunk_size_bytes: 400, + ..McapWriteConfig::default() + }; + let samples: Vec = (0..4) + .map(|i| Sample { + topic: "t".to_string(), + message_encoding: "raw".to_string(), + zenoh_encoding: "zenoh/bytes".to_string(), + schema_name: None, + publish_time_ns: i, + log_time_ns: i, + data: vec![0u8; 500], + }) + .collect(); + let manifest = seal_samples( + &rec_dir, + "rec", + "m", + RecordingMode::RingBuffer, + None, + Selection::default(), + &samples, + &cfg, + 0, + ) + .unwrap(); + // Each sample exceeds the 400-byte target → one chunk per sample. + assert_eq!(manifest.chunks.len(), 4); + crate::storage::manifest::validate(&manifest).unwrap(); + for (i, c) in manifest.chunks.iter().enumerate() { + assert_eq!(c.index, i as u32); + assert!(rec_dir.join("chunks").join(&c.name).exists()); + } + } + + #[test] + fn seal_empty_snapshot_produces_open_free_manifest() { + let dir = tempfile::tempdir().unwrap(); + let rec_dir = dir.path().join("rec"); + let manifest = seal_samples( + &rec_dir, + "rec", + "m", + RecordingMode::RingBuffer, + Some(5), + Selection::default(), + &[], + &McapWriteConfig::default(), + 4242, + ) + .unwrap(); + assert!(manifest.chunks.is_empty()); + assert!(manifest.channels.is_empty()); + assert_eq!(manifest.started_at_ns, 4242); + assert_eq!(manifest.ended_at_ns, Some(4242)); + crate::storage::manifest::validate(&manifest).unwrap(); + } +} From a16de97da80201a8dce7b85897b4847f5f4dd446 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 01:19:22 +0530 Subject: [PATCH 13/19] fix(storage): address PR3 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of d1adc50 surfaced no correctness bugs; these harden replay fidelity, scaling, and shared-mechanism reuse. - Replay back-pressures instead of dropping: ZenohReplaySink now PUTs with CongestionControl::Block (not the put-default Drop), so a slow link/subscriber stalls the (already-paced) replay rather than silently losing samples (§4.5). - Planner pre-parses --topics/--exclude key patterns once instead of re-parsing them per message (was O(messages × patterns) KeyExpr parses in plan()). - run_replay re-checks cancellation after the inter-message sleep, before the publish, so Ctrl-C in that window doesn't emit one extra sample. - Shared atomic write: extract storage::atomic_write (tmp + fsync + rename); manifest::save_atomic and ring_buffer chunk writes both delegate, so the crash-atomicity guarantee lives in one place (§3.4.5). - Warn before replaying a >=1 GiB recording (v1 buffers it fully in RAM). - Document that McapWriteConfig.chunk_size_bytes is an uncompressed-payload threshold (rosbag2 semantics), so on-disk .mcap sizes differ post-zstd. 122 storage tests pass; PR3 + manifest clippy-clean; s3 feature still builds. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 15 ++++++ crates/bubbaloop/src/storage/manifest.rs | 32 ++---------- crates/bubbaloop/src/storage/mod.rs | 23 +++++++++ crates/bubbaloop/src/storage/replay.rs | 54 ++++++++++++++------- crates/bubbaloop/src/storage/ring_buffer.rs | 27 +++-------- 5 files changed, 86 insertions(+), 65 deletions(-) diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index 6fffb581..bbf300bc 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -717,9 +717,14 @@ impl storage::ReplaySink for ZenohReplaySink { encoding: &str, payload: &[u8], ) -> std::result::Result<(), storage::ReplayError> { + // `CongestionControl::Block` (not the put-default `Drop`) so a slow link + // or subscriber back-pressures the replay instead of silently dropping + // samples — replay must be faithful (§4.5), and `run_replay` already + // paces publishes, so blocking only stretches timing, never loses data. self.session .put(topic, payload.to_vec()) .encoding(zenoh::bytes::Encoding::from(encoding)) + .congestion_control(zenoh::qos::CongestionControl::Block) .await .map_err(|e| storage::ReplayError::Publish { topic: topic.to_string(), @@ -733,6 +738,16 @@ async fn run_replay_cmd(args: ReplayArgs) -> Result<()> { let dir = storage::recording_dir(&args.name)?; let recording = manifest::load_dir(&dir) .with_context(|| format!("no recording named '{}' at {}", args.name, dir.display()))?; + // v1 buffers the whole recording in memory (no streaming reader yet); warn + // before pulling a large one in so an OOM is at least diagnosable. + const LARGE_RECORDING_BYTES: u64 = 1 << 30; // 1 GiB + if recording.size_bytes >= LARGE_RECORDING_BYTES { + log::warn!( + "recording '{}' is {:.1} GiB; replay loads it fully into memory", + args.name, + recording.size_bytes as f64 / (1u64 << 30) as f64, + ); + } let messages = storage::read_recording_messages(&dir, &recording) .with_context(|| format!("reading MCAP chunks for '{}'", args.name))?; if messages.is_empty() { diff --git a/crates/bubbaloop/src/storage/manifest.rs b/crates/bubbaloop/src/storage/manifest.rs index a8095885..a21db28d 100644 --- a/crates/bubbaloop/src/storage/manifest.rs +++ b/crates/bubbaloop/src/storage/manifest.rs @@ -13,7 +13,6 @@ use super::integrity; use super::recording::{Chunk, Recording, MANIFEST_SCHEMA_VERSION}; -use std::io::Write; use std::path::{Path, PathBuf}; /// Standard manifest filename at a recording's root. @@ -58,36 +57,11 @@ pub fn save_atomic(path: impl AsRef, recording: &Recording) -> Result<(), let bytes = serde_json::to_vec_pretty(recording) .map_err(|e| ManifestError::Serialize(e.to_string()))?; - let parent = path.parent().ok_or_else(|| ManifestError::Io { + // Durable write-tmp-fsync-rename lives in one shared place (§3.4.5). + crate::storage::atomic_write(path, &bytes).map_err(|e| ManifestError::Io { path: path.display().to_string(), - detail: "manifest path has no parent directory".to_string(), - })?; - std::fs::create_dir_all(parent).map_err(|e| ManifestError::Io { - path: parent.display().to_string(), detail: e.to_string(), - })?; - - // Sibling temp file keeps the rename on the same filesystem. - let tmp = path.with_extension("json.tmp"); - { - let mut f = std::fs::File::create(&tmp).map_err(|e| ManifestError::Io { - path: tmp.display().to_string(), - detail: e.to_string(), - })?; - f.write_all(&bytes).map_err(|e| ManifestError::Io { - path: tmp.display().to_string(), - detail: e.to_string(), - })?; - f.sync_all().map_err(|e| ManifestError::Io { - path: tmp.display().to_string(), - detail: e.to_string(), - })?; - } - std::fs::rename(&tmp, path).map_err(|e| ManifestError::Io { - path: path.display().to_string(), - detail: e.to_string(), - })?; - Ok(()) + }) } /// Save the manifest into a recording directory (`{dir}/manifest.json`). diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index 31fea9a2..b0790119 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -114,6 +114,29 @@ pub fn chunks_path(recording_dir: impl AsRef) -> PathBuf { recording_dir.as_ref().join("chunks") } +/// Atomically write `bytes` to `path` via a sibling temp file + fsync + rename. +/// +/// The temp file is `{path}.tmp` (same directory → same filesystem → the rename +/// is atomic), fsynced before the rename, so a crash mid-write leaves either the +/// old file or the complete new one — never a partial. Shared by every durable +/// write in the subsystem (`manifest.json`, sealed chunk files) so the +/// crash-atomicity guarantee lives in exactly one place. +pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path) +} + /// Deterministic remote object key for a recording's manifest (spec §8): /// `{machine_id}/{recording_name}/manifest.json`. pub fn object_key_manifest(machine_id: &str, recording_name: &str) -> String { diff --git a/crates/bubbaloop/src/storage/replay.rs b/crates/bubbaloop/src/storage/replay.rs index 48e6a3d6..56dc483b 100644 --- a/crates/bubbaloop/src/storage/replay.rs +++ b/crates/bubbaloop/src/storage/replay.rs @@ -135,9 +135,31 @@ pub fn parse_remap(arg: &str) -> Result<(String, String), ReplayError> { Ok((from.to_string(), to.to_string())) } -/// Whether `topic` is selected by `patterns` (any pattern whose key expression -/// intersects the concrete topic key). An empty pattern list matches everything. -fn matches_any(topic: &str, patterns: &[String]) -> Result { +/// Parse each pattern string into an owned key expression once, up front, so the +/// per-message filter never re-parses them (the planner runs this O(messages × +/// patterns) otherwise). +fn parse_patterns( + patterns: &[String], +) -> Result>, ReplayError> { + patterns + .iter() + .map(|pat| { + zenoh::key_expr::KeyExpr::try_from(pat.clone()).map_err(|e| { + ReplayError::InvalidPattern { + pattern: pat.clone(), + detail: e.to_string(), + } + }) + }) + .collect() +} + +/// Whether the concrete `topic` key intersects any of the pre-parsed `patterns`. +/// An empty pattern list matches everything. +fn matches_any( + topic: &str, + patterns: &[zenoh::key_expr::KeyExpr<'static>], +) -> Result { if patterns.is_empty() { return Ok(true); } @@ -146,18 +168,7 @@ fn matches_any(topic: &str, patterns: &[String]) -> Result { pattern: topic.to_string(), detail: e.to_string(), })?; - for pat in patterns { - let pat_ke = zenoh::key_expr::KeyExpr::try_from(pat.as_str()).map_err(|e| { - ReplayError::InvalidPattern { - pattern: pat.clone(), - detail: e.to_string(), - } - })?; - if pat_ke.intersects(&topic_ke) { - return Ok(true); - } - } - Ok(false) + Ok(patterns.iter().any(|pat| pat.intersects(&topic_ke))) } /// Apply remaps to a topic (first matching `from` wins). Remaps match the full @@ -185,6 +196,10 @@ pub fn plan( return Err(ReplayError::InvalidRate(opts.rate)); } + // Pre-parse the include/exclude key patterns once (not per message). + let include = parse_patterns(&opts.topics)?; + let exclude = parse_patterns(&opts.exclude)?; + // Filter: topic include/exclude and the time-window trim. let mut kept: Vec<&ReplayMessage> = Vec::new(); for m in messages { @@ -199,10 +214,10 @@ pub fn plan( continue; } } - if !matches_any(&m.topic, &opts.topics)? { + if !matches_any(&m.topic, &include)? { continue; } - if !opts.exclude.is_empty() && matches_any(&m.topic, &opts.exclude)? { + if !exclude.is_empty() && matches_any(&m.topic, &exclude)? { continue; } kept.push(m); @@ -328,6 +343,11 @@ pub async fn run_replay( _ = cancel.cancelled() => return Ok(stats), _ = tokio::time::sleep_until(deadline) => {} } + // A cancel may have fired in the gap between the sleep completing and + // here; honor it before emitting one more sample. + if cancel.is_cancelled() { + return Ok(stats); + } sink.publish(&pm.topic, &pm.zenoh_encoding, &pm.data) .await?; stats.messages_published += 1; diff --git a/crates/bubbaloop/src/storage/ring_buffer.rs b/crates/bubbaloop/src/storage/ring_buffer.rs index 319bfa8a..f905b075 100644 --- a/crates/bubbaloop/src/storage/ring_buffer.rs +++ b/crates/bubbaloop/src/storage/ring_buffer.rs @@ -165,7 +165,11 @@ impl RingBuffer { /// rosbag2 verbatim: 786 432-byte chunks, zstd, fast level, CRC on). #[derive(Debug, Clone, Copy)] pub struct McapWriteConfig { - /// Target size of one on-disk chunk file before rolling to the next. + /// Roll to a new chunk file once this many **uncompressed payload** bytes + /// have been written to the current one. This is the rosbag2 `chunk_size` + /// knob measured the same way (raw message bytes, pre-compression); the + /// on-disk `.mcap` is smaller after zstd, so file sizes will not equal this + /// value exactly. pub chunk_size_bytes: u32, /// Chunk compression codec. pub compression: CompressionKind, @@ -440,25 +444,10 @@ pub fn seal_samples( Ok(manifest) } -/// Atomically write `bytes` to `path` via a sibling temp file + rename. +/// Atomically write a finalized chunk file, mapping IO failures to the module's +/// error type. The write-tmp-fsync-rename mechanism is shared (§3.4.5). fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), RingBufferError> { - use std::io::Write; - let tmp = path.with_extension("mcap.tmp"); - { - let mut f = std::fs::File::create(&tmp).map_err(|e| RingBufferError::Io { - path: tmp.display().to_string(), - detail: e.to_string(), - })?; - f.write_all(bytes).map_err(|e| RingBufferError::Io { - path: tmp.display().to_string(), - detail: e.to_string(), - })?; - f.sync_all().map_err(|e| RingBufferError::Io { - path: tmp.display().to_string(), - detail: e.to_string(), - })?; - } - std::fs::rename(&tmp, path).map_err(|e| RingBufferError::Io { + crate::storage::atomic_write(path, bytes).map_err(|e| RingBufferError::Io { path: path.display().to_string(), detail: e.to_string(), }) From 173e14c710efcfb229c9eceec27f8753c39aae54 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 01:47:46 +0530 Subject: [PATCH 14/19] =?UTF-8?q?feat(storage):=20add=20sync=20queue=20dri?= =?UTF-8?q?ver=20(PR4,=20=C2=A73.4.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the async sync driver on top of the PR2 sync core — the long-deferred piece, blocked until now only because it consumes ChunkFinalized events. It's self-contained and exercised end-to-end against LocalFs as a stand-in remote, so it needs neither the recorder node nor a live R2. - `ChunkFinalized` event + `SyncHandle`/`spawn` for the recorder to notify the driver over an in-process MPSC channel (§3.4.1 trigger 1). - `SyncQueue`: the synchronous, clock-injected policy core — bounded FIFO with dedup + capacity back-pressure (§3.4.2), the shared-backoff retry gate that pauses the whole queue as a unit and resets on first success (§3.4.3), `NoSuchBucket` → pause-entirely, and dead-letter persistence (§3.4.6). - `run_driver`: thin async shell — absorbs events, runs up to max_concurrent uploads per batch via upload_one (§3.4.4), marks each chunk's manifest (§3.4.5), and coalesces one manifest re-upload per recording per batch. Exits when the channel closes and the queue drains, or on cancel. - `scan_pending`/`scan_pending_in`: the startup scan (§3.4.1 trigger 3) that re-enqueues any chunk with no uploaded_at_ns. 5 tests (queue dedup/cap, retryable→dead-letter past max_elapsed, full seal→upload→manifest-marked round-trip, corrupt-chunk dead-letter, startup scan). 127 storage tests pass; clippy-clean. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/storage/mod.rs | 5 + crates/bubbaloop/src/storage/sync.rs | 13 +- crates/bubbaloop/src/storage/sync_driver.rs | 749 ++++++++++++++++++++ 3 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 crates/bubbaloop/src/storage/sync_driver.rs diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index b0790119..423eb010 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -33,6 +33,7 @@ pub mod replay; pub mod ring_buffer; pub mod secrets; pub mod sync; +pub mod sync_driver; use std::path::{Path, PathBuf}; @@ -51,6 +52,10 @@ pub use sync::{ classify, BackoffSchedule, DeadLetterEntry, DeadLetterList, ErrorClass, SharedBackoff, SyncError, UploadJob, UploadOutcome, }; +pub use sync_driver::{ + run_driver, scan_pending, scan_pending_in, spawn as spawn_sync, ChunkFinalized, EnqueueResult, + SyncConfig, SyncHandle, SyncQueue, +}; /// Base bubbaloop directory: `~/.bubbaloop`. pub fn bubbaloop_dir() -> Result { diff --git a/crates/bubbaloop/src/storage/sync.rs b/crates/bubbaloop/src/storage/sync.rs index 66ec6a13..128b1ed7 100644 --- a/crates/bubbaloop/src/storage/sync.rs +++ b/crates/bubbaloop/src/storage/sync.rs @@ -19,12 +19,13 @@ //! - [`pending_jobs`] / [`mark_chunk_uploaded`] — the startup-scan enqueue and //! the manifest mutation that records `uploaded_at_ns`. //! -//! The async **queue driver** (the bounded `VecDeque`, the concurrency -//! semaphore, and the in-process MPSC of `ChunkFinalized` events) is intentionally -//! *not* here: it consumes events the recorder emits, and the recorder lands in a -//! later PR. Everything the driver needs to make decisions lives in this module -//! and is unit-tested against [`LocalFs`](super::backend::local) as a stand-in -//! remote, with no clock or network dependency (callers pass `now_ns`). +//! The async **queue driver** (the bounded queue, the concurrency cap, and the +//! in-process MPSC of `ChunkFinalized` events) lives in +//! [`sync_driver`](super::sync_driver), built on this core. The recorder will +//! emit those events; until then the driver is exercised end-to-end against +//! [`LocalFs`](super::backend::local) as a stand-in remote. Everything the driver +//! needs to make decisions lives in this module with no clock or network +//! dependency (callers pass `now_ns`). use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/crates/bubbaloop/src/storage/sync_driver.rs b/crates/bubbaloop/src/storage/sync_driver.rs new file mode 100644 index 00000000..2a9f318b --- /dev/null +++ b/crates/bubbaloop/src/storage/sync_driver.rs @@ -0,0 +1,749 @@ +//! The async sync **queue driver** (spec §3.4.2) — the bounded queue, the +//! concurrency cap, and the shared-backoff orchestration that sit on top of the +//! [`sync`](super::sync) core. +//! +//! The driver is the third trigger source in §3.4.1 made real: it consumes +//! [`ChunkFinalized`] events over an in-process MPSC channel (emitted by the +//! recorder when it renames `.mcap.active` → `.mcap`), plus an optional +//! startup-scan seed of anything left un-uploaded ([`scan_pending`]). For each +//! chunk it runs the §3.4.4 upload sequence via [`upload_one`](super::sync::upload_one), +//! then re-uploads the recording's `manifest.json` (coalesced per batch, §3.4.4 +//! step 5) so remote state stays crash-consistent. +//! +//! Policy lives in the synchronous, clock-injected [`SyncQueue`] state machine so +//! it unit-tests without async/timing flakiness; [`run_driver`] is the thin async +//! shell that does the IO and the waiting. Shared backoff (§3.4.3) pauses the +//! whole queue as a unit on failure and resets on the first success; +//! `NoSuchBucket` pauses sync entirely until the daemon is reconfigured. + +use std::collections::{HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::backend::StorageBackend; +use super::sync::{ + classify, mark_chunk_uploaded, upload_one, BackoffSchedule, DeadLetterEntry, DeadLetterList, + ErrorClass, SharedBackoff, UploadJob, UploadOutcome, +}; +use super::{manifest, object_key_chunk, object_key_manifest, recordings_dir}; + +/// A finalized chunk ready to upload (spec §3.4.1 trigger 1). Carries everything +/// the driver needs to build the upload job, mark the manifest, and re-upload it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChunkFinalized { + /// Recording the chunk belongs to. + pub recording_name: String, + /// Recording directory (root of `manifest.json` + `chunks/`). + pub recording_dir: PathBuf, + /// Machine that produced it (object-key prefix). + pub machine_id: String, + /// Zero-based chunk index. + pub chunk_index: u32, + /// Manifest SHA-256 (lowercase hex). + pub sha256: String, + /// Local chunk file to upload. + pub local_path: PathBuf, +} + +impl ChunkFinalized { + /// The dedup key — a chunk is never queued twice. + fn key(&self) -> (String, u32) { + (self.recording_name.clone(), self.chunk_index) + } + + /// Build the per-chunk [`UploadJob`] (deterministic object key, §8). + fn to_job(&self) -> UploadJob { + UploadJob { + recording_name: self.recording_name.clone(), + chunk_index: self.chunk_index, + local_path: self.local_path.clone(), + sha256: self.sha256.clone(), + object_key: object_key_chunk( + &self.machine_id, + &self.recording_name, + self.chunk_index, + &self.sha256, + ), + } + } +} + +/// Tuning knobs for the driver (spec §3.4.2 defaults). +#[derive(Debug, Clone)] +pub struct SyncConfig { + /// Max concurrent uploads (default 3). + pub max_concurrent: usize, + /// Bounded queue capacity (default 1000); enqueues past this are rejected. + pub queue_cap: usize, + /// Shared backoff schedule. + pub backoff: BackoffSchedule, + /// Where the persisted dead-letter list lives. + pub dead_letter_path: PathBuf, +} + +impl SyncConfig { + /// Defaults with the dead-letter list at `dead_letter_path`. + pub fn with_dead_letter_path(dead_letter_path: PathBuf) -> Self { + Self { + max_concurrent: 3, + queue_cap: 1000, + backoff: BackoffSchedule::default(), + dead_letter_path, + } + } +} + +/// Result of trying to enqueue a chunk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnqueueResult { + /// Newly queued. + Accepted, + /// Already queued (same recording + chunk index) — ignored. + Duplicate, + /// The queue is at `queue_cap` — back-pressure (§3.4.2). + Full, +} + +/// The synchronous policy core: a bounded FIFO of pending chunks plus the shared +/// backoff gate, pause flag, and dead-letter list. No IO except dead-letter +/// persistence; no clock except the `now_ns` callers pass in, so every transition +/// is unit-testable. +pub struct SyncQueue { + pending: VecDeque, + queued: HashSet<(String, u32)>, + cap: usize, + backoff: SharedBackoff, + /// Earliest time (ns) a retry may run — the shared backoff gate. + gate_ns: u64, + /// Set on `NoSuchBucket`: sync stops uploading until reconfigured (§3.4.3). + paused: bool, + dead_letters: DeadLetterList, + dead_letter_path: PathBuf, +} + +impl SyncQueue { + /// Build a queue, loading any persisted dead-letter list (so terminal + /// failures survive a daemon restart, §3.4.6). + pub fn new(cap: usize, backoff: BackoffSchedule, dead_letter_path: PathBuf) -> Self { + let dead_letters = DeadLetterList::load(&dead_letter_path).unwrap_or_default(); + Self { + pending: VecDeque::new(), + queued: HashSet::new(), + cap, + backoff: SharedBackoff::new(backoff), + gate_ns: 0, + paused: false, + dead_letters, + dead_letter_path, + } + } + + /// Enqueue a chunk unless it is a duplicate or the queue is full. + pub fn enqueue(&mut self, event: ChunkFinalized) -> EnqueueResult { + let key = event.key(); + if self.queued.contains(&key) { + return EnqueueResult::Duplicate; + } + if self.pending.len() >= self.cap { + return EnqueueResult::Full; + } + self.queued.insert(key); + self.pending.push_back(event); + EnqueueResult::Accepted + } + + /// Whether sync is paused (misconfiguration, §3.4.3). + pub fn is_paused(&self) -> bool { + self.paused + } + + /// Number of chunks waiting (queued, not in flight). + pub fn pending_len(&self) -> usize { + self.pending.len() + } + + /// The shared backoff gate (earliest retry time, ns). + pub fn gate_ns(&self) -> u64 { + self.gate_ns + } + + /// Whether at least one job can run right now (`now_ns` past the gate, not + /// paused, something pending). + pub fn ready(&self, now_ns: u64) -> bool { + !self.paused && now_ns >= self.gate_ns && !self.pending.is_empty() + } + + /// Pop up to `n` jobs that are ready to run now (FIFO). + pub fn take_ready(&mut self, now_ns: u64, n: usize) -> Vec { + if !self.ready(now_ns) { + return Vec::new(); + } + let mut out = Vec::new(); + while out.len() < n { + match self.pending.pop_front() { + Some(ev) => { + self.queued.remove(&ev.key()); + out.push(ev); + } + None => break, + } + } + out + } + + /// A chunk uploaded successfully — reset the shared backoff for everyone and + /// drop the gate (§3.4.3: any success resets to attempt 1). + pub fn on_success(&mut self) { + self.backoff.on_success(); + self.gate_ns = 0; + } + + /// A retryable failure: re-queue the chunk at the front and advance the shared + /// backoff. If the failure streak ran past `max_elapsed`, dead-letter it + /// instead of retrying forever (§3.4.3). Returns `true` if it was retried. + pub fn on_retryable_failure( + &mut self, + event: ChunkFinalized, + reason: String, + now_ns: u64, + ) -> Result { + let decision = self.backoff.on_failure(now_ns); + if decision.dead_letter { + self.dead_letter(event, reason, decision.failures, now_ns)?; + return Ok(false); + } + self.gate_ns = + now_ns.saturating_add(decision.delay.as_nanos().min(u64::MAX as u128) as u64); + // Re-queue at the front so the failed chunk is retried before newer ones. + self.queued.insert(event.key()); + self.pending.push_front(event); + Ok(true) + } + + /// A terminal failure (bad auth, local corruption, conflict): dead-letter + /// immediately, never retry (§3.4.3 / §3.4.4 step 3). + pub fn on_terminal_failure( + &mut self, + event: ChunkFinalized, + reason: String, + now_ns: u64, + ) -> Result<(), super::sync::SyncError> { + self.dead_letter(event, reason, self.backoff.failures(), now_ns) + } + + /// A misconfiguration affecting every job (`NoSuchBucket`): pause sync + /// entirely and re-queue the chunk for when sync resumes (§3.4.3). + pub fn on_pause(&mut self, event: ChunkFinalized) { + self.paused = true; + self.queued.insert(event.key()); + self.pending.push_front(event); + } + + fn dead_letter( + &mut self, + event: ChunkFinalized, + reason: String, + attempts: u32, + now_ns: u64, + ) -> Result<(), super::sync::SyncError> { + self.dead_letters.add(DeadLetterEntry { + recording_name: event.recording_name.clone(), + chunk_index: event.chunk_index, + object_key: event.to_job().object_key, + sha256: event.sha256.clone(), + reason, + failed_at_ns: now_ns, + attempts, + }); + self.dead_letters.save_atomic(&self.dead_letter_path) + } + + /// The persisted dead-letter list. + pub fn dead_letters(&self) -> &DeadLetterList { + &self.dead_letters + } +} + +/// Walk `~/.bubbaloop/recordings/*/manifest.json` and build a [`ChunkFinalized`] +/// for every chunk with no `uploaded_at_ns` — the startup scan (§3.4.1 trigger 3) +/// that recovers anything the daemon missed while down. +pub fn scan_pending() -> Vec { + match recordings_dir() { + Ok(root) => scan_pending_in(&root), + Err(_) => Vec::new(), + } +} + +/// [`scan_pending`] against an explicit recordings root (testable). +pub fn scan_pending_in(recordings_root: &Path) -> Vec { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(recordings_root) { + Ok(e) => e, + Err(_) => return out, + }; + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let Ok(recording) = manifest::load_dir(&dir) else { + continue; + }; + let chunks_dir = dir.join("chunks"); + for chunk in recording.chunks.iter().filter(|c| !c.is_uploaded()) { + out.push(ChunkFinalized { + recording_name: recording.name.clone(), + recording_dir: dir.clone(), + machine_id: recording.machine_id.clone(), + chunk_index: chunk.index, + sha256: chunk.sha256.clone(), + local_path: chunks_dir.join(&chunk.name), + }); + } + } + out +} + +/// Current wall clock in nanoseconds since the Unix epoch. +fn now_ns() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos().min(u64::MAX as u128) as u64) + .unwrap_or(0) +} + +/// Re-upload one recording's `manifest.json` to the backend (§3.4.4 step 5). The +/// manifest is small and mutable, so it goes without a checksum header. +async fn upload_manifest( + backend: &dyn StorageBackend, + recording_dir: &Path, + machine_id: &str, + recording_name: &str, +) -> Result<(), super::backend::BackendError> { + let path = manifest::manifest_path(recording_dir); + let bytes = tokio::fs::read(&path) + .await + .map_err(|e| super::backend::BackendError::Io { + key: path.display().to_string(), + detail: e.to_string(), + })?; + let key = object_key_manifest(machine_id, recording_name); + backend.put(&key, &bytes, None).await.map(|_| ()) +} + +/// A handle the recorder uses to notify the driver of finalized chunks. +#[derive(Debug, Clone)] +pub struct SyncHandle { + tx: mpsc::Sender, +} + +impl SyncHandle { + /// Notify the driver that a chunk was finalized. Returns `false` if the driver + /// has shut down (receiver dropped). + pub async fn notify_chunk(&self, event: ChunkFinalized) -> bool { + self.tx.send(event).await.is_ok() + } +} + +/// Spawn the driver on the Tokio runtime, seeding it with a startup scan, and +/// return a [`SyncHandle`] plus the task's `JoinHandle`. The task runs until +/// `cancel` fires or the handle (and all clones) are dropped and the queue drains. +pub fn spawn( + backend: Arc, + cfg: SyncConfig, + initial: Vec, + cancel: CancellationToken, +) -> (SyncHandle, tokio::task::JoinHandle<()>) { + let (tx, rx) = mpsc::channel(cfg.queue_cap.max(1)); + let handle = SyncHandle { tx }; + let task = tokio::spawn(run_driver(backend, cfg, initial, rx, cancel)); + (handle, task) +} + +/// The async driver loop. Seeds the queue with `initial`, then services +/// [`ChunkFinalized`] events from `rx` until the channel closes and the queue +/// drains, or `cancel` fires. Uploads run in batches of up to +/// `cfg.max_concurrent`; the shared backoff gates the whole queue between +/// failures; successful uploads mark the manifest and coalesce one manifest +/// re-upload per recording per batch. +pub async fn run_driver( + backend: Arc, + cfg: SyncConfig, + initial: Vec, + mut rx: mpsc::Receiver, + cancel: CancellationToken, +) { + let mut queue = SyncQueue::new(cfg.queue_cap, cfg.backoff, cfg.dead_letter_path.clone()); + for ev in initial { + queue.enqueue(ev); + } + + let mut channel_open = true; + loop { + if cancel.is_cancelled() { + return; + } + // Absorb everything immediately available without blocking. + loop { + match rx.try_recv() { + Ok(ev) => { + queue.enqueue(ev); + } + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + channel_open = false; + break; + } + } + } + + // Paused (misconfig): stop uploading, just keep draining the channel so we + // notice shutdown. Stays paused until the process is reconfigured/restarted. + if queue.is_paused() { + if !channel_open { + return; + } + tokio::select! { + _ = cancel.cancelled() => return, + ev = rx.recv() => match ev { + Some(e) => { queue.enqueue(e); } + None => channel_open = false, + }, + } + continue; + } + + let now = now_ns(); + if queue.ready(now) { + run_batch(backend.as_ref(), &mut queue, cfg.max_concurrent, now).await; + continue; + } + + if queue.pending_len() > 0 { + // Something is queued but gated by backoff — wait out the gate (or a + // new event / cancel). + let wait = Duration::from_nanos(queue.gate_ns().saturating_sub(now)); + if channel_open { + tokio::select! { + _ = cancel.cancelled() => return, + _ = tokio::time::sleep(wait) => {} + ev = rx.recv() => match ev { + Some(e) => { queue.enqueue(e); } + None => channel_open = false, + }, + } + } else { + tokio::select! { + _ = cancel.cancelled() => return, + _ = tokio::time::sleep(wait) => {} + } + } + continue; + } + + // Queue empty: block for the next event, or exit when the channel is closed. + if !channel_open { + return; + } + tokio::select! { + _ = cancel.cancelled() => return, + ev = rx.recv() => match ev { + Some(e) => { queue.enqueue(e); } + None => channel_open = false, + }, + } + } +} + +/// Run up to `max_concurrent` ready uploads, then apply their outcomes and +/// coalesce one manifest re-upload per touched recording. +async fn run_batch( + backend: &dyn StorageBackend, + queue: &mut SyncQueue, + max_concurrent: usize, + now: u64, +) { + let batch = queue.take_ready(now, max_concurrent); + if batch.is_empty() { + return; + } + + // Upload the batch concurrently (each future owns its event). + let results = futures::future::join_all(batch.into_iter().map(|ev| async move { + let result = upload_one(backend, &ev.to_job()).await; + (ev, result) + })) + .await; + + // Recordings that had a successful chunk → one coalesced manifest re-upload. + let mut to_refresh_manifest: Vec<(PathBuf, String, String)> = Vec::new(); + + for (event, result) in results { + match result { + Ok(outcome) => { + let etag = match &outcome { + UploadOutcome::Uploaded { etag } | UploadOutcome::AlreadyPresent { etag } => { + etag.clone() + } + UploadOutcome::Conflict => None, + }; + if matches!(outcome, UploadOutcome::Conflict) { + // Same key, different bytes — possible bucket collision (§3.4.4). + log::warn!( + "sync: chunk {}#{} conflicts with existing remote object; dead-lettering", + event.recording_name, + event.chunk_index + ); + let _ = queue.on_terminal_failure( + event, + "remote object exists with different content".to_string(), + now, + ); + continue; + } + // Mark the chunk uploaded in the manifest (§3.4.5). + match mark_chunk_uploaded(&event.recording_dir, event.chunk_index, now, etag) { + Ok(_) => { + queue.on_success(); + if !to_refresh_manifest + .iter() + .any(|(_, _, n)| n == &event.recording_name) + { + to_refresh_manifest.push(( + event.recording_dir.clone(), + event.machine_id.clone(), + event.recording_name.clone(), + )); + } + } + Err(e) => log::error!( + "sync: uploaded {}#{} but failed to mark manifest: {e}", + event.recording_name, + event.chunk_index + ), + } + } + Err(err) => match classify(&err) { + ErrorClass::PauseSync => { + log::error!( + "sync: backend misconfigured ({err}); pausing sync until reconfigured" + ); + queue.on_pause(event); + } + ErrorClass::Terminal => { + log::error!( + "sync: terminal error on {}#{}: {err}; dead-lettering", + event.recording_name, + event.chunk_index + ); + let _ = queue.on_terminal_failure(event, err.to_string(), now); + } + ErrorClass::Retryable => { + log::warn!( + "sync: retryable error on {}#{}: {err}", + event.recording_name, + event.chunk_index + ); + let _ = queue.on_retryable_failure(event, err.to_string(), now); + } + }, + } + } + + // Coalesced manifest re-uploads (best-effort; a failure just means the next + // chunk's success will refresh it). + for (dir, machine_id, name) in to_refresh_manifest { + if let Err(e) = upload_manifest(backend, &dir, &machine_id, &name).await { + log::warn!("sync: manifest re-upload for {name} failed: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::backend::local::LocalFs; + use crate::storage::recording::{RecordingMode, Selection}; + use crate::storage::ring_buffer::{seal_samples, McapWriteConfig, Sample}; + use crate::storage::{manifest, object_key_chunk}; + + const SEC: u64 = 1_000_000_000; + + fn sample(topic: &str, ns: u64) -> Sample { + Sample { + topic: topic.to_string(), + message_encoding: "raw".into(), + zenoh_encoding: "zenoh/bytes".into(), + schema_name: None, + publish_time_ns: ns, + log_time_ns: ns, + data: vec![1, 2, 3, 4], + } + } + + /// Seal a real 2-chunk recording under `root/{name}` and return its dir. + fn make_recording(root: &Path, name: &str, machine: &str) -> (PathBuf, Vec) { + let dir = root.join(name); + let cfg = McapWriteConfig { + chunk_size_bytes: 1, // force one chunk per sample + ..McapWriteConfig::default() + }; + let samples = vec![sample("t/a", SEC), sample("t/b", 2 * SEC)]; + let recording = seal_samples( + &dir, + name, + machine, + RecordingMode::Streaming, + None, + Selection::default(), + &samples, + &cfg, + 0, + ) + .unwrap(); + let events = recording + .chunks + .iter() + .map(|c| ChunkFinalized { + recording_name: name.to_string(), + recording_dir: dir.clone(), + machine_id: machine.to_string(), + chunk_index: c.index, + sha256: c.sha256.clone(), + local_path: dir.join("chunks").join(&c.name), + }) + .collect(); + (dir, events) + } + + #[test] + fn queue_dedups_and_caps() { + let dl = std::env::temp_dir().join("dl_test_unused.json"); + let mut q = SyncQueue::new(2, BackoffSchedule::default(), dl); + let ev = ChunkFinalized { + recording_name: "r".into(), + recording_dir: PathBuf::from("/x"), + machine_id: "m".into(), + chunk_index: 0, + sha256: "ab".repeat(32), + local_path: PathBuf::from("/x/c0"), + }; + assert_eq!(q.enqueue(ev.clone()), EnqueueResult::Accepted); + assert_eq!(q.enqueue(ev.clone()), EnqueueResult::Duplicate); + let ev1 = ChunkFinalized { + chunk_index: 1, + ..ev.clone() + }; + let ev2 = ChunkFinalized { + chunk_index: 2, + ..ev.clone() + }; + assert_eq!(q.enqueue(ev1), EnqueueResult::Accepted); + assert_eq!(q.enqueue(ev2), EnqueueResult::Full); // cap is 2 + } + + #[test] + fn retryable_failure_gates_then_dead_letters_past_max_elapsed() { + let tmp = tempfile::tempdir().unwrap(); + let dl = tmp.path().join("dead_letter.json"); + // max_elapsed = 0 → the very first failure dead-letters. + let schedule = BackoffSchedule { + initial: Duration::from_millis(5), + max: Duration::from_millis(5), + max_elapsed: Duration::ZERO, + }; + let mut q = SyncQueue::new(10, schedule, dl); + let ev = ChunkFinalized { + recording_name: "r".into(), + recording_dir: PathBuf::from("/x"), + machine_id: "m".into(), + chunk_index: 0, + sha256: "ab".repeat(32), + local_path: PathBuf::from("/x/c0"), + }; + let retried = q.on_retryable_failure(ev, "boom".into(), 1_000).unwrap(); + assert!(!retried, "max_elapsed=0 → dead-letter on first failure"); + assert_eq!(q.dead_letters().len(), 1); + } + + #[tokio::test] + async fn driver_uploads_all_chunks_and_marks_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let recs = tmp.path().join("recordings"); + std::fs::create_dir_all(&recs).unwrap(); + let (dir, events) = make_recording(&recs, "rec", "m"); + assert_eq!(events.len(), 2); + + let backend = Arc::new(LocalFs::new(tmp.path().join("remote"))); + let cfg = SyncConfig::with_dead_letter_path(tmp.path().join("dead_letter.json")); + let cancel = CancellationToken::new(); + let (handle, task) = spawn(backend.clone(), cfg, Vec::new(), cancel); + + for ev in events { + assert!(handle.notify_chunk(ev).await); + } + drop(handle); // close the channel → driver drains then exits + task.await.unwrap(); + + // Every chunk is marked uploaded in the manifest... + let recording = manifest::load_dir(&dir).unwrap(); + assert!(recording.is_fully_uploaded()); + // ...and present in the backend, plus the manifest object. + for chunk in &recording.chunks { + let key = object_key_chunk("m", "rec", chunk.index, &chunk.sha256); + assert!( + backend.head(&key).await.unwrap().is_some(), + "chunk in backend" + ); + } + let mkey = crate::storage::object_key_manifest("m", "rec"); + assert!( + backend.head(&mkey).await.unwrap().is_some(), + "manifest in backend" + ); + } + + #[tokio::test] + async fn driver_dead_letters_corrupt_chunk() { + let tmp = tempfile::tempdir().unwrap(); + let recs = tmp.path().join("recordings"); + std::fs::create_dir_all(&recs).unwrap(); + let (dir, events) = make_recording(&recs, "rec", "m"); + + // Corrupt the first chunk's bytes on disk → SHA-256 mismatch (terminal). + let bad = &events[0].local_path; + std::fs::write(bad, b"corrupted not-an-mcap").unwrap(); + + let backend = Arc::new(LocalFs::new(tmp.path().join("remote"))); + let dl_path = tmp.path().join("dead_letter.json"); + let cfg = SyncConfig::with_dead_letter_path(dl_path.clone()); + let (handle, task) = spawn(backend.clone(), cfg, events, CancellationToken::new()); + drop(handle); + task.await.unwrap(); + + // The corrupt chunk is dead-lettered and NOT marked uploaded; the healthy + // chunk still uploads. + let dead = DeadLetterList::load(&dl_path).unwrap(); + assert_eq!(dead.len(), 1); + assert_eq!(dead.entries()[0].chunk_index, 0); + let recording = manifest::load_dir(&dir).unwrap(); + assert!(!recording.chunks[0].is_uploaded()); + assert!(recording.chunks[1].is_uploaded()); + } + + #[test] + fn scan_pending_finds_unuploaded_chunks() { + let tmp = tempfile::tempdir().unwrap(); + let recs = tmp.path().join("recordings"); + std::fs::create_dir_all(&recs).unwrap(); + make_recording(&recs, "rec_a", "m"); + make_recording(&recs, "rec_b", "m"); + let pending = scan_pending_in(&recs); + // 2 recordings × 2 chunks each, all un-uploaded. + assert_eq!(pending.len(), 4); + } +} From ff2b041a89056aee6c9713e5e26d8dd34c45ffab Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 01:56:28 +0530 Subject: [PATCH 15/19] =?UTF-8?q?feat(mcp):=20add=20read-only=20storage=20?= =?UTF-8?q?observability=20tools=20(PR4,=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the §6.1 CLI↔MCP pairing: the read tools an agent needs to inspect storage state before acting (§6.2 calls this out as critical — agents lack the human's dashboard/`cat` context). - mcp/storage_tools.rs: a second `#[tool_router]` block (merged into the server router via `+`) with `storage_config_get`, `storage_list`, `storage_info`, `storage_profile_list`, `storage_profile_show`. Process-local reads that dispatch straight to the storage core (no PlatformOperations indirection needed for filesystem inspection). Secrets never leave secrets.toml. - rbac.rs: all five mapped to Viewer tier (not unknown-defaults-to-Admin, §6.4), with a test that keys off the canonical STORAGE_READ_TOOLS list. 47 MCP integration tests still pass (router merge is non-disruptive); 131 mcp + 127 storage unit tests pass; clippy-clean. Deferred (separate follow-up): the mutating tools (configure, profile create/rm, upload, download, reconcile, rm) need the CLI handlers refactored to return structured results; the Zenoh/long-running tools (topics_list, replay_start/stop/status) need a session registry; record_* needs the out-of-tree recorder node. The blocking §6.1 parity test lands once that surface is complete. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/mcp/mod.rs | 1 + crates/bubbaloop/src/mcp/rbac.rs | 17 +- crates/bubbaloop/src/mcp/storage_tools.rs | 242 ++++++++++++++++++++++ crates/bubbaloop/src/mcp/tools.rs | 3 +- 4 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 crates/bubbaloop/src/mcp/storage_tools.rs diff --git a/crates/bubbaloop/src/mcp/mod.rs b/crates/bubbaloop/src/mcp/mod.rs index e9fb8140..d1f18450 100644 --- a/crates/bubbaloop/src/mcp/mod.rs +++ b/crates/bubbaloop/src/mcp/mod.rs @@ -9,6 +9,7 @@ pub mod daemon_platform; pub mod mock_platform; pub mod platform; pub mod rbac; +pub mod storage_tools; mod tools; pub mod toolsets; diff --git a/crates/bubbaloop/src/mcp/rbac.rs b/crates/bubbaloop/src/mcp/rbac.rs index 7651b838..7b1fe1c7 100644 --- a/crates/bubbaloop/src/mcp/rbac.rs +++ b/crates/bubbaloop/src/mcp/rbac.rs @@ -74,7 +74,13 @@ pub fn required_tier(tool_name: &str) -> Tier { // structure but don't mutate state. | "toolset_list" | "toolset_get_tools" - | "toolset_enable" => Tier::Viewer, + | "toolset_enable" + // Storage read tools (§6.2) — inspection only, viewer tier. + | "storage_config_get" + | "storage_list" + | "storage_info" + | "storage_profile_list" + | "storage_profile_show" => Tier::Viewer, // Operator tools (day-to-day operations) "node_start" @@ -149,6 +155,15 @@ mod tests { assert_eq!(required_tier("world_state_list"), Tier::Viewer); } + #[test] + fn storage_read_tools_are_viewer() { + // Every read tool registered by storage_tools.rs must resolve to Viewer, + // not the unknown-defaults-to-Admin fallback (§6.4). + for name in crate::mcp::storage_tools::STORAGE_READ_TOOLS { + assert_eq!(required_tier(name), Tier::Viewer, "tool {name}"); + } + } + #[test] fn test_required_tier_operator_tools() { assert_eq!(required_tier("node_start"), Tier::Operator); diff --git a/crates/bubbaloop/src/mcp/storage_tools.rs b/crates/bubbaloop/src/mcp/storage_tools.rs new file mode 100644 index 00000000..13580411 --- /dev/null +++ b/crates/bubbaloop/src/mcp/storage_tools.rs @@ -0,0 +1,242 @@ +//! Storage MCP tools (spec §6) — the read-only **observability** slice. +//! +//! The spec's §6.1 completeness rule pairs every `storage` CLI verb with an MCP +//! tool because "agents are first-class operators … anything a human can do at +//! the terminal, an agent can do through MCP." The most load-bearing of those for +//! an agent are the **read** tools: an agent must be able to see what is +//! configured, what is recorded, and what profiles exist *before* it acts +//! (§6.2 stresses this for `storage_config_get` / `storage_list`). +//! +//! This module registers those read tools as a second `ToolRouter` merged into +//! the server in [`BubbaLoopMcpServer::new`](super::BubbaLoopMcpServer). They are +//! process-local (filesystem under `~/.bubbaloop`), so they dispatch straight to +//! the [`storage`](crate::storage) core rather than through `PlatformOperations`. +//! The mutating tools (`storage_upload/download/reconcile/rm/configure/profile_*`) +//! and the Zenoh/long-running ones (`storage_topics_list`, `storage_replay_*`, +//! and the recorder-backed `storage_record_*`) land in a follow-up. + +use super::platform::PlatformOperations; +use super::BubbaLoopMcpServer; +use crate::storage::{self, manifest, profile}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::*; +use rmcp::{tool, tool_router}; +use schemars::JsonSchema; +use serde::Deserialize; + +/// The names of the storage tools registered by this module — used by the RBAC +/// map and the CLI↔MCP parity check so the two never drift. +#[allow(dead_code)] // referenced from rbac tests + the future §6.1 parity test +pub(crate) const STORAGE_READ_TOOLS: &[&str] = &[ + "storage_config_get", + "storage_list", + "storage_info", + "storage_profile_list", + "storage_profile_show", +]; + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageNameRequest { + /// Name of the recording (for `storage_info`) or profile (for + /// `storage_profile_show`). + name: String, +} + +#[derive(Deserialize, JsonSchema, Default)] +pub(crate) struct StorageListRequest { + /// Only list recordings from this machine id. + #[serde(default)] + machine: Option, + /// Only list recordings carrying this tag. + #[serde(default)] + tag: Option, + /// Cap the number of summaries returned (default 100) to bound token use. + #[serde(default)] + limit: Option, +} + +/// Serialize `v` as pretty JSON into a successful tool result. +fn ok_json(v: &T) -> CallToolResult { + CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(v).unwrap_or_else(|_| "null".to_string()), + )]) +} + +/// A user-facing error returned as tool text (matches the existing tool +/// convention of surfacing errors as content rather than protocol errors). +fn err_text(msg: impl Into) -> CallToolResult { + CallToolResult::success(vec![Content::text(format!("Error: {}", msg.into()))]) +} + +/// The §6.2 list summary for one recording. +fn recording_summary(r: &storage::Recording) -> serde_json::Value { + serde_json::json!({ + "name": r.name, + "machine_id": r.machine_id, + "duration_ns": r.duration_ns, + "size_bytes": r.size_bytes, + "chunk_count": r.chunks.len(), + "uploaded_chunk_count": r.uploaded_chunk_count(), + "started_at_ns": r.started_at_ns, + "tags": r.tags, + }) +} + +#[tool_router(router = storage_tool_router, vis = "pub(crate)")] +impl BubbaLoopMcpServer

{ + #[tool( + name = "storage_config_get", + description = "Return the current [storage] config block (backend, endpoint, bucket, auto_upload, retention). Secrets are never included — they live in secrets.toml. Lets an agent see what storage is configured before suggesting changes.", + annotations( + title = "Get storage config", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_config_get(&self) -> Result { + log::info!("[MCP] tool=storage_config_get"); + match storage::StorageConfig::load() { + Ok(cfg) => Ok(ok_json(&cfg)), + Err(e) => Ok(err_text(format!("no storage config available: {e}"))), + } + } + + #[tool( + name = "storage_list", + description = "List local recordings with a one-line summary each (name, machine_id, duration_ns, size_bytes, chunk_count, uploaded_chunk_count, started_at_ns, tags). Optional machine/tag filters; limit defaults to 100.", + annotations( + title = "List recordings", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_list( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_list"); + let root = match storage::recordings_dir() { + Ok(r) => r, + Err(e) => return Ok(err_text(format!("cannot resolve recordings dir: {e}"))), + }; + let mut summaries: Vec<(u64, serde_json::Value)> = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&root) { + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let Ok(rec) = manifest::load_dir(&dir) else { + continue; + }; + if let Some(m) = &req.machine { + if &rec.machine_id != m { + continue; + } + } + if let Some(t) = &req.tag { + if !rec.tags.contains(t) { + continue; + } + } + summaries.push((rec.started_at_ns, recording_summary(&rec))); + } + } + // Most recent first, then bound the count. + summaries.sort_by_key(|(ts, _)| std::cmp::Reverse(*ts)); + let limit = req.limit.unwrap_or(100) as usize; + let out: Vec = + summaries.into_iter().take(limit).map(|(_, v)| v).collect(); + Ok(ok_json(&out)) + } + + #[tool( + name = "storage_info", + description = "Return the full parsed manifest.json for one recording, including per-chunk upload status. Use storage_list to discover names.", + annotations( + title = "Recording info", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_info( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_info name={}", req.name); + let dir = match storage::recording_dir(&req.name) { + Ok(d) => d, + Err(e) => return Ok(err_text(format!("invalid recording name: {e}"))), + }; + match manifest::load_dir(&dir) { + Ok(rec) => Ok(ok_json(&rec)), + Err(e) => Ok(err_text(format!("no recording '{}': {e}", req.name))), + } + } + + #[tool( + name = "storage_profile_list", + description = "List recording profiles in ~/.bubbaloop/profiles as {name, description, profile_sha256}.", + annotations( + title = "List profiles", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_profile_list(&self) -> Result { + log::info!("[MCP] tool=storage_profile_list"); + let dir = match profile::profiles_dir() { + Ok(d) => d, + Err(e) => return Ok(err_text(format!("cannot resolve profiles dir: {e}"))), + }; + let mut out = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("yaml") { + continue; + } + let Ok(p) = profile::load(&path) else { + continue; + }; + out.push(serde_json::json!({ + "name": p.name, + "description": p.description, + "profile_sha256": profile::canonical_sha256(&p), + })); + } + } + out.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + Ok(ok_json(&out)) + } + + #[tool( + name = "storage_profile_show", + description = "Return one recording profile (parsed to JSON). Use storage_profile_list to discover names.", + annotations( + title = "Show profile", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_profile_show( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_profile_show name={}", req.name); + match profile::load_named(&req.name) { + Ok(p) => Ok(ok_json(&p)), + Err(e) => Ok(err_text(format!("no profile '{}': {e}", req.name))), + } + } +} diff --git a/crates/bubbaloop/src/mcp/tools.rs b/crates/bubbaloop/src/mcp/tools.rs index dc5148ca..4a72a8ae 100644 --- a/crates/bubbaloop/src/mcp/tools.rs +++ b/crates/bubbaloop/src/mcp/tools.rs @@ -399,7 +399,8 @@ impl BubbaLoopMcpServer

{ Self { platform, auth_token, - tool_router: Self::tool_router(), + // Merge the storage read tools (§6) into the main router. + tool_router: Self::tool_router() + Self::storage_tool_router(), machine_id, mode_ceiling: super::rbac::Tier::Admin, enabled_toolsets: std::sync::Arc::new(std::sync::RwLock::new( From cb5fb255602aec1c49534fc44e9efc34130b29ea Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 02:22:35 +0530 Subject: [PATCH 16/19] =?UTF-8?q?feat(daemon):=20wire=20background=20stora?= =?UTF-8?q?ge=20sync=20driver=20into=20daemon=20startup=20(PR4,=20=C2=A73.?= =?UTF-8?q?4.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start the §3.4.2 sync queue driver from `daemon::run` when `[storage].auto_upload` is enabled: seed it with a startup scan of un-uploaded chunks, bridge the daemon's `watch` shutdown signal to the driver's `CancellationToken`, and hold the `SyncHandle` for the daemon's lifetime so the event channel stays open (ready for the recorder's `ChunkFinalized` notifications). Gracefully disabled (with a log line) when there is no `[storage]` config, auto_upload is off, or the backend can't be built. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/daemon/mod.rs | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/bubbaloop/src/daemon/mod.rs b/crates/bubbaloop/src/daemon/mod.rs index 00037267..0ef0d8e5 100644 --- a/crates/bubbaloop/src/daemon/mod.rs +++ b/crates/bubbaloop/src/daemon/mod.rs @@ -662,6 +662,69 @@ async fn dispatch_daemon_command( /// Run the daemon with the given configuration. /// /// This is the main entry point called by `bubbaloop daemon`. +/// Start the background storage sync driver (§3.4.2) if `[storage].auto_upload` +/// is enabled, returning its task handle (if started). +/// +/// The driver runs the §3.4.4 upload sequence for every finalized chunk: it is +/// seeded with a startup scan of anything left un-uploaded ([`scan_pending`]) and +/// then idles, ready to receive `ChunkFinalized` notifications from the recorder +/// path (wired in a later slice). We bridge the daemon's `watch` shutdown signal +/// to the driver's `CancellationToken`, and keep the [`SyncHandle`] alive for the +/// daemon's lifetime so the channel stays open until shutdown. +/// +/// [`scan_pending`]: crate::storage::sync_driver::scan_pending +/// [`SyncHandle`]: crate::storage::sync_driver::SyncHandle +fn start_storage_sync( + mut shutdown_rx: tokio::sync::watch::Receiver<()>, +) -> Option> { + use crate::storage::{self, sync_driver}; + + let cfg = match storage::StorageConfig::load() { + Ok(c) => c, + Err(e) => { + log::info!("Storage sync disabled: no [storage] config ({e})"); + return None; + } + }; + if !cfg.auto_upload { + log::info!("Storage sync disabled: [storage].auto_upload = false"); + return None; + } + let backend: std::sync::Arc = match cfg.build_backend() { + Ok(b) => std::sync::Arc::from(b), + Err(e) => { + log::warn!("Storage sync disabled: cannot build backend: {e}"); + return None; + } + }; + let dead_letter_path = match storage::recordings_dir() { + Ok(d) => d.join("dead_letter.json"), + Err(e) => { + log::warn!("Storage sync disabled: cannot resolve recordings dir: {e}"); + return None; + } + }; + + let sync_cfg = sync_driver::SyncConfig::with_dead_letter_path(dead_letter_path); + let initial = sync_driver::scan_pending(); + log::info!( + "Starting storage sync (backend = {}, {} pending chunk(s) from startup scan)...", + cfg.backend, + initial.len() + ); + + let cancel = tokio_util::sync::CancellationToken::new(); + let (handle, task) = sync_driver::spawn(backend, sync_cfg, initial, cancel.clone()); + // Bridge the daemon shutdown signal to the driver's cancel token, holding the + // SyncHandle until shutdown so the driver's event channel stays open. + tokio::spawn(async move { + let _ = shutdown_rx.changed().await; + cancel.cancel(); + drop(handle); + }); + Some(task) +} + pub async fn run(zenoh_endpoint: Option) -> Result<(), Box> { use tokio::sync::watch; @@ -794,6 +857,10 @@ pub async fn run(zenoh_endpoint: Option) -> Result<(), Box) -> Result<(), Box Date: Mon, 15 Jun 2026 02:22:53 +0530 Subject: [PATCH 17/19] =?UTF-8?q?feat(mcp):=20add=20mutating=20+=20Zenoh?= =?UTF-8?q?=20+=20replay=20storage=20tools,=20completing=20=C2=A76.1=20par?= =?UTF-8?q?ity=20(PR4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the §6 storage MCP surface so every `storage` CLI verb has an MCP tool ("anything a human can do at the terminal, an agent can do through MCP"): - mutating: storage_configure (admin), storage_profile_create/rm (operator), storage_upload/download/reconcile (operator), storage_rm (admin) - Zenoh: storage_topics_list (viewer) — live fleet topics via node manifests - replay: storage_replay_start/stop/status — a background, cancellable replay session tracked in an in-process registry (new server field, Arc-shared across clones) To avoid divergence, the CLI handlers are refactored into reusable cores (apply_configure / apply_profile_create / download_recording / remove_recording) that both the CLI verb and the MCP tool call; upload is unified behind a new `storage::sync::upload_pending` helper (per-recording HEAD-before-PUT loop + coalesced manifest re-upload) returning an `UploadSummary`. RBAC tiers added for all new tools; a §6.1 parity unit test asserts every CLI verb maps to a registered MCP tool, plus a write-tool RBAC-tier test. Registered as a third ToolRouter merged in BubbaLoopMcpServer::new. Read tools' ok_json/err_text/StorageNameRequest are reused. 127 storage + 134 mcp unit tests + 47 integration_mcp pass; clippy-clean; s3 feature still compiles. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 440 +++++---- crates/bubbaloop/src/mcp/mod.rs | 6 + crates/bubbaloop/src/mcp/rbac.rs | 50 +- crates/bubbaloop/src/mcp/storage_tools.rs | 9 +- .../bubbaloop/src/mcp/storage_tools_write.rs | 887 ++++++++++++++++++ crates/bubbaloop/src/mcp/tools.rs | 7 +- crates/bubbaloop/src/storage/mod.rs | 4 +- crates/bubbaloop/src/storage/sync.rs | 89 +- 8 files changed, 1313 insertions(+), 179 deletions(-) create mode 100644 crates/bubbaloop/src/mcp/storage_tools_write.rs diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index bbf300bc..e1088295 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -23,12 +23,13 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use argh::FromArgs; +use serde::Serialize; use crate::storage::{ self, integrity, manifest, object_key_chunk, object_key_manifest, reconcile::ReconcileReport, recording::{Chunk, Recording}, - ReconcileOptions, StorageBackend, StorageConfig, UploadJob, UploadOutcome, + ReconcileOptions, StorageBackend, StorageConfig, }; /// Build the configured backend for a `--remote` operation, refusing to run @@ -422,103 +423,60 @@ fn run_info(args: InfoArgs) -> Result<()> { async fn run_upload(args: UploadArgs) -> Result<()> { let dir = storage::recording_dir(&args.name)?; - let mut recording = - manifest::load_dir(&dir).with_context(|| format!("no recording named '{}'", args.name))?; let backend = StorageConfig::load()?.build_backend()?; + let summary = storage::upload_pending(&*backend, &dir, now_ns()) + .await + .with_context(|| format!("uploading '{}'", args.name))?; - let pending: Vec = recording - .chunks - .iter() - .enumerate() - .filter(|(_, c)| !c.is_uploaded()) - .map(|(i, _)| i) - .collect(); - if pending.is_empty() { + if summary.uploaded == 0 && summary.is_complete() { log::info!( - "'{}' is already fully uploaded ({} chunks).", + "'{}' is already fully uploaded ({} chunk(s)).", args.name, - recording.chunks.len() + summary.total_chunks ); return Ok(()); } - - let ts = now_ns(); - let mut uploaded = 0usize; - let mut conflicts = 0usize; - let mut failed = 0usize; - for i in pending { - let chunk = recording.chunks[i].clone(); - let job = UploadJob::for_chunk(&recording.machine_id, &dir, &recording.name, &chunk); - match storage::sync::upload_one(&*backend, &job).await { - Ok(UploadOutcome::Uploaded { etag }) | Ok(UploadOutcome::AlreadyPresent { etag }) => { - // Mutate in memory; persist the manifest once after the loop - // rather than reloading + rewriting it per chunk. - recording.chunks[i].uploaded_at_ns = Some(ts); - if etag.is_some() { - recording.chunks[i].remote_etag = etag; - } - uploaded += 1; - } - Ok(UploadOutcome::Conflict) => { - log::warn!( - "chunk {} conflicts with different remote content at {} — skipping (run reconcile)", - chunk.index, - job.object_key - ); - conflicts += 1; - } - Err(e) => { - log::error!( - "chunk {} upload failed: {e} ({:?})", - chunk.index, - storage::classify(&e) - ); - failed += 1; - } - } - } - - // Only persist + re-upload the manifest when something actually changed, so - // an all-failed/all-conflict run never clobbers newer remote manifest state. - if uploaded > 0 { - manifest::save_dir(&dir, &recording).context("saving updated manifest")?; - upload_manifest(&*backend, &dir, &recording.machine_id, &recording.name).await?; - } - log::info!( - "Uploaded {uploaded} chunk(s); {conflicts} conflict(s); {failed} failure(s) for '{}'.", + "Uploaded {} chunk(s); {} conflict(s); {} failure(s) for '{}'.", + summary.uploaded, + summary.conflicts, + summary.failed, args.name ); - if failed > 0 || conflicts > 0 { - bail!("upload incomplete: {conflicts} conflict(s), {failed} failure(s)"); + if !summary.is_complete() { + bail!( + "upload incomplete: {} conflict(s), {} failure(s)", + summary.conflicts, + summary.failed + ); } Ok(()) } -async fn upload_manifest( - backend: &dyn StorageBackend, - recording_dir: &Path, - machine_id: &str, - name: &str, -) -> Result<()> { - let path = manifest::manifest_path(recording_dir); - let bytes = std::fs::read(&path).context("reading manifest for upload")?; - let digest = integrity::sha256(&bytes); - let key = object_key_manifest(machine_id, name); - backend - .put(&key, &bytes, Some(&digest)) - .await - .context("uploading manifest")?; - Ok(()) -} - // --------------------------------------------------------------------------- // download // --------------------------------------------------------------------------- -async fn run_download(args: DownloadArgs) -> Result<()> { - let local_dir = storage::recording_dir(&args.name)?; - let dest: PathBuf = match &args.to { +/// Structured result of [`download_recording`]. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DownloadSummary { + pub name: String, + pub dest: String, + pub verified_chunks: usize, +} + +/// Download + SHA-256-verify every chunk of `name` from the configured backend +/// into `to` (default: the local recording dir), then persist the manifest +/// alongside. `machine` is only needed when there is no local manifest from which +/// to read the machine-scoped object keys. Shared by the `download` CLI verb and +/// the `storage_download` MCP tool (§6.1). +pub(crate) async fn download_recording( + name: &str, + to: Option, + machine: Option, +) -> Result { + let local_dir = storage::recording_dir(name)?; + let dest: PathBuf = match &to { Some(p) => PathBuf::from(p), None => local_dir.clone(), }; @@ -529,10 +487,10 @@ async fn run_download(args: DownloadArgs) -> Result<()> { let recording = match manifest::load_dir(&local_dir) { Ok(r) => r, Err(_) => { - let machine = args.machine.clone().context( - "no local manifest; pass --machine so the remote manifest key can be built", + let machine = machine.clone().context( + "no local manifest; pass machine id so the remote manifest key can be built", )?; - let key = object_key_manifest(&machine, &args.name); + let key = object_key_manifest(&machine, name); let bytes = backend .get(&key) .await @@ -581,10 +539,20 @@ async fn run_download(args: DownloadArgs) -> Result<()> { // Persist the manifest alongside the chunks. manifest::save_dir(&dest, &recording).context("writing manifest")?; + Ok(DownloadSummary { + name: name.to_string(), + dest: dest.display().to_string(), + verified_chunks: verified, + }) +} + +async fn run_download(args: DownloadArgs) -> Result<()> { + let summary = download_recording(&args.name, args.to, args.machine).await?; log::info!( - "Downloaded and verified {verified} chunk(s) for '{}' to {}.", - args.name, - dest.display() + "Downloaded and verified {} chunk(s) for '{}' to {}.", + summary.verified_chunks, + summary.name, + summary.dest ); Ok(()) } @@ -646,55 +614,84 @@ fn format_reconcile(r: &ReconcileReport) -> Vec { // rm // --------------------------------------------------------------------------- -async fn run_rm(args: RmArgs) -> Result<()> { +/// Structured result of [`remove_recording`]. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct RemoveSummary { + pub name: String, + pub removed_local: bool, + pub removed_remote_objects: usize, +} + +/// Delete a recording locally and (when `remote`) its remote objects. `machine` +/// is only consulted for remote deletion when there is no local manifest. A name +/// that matches nothing (local or remote) is an error. Shared by the `rm` CLI +/// verb and the `storage_rm` MCP tool (§6.1). +pub(crate) async fn remove_recording( + name: &str, + remote: bool, + machine: Option, +) -> Result { // recording_dir validates the name, so a traversal name can't reach // remove_dir_all or a remote prefix. - let dir = storage::recording_dir(&args.name)?; - let mut removed_anything = false; + let dir = storage::recording_dir(name)?; + let mut removed_remote_objects = 0usize; - if args.remote { + if remote { let backend = require_remote_backend()?; // Determine the machine id (object keys are machine-scoped). let machine = match manifest::load_dir(&dir) { Ok(r) => r.machine_id, - Err(_) => args - .machine + Err(_) => machine .clone() - .context("no local manifest; pass --machine to delete remote objects")?, + .context("no local manifest; pass machine id to delete remote objects")?, }; - let prefix = format!("{machine}/{}/", args.name); + let prefix = format!("{machine}/{name}/"); let objects = backend .list(&prefix) .await .context("listing remote objects")?; - let count = objects.len(); for obj in objects { backend .delete(&obj.key) .await .with_context(|| format!("deleting {}", obj.key))?; + removed_remote_objects += 1; } - if count > 0 { - removed_anything = true; - } - log::info!("Deleted {count} remote object(s) for '{}'.", args.name); } - if dir.exists() { + let removed_local = dir.exists(); + if removed_local { std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?; - removed_anything = true; - log::info!("Deleted local recording '{}'.", args.name); } // A name that matched nothing (local or remote) is an error, not a silent // success — otherwise a typo with --remote exits 0 having done nothing. - if !removed_anything { - let scope = if args.remote { + if !removed_local && removed_remote_objects == 0 { + let scope = if remote { "no local directory and no matching remote objects" } else { "no local directory" }; - bail!("no recording named '{}' found ({scope})", args.name); + bail!("no recording named '{name}' found ({scope})"); + } + Ok(RemoveSummary { + name: name.to_string(), + removed_local, + removed_remote_objects, + }) +} + +async fn run_rm(args: RmArgs) -> Result<()> { + let summary = remove_recording(&args.name, args.remote, args.machine).await?; + if summary.removed_remote_objects > 0 { + log::info!( + "Deleted {} remote object(s) for '{}'.", + summary.removed_remote_objects, + summary.name + ); + } + if summary.removed_local { + log::info!("Deleted local recording '{}'.", summary.name); } Ok(()) } @@ -704,9 +701,10 @@ async fn run_rm(args: RmArgs) -> Result<()> { // --------------------------------------------------------------------------- /// A [`ReplaySink`] that re-publishes onto a live Zenoh session, preserving the -/// recorded encoding (§4.5). -struct ZenohReplaySink { - session: std::sync::Arc, +/// recorded encoding (§4.5). Shared with the `storage_replay_start` MCP tool so +/// CLI and agent replay are byte-identical (§6.1). +pub(crate) struct ZenohReplaySink { + pub(crate) session: std::sync::Arc, } #[async_trait::async_trait] @@ -920,54 +918,113 @@ pub struct ConfigureArgs { secret_access_key: Option, } -fn run_configure(args: ConfigureArgs) -> Result<()> { +/// Flag/field-driven inputs to [`apply_configure`] — the shared core behind the +/// `configure` CLI verb and the `storage_configure` MCP tool (§6.1). +#[derive(Debug, Clone, Default)] +pub(crate) struct ConfigureParams { + pub backend: String, + pub endpoint: Option, + pub region: Option, + pub bucket: Option, + pub no_auto_upload: bool, + pub retention_days_local: Option, + pub disk_quota_gb: Option, + pub local_path: Option, + pub access_key_id: Option, + pub secret_access_key: Option, +} + +/// Structured result of [`apply_configure`] (no secrets — those only ever touch +/// `secrets.toml`). +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ConfigureSummary { + pub backend: String, + pub bucket: Option, + pub auto_upload: bool, + pub secrets_written: bool, + /// Set when a remote backend was configured without credentials. + pub credentials_warning: Option, +} + +/// Validate inputs, write the `[storage]` config (and `secrets.toml` when both +/// credential halves are supplied), and return a structured summary. Performs no +/// user-facing logging so it composes cleanly into both the CLI and MCP. +pub(crate) fn apply_configure(p: ConfigureParams) -> Result { const BACKENDS: [&str; 5] = ["r2", "s3", "gcs", "minio", "local"]; - if !BACKENDS.contains(&args.backend.as_str()) { + if !BACKENDS.contains(&p.backend.as_str()) { bail!( "unknown backend {:?} (expected one of: {})", - args.backend, + p.backend, BACKENDS.join(", ") ); } - let is_remote = args.backend != "local"; - if is_remote && args.bucket.is_none() { - bail!("--bucket is required for the {:?} backend", args.backend); + let is_remote = p.backend != "local"; + if is_remote && p.bucket.is_none() { + bail!("bucket is required for the {:?} backend", p.backend); + } + // Reject a lone credential half before writing anything. + if p.access_key_id.is_some() != p.secret_access_key.is_some() { + bail!("access_key_id and secret_access_key must be provided together"); } let cfg = StorageConfig { - backend: args.backend.clone(), + backend: p.backend.clone(), + endpoint: p.endpoint, + region: p.region, + bucket: p.bucket.clone(), + auto_upload: !p.no_auto_upload, + retention_days_local: p.retention_days_local, + disk_quota_gb: p.disk_quota_gb, + recorder_instance: None, + local_path: p.local_path.map(PathBuf::from), + }; + cfg.save().context("writing config.toml")?; + + let mut secrets_written = false; + if let (Some(id), Some(secret)) = (p.access_key_id, p.secret_access_key) { + use storage::secrets::{R2Credentials, Secret}; + let creds = R2Credentials { + access_key_id: Secret::new(id), + secret_access_key: Secret::new(secret), + }; + let path = storage::secrets::default_path()?; + storage::secrets::save_r2(&path, &creds).context("writing secrets.toml")?; + secrets_written = true; + } + + let credentials_warning = (is_remote && !secrets_written).then(|| { + "no credentials configured; set them (secrets.toml, chmod 0600) before uploading" + .to_string() + }); + + Ok(ConfigureSummary { + backend: cfg.backend, + bucket: cfg.bucket, + auto_upload: cfg.auto_upload, + secrets_written, + credentials_warning, + }) +} + +fn run_configure(args: ConfigureArgs) -> Result<()> { + let summary = apply_configure(ConfigureParams { + backend: args.backend, endpoint: args.endpoint, region: args.region, bucket: args.bucket, - auto_upload: !args.no_auto_upload, + no_auto_upload: args.no_auto_upload, retention_days_local: args.retention_days_local, disk_quota_gb: args.disk_quota_gb, - recorder_instance: None, - local_path: args.local_path.map(PathBuf::from), - }; - cfg.save().context("writing config.toml")?; - log::info!("Wrote [storage] config (backend = {}).", cfg.backend); - - // Credentials: write secrets.toml only when both halves are supplied. - match (args.access_key_id, args.secret_access_key) { - (Some(id), Some(secret)) => { - use storage::secrets::{R2Credentials, Secret}; - let creds = R2Credentials { - access_key_id: Secret::new(id), - secret_access_key: Secret::new(secret), - }; - let path = storage::secrets::default_path()?; - storage::secrets::save_r2(&path, &creds).context("writing secrets.toml")?; - log::info!("Wrote credentials to {} (chmod 0600).", path.display()); - } - (None, None) => { - if is_remote { - log::warn!( - "no credentials supplied; set --access-key-id/--secret-access-key (or edit ~/.bubbaloop/secrets.toml) before uploading" - ); - } - } - _ => bail!("--access-key-id and --secret-access-key must be provided together"), + local_path: args.local_path, + access_key_id: args.access_key_id, + secret_access_key: args.secret_access_key, + })?; + log::info!("Wrote [storage] config (backend = {}).", summary.backend); + if summary.secrets_written { + log::info!("Wrote credentials to secrets.toml (chmod 0600)."); + } + if let Some(warn) = &summary.credentials_warning { + log::warn!("{warn}"); } Ok(()) } @@ -1075,62 +1132,111 @@ fn profile_path(name: &str) -> Result { Ok(storage::profile::profiles_dir()?.join(format!("{name}.yaml"))) } -fn run_profile_create(args: ProfileCreateArgs) -> Result<()> { +/// Inputs to [`apply_profile_create`] — shared by the `profile create` CLI verb +/// and the `storage_profile_create` MCP tool (§6.1). +#[derive(Debug, Clone, Default)] +pub(crate) struct ProfileCreateParams { + pub name: String, + pub description: Option, + pub topics: Option, + pub regex: Option, + pub exclude: Option, + pub include_local: bool, + pub chunk_size_bytes: Option, + pub compression: Option, + pub mode: Option, + pub window_secs: Option, + pub trigger: Option, + pub force: bool, +} + +/// Structured result of [`apply_profile_create`]. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ProfileSummary { + pub name: String, + pub path: String, + pub sha256: String, +} + +/// Validate and write a profile (the v1 schema check happens in `profile::save`), +/// returning a structured summary. No user-facing logging. +pub(crate) fn apply_profile_create(p: ProfileCreateParams) -> Result { use storage::profile::{CompressionKind, Profile, DEFAULT_CHUNK_SIZE_BYTES}; use storage::recording::{RecordingMode, Trigger}; - let path = profile_path(&args.name)?; - if path.exists() && !args.force { + let path = profile_path(&p.name)?; + if path.exists() && !p.force { bail!( - "profile '{}' already exists (use --force to overwrite)", - args.name + "profile '{}' already exists (use force/--force to overwrite)", + p.name ); } - let compression = match args.compression.as_deref() { + let compression = match p.compression.as_deref() { None | Some("zstd") => CompressionKind::Zstd, Some("lz4") => CompressionKind::Lz4, Some("none") => CompressionKind::None, - Some(other) => bail!("unknown --compression {other:?} (expected zstd|lz4|none)"), + Some(other) => bail!("unknown compression {other:?} (expected zstd|lz4|none)"), }; - let mode = match args.mode.as_deref() { + let mode = match p.mode.as_deref() { None | Some("streaming") => RecordingMode::Streaming, Some("ring_buffer") => RecordingMode::RingBuffer, - Some(other) => bail!("unknown --mode {other:?} (expected streaming|ring_buffer)"), + Some(other) => bail!("unknown mode {other:?} (expected streaming|ring_buffer)"), }; - let trigger = match args.trigger.as_deref() { + let trigger = match p.trigger.as_deref() { None => None, Some("manual") => Some(Trigger::Manual), Some("on-event") => Some(Trigger::OnEvent), - Some(other) => bail!("unknown --trigger {other:?} (expected manual|on-event)"), + Some(other) => bail!("unknown trigger {other:?} (expected manual|on-event)"), }; let profile = Profile { - name: args.name.clone(), - description: args.description, - topics: split_csv(args.topics.as_deref()), - regex: args.regex, - exclude: split_csv(args.exclude.as_deref()), - include_local: args.include_local, - chunk_size_bytes: args.chunk_size_bytes.unwrap_or(DEFAULT_CHUNK_SIZE_BYTES), + name: p.name.clone(), + description: p.description, + topics: split_csv(p.topics.as_deref()), + regex: p.regex, + exclude: split_csv(p.exclude.as_deref()), + include_local: p.include_local, + chunk_size_bytes: p.chunk_size_bytes.unwrap_or(DEFAULT_CHUNK_SIZE_BYTES), compression, compression_level: Default::default(), chunk_crc: true, mode, - window_secs: args.window_secs, + window_secs: p.window_secs, trigger, sending_queue: Default::default(), retry_on_failure: Default::default(), }; - // save() validates against the v1 schema before writing. storage::profile::save(&path, &profile) - .with_context(|| format!("saving profile '{}'", args.name))?; + .with_context(|| format!("saving profile '{}'", p.name))?; + Ok(ProfileSummary { + name: p.name, + path: path.display().to_string(), + sha256: storage::profile::canonical_sha256(&profile), + }) +} + +fn run_profile_create(args: ProfileCreateArgs) -> Result<()> { + let summary = apply_profile_create(ProfileCreateParams { + name: args.name, + description: args.description, + topics: args.topics, + regex: args.regex, + exclude: args.exclude, + include_local: args.include_local, + chunk_size_bytes: args.chunk_size_bytes, + compression: args.compression, + mode: args.mode, + window_secs: args.window_secs, + trigger: args.trigger, + force: args.force, + })?; log::info!( "Created profile '{}' at {} (sha256 {}).", - args.name, - path.display(), - storage::profile::canonical_sha256(&profile) + summary.name, + summary.path, + summary.sha256 ); Ok(()) } @@ -1209,7 +1315,7 @@ fn split_csv(value: Option<&str>) -> Vec { .unwrap_or_default() } -fn now_ns() -> u64 { +pub(crate) fn now_ns() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/bubbaloop/src/mcp/mod.rs b/crates/bubbaloop/src/mcp/mod.rs index d1f18450..0cfb6e40 100644 --- a/crates/bubbaloop/src/mcp/mod.rs +++ b/crates/bubbaloop/src/mcp/mod.rs @@ -10,6 +10,7 @@ pub mod mock_platform; pub mod platform; pub mod rbac; pub mod storage_tools; +pub mod storage_tools_write; mod tools; pub mod toolsets; @@ -118,6 +119,9 @@ pub struct BubbaLoopMcpServer /// `Arc>` because reads (every `list_tools` / `get_tool` /// call) vastly outnumber writes (only `toolset_enable` mutates). pub(crate) enabled_toolsets: Arc>>, + /// Live/finished replay sessions backing `storage_replay_*` (§6). Shared via + /// `Arc` across server clones so `start` and `status` see the same registry. + pub(crate) replay_sessions: storage_tools_write::ReplayRegistry, } // Manual Clone impl: P doesn't need Clone because it's behind Arc. @@ -131,6 +135,8 @@ impl Clone for BubbaLoopMcpServer

{ mode_ceiling: self.mode_ceiling, // Share toolset state via Arc — clones see the same enable/disable mutations. enabled_toolsets: self.enabled_toolsets.clone(), + // Share the replay registry so status/stop reach start's sessions. + replay_sessions: self.replay_sessions.clone(), } } } diff --git a/crates/bubbaloop/src/mcp/rbac.rs b/crates/bubbaloop/src/mcp/rbac.rs index 7b1fe1c7..5a19a402 100644 --- a/crates/bubbaloop/src/mcp/rbac.rs +++ b/crates/bubbaloop/src/mcp/rbac.rs @@ -80,7 +80,10 @@ pub fn required_tier(tool_name: &str) -> Tier { | "storage_list" | "storage_info" | "storage_profile_list" - | "storage_profile_show" => Tier::Viewer, + | "storage_profile_show" + // Storage observability over Zenoh / replay status — read-only, viewer. + | "storage_topics_list" + | "storage_replay_status" => Tier::Viewer, // Operator tools (day-to-day operations) "node_start" @@ -98,7 +101,15 @@ pub fn required_tier(tool_name: &str) -> Tier { | "mission_resume" | "mission_cancel" | "belief_update" - | "grab_frame" => Tier::Operator, + | "grab_frame" + // Storage day-to-day operations (§6.2) — operator tier. + | "storage_upload" + | "storage_download" + | "storage_reconcile" + | "storage_profile_create" + | "storage_profile_rm" + | "storage_replay_start" + | "storage_replay_stop" => Tier::Operator, // Admin tools (system modification) "node_install" @@ -112,7 +123,10 @@ pub fn required_tier(tool_name: &str) -> Tier { | "context_configure" | "alert_register" | "alert_unregister" - | "constraint_register" => Tier::Admin, + | "constraint_register" + // Storage system modification (§6.2) — admin tier. + | "storage_configure" + | "storage_rm" => Tier::Admin, // Unknown tools default to admin (principle of least privilege) _ => Tier::Admin, @@ -164,6 +178,36 @@ mod tests { } } + #[test] + fn storage_write_tools_have_expected_tiers() { + // The mutating / Zenoh / replay storage tools must each have an explicit, + // non-default tier matching the spec §6.2 RBAC table. + let expected = [ + ("storage_topics_list", Tier::Viewer), + ("storage_replay_status", Tier::Viewer), + ("storage_upload", Tier::Operator), + ("storage_download", Tier::Operator), + ("storage_reconcile", Tier::Operator), + ("storage_profile_create", Tier::Operator), + ("storage_profile_rm", Tier::Operator), + ("storage_replay_start", Tier::Operator), + ("storage_replay_stop", Tier::Operator), + ("storage_configure", Tier::Admin), + ("storage_rm", Tier::Admin), + ]; + for (name, tier) in expected { + assert_eq!(required_tier(name), tier, "tool {name}"); + } + // Every registered write tool must appear in the expected table — so a new + // tool can't slip in with the unknown-defaults-to-Admin fallback. + for name in crate::mcp::storage_tools_write::STORAGE_WRITE_TOOLS { + assert!( + expected.iter().any(|(n, _)| n == name), + "no RBAC tier asserted for storage tool {name}" + ); + } + } + #[test] fn test_required_tier_operator_tools() { assert_eq!(required_tier("node_start"), Tier::Operator); diff --git a/crates/bubbaloop/src/mcp/storage_tools.rs b/crates/bubbaloop/src/mcp/storage_tools.rs index 13580411..f91d8e1d 100644 --- a/crates/bubbaloop/src/mcp/storage_tools.rs +++ b/crates/bubbaloop/src/mcp/storage_tools.rs @@ -39,7 +39,7 @@ pub(crate) const STORAGE_READ_TOOLS: &[&str] = &[ pub(crate) struct StorageNameRequest { /// Name of the recording (for `storage_info`) or profile (for /// `storage_profile_show`). - name: String, + pub(crate) name: String, } #[derive(Deserialize, JsonSchema, Default)] @@ -55,8 +55,9 @@ pub(crate) struct StorageListRequest { limit: Option, } -/// Serialize `v` as pretty JSON into a successful tool result. -fn ok_json(v: &T) -> CallToolResult { +/// Serialize `v` as pretty JSON into a successful tool result. Shared with the +/// mutating storage tools in [`storage_tools_write`](super::storage_tools_write). +pub(crate) fn ok_json(v: &T) -> CallToolResult { CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(v).unwrap_or_else(|_| "null".to_string()), )]) @@ -64,7 +65,7 @@ fn ok_json(v: &T) -> CallToolResult { /// A user-facing error returned as tool text (matches the existing tool /// convention of surfacing errors as content rather than protocol errors). -fn err_text(msg: impl Into) -> CallToolResult { +pub(crate) fn err_text(msg: impl Into) -> CallToolResult { CallToolResult::success(vec![Content::text(format!("Error: {}", msg.into()))]) } diff --git a/crates/bubbaloop/src/mcp/storage_tools_write.rs b/crates/bubbaloop/src/mcp/storage_tools_write.rs new file mode 100644 index 00000000..a20686ee --- /dev/null +++ b/crates/bubbaloop/src/mcp/storage_tools_write.rs @@ -0,0 +1,887 @@ +//! Storage MCP tools (spec §6) — the **mutating**, **Zenoh**, and **replay** +//! slice that completes the §6.1 CLI↔MCP parity surface. +//! +//! The read-only observability tools live in +//! [`storage_tools`](super::storage_tools); this module adds everything that +//! changes state or talks to the live fleet: +//! +//! - mutating: `storage_configure`, `storage_profile_create`, +//! `storage_profile_rm`, `storage_upload`, `storage_download`, +//! `storage_reconcile`, `storage_rm`, +//! - Zenoh: `storage_topics_list` (live fleet topics via the node manifests), +//! - replay: `storage_replay_start` / `storage_replay_stop` / +//! `storage_replay_status` (a long-running in-process replay session). +//! +//! Each tool dispatches to the same core the CLI verb uses — the +//! [`apply_configure`](crate::cli::storage::apply_configure) / +//! [`apply_profile_create`](crate::cli::storage::apply_profile_create) / +//! [`download_recording`](crate::cli::storage::download_recording) / +//! [`remove_recording`](crate::cli::storage::remove_recording) cores and the +//! shared [`upload_pending`](crate::storage::upload_pending) helper — so an agent +//! and a human get byte-identical behavior (§6.1). The tools are registered as a +//! third [`ToolRouter`] merged into the server in +//! [`BubbaLoopMcpServer::new`](super::BubbaLoopMcpServer). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::*; +use rmcp::{tool, tool_router}; +use schemars::JsonSchema; +use serde::Deserialize; +use tokio_util::sync::CancellationToken; + +use super::platform::PlatformOperations; +use super::storage_tools::{err_text, ok_json, StorageNameRequest}; +use super::BubbaLoopMcpServer; +use crate::storage::{self, manifest, ReplayOptions}; + +/// Tool names registered by this module — used by the RBAC map and the §6.1 +/// parity check so the surfaces never drift. +#[allow(dead_code)] // referenced from rbac tests + the §6.1 parity test +pub(crate) const STORAGE_WRITE_TOOLS: &[&str] = &[ + "storage_configure", + "storage_profile_create", + "storage_profile_rm", + "storage_upload", + "storage_download", + "storage_reconcile", + "storage_rm", + "storage_topics_list", + "storage_replay_start", + "storage_replay_stop", + "storage_replay_status", +]; + +// --------------------------------------------------------------------------- +// In-process replay-session registry (backs storage_replay_*) +// --------------------------------------------------------------------------- + +/// A registry of live/finished replay sessions, shared (via `Arc`) across every +/// clone of the MCP server so `start` on one connection is visible to `status` +/// on another. Cheap to clone; a poisoned lock degrades to an empty view rather +/// than panicking the tool call. +#[derive(Clone, Default)] +pub(crate) struct ReplayRegistry { + sessions: Arc>>, +} + +struct ReplaySession { + recording_name: String, + started_at_ns: u64, + rate: f64, + cancel: CancellationToken, + state: Arc>, +} + +/// Lifecycle of one replay session. `run_replay` only yields its totals at the +/// end, so a running session reports no progress count (v1 limitation). +enum ReplayRunState { + Running, + Finished { + messages_published: u64, + loops_completed: u64, + }, + Failed { + error: String, + }, +} + +impl ReplayRegistry { + fn insert(&self, id: String, session: ReplaySession) { + if let Ok(mut g) = self.sessions.lock() { + g.insert(id, session); + } + } + + /// Cancel a session by id; returns `false` if no such session exists. + fn stop(&self, id: &str) -> bool { + if let Ok(g) = self.sessions.lock() { + if let Some(s) = g.get(id) { + s.cancel.cancel(); + return true; + } + } + false + } + + fn status_json(&self, id: &str) -> Option { + let g = self.sessions.lock().ok()?; + g.get(id).map(|s| session_json(id, s)) + } + + fn all_json(&self) -> Vec { + match self.sessions.lock() { + Ok(g) => g.iter().map(|(id, s)| session_json(id, s)).collect(), + Err(_) => Vec::new(), + } + } +} + +fn session_json(id: &str, s: &ReplaySession) -> serde_json::Value { + let guard = s.state.lock().unwrap_or_else(|p| p.into_inner()); + let status = match &*guard { + ReplayRunState::Running => serde_json::json!({ "state": "running" }), + ReplayRunState::Finished { + messages_published, + loops_completed, + } => serde_json::json!({ + "state": "finished", + "messages_published": messages_published, + "loops_completed": loops_completed, + }), + ReplayRunState::Failed { error } => serde_json::json!({ + "state": "failed", + "error": error, + }), + }; + serde_json::json!({ + "id": id, + "recording_name": s.recording_name, + "rate": s.rate, + "started_at_ns": s.started_at_ns, + "status": status, + }) +} + +// --------------------------------------------------------------------------- +// Request payloads +// --------------------------------------------------------------------------- + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageConfigureRequest { + /// Backend: r2 | s3 | gcs | minio | local. + backend: String, + /// S3-compatible endpoint URL (cloud backends). + #[serde(default)] + endpoint: Option, + /// Region (R2 uses "auto"). + #[serde(default)] + region: Option, + /// Bucket name (required for cloud backends). + #[serde(default)] + bucket: Option, + /// Disable background sync of finalized chunks. + #[serde(default)] + no_auto_upload: bool, + /// Auto-delete local recordings N days after successful upload. + #[serde(default)] + retention_days_local: Option, + /// Soft cap on the local recordings directory (GiB). + #[serde(default)] + disk_quota_gb: Option, + /// Object-store root for the local backend. + #[serde(default)] + local_path: Option, + /// Access key id (written to secrets.toml, chmod 0600; never echoed back). + #[serde(default)] + access_key_id: Option, + /// Secret access key (written to secrets.toml, chmod 0600; never echoed back). + #[serde(default)] + secret_access_key: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageProfileCreateRequest { + /// Profile name. + name: String, + /// Human description. + #[serde(default)] + description: Option, + /// Additive include patterns (comma-separated). + #[serde(default)] + topics: Option, + /// Additive regex include. + #[serde(default)] + regex: Option, + /// Subtractive exclude patterns (comma-separated). + #[serde(default)] + exclude: Option, + /// Also include SHM-only bubbaloop/local/** topics. + #[serde(default)] + include_local: bool, + /// Target chunk size in bytes (default 786432). + #[serde(default)] + chunk_size_bytes: Option, + /// Compression codec: zstd | lz4 | none (default zstd). + #[serde(default)] + compression: Option, + /// Capture mode: streaming | ring_buffer (default streaming). + #[serde(default)] + mode: Option, + /// Ring-buffer window length in seconds (required with mode=ring_buffer). + #[serde(default)] + window_secs: Option, + /// Ring-buffer trigger: manual | on-event (required with mode=ring_buffer). + #[serde(default)] + trigger: Option, + /// Overwrite an existing profile of the same name. + #[serde(default)] + force: bool, +} + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageDownloadRequest { + /// Recording name. + name: String, + /// Destination directory (default: the local recording directory). + #[serde(default)] + to: Option, + /// Machine id, only needed when there is no local manifest to read it from. + #[serde(default)] + machine: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageReconcileRequest { + /// Recording name. + name: String, + /// Also re-download chunks present remotely but missing locally. + #[serde(default)] + restore: bool, +} + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageRmRequest { + /// Recording name. + name: String, + /// Also delete the recording's objects from the remote backend. + #[serde(default)] + remote: bool, + /// Machine id for remote deletion when there is no local manifest. + #[serde(default)] + machine: Option, +} + +#[derive(Deserialize, JsonSchema, Default)] +pub(crate) struct StorageTopicsRequest { + /// Only show topics from this machine id. + #[serde(default)] + machine: Option, + /// Only show topics whose key contains this substring (case-insensitive). + #[serde(default)] + grep: Option, + /// Query timeout in seconds (default 2). + #[serde(default)] + timeout_secs: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageReplayStartRequest { + /// Recording name. + name: String, + /// Playback speed multiplier (default 1.0; 2.0 = twice as fast). + #[serde(default)] + rate: Option, + /// Loop the recording until stopped. + #[serde(default, rename = "loop")] + loop_playback: bool, + /// Start offset into the recording, in seconds. + #[serde(default)] + start_time: Option, + /// End offset into the recording, in seconds. + #[serde(default)] + end_time: Option, + /// Only replay topics intersecting these key patterns (comma-separated). + #[serde(default)] + topics: Option, + /// Drop topics intersecting these key patterns (comma-separated). + #[serde(default)] + exclude: Option, + /// Remap topics on publish; each entry `from:=to`. + #[serde(default)] + remap: Vec, + /// Schedule by recorder log_time instead of publish_time. + #[serde(default)] + use_log_time: bool, +} + +#[derive(Deserialize, JsonSchema)] +pub(crate) struct StorageReplayIdRequest { + /// Replay session id returned by storage_replay_start. + id: String, +} + +#[derive(Deserialize, JsonSchema, Default)] +pub(crate) struct StorageReplayStatusRequest { + /// Replay session id; omit to list all sessions. + #[serde(default)] + id: Option, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Split a comma-separated value into trimmed, non-empty items. +fn split_csv(value: Option<&str>) -> Vec { + value + .map(|s| { + s.split(',') + .map(|x| x.trim()) + .filter(|x| !x.is_empty()) + .map(|x| x.to_string()) + .collect() + }) + .unwrap_or_default() +} + +/// Build the configured backend, surfacing config/credential errors as tool text. +fn load_backend() -> Result, String> { + storage::StorageConfig::load() + .map_err(|e| format!("no storage config: {e}"))? + .build_backend() + .map_err(|e| format!("cannot build backend: {e}")) +} + +fn reconcile_json(r: &storage::ReconcileReport) -> serde_json::Value { + serde_json::json!({ + "is_clean": r.is_clean(), + "chunks_diffed": r.chunks_diffed, + "chunks_uploaded": r.chunks_uploaded, + "chunks_reuploaded": r.chunks_reuploaded, + "chunks_redownloaded": r.chunks_redownloaded, + "chunks_verified_ok": r.chunks_verified_ok, + "chunks_marked_uploaded": r.chunks_marked_uploaded, + "orphans_local": r.orphans_local.len(), + "orphans_remote": r.orphans_remote.len(), + "errors": r.errors.iter().map(|e| serde_json::json!({ + "chunk_index": e.index, + "kind": format!("{:?}", e.kind), + "detail": e.detail, + })).collect::>(), + }) +} + +fn topics_json(machines: &[storage::discover::MachineTopics]) -> serde_json::Value { + serde_json::Value::Array( + machines + .iter() + .map(|m| { + serde_json::json!({ + "machine_id": m.machine_id, + "nodes": m.nodes.iter().map(|n| serde_json::json!({ + "instance_name": n.instance_name, + "role": n.role, + "topics": n.topics.iter().map(|t| serde_json::json!({ + "topic": t.topic, + "live": t.is_live(), + })).collect::>(), + })).collect::>(), + }) + }) + .collect(), + ) +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +#[tool_router(router = storage_write_tool_router, vis = "pub(crate)")] +impl BubbaLoopMcpServer

{ + #[tool( + name = "storage_configure", + description = "Write the [storage] config block (backend, endpoint, region, bucket, auto_upload, retention, local_path) and, when both access_key_id and secret_access_key are given, the credentials to secrets.toml (chmod 0600). Secrets are never echoed back. Admin tier.", + annotations( + title = "Configure storage", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_configure( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_configure backend={}", req.backend); + let params = crate::cli::storage::ConfigureParams { + backend: req.backend, + endpoint: req.endpoint, + region: req.region, + bucket: req.bucket, + no_auto_upload: req.no_auto_upload, + retention_days_local: req.retention_days_local, + disk_quota_gb: req.disk_quota_gb, + local_path: req.local_path, + access_key_id: req.access_key_id, + secret_access_key: req.secret_access_key, + }; + match crate::cli::storage::apply_configure(params) { + Ok(summary) => Ok(ok_json(&summary)), + Err(e) => Ok(err_text(e.to_string())), + } + } + + #[tool( + name = "storage_profile_create", + description = "Create a recording profile (validated against the v1 schema) under ~/.bubbaloop/profiles. Returns {name, path, sha256}. Operator tier.", + annotations( + title = "Create profile", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false + ) + )] + async fn storage_profile_create( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_profile_create name={}", req.name); + let params = crate::cli::storage::ProfileCreateParams { + name: req.name, + description: req.description, + topics: req.topics, + regex: req.regex, + exclude: req.exclude, + include_local: req.include_local, + chunk_size_bytes: req.chunk_size_bytes, + compression: req.compression, + mode: req.mode, + window_secs: req.window_secs, + trigger: req.trigger, + force: req.force, + }; + match crate::cli::storage::apply_profile_create(params) { + Ok(summary) => Ok(ok_json(&summary)), + Err(e) => Ok(err_text(e.to_string())), + } + } + + #[tool( + name = "storage_profile_rm", + description = "Delete a recording profile by name from ~/.bubbaloop/profiles. Operator tier.", + annotations( + title = "Delete profile", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = false + ) + )] + async fn storage_profile_rm( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_profile_rm name={}", req.name); + if let Err(e) = storage::validate_recording_name(&req.name) { + return Ok(err_text(format!("invalid profile name: {e}"))); + } + let dir = match storage::profile::profiles_dir() { + Ok(d) => d, + Err(e) => return Ok(err_text(format!("cannot resolve profiles dir: {e}"))), + }; + let path = dir.join(format!("{}.yaml", req.name)); + if !path.exists() { + return Ok(err_text(format!("no profile '{}'", req.name))); + } + match std::fs::remove_file(&path) { + Ok(_) => Ok(ok_json(&serde_json::json!({ "removed_profile": req.name }))), + Err(e) => Ok(err_text(format!("removing profile: {e}"))), + } + } + + #[tool( + name = "storage_upload", + description = "Upload a recording's un-uploaded chunks to the configured backend, following the idempotent HEAD-before-PUT sequence, then re-upload manifest.json. Returns per-recording counts {total_chunks, already_uploaded, uploaded, conflicts, failed}. Operator tier.", + annotations( + title = "Upload recording", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn storage_upload( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_upload name={}", req.name); + let dir = match storage::recording_dir(&req.name) { + Ok(d) => d, + Err(e) => return Ok(err_text(format!("invalid recording name: {e}"))), + }; + let backend = match load_backend() { + Ok(b) => b, + Err(e) => return Ok(err_text(e)), + }; + match storage::upload_pending(&*backend, &dir, crate::cli::storage::now_ns()).await { + Ok(summary) => Ok(ok_json(&summary)), + Err(e) => Ok(err_text(format!("upload failed: {e}"))), + } + } + + #[tool( + name = "storage_download", + description = "Download + SHA-256-verify every chunk of a recording from the backend into `to` (default: the local recording dir). Pass `machine` only when there is no local manifest. Operator tier.", + annotations( + title = "Download recording", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn storage_download( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_download name={}", req.name); + match crate::cli::storage::download_recording(&req.name, req.to, req.machine).await { + Ok(summary) => Ok(ok_json(&summary)), + Err(e) => Ok(err_text(e.to_string())), + } + } + + #[tool( + name = "storage_reconcile", + description = "Heal a recording's local-manifest / local-files / remote-objects divergence (§3.5): re-upload missing chunks, verify hashes, flag orphans; with restore=true also re-download chunks present remotely but missing locally. Returns the reconcile report. Operator tier.", + annotations( + title = "Reconcile recording", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn storage_reconcile( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_reconcile name={}", req.name); + let dir = match storage::recording_dir(&req.name) { + Ok(d) => d, + Err(e) => return Ok(err_text(format!("invalid recording name: {e}"))), + }; + let backend = match load_backend() { + Ok(b) => b, + Err(e) => return Ok(err_text(e)), + }; + let opts = storage::ReconcileOptions { + restore: req.restore, + }; + match storage::reconcile(&dir, &*backend, &opts, crate::cli::storage::now_ns()).await { + Ok(report) => Ok(ok_json(&reconcile_json(&report))), + Err(e) => Ok(err_text(format!("reconcile failed: {e}"))), + } + } + + #[tool( + name = "storage_rm", + description = "Delete a recording locally and, with remote=true, its objects from the backend. Pass `machine` for remote deletion when there is no local manifest. A name matching nothing is an error. Admin tier.", + annotations( + title = "Delete recording", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn storage_rm( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!( + "[MCP] tool=storage_rm name={} remote={}", + req.name, + req.remote + ); + match crate::cli::storage::remove_recording(&req.name, req.remote, req.machine).await { + Ok(summary) => Ok(ok_json(&summary)), + Err(e) => Ok(err_text(e.to_string())), + } + } + + #[tool( + name = "storage_topics_list", + description = "List live fleet topics by querying the node manifest queryables over Zenoh, grouped by machine and node (each topic flagged live/idle). Optional machine + grep filters. Viewer tier. Requires the daemon's Zenoh session.", + annotations( + title = "List live topics", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ) + )] + async fn storage_topics_list( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_topics_list"); + let session = match self.platform.zenoh_session() { + Some(s) => s, + None => { + return Ok(err_text( + "no Zenoh session available (live topics require the daemon)", + )) + } + }; + let timeout = std::time::Duration::from_secs(req.timeout_secs.unwrap_or(2).max(1)); + match storage::discover::discover( + &session, + timeout, + req.grep.as_deref(), + req.machine.as_deref(), + ) + .await + { + Ok(machines) => Ok(ok_json(&topics_json(&machines))), + Err(e) => Ok(err_text(format!("topic discovery failed: {e}"))), + } + } + + #[tool( + name = "storage_replay_start", + description = "Start replaying a recording back into Zenoh (ros2-bag style: rate, loop, start/end offsets in seconds, topic include/exclude, remap from:=to, use_log_time). Returns a replay session id; runs in the background until it finishes or storage_replay_stop is called. Operator tier. Requires the daemon's Zenoh session.", + annotations( + title = "Start replay", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = false, + open_world_hint = true + ) + )] + async fn storage_replay_start( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_replay_start name={}", req.name); + let session = match self.platform.zenoh_session() { + Some(s) => s, + None => { + return Ok(err_text( + "no Zenoh session available (replay requires the daemon)", + )) + } + }; + let dir = match storage::recording_dir(&req.name) { + Ok(d) => d, + Err(e) => return Ok(err_text(format!("invalid recording name: {e}"))), + }; + let rate = req.rate.unwrap_or(1.0); + if !rate.is_finite() || rate <= 0.0 { + return Ok(err_text("rate must be a positive, finite number")); + } + // Pre-parse remaps so a bad `from:=to` fails fast (before spawning). + let remap = match req + .remap + .iter() + .map(|r| storage::replay::parse_remap(r)) + .collect::, _>>() + { + Ok(r) => r, + Err(e) => return Ok(err_text(format!("invalid remap: {e}"))), + }; + + let started_at_ns = crate::cli::storage::now_ns(); + let id = format!("{}-{}", req.name, started_at_ns); + let cancel = CancellationToken::new(); + let state = Arc::new(Mutex::new(ReplayRunState::Running)); + + // Capture everything the background task needs. + let task_state = state.clone(); + let task_cancel = cancel.clone(); + let name = req.name.clone(); + let topics = split_csv(req.topics.as_deref()); + let exclude = split_csv(req.exclude.as_deref()); + let use_log_time = req.use_log_time; + let loop_playback = req.loop_playback; + let start_time = req.start_time; + let end_time = req.end_time; + + tokio::spawn(async move { + let set_failed = |st: &Arc>, msg: String| { + *st.lock().unwrap_or_else(|p| p.into_inner()) = + ReplayRunState::Failed { error: msg }; + }; + let recording = match manifest::load_dir(&dir) { + Ok(r) => r, + Err(e) => return set_failed(&task_state, format!("no recording '{name}': {e}")), + }; + let messages = match storage::read_recording_messages(&dir, &recording) { + Ok(m) => m, + Err(e) => return set_failed(&task_state, format!("reading MCAP chunks: {e}")), + }; + if messages.is_empty() { + *task_state.lock().unwrap_or_else(|p| p.into_inner()) = ReplayRunState::Finished { + messages_published: 0, + loops_completed: 0, + }; + return; + } + // start/end offsets (seconds) are relative to the first message on the + // selected timeline; convert to absolute ns for the planner. + let base = messages + .iter() + .map(|m| { + if use_log_time { + m.log_time_ns + } else { + m.publish_time_ns + } + }) + .min() + .unwrap_or(0); + let to_abs = |secs: f64| -> u64 { base.saturating_add((secs.max(0.0) * 1e9) as u64) }; + let opts = ReplayOptions { + rate, + loop_playback, + start_time_ns: start_time.map(to_abs), + end_time_ns: end_time.map(to_abs), + topics, + exclude, + remap, + use_log_time, + }; + let sink = crate::cli::storage::ZenohReplaySink { session }; + match storage::run_replay(&messages, &opts, &sink, task_cancel).await { + Ok(stats) => { + *task_state.lock().unwrap_or_else(|p| p.into_inner()) = + ReplayRunState::Finished { + messages_published: stats.messages_published, + loops_completed: stats.loops_completed, + }; + } + Err(e) => set_failed(&task_state, format!("replay error: {e}")), + } + }); + + self.replay_sessions.insert( + id.clone(), + ReplaySession { + recording_name: req.name.clone(), + started_at_ns, + rate, + cancel, + state, + }, + ); + Ok(ok_json(&serde_json::json!({ + "replay_id": id, + "recording_name": req.name, + "state": "running", + }))) + } + + #[tool( + name = "storage_replay_stop", + description = "Stop a running replay session by id (from storage_replay_start). Operator tier.", + annotations( + title = "Stop replay", + read_only_hint = false, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_replay_stop( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_replay_stop id={}", req.id); + if self.replay_sessions.stop(&req.id) { + Ok(ok_json(&serde_json::json!({ "stopped": req.id }))) + } else { + Ok(err_text(format!("no replay session '{}'", req.id))) + } + } + + #[tool( + name = "storage_replay_status", + description = "Report replay session status. Pass an id for one session, or omit to list all (each with state running|finished|failed and, when finished, messages_published + loops_completed). Viewer tier.", + annotations( + title = "Replay status", + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false + ) + )] + async fn storage_replay_status( + &self, + Parameters(req): Parameters, + ) -> Result { + log::info!("[MCP] tool=storage_replay_status"); + match req.id { + Some(id) => match self.replay_sessions.status_json(&id) { + Some(j) => Ok(ok_json(&j)), + None => Ok(err_text(format!("no replay session '{id}'"))), + }, + None => Ok(ok_json(&self.replay_sessions.all_json())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::platform::DaemonPlatform; + + /// Collect every storage tool name registered across the read + write routers. + fn registered_storage_tools() -> std::collections::HashSet { + let mut names = std::collections::HashSet::new(); + for tool in BubbaLoopMcpServer::::storage_tool_router().list_all() { + names.insert(tool.name.to_string()); + } + for tool in BubbaLoopMcpServer::::storage_write_tool_router().list_all() { + names.insert(tool.name.to_string()); + } + names + } + + /// §6.1 parity: every `storage` CLI verb must have a corresponding MCP tool, + /// because "anything a human can do at the terminal, an agent can do through + /// MCP". This test is the guardrail that keeps the two surfaces in lockstep. + #[test] + fn every_cli_verb_has_an_mcp_tool() { + let names = registered_storage_tools(); + // (CLI verb, the MCP tool that fulfils it). + let cli_to_mcp = [ + ("list", "storage_list"), + ("info", "storage_info"), + ("upload", "storage_upload"), + ("download", "storage_download"), + ("reconcile", "storage_reconcile"), + ("rm", "storage_rm"), + ("replay", "storage_replay_start"), + ("profile create", "storage_profile_create"), + ("profile list", "storage_profile_list"), + ("profile show", "storage_profile_show"), + ("profile rm", "storage_profile_rm"), + ("configure", "storage_configure"), + ("topics", "storage_topics_list"), + ]; + for (verb, tool) in cli_to_mcp { + assert!( + names.contains(tool), + "CLI verb `storage {verb}` has no MCP tool `{tool}` (§6.1 parity)" + ); + } + } + + /// Every name in [`STORAGE_WRITE_TOOLS`] must actually be registered by the + /// write router (catches a const that drifts from the `#[tool]` methods). + #[test] + fn write_tools_const_matches_router() { + let registered: std::collections::HashSet = + BubbaLoopMcpServer::::storage_write_tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + for name in STORAGE_WRITE_TOOLS { + assert!( + registered.contains(*name), + "STORAGE_WRITE_TOOLS lists `{name}` but no such tool is registered" + ); + } + assert_eq!( + registered.len(), + STORAGE_WRITE_TOOLS.len(), + "write router has tools not listed in STORAGE_WRITE_TOOLS" + ); + } +} diff --git a/crates/bubbaloop/src/mcp/tools.rs b/crates/bubbaloop/src/mcp/tools.rs index 4a72a8ae..3ebfc68c 100644 --- a/crates/bubbaloop/src/mcp/tools.rs +++ b/crates/bubbaloop/src/mcp/tools.rs @@ -399,13 +399,16 @@ impl BubbaLoopMcpServer

{ Self { platform, auth_token, - // Merge the storage read tools (§6) into the main router. - tool_router: Self::tool_router() + Self::storage_tool_router(), + // Merge the storage read + write/zenoh/replay tools (§6) into the main router. + tool_router: Self::tool_router() + + Self::storage_tool_router() + + Self::storage_write_tool_router(), machine_id, mode_ceiling: super::rbac::Tier::Admin, enabled_toolsets: std::sync::Arc::new(std::sync::RwLock::new( super::toolsets::default_enabled(), )), + replay_sessions: super::storage_tools_write::ReplayRegistry::default(), } } diff --git a/crates/bubbaloop/src/storage/mod.rs b/crates/bubbaloop/src/storage/mod.rs index 423eb010..dbd7cc87 100644 --- a/crates/bubbaloop/src/storage/mod.rs +++ b/crates/bubbaloop/src/storage/mod.rs @@ -49,8 +49,8 @@ pub use replay::{ }; pub use ring_buffer::{seal_samples, McapWriteConfig, RingBuffer, RingBufferError, Sample}; pub use sync::{ - classify, BackoffSchedule, DeadLetterEntry, DeadLetterList, ErrorClass, SharedBackoff, - SyncError, UploadJob, UploadOutcome, + classify, upload_pending, BackoffSchedule, DeadLetterEntry, DeadLetterList, ErrorClass, + SharedBackoff, SyncError, UploadJob, UploadOutcome, UploadSummary, }; pub use sync_driver::{ run_driver, scan_pending, scan_pending_in, spawn as spawn_sync, ChunkFinalized, EnqueueResult, diff --git a/crates/bubbaloop/src/storage/sync.rs b/crates/bubbaloop/src/storage/sync.rs index 128b1ed7..aaca068f 100644 --- a/crates/bubbaloop/src/storage/sync.rs +++ b/crates/bubbaloop/src/storage/sync.rs @@ -36,7 +36,7 @@ use super::backend::{BackendError, StorageBackend}; use super::integrity; use super::profile::RetryOnFailure; use super::recording::{Chunk, Recording}; -use super::{manifest, object_key_chunk, StoragePathError}; +use super::{manifest, object_key_chunk, object_key_manifest, StoragePathError}; // --------------------------------------------------------------------------- // Error classification (§3.4.3) @@ -344,6 +344,90 @@ pub fn mark_chunk_uploaded( Ok(true) } +/// Per-recording outcome counts from [`upload_pending`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct UploadSummary { + /// Chunks in the recording. + pub total_chunks: usize, + /// Chunks already uploaded before this call (skipped). + pub already_uploaded: usize, + /// Chunks freshly uploaded (or confirmed present) this call. + pub uploaded: usize, + /// Chunks whose key holds different remote content (skipped; needs reconcile). + pub conflicts: usize, + /// Chunks whose upload errored. + pub failed: usize, +} + +impl UploadSummary { + /// Whether every pending chunk uploaded cleanly (no conflicts/failures). + pub fn is_complete(&self) -> bool { + self.conflicts == 0 && self.failed == 0 + } +} + +/// Upload every un-uploaded chunk of the recording at `recording_dir` to +/// `backend`, following the §3.4.4 sequence per chunk, marking successes in the +/// manifest and — if anything changed — persisting it and re-uploading +/// `manifest.json` (§3.4.4 step 5). +/// +/// Never bails mid-loop: conflicts and failures are tallied in the returned +/// [`UploadSummary`] so the caller decides how to report them. Shared by the +/// `storage upload` CLI verb and the `storage_upload` MCP tool so both behave +/// identically (§6.1). +pub async fn upload_pending( + backend: &dyn StorageBackend, + recording_dir: &Path, + now_ns: u64, +) -> Result { + let mut recording = manifest::load_dir(recording_dir)?; + let mut summary = UploadSummary { + total_chunks: recording.chunks.len(), + ..Default::default() + }; + let mut changed = false; + + for i in 0..recording.chunks.len() { + if recording.chunks[i].is_uploaded() { + summary.already_uploaded += 1; + continue; + } + let job = UploadJob::for_chunk( + &recording.machine_id, + recording_dir, + &recording.name, + &recording.chunks[i], + ); + match upload_one(backend, &job).await { + Ok(UploadOutcome::Uploaded { etag }) | Ok(UploadOutcome::AlreadyPresent { etag }) => { + recording.chunks[i].uploaded_at_ns = Some(now_ns); + if etag.is_some() { + recording.chunks[i].remote_etag = etag; + } + summary.uploaded += 1; + changed = true; + } + Ok(UploadOutcome::Conflict) => summary.conflicts += 1, + Err(_) => summary.failed += 1, + } + } + + // Only persist + re-upload when something changed, so an all-failed run never + // clobbers newer remote manifest state. + if changed { + manifest::save_dir(recording_dir, &recording)?; + let path = manifest::manifest_path(recording_dir); + let bytes = std::fs::read(&path).map_err(|e| SyncError::Io { + path: path.display().to_string(), + detail: e.to_string(), + })?; + let key = object_key_manifest(&recording.machine_id, &recording.name); + backend.put(&key, &bytes, None).await?; + } + + Ok(summary) +} + // --------------------------------------------------------------------------- // Dead-letter list (§3.4.6) // --------------------------------------------------------------------------- @@ -486,6 +570,9 @@ pub enum SyncError { /// Storage path could not be resolved. #[error("storage path error: {0}")] Path(#[from] StoragePathError), + /// A backend operation failed (e.g. the coalesced manifest re-upload). + #[error("backend error: {0}")] + Backend(#[from] BackendError), } #[cfg(test)] From 733afba28b61b5c7e0f1320700e1b457a228cdab Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 02:23:02 +0530 Subject: [PATCH 18/19] =?UTF-8?q?docs(storage):=20add=20storage=20concepts?= =?UTF-8?q?=20+=20internals=20docs,=20update=20ARCHITECTURE/ROADMAP=20(PR4?= =?UTF-8?q?,=20=C2=A714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/concepts/storage.md: user-facing model (recording = manifest + MCAP chunks, content-addressed keys, profiles, secrets isolation) + a 7-command local-backend walkthrough + the MCP parity/RBAC table. - docs/concepts/storage-internals.md: manifest schema + forward-compat, streaming SHA-256 verified on upload and download, the §3.4 sync queue (bounded FIFO, shared backoff, NoSuchBucket pause, dead-letter), HEAD-before-PUT idempotency, the §3.5 reconcile diff-matrix, ring-buffer mode, and paced replay. - ARCHITECTURE.md: storage as the third pillar. - ROADMAP.md: fleet storage phase with in-tree pieces done and mcap-recorder node + dashboard Recordings tab remaining. Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 41 +++++ ROADMAP.md | 30 +++ docs/concepts/storage-internals.md | 282 +++++++++++++++++++++++++++++ docs/concepts/storage.md | 217 ++++++++++++++++++++++ 4 files changed, 570 insertions(+) create mode 100644 docs/concepts/storage-internals.md create mode 100644 docs/concepts/storage.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2ca1eb69..c90ca8c0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,6 +59,11 @@ If it's app-layer complexity → reject it. If it strengthens sensor drivers → │ │ Telemetry watchdog (memory/CPU/disk monitoring) │ │ │ └──────────────────────┬─────────────────────────────┘ │ │ ┌──────────────────────┴─────────────────────────────┐ │ +│ │ Storage (fleet recording subsystem) │ │ +│ │ mcap-recorder sink | content-addressed sync │ │ +│ │ reconcile | replay | S3-compatible backends │ │ +│ └──────────────────────┬─────────────────────────────┘ │ +│ ┌──────────────────────┴─────────────────────────────┐ │ │ │ Zenoh Data Plane (zero-copy, real-time) │ │ │ └──────┬───────────┬───────────┬─────────────────────┘ │ └─────────┼───────────┼───────────┼────────────────────────┘ @@ -68,6 +73,11 @@ If it's app-layer complexity → reject it. If it strengthens sensor drivers → └────────┘ └────────┘ └────────┘ ``` +**Three pillars on the data plane:** the **node runtime** produces live data, the +**agent runtime** reasons over it, and **storage** captures the fleet's Zenoh +traffic to disk, ships it to an S3-compatible backend, and replays it +byte-identically. See [docs/concepts/storage.md](docs/concepts/storage.md). + **Entry points:** - `bubbaloop agent setup` — interactive wizard: configure provider, model, and identity. No daemon needed. - `bubbaloop agent chat` — thin Zenoh CLI client (LLM runs daemon-side) @@ -304,6 +314,37 @@ The daemon runs a cross-platform resource watchdog (`daemon/telemetry/`) that pr --- +## Storage (Fleet Recording) + +The third pillar alongside the node and agent runtimes. Storage records the +fleet's Zenoh traffic into self-describing recordings, syncs them to an +S3-compatible backend, and replays them. Full detail in +[docs/concepts/storage.md](docs/concepts/storage.md) and +[docs/concepts/storage-internals.md](docs/concepts/storage-internals.md). + +**Model:** a recording is `manifest.json` (the source of truth — no SQLite index) +plus `chunks/` of MCAP (rosbag2 defaults: 786432-byte chunks, zstd, dual +`publish_time`/`log_time`). Object keys are content-addressed +(`{machine_id}/{recording}/chunk-{idx}-{sha256}`) so re-uploads are idempotent and +a bucket is the sharing unit. + +| Concern | Implementation | +|---------|----------------| +| **Recorder** | `mcap-recorder` marketplace sink node (`role: sink`), one per fleet in `client` mode; idle until it receives a `start` command | +| **Sync** | Daemon background task, chunk-finalize-driven, bounded FIFO + shared backoff that pauses the queue on failure; dead-letter list persisted to disk | +| **Integrity** | Streaming SHA-256 per chunk — in filename, manifest, and `x-amz-checksum-sha256`; verified again on download. Mandatory, not configurable | +| **Reconcile** | Diffs local manifest vs local files vs remote objects; heals partial uploads; never deletes (idempotent, safe to re-run) | +| **Replay** | Pure planner + `ReplaySink` trait; paced Zenoh re-publish with `CongestionControl::Block` for faithful, lossless playback | +| **Secrets** | Cloud credentials only in `~/.bubbaloop/secrets.toml` (0600), never in `config.toml` or logs | + +**Control surfaces:** the `bubbaloop storage` CLI +(`configure | topics | profile | list | info | upload | download | reconcile | +replay | rm`) and a paired `storage_*` MCP tool per verb (read = viewer, +day-to-day = operator, config/remote-delete = admin). The dashboard Recordings +tab is a control surface (list / start / stop / download), not a data aggregator. + +--- + ## Topic Hierarchy ``` diff --git a/ROADMAP.md b/ROADMAP.md index 6e41f5ec..f1ba8828 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,6 +70,9 @@ bubbaloop agent chat # interactive REPL │ │ Node manager | systemd/D-Bus | Marketplace │ │ │ └──────────────────────┬─────────────────────────────┘ │ │ ┌──────────────────────┴─────────────────────────────┐ │ +│ │ Storage (fleet recording: record/sync/replay) │ │ +│ └──────────────────────┬─────────────────────────────┘ │ +│ ┌──────────────────────┴─────────────────────────────┐ │ │ │ Zenoh Data Plane (zero-copy, real-time) │ │ │ └──────┬───────────┬───────────┬─────────────────────┘ │ └─────────┼───────────┼───────────┼────────────────────────┘ @@ -285,6 +288,33 @@ Built-in actions: `check_all_health`, `restart`, `capture_frame`, `start_node`, --- +### Phase 4c: Fleet Storage (Recording → Sync → Replay) + +**Goal:** Capture fleet-wide Zenoh traffic to self-describing recordings, ship +them to an S3-compatible backend, and replay them byte-identically. The third +data-plane pillar alongside the node and agent runtimes. + +See [docs/concepts/storage.md](docs/concepts/storage.md) and +[docs/concepts/storage-internals.md](docs/concepts/storage-internals.md). + +**Deliverables:** +- [x] `storage` subsystem in-tree (`crates/bubbaloop/src/storage/`): `StorageBackend` trait with `local` + S3-compatible (R2/AWS/GCS/MinIO via `aws-sdk-s3`, behind the `s3` feature) +- [x] Self-describing recordings: `manifest.json` source of truth (no SQLite index), forward-compatible schema with `extra` passthrough +- [x] Content-addressed object keys (`{machine_id}/{recording}/chunk-{idx}-{sha256}`) — idempotent re-uploads, bucket = sharing unit +- [x] Streaming SHA-256 per chunk — in filename + manifest + `x-amz-checksum-sha256`, re-verified on upload, verified on download +- [x] Background sync driver: chunk-finalize-driven bounded FIFO, shared backoff that pauses the queue on failure, disk-persisted dead-letter list +- [x] Reconcile diff-matrix (local manifest vs local files vs remote objects), `--restore`, never-deletes / idempotent +- [x] Replay: pure planner + `ReplaySink` trait, paced Zenoh re-publish with `CongestionControl::Block` (rate/loop/trim/filter/remap/log-time) +- [x] Profiles (`~/.bubbaloop/profiles/*.yaml`): additive topic selection, MCAP/compression knobs, streaming + ring_buffer modes +- [x] Secrets isolated in `~/.bubbaloop/secrets.toml` (0600, zeroize-on-drop), never in `config.toml` or logs +- [x] Path-traversal validation on recording/profile names; canonical chunk filenames +- [x] CLI: `storage configure | topics | profile create/list/show/rm | list | info | upload | download | reconcile | replay | rm` +- [x] MCP parity: paired `storage_*` tool per CLI verb + MCP-only state-inspection tools (RBAC: read=viewer, day-to-day=operator, config/rm=admin) +- [ ] `mcap-recorder` marketplace node (`role: sink`, `bubbaloop-nodes-official/sinks/`) — drives recording-start; ring-buffer flush; rosbag2 MCAP defaults + dual timestamps +- [ ] Dashboard "Recordings" tab — list (local + remote) + start/stop with topic picker + download; browser-recorder deprecation banner + +--- + ### Phase 5: Polish + "5 Minutes to Magic" **Goal:** Install → configure → chat in under 5 minutes. diff --git a/docs/concepts/storage-internals.md b/docs/concepts/storage-internals.md new file mode 100644 index 00000000..a19c422e --- /dev/null +++ b/docs/concepts/storage-internals.md @@ -0,0 +1,282 @@ +--- +description: "How Bubbaloop storage works under the hood: the manifest schema, streaming SHA-256 integrity, the sync queue driver, the reconcile diff-matrix, ring-buffer memory, and the replay planner." +--- + +# Storage Internals + +The mechanics behind [Storage](storage.md): the on-disk manifest, the integrity +flow, the background sync queue, the reconcile diff-matrix, ring-buffer memory, +and replay. Read [Storage](storage.md) first for the model and a walkthrough. + +--- + +## The Manifest + +`manifest.json` at a recording's root is the **only** source of truth. There is +no separate index or database — `storage list` scans +`~/.bubbaloop/recordings/*/manifest.json`. + +```json +{ + "schema_version": 1, + "name": "outdoor_test_3", + "machine_id": "jetson_alpha", + "started_at_ns": 1748275200000000000, + "ended_at_ns": 1748275300000000000, + "mode": "streaming", + "selection": { "topics": ["bubbaloop/global/*/cam_*/compressed"], "exclude": ["**/health"], "include_local": false }, + "channels": [ + { "topic": "...cam_front/compressed", "channel_id": 0, "message_count": 12345, + "message_encoding": "cbor", "zenoh_encoding": "application/cbor" } + ], + "chunks": [ + { "name": "chunk-000000-a1b2c3d4.mcap", "index": 0, "size_bytes": 786432, + "sha256": "a1b2c3d4…(64 hex)…", "log_time_first_ns": 1748275200000000000, + "uploaded_at_ns": 1748275305000000000, "remote_etag": "\"a1b2…\"" } + ], + "profile_name": "demo", + "profile_sha256": "abc123…", + "tags": [] +} +``` + +**Forward-compatible by design.** The loader carries a `schema_version` and +preserves unknown fields on round-trip via an `extra` passthrough on the +`Recording` and `Chunk` types — a future writer's fields survive an older reader +loading and re-saving the manifest, so no data is lost across versions. + +**Derived state, not stored flags:** + +- **Sync status is per-chunk.** A chunk is uploaded iff its `uploaded_at_ns` is + set; the recording is fully synced iff *every* chunk is. There is no top-level + status enum — partial uploads are visible and reconcilable without ambiguity. +- **Lifecycle status derives from `ended_at_ns`.** Set → `complete`; null while + the recorder is running that session → `recording`; null while it is *not* → + `interrupted` (a crash). `storage list` computes all of this client-side. + +Every manifest write is `write_tmp_then_rename` (write a sibling `.tmp`, fsync, +rename over the target). A crash mid-write leaves either the old or the new +manifest intact, never a half-written one. The recorder's "append a chunk entry" +path and sync's "mark a chunk uploaded" path share one per-recording +`ManifestHandle` mutex, so the two writers on the daemon never race. + +--- + +## Integrity: Streaming SHA-256, Verified Both Ways + +Integrity is mandatory and end-to-end. The SHA-256 of each chunk is computed +**streaming during the write** (no second pass), then used at every boundary: + +1. **At finalize** — the digest is embedded in the chunk filename + (`chunk-{idx:06d}-{sha256_prefix8}.mcap`) and written full (64 hex chars) into + the manifest. +2. **On upload** — the local file's SHA-256 is **re-computed and re-verified** + against the manifest (never trusted blindly); a mismatch is local corruption + (`BadDigest`) and dead-letters the job. For cloud backends the digest is sent + as `x-amz-checksum-sha256` so the server validates it too. +3. **On download** — every chunk is verified on receipt against the manifest's + SHA-256; a mismatch fails loud and the corrupted file is **never written** to + disk. + +The streaming digest is proven equal to a one-shot digest against known test +vectors. + +### Path-traversal validation + +Recording names and profile names are validated before they ever touch the +filesystem or a remote prefix — a name like `../../etc` can't reach +`remove_dir_all`, a download path, or an object key. On download, the on-disk +chunk filename is **derived** from `(index, sha256)` via the canonical +`chunk-{idx:06d}-{sha256_prefix8}.mcap` form rather than trusting the manifest's +`name` field as a path segment, removing all doubt about what gets written. + +--- + +## The Sync Queue Driver + +Background sync replaces the old standalone `uploader` node. It runs as a single +task inside the daemon when `[storage].auto_upload = true`, and is +**chunk-finalize-driven**, not poll-driven: when the MCAP writer renames +`.mcap.active` → `.mcap` it emits a `ChunkFinalized` event on an in-process +channel that sync consumes — zero-latency, no filesystem polling. A startup scan +of the manifests (enqueueing any chunk with `uploaded_at_ns` still null) is the +safety net for anything missed while the daemon was down. + +**Bounded FIFO queue.** A `VecDeque` (default cap 1000) with a +semaphore bounding concurrency (default 3 parallel uploads). If the queue fills, +the recorder's finalize path back-pressures rather than silently filling disk. + +**Shared backoff — the whole queue pauses as a unit.** Backoff is *not* per-job. +If one upload fails because the backend is down, every subsequent upload will +fail too, so burning the queue retrying doomed jobs is pointless. Instead a +single shared `BackoffState` pauses the queue and stretches retries +`5s → 10s → 20s → 40s → 60s` (clamped). The **first successful upload after a +failure resets the shared backoff to attempt 1 for everyone.** + +**Manifest re-upload dedup.** The manifest is re-uploaded after each +chunk-completion (so remote state is crash-resilient), but a 500 ms coalescing +window collapses a burst of chunk-finalizes into a single manifest re-upload — +the manifest is identical regardless of which chunk triggered it. + +**Retryable vs terminal errors:** + +| Class | Errors | Handling | +|-------|--------|----------| +| Retryable | network timeouts, 5xx, throttling (429), DNS | retry under the shared backoff | +| Terminal | `Forbidden` (bad auth), `BadDigest` (local corruption) | → dead-letter immediately | +| `NoSuchBucket` | config is wrong | **pause sync entirely until reconfigured** | + +After `max_elapsed_time` (5m default), a job moves to a **dead-letter list** +persisted at `~/.bubbaloop/storage/dead_letter.json` (`write_tmp_then_rename`). +Dead-letter retry is **not** automatic — terminal failures got there for a +reason, and surfacing them to a human is intentional. The next manual +`storage upload` or `storage reconcile` flushes the list. On daemon restart, the +union of the persisted dead-letter list and the startup manifest scan restores +full queue state. + +> **Why no file-backed queue in v1.** The profile's `sending_queue.storage_dir` +> field exists for forward-compat with OTel Collector semantics, but v1 +> reconstructs the queue from the manifest scan on startup rather than persisting +> the queue itself. A file-backed queue would need its own corruption story; the +> manifest-scan approach is simpler and equally crash-resilient. + +### Idempotent upload: HEAD before PUT + +Each chunk uploads via a HEAD-before-PUT sequence that makes re-uploads no-ops +and never silently overwrites: + +``` +1. Re-verify local SHA-256 against the manifest → mismatch ⇒ BadDigest, dead-letter +2. HEAD the deterministic object key + 200 + matching sha256 ⇒ already uploaded; record uploaded_at_ns; done + 200 + mismatched sha256 ⇒ someone else's content at our key; warn + dead-letter for review + 404 ⇒ proceed to PUT +3. PUT with x-amz-checksum-sha256 + 200 ⇒ record uploaded_at_ns + remote_etag (write_tmp_then_rename) + 400/Digest⇒ local corruption ⇒ dead-letter + 403 ⇒ terminal ⇒ dead-letter, pause sync + 5xx/net ⇒ retry under shared backoff +4. Re-upload the (small) manifest.json +``` + +The CLI `storage upload` verb runs this same `upload_one` per chunk, mutating the +manifest in memory and persisting it once at the end — and only when something +actually changed, so an all-failed or all-conflict run never clobbers newer +remote manifest state. + +--- + +## Reconcile: The Diff-Matrix + +`storage reconcile ` is the user-facing healing primitive. It diffs **three +sources of truth** and converges them: the local manifest, the local chunk files, +and the remote objects (listed via `ListObjectsV2` under the recording's prefix). +It holds the recording's `ManifestHandle` mutex (so no concurrent sync), runs a +single pass, and re-uploads the manifest at the end. + +For each chunk index in the manifest: + +| Manifest | Local file | Remote | SHA-256 | Action | +|:---:|:---:|:---:|---|---| +| ✓ | ✓ | ✓ | all match | mark `uploaded_at_ns` if it was null; else no-op | +| ✓ | ✓ | ✗ | local matches manifest | **upload** the missing chunk | +| ✓ | ✓ | ✓ | remote mismatches | **re-upload** local (deterministic key ⇒ safe overwrite); warn | +| ✓ | ✓ | ✓ | local mismatches manifest | **local corruption** — error, do not upload | +| ✓ | ✗ | ✓ | remote matches manifest | local deleted (retention sweep) — **re-download** if `--restore`, else mark and continue | +| ✓ | ✗ | ✗ | — | gone everywhere — manifest broken, mark recording `corrupt` | +| ✗ | ✓ | ? | — | **orphan local file** — warn, do not touch | +| ✗ | ✗ | ✓ | — | **orphan remote object** — warn, do not auto-delete | + +**Reconcile never deletes anything** (local or remote). It only uploads missing +chunks, re-uploads mismatched ones, re-downloads (with `--restore`), and reports +orphans. That keeps it **idempotent and safe to re-run** — running it five times +produces the same result as once, and never destroys data. Deletion is a separate +explicit concern (`storage rm`, retention-driven local cleanup of uploaded +recordings, and a future `storage gc --remote` for orphans). The CLI returns a +`ReconcileReport` — `chunks_diffed / uploaded / reuploaded / redownloaded / +verified_ok / marked_uploaded`, plus orphan counts and per-chunk errors. + +--- + +## Ring-Buffer Mode + +Ring-buffer mode is the pre-trigger differentiator — "keep the last N seconds, let +me seal it when something interesting happens." The window is held **purely in +RAM** (no disk spillover in v1, which would double I/O and complicate retention), +bounded by **two caps that both apply**: + +- a **time window** (`window_secs`), and +- a **256 MiB byte cap** (`ring_max_bytes`, the OOM safety net). + +Samples are evicted **FIFO** whenever *either* cap is exceeded. Sizing is the +user's responsibility — budget RAM accordingly: + +| Stream rate | 60 s window | Notes | +|-------------|-------------|-------| +| 1 Mbps | ~7.5 MB | telemetry, IMU | +| 100 Mbps | ~750 MB | exceeds the 256 MiB cap — the byte cap evicts first | + +`flush` atomically snapshots the current ring contents, writes them to a +**brand-new finalized recording directory** (`{name}-flush-{ISO}/`) with its own +manifest, and resets the window-start anchor while keeping still-in-window +samples (they may belong to the next window too). The sealed recording's name is +returned so you can immediately `info` / `upload` it. Concurrent flushes of the +same session serialize via the session mutex; different sessions flush +concurrently. + +--- + +## Replay + +Replay reads a recording's MCAP chunks and re-publishes them into Zenoh with +their **original encoding and timing**. The design separates a **pure planner** +from a **sink**: + +- The **planner** is pure and testable: given the recorded messages and + `ReplayOptions` (`rate`, `loop`, `start_time_ns` / `end_time_ns` trim, + `topics` / `exclude` filters, `remap` rules, `use_log_time`), it decides what to + publish and when. `--start-time` / `--end-time` are offsets in seconds from the + recording's first message on the selected timeline; the planner trims against + the recorded timestamps. Timing defaults to `publish_time`; `--use-log-time` + schedules by the recorder's `log_time` instead. +- The **`ReplaySink` trait** is the output boundary. The CLI's `ZenohReplaySink` + re-publishes onto a live Zenoh session, restoring each message's recorded + encoding (`zenoh.encoding` from the MCAP channel metadata, per the wire-format + contract) so payloads are byte-identical. Tests swap in a fake sink. + +Crucially the sink publishes with **`CongestionControl::Block`** (not Zenoh's +put-default `Drop`): a slow link or subscriber back-pressures the replay instead +of silently dropping samples. Because the planner already paces publishes, +blocking only ever stretches timing — it never loses data, which is what makes +replay faithful. Ctrl-C cancels (and is the only way to stop `--loop`). v1 +buffers the whole recording into memory and warns above 1 GiB. + +--- + +## Agent Operations + +Storage is MCP-everywhere: every CLI verb has a paired `storage_*` tool. A few +tools are **MCP-only** — they exist because agents need state inspection that +humans get from the dashboard or process introspection, and they have no CLI +counterpart: + +| MCP-only tool | Why it exists | +|---------------|---------------| +| `storage_config_get` | Lets an agent read the current `[storage]` config (secrets redacted) before suggesting changes. A human just reads `~/.bubbaloop/config.toml`. | +| `storage_record_sessions` | Lists **active** recording sessions across the fleet (backed by the recorder's `status` queryable). Without it an agent can't see what's already recording before starting another. A human looks at the dashboard. | +| `storage_replay_start` / `storage_replay_stop` / `storage_replay_status` | Replay is long-running; MCP calls have no Ctrl-C, so the single CLI `replay` verb expands to a `_start`/`_stop`/`_status` triad on the MCP side. | + +RBAC mirrors blast radius: **read = viewer** (`storage_config_get`, +`storage_list`, `storage_info`, `storage_profile_list`, `storage_profile_show`, +`storage_topics_list`, `storage_replay_status`); **day-to-day = operator** +(`storage_upload`, `storage_download`, `storage_reconcile`, `storage_replay_*`, +`storage_profile_create`/`_rm`); **config / destructive = admin** +(`storage_configure`, `storage_rm`). + +--- + +## Next Steps + +- [Storage](storage.md) — the model, profiles, secrets, and the end-to-end walkthrough +- [Wire Format](wire-format.md) — the provenance envelope replay preserves byte-for-byte +- [Messaging](messaging.md) — Zenoh pub/sub, queryables, and congestion control diff --git a/docs/concepts/storage.md b/docs/concepts/storage.md new file mode 100644 index 00000000..b9beab90 --- /dev/null +++ b/docs/concepts/storage.md @@ -0,0 +1,217 @@ +--- +description: "Fleet recording in Bubbaloop. MCAP chunks + manifest, content-addressed upload to S3-compatible backends, reconcile, and replay — driven from the CLI and MCP." +--- + +# Storage + +Fleet recording → MCAP chunks + manifest → content-addressed upload to an +S3-compatible backend → reconcile → replay. + +Storage is the third pillar of Bubbaloop, alongside the **node runtime** and the +**agent runtime**. Where nodes produce live data and agents reason over it, +storage *captures* the fleet's Zenoh traffic to disk, ships it to the cloud, and +plays it back — with byte-identical payloads and original timing. + +For the on-disk formats, integrity flow, sync queue, and reconcile diff-matrix, +see [Storage Internals](storage-internals.md). + +--- + +## The Model + +A **Recording** is a self-describing directory. There is no separate index, no +SQLite, no metadata service — `manifest.json` *is* the source of truth. + +``` +~/.bubbaloop/recordings/{name}/ +├── manifest.json ← the source of truth (schema, chunks, sha256, upload state) +└── chunks/ + ├── chunk-000000-a1b2c3d4.mcap ← MCAP, with the chunk's sha256 prefix in the filename + └── chunk-000001-e5f6a7b8.mcap +``` + +| Piece | What it is | +|-------|-----------| +| **manifest.json** | Recording metadata: machine_id, time bounds, topic selection, per-channel stats, and one entry per chunk (name, size, full SHA-256, upload state). Forward-compatible (`schema_version`, unknown fields preserved). | +| **chunks/** | The recorded data, written as MCAP files. Defaults match rosbag2 verbatim: **786432-byte chunks, `zstd` compression, CRC on.** Every message keeps **both** `publish_time` (the Zenoh sample timestamp) and `log_time` (the recorder's clock at receipt). | +| **SHA-256 per chunk** | Computed streaming during the write, embedded in the filename, stored full in the manifest, and (for cloud backends) sent as `x-amz-checksum-sha256`. Verified again on download. Integrity is mandatory, not configurable. | + +### Content-addressed object keys + +Uploads are deterministic and idempotent. The object key embeds the machine, +recording, chunk index, and SHA-256 prefix: + +``` +{bucket}/{machine_id}/{recording_name}/manifest.json +{bucket}/{machine_id}/{recording_name}/chunks/chunk-{idx:06d}-{sha256_prefix8}.mcap +``` + +Same content → same key, so a re-upload is a no-op (caught by a `HEAD` check). +Changed content → different key, so nothing is ever silently overwritten. The +`{machine_id}/` prefix also makes a **bucket the sharing unit**: point two +machines at the same bucket and `storage list --remote` is fleet-wide. + +### Backends + +A `StorageBackend` trait abstracts the object store. Two implementations ship: + +| Backend | `--backend` value | Notes | +|---------|-------------------|-------| +| **Local FS** | `local` | An on-disk object store under `--local-path`. Runs the whole flow without any cloud credentials — ideal for trying things out and for tests. | +| **S3-compatible** | `r2`, `s3`, `gcs`, `minio` | Cloudflare R2 / AWS / GCS / MinIO via `aws-sdk-s3`. Built with the `s3` cargo feature. | + +### Profiles + +A **profile** is a small committable YAML file at +`~/.bubbaloop/profiles/{name}.yaml` that captures *what to record and how*: the +topic selection, MCAP/compression knobs, and the capture mode. Commit them to a +repo and a new teammate gets "the record button" the team uses. + +```yaml +name: demo +description: "Camera + IMU for outdoor demos" +topics: # additive include patterns + - 'bubbaloop/global/*/cam_*/compressed' +regex: '^bubbaloop/global/.+/imu/.+' # additive regex include +exclude: # subtractive + - '**/health' +include_local: false # default: skip SHM-only bubbaloop/local/** +chunk_size_bytes: 786432 +compression: zstd +mode: streaming # streaming | ring_buffer +``` + +Topic selection **composes additively**: the final set is +`(topics ∪ regex_match) - exclude`. The two capture modes: + +- **`streaming`** — record everything matching the selection until you stop. The default. +- **`ring_buffer`** — keep only a sliding window (by time and a 256 MiB byte cap) in memory; `flush` seals the current window as a brand-new finalized recording and keeps going. This is the pre-trigger / "capture the last 60 seconds" mode that rosbag2 lacks. See [Storage Internals → Ring buffer](storage-internals.md#ring-buffer-mode). + +### Secrets + +Cloud credentials live **only** in `~/.bubbaloop/secrets.toml` (chmod `0600`), +never in `config.toml`, never in a profile, never in logs. + +```toml +[storage.r2] +access_key_id = "..." +secret_access_key = "..." +``` + +`storage configure --access-key-id … --secret-access-key …` writes this file for +you with the right permissions. The loader wraps secrets in zeroize-on-drop, +`Debug`-redacted types so they can't leak through logging. + +--- + +## End-to-End Walkthrough (local backend, no cloud) + +This runs the full lifecycle against the `local` backend, so no R2 account or +credentials are needed. Recording itself is driven by the `mcap-recorder` +node (see the note below); every other verb — `list`, `info`, `upload`, +`download`, `reconcile`, `rm`, `replay`, `profile`, `configure`, `topics` — works +today against any backend, including `local`. + +```bash +# 1. Point storage at an on-disk object store (no credentials needed). +bubbaloop storage configure --backend local --local-path /tmp/objstore + +# 2. See what's live on the fleet before you record. +bubbaloop storage topics + +# 3. Record. Recording-start is driven by the mcap-recorder node +# (a separate marketplace node — see the note below). It writes the +# recording into ~/.bubbaloop/recordings/{name}/. + +# 4. List local recordings (NAME, MACHINE, MODE, CHUNKS, SYNC, STATUS, STARTED). +bubbaloop storage list + +# 5. Inspect one recording's manifest (pretty-printed JSON). +bubbaloop storage info + +# 6. Upload its chunks to the configured backend. Idempotent — already-uploaded +# chunks are skipped by SHA-256 match. +bubbaloop storage upload + +# 7. Reconcile (heal any local/remote divergence) and replay back into Zenoh. +bubbaloop storage reconcile +bubbaloop storage replay --rate 1.0 +``` + +`replay` re-publishes the recorded samples into Zenoh with their original +encoding and timing — `--rate` scales speed, `--loop` repeats, `--start-time` / +`--end-time` trim, `--topics` / `--exclude` filter, and `--remap from:=to` +re-keys. Press Ctrl-C to stop. + +!!! note "The recorder is a separate marketplace node" + Honest scoping: **recording-start is driven by the `mcap-recorder` node**, a + sink node from the marketplace (in `bubbaloop-nodes-official/sinks/`, distinct + from the official nodes that ship in this repo). It sits idle until it + receives a `start` command, then writes MCAP chunks + a manifest into + `~/.bubbaloop/recordings/`. Once a recording exists on disk, every other + storage verb operates on it directly — none of them need the recorder + running. The local backend means you can exercise `list → info → upload → + reconcile → replay` end-to-end without ever touching the cloud. + +### `list` output + +`storage list` scans `~/.bubbaloop/recordings/*/manifest.json` and prints a +table. `SYNC` is `synced` once every chunk has an `uploaded_at_ns`, else `local`; +`STATUS` is `complete` once the manifest is closed, `open` while it's still being +written, or `corrupt` if reconcile flagged missing chunks. + +``` +NAME MACHINE MODE CHUNKS SYNC STATUS STARTED +outdoor_test_3 jetson_alpha streaming 12/12 synced complete 2026-06-15 09:13:20 +indoor_imu_only jetson_beta streaming 0/3 local open 2026-06-15 09:40:02 +``` + +Add `--remote` to list recordings in the configured backend instead (it lists +objects under the bucket prefix and reads each remote `manifest.json`). + +--- + +## Agents Drive the Same Operations (MCP) + +Storage follows Bubbaloop's MCP-everywhere rule: **every CLI verb has a paired +`storage_*` MCP tool**, so an agent can do anything a human can at the terminal. +A handful of MCP-only tools exist for state inspection humans get from the +dashboard or process introspection. + +| Group | Tools | +|-------|-------| +| **Read (viewer)** | `storage_config_get`, `storage_list`, `storage_info`, `storage_profile_list`, `storage_profile_show` | +| **Day-to-day (operator)** | `storage_upload`, `storage_download`, `storage_reconcile`, `storage_profile_create`, `storage_profile_rm` | +| **Config / destructive (admin)** | `storage_configure`, `storage_rm` (`remote` delete is admin even though local-only `rm` is operator-equivalent) | +| **Topic discovery (viewer)** | `storage_topics_list` — queries the fleet's `bubbaloop/**/manifest` queryables over Zenoh | +| **Replay (operator)** | `storage_replay_start` / `storage_replay_stop` / `storage_replay_status` — replay is long-running, so it gets a `_start`/`_stop`/`_status` triad (MCP calls have no Ctrl-C) | + +The RBAC tiers mirror the CLI's blast radius: **read = viewer, day-to-day = +operator, config and remote-delete = admin.** See +[Storage Internals → Agent operations](storage-internals.md#agent-operations) +for the MCP-only tools and why they exist. + +--- + +## Topology and Limits + +The recorder runs as **one node, anywhere, in `client` mode**. Because every +Bubbaloop node already connects as a client to `zenohd`, the recorder sees the +entire `bubbaloop/global/**` namespace — it's the centralized pattern (one +process per fleet), like rosbag2 and the Foxglove Agent. Edge devices do **not** +need cloud credentials; only the host running sync does. + +This centralized model is validated for small-to-medium fleets (≲10 nodes). It +breaks down when sustained topic bandwidth exceeds ~50% of the router's link or +the recorder pegs ~80% of one CPU core. The documented escape hatch is a v2 +federated topology (per-instance recorders that store-and-forward) — the object +key layout (`{machine_id}/{recording_name}/`) already accommodates it, so no wire +format changes are needed to get there. + +--- + +## Next Steps + +- [Storage Internals](storage-internals.md) — manifest schema, integrity flow, sync queue, reconcile diff-matrix, ring buffer, replay +- [Messaging](messaging.md) — the Zenoh pub/sub and queryable layer storage records and replays onto +- [Architecture](architecture.md) — layer model, daemon, the three pillars From 774296f3c29f2ba1bd351d7d319d680b6189c222 Mon Sep 17 00:00:00 2001 From: Siddharth Rambhia Date: Mon, 15 Jun 2026 02:55:30 +0530 Subject: [PATCH 19/19] fix(storage): address PR4 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage_download (MCP): drop the caller-supplied `to` destination — an operator-tier agent could otherwise make the daemon write files at an arbitrary path. The MCP tool now always targets the canonical local recording dir; the CLI keeps `--to` (trusted human, own process). - storage_replay_start (MCP): run the synchronous whole-recording MCAP read on tokio's blocking pool (spawn_blocking) instead of an async worker, and port the ≥1 GiB "loads fully into memory" warning from the CLI path. - ReplayRegistry: evict completed/failed sessions on insert so a long-lived daemon can't leak session entries (a looping replay never left Running); session ids now come from a monotonic counter instead of name+now_ns, removing the same-nanosecond collision that could orphan an uncancellable replay. Register the session before spawning so status/stop are authoritative immediately. - upload_pending: log each chunk's error (with transient/terminal classification) and conflict (with object key + "run reconcile") instead of collapsing them into opaque counts; both the CLI verb and storage_upload MCP tool benefit. Also re-upload manifest.json whenever the recording is fully uploaded — not only when a chunk changed — so a prior run that uploaded all chunks but failed the manifest PUT self-heals on retry rather than requiring reconcile. - Drop misleading unconditional "Starting storage sync..." daemon log and the dead Default derives on ConfigureParams/ProfileCreateParams. Adds ReplayRegistry unit tests (unique ids, terminal-session eviction, stop). 127 storage + 136 mcp + 47 integration_mcp pass; clippy-clean; s3 compiles. Co-Authored-By: Claude Opus 4.8 --- crates/bubbaloop/src/cli/storage.rs | 4 +- crates/bubbaloop/src/daemon/mod.rs | 2 +- .../bubbaloop/src/mcp/storage_tools_write.rs | 134 ++++++++++++++---- crates/bubbaloop/src/storage/sync.rs | 39 ++++- 4 files changed, 147 insertions(+), 32 deletions(-) diff --git a/crates/bubbaloop/src/cli/storage.rs b/crates/bubbaloop/src/cli/storage.rs index e1088295..0650814e 100644 --- a/crates/bubbaloop/src/cli/storage.rs +++ b/crates/bubbaloop/src/cli/storage.rs @@ -920,7 +920,7 @@ pub struct ConfigureArgs { /// Flag/field-driven inputs to [`apply_configure`] — the shared core behind the /// `configure` CLI verb and the `storage_configure` MCP tool (§6.1). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub(crate) struct ConfigureParams { pub backend: String, pub endpoint: Option, @@ -1134,7 +1134,7 @@ fn profile_path(name: &str) -> Result { /// Inputs to [`apply_profile_create`] — shared by the `profile create` CLI verb /// and the `storage_profile_create` MCP tool (§6.1). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub(crate) struct ProfileCreateParams { pub name: String, pub description: Option, diff --git a/crates/bubbaloop/src/daemon/mod.rs b/crates/bubbaloop/src/daemon/mod.rs index 0ef0d8e5..8d6af9f8 100644 --- a/crates/bubbaloop/src/daemon/mod.rs +++ b/crates/bubbaloop/src/daemon/mod.rs @@ -858,7 +858,7 @@ pub async fn run(zenoh_endpoint: Option) -> Result<(), Box>>, + /// Monotonic sequence for collision-free session ids. + next_seq: Arc, } struct ReplaySession { @@ -75,6 +77,14 @@ struct ReplaySession { state: Arc>, } +impl ReplayRunState { + /// Whether the session has run to completion (success or failure) — used to + /// evict it from the registry so finished sessions can't accumulate forever. + fn is_terminal(&self) -> bool { + !matches!(self, ReplayRunState::Running) + } +} + /// Lifecycle of one replay session. `run_replay` only yields its totals at the /// end, so a running session reports no progress count (v1 limitation). enum ReplayRunState { @@ -89,8 +99,25 @@ enum ReplayRunState { } impl ReplayRegistry { + /// A collision-free session id (`{recording}-{seq}`). Unlike a wall-clock + /// suffix, the monotonic counter can't repeat across rapid starts. + fn next_id(&self, recording_name: &str) -> String { + let seq = self + .next_seq + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!("{recording_name}-{seq}") + } + fn insert(&self, id: String, session: ReplaySession) { if let Ok(mut g) = self.sessions.lock() { + // Evict completed sessions first so the registry can't grow without + // bound over a long-lived daemon (the leak a looping replay would cause). + g.retain(|_, s| { + !s.state + .lock() + .unwrap_or_else(|p| p.into_inner()) + .is_terminal() + }); g.insert(id, session); } } @@ -225,9 +252,6 @@ pub(crate) struct StorageProfileCreateRequest { pub(crate) struct StorageDownloadRequest { /// Recording name. name: String, - /// Destination directory (default: the local recording directory). - #[serde(default)] - to: Option, /// Machine id, only needed when there is no local manifest to read it from. #[serde(default)] machine: Option, @@ -516,7 +540,7 @@ impl BubbaLoopMcpServer

{ #[tool( name = "storage_download", - description = "Download + SHA-256-verify every chunk of a recording from the backend into `to` (default: the local recording dir). Pass `machine` only when there is no local manifest. Operator tier.", + description = "Download + SHA-256-verify every chunk of a recording from the backend into its local recording directory (~/.bubbaloop/recordings/). Pass `machine` only when there is no local manifest. Operator tier.", annotations( title = "Download recording", read_only_hint = false, @@ -530,7 +554,10 @@ impl BubbaLoopMcpServer

{ Parameters(req): Parameters, ) -> Result { log::info!("[MCP] tool=storage_download name={}", req.name); - match crate::cli::storage::download_recording(&req.name, req.to, req.machine).await { + // Force the canonical local recording dir: unlike the CLI (run by a trusted + // human), the MCP tool runs inside the daemon, so we never let a caller + // choose an arbitrary `to` path (would be an arbitrary-write primitive). + match crate::cli::storage::download_recording(&req.name, None, req.machine).await { Ok(summary) => Ok(ok_json(&summary)), Err(e) => Ok(err_text(e.to_string())), } @@ -677,13 +704,26 @@ impl BubbaLoopMcpServer

{ }; let started_at_ns = crate::cli::storage::now_ns(); - let id = format!("{}-{}", req.name, started_at_ns); + let id = self.replay_sessions.next_id(&req.name); let cancel = CancellationToken::new(); let state = Arc::new(Mutex::new(ReplayRunState::Running)); + // Register the session BEFORE spawning so status/stop are authoritative the + // moment start returns (the task shares `state`/`cancel` via clones). + self.replay_sessions.insert( + id.clone(), + ReplaySession { + recording_name: req.name.clone(), + started_at_ns, + rate, + cancel: cancel.clone(), + state: state.clone(), + }, + ); + // Capture everything the background task needs. - let task_state = state.clone(); - let task_cancel = cancel.clone(); + let task_state = state; + let task_cancel = cancel; let name = req.name.clone(); let topics = split_csv(req.topics.as_deref()); let exclude = split_csv(req.exclude.as_deref()); @@ -697,13 +737,26 @@ impl BubbaLoopMcpServer

{ *st.lock().unwrap_or_else(|p| p.into_inner()) = ReplayRunState::Failed { error: msg }; }; - let recording = match manifest::load_dir(&dir) { - Ok(r) => r, - Err(e) => return set_failed(&task_state, format!("no recording '{name}': {e}")), - }; - let messages = match storage::read_recording_messages(&dir, &recording) { - Ok(m) => m, - Err(e) => return set_failed(&task_state, format!("reading MCAP chunks: {e}")), + // Loading + decoding the MCAP chunks is synchronous, blocking IO that can + // buffer a large recording fully into memory — run it on the blocking + // pool so it never stalls an async worker thread. + let loaded = tokio::task::spawn_blocking(move || { + let recording = + manifest::load_dir(&dir).map_err(|e| format!("no recording '{name}': {e}"))?; + if recording.size_bytes >= (1 << 30) { + log::warn!( + "replay '{name}' is {:.1} GiB; it loads fully into memory", + recording.size_bytes as f64 / (1u64 << 30) as f64, + ); + } + storage::read_recording_messages(&dir, &recording) + .map_err(|e| format!("reading MCAP chunks: {e}")) + }) + .await; + let messages = match loaded { + Ok(Ok(m)) => m, + Ok(Err(e)) => return set_failed(&task_state, e), + Err(e) => return set_failed(&task_state, format!("replay read task failed: {e}")), }; if messages.is_empty() { *task_state.lock().unwrap_or_else(|p| p.into_inner()) = ReplayRunState::Finished { @@ -749,16 +802,6 @@ impl BubbaLoopMcpServer

{ } }); - self.replay_sessions.insert( - id.clone(), - ReplaySession { - recording_name: req.name.clone(), - started_at_ns, - rate, - cancel, - state, - }, - ); Ok(ok_json(&serde_json::json!({ "replay_id": id, "recording_name": req.name, @@ -820,6 +863,47 @@ mod tests { use super::*; use crate::mcp::platform::DaemonPlatform; + fn session(state: ReplayRunState) -> ReplaySession { + ReplaySession { + recording_name: "rec".into(), + started_at_ns: 0, + rate: 1.0, + cancel: CancellationToken::new(), + state: Arc::new(Mutex::new(state)), + } + } + + #[test] + fn registry_ids_are_unique_and_terminal_sessions_are_evicted() { + let reg = ReplayRegistry::default(); + let a = reg.next_id("rec"); + let b = reg.next_id("rec"); + assert_ne!(a, b, "ids must not collide"); + + // A finished session, then a running one: the finished entry is pruned on + // the next insert so the registry can't grow without bound. + reg.insert( + a.clone(), + session(ReplayRunState::Finished { + messages_published: 1, + loops_completed: 1, + }), + ); + reg.insert(b.clone(), session(ReplayRunState::Running)); + assert!(reg.status_json(&a).is_none(), "finished session evicted"); + assert!(reg.status_json(&b).is_some(), "running session retained"); + assert_eq!(reg.all_json().len(), 1); + } + + #[test] + fn stop_returns_false_for_unknown_session() { + let reg = ReplayRegistry::default(); + assert!(!reg.stop("nope")); + let id = reg.next_id("rec"); + reg.insert(id.clone(), session(ReplayRunState::Running)); + assert!(reg.stop(&id)); + } + /// Collect every storage tool name registered across the read + write routers. fn registered_storage_tools() -> std::collections::HashSet { let mut names = std::collections::HashSet::new(); diff --git a/crates/bubbaloop/src/storage/sync.rs b/crates/bubbaloop/src/storage/sync.rs index aaca068f..13e9ea5e 100644 --- a/crates/bubbaloop/src/storage/sync.rs +++ b/crates/bubbaloop/src/storage/sync.rs @@ -407,15 +407,46 @@ pub async fn upload_pending( summary.uploaded += 1; changed = true; } - Ok(UploadOutcome::Conflict) => summary.conflicts += 1, - Err(_) => summary.failed += 1, + Ok(UploadOutcome::Conflict) => { + // Surface the conflicting key + remediation, like the old per-chunk + // CLI warning — both the CLI verb and the MCP tool route through here. + log::warn!( + "sync: chunk {} of '{}' conflicts with different remote content at {} — skipping (run reconcile)", + recording.chunks[i].index, + recording.name, + job.object_key + ); + summary.conflicts += 1; + } + Err(e) => { + // Preserve the error text + transient/terminal classification rather + // than collapsing every failure into an opaque count. + log::error!( + "sync: chunk {} of '{}' upload failed: {e} ({:?})", + recording.chunks[i].index, + recording.name, + classify(&e) + ); + summary.failed += 1; + } } } - // Only persist + re-upload when something changed, so an all-failed run never - // clobbers newer remote manifest state. + // Persist locally only when a chunk actually changed, so an all-failed run + // never clobbers newer remote manifest state. if changed { manifest::save_dir(recording_dir, &recording)?; + } + // Re-upload manifest.json when a chunk changed, OR when the recording is fully + // uploaded with no failures. The latter heals a prior run that uploaded every + // chunk (marking them locally) but failed the final manifest PUT — without it + // the per-chunk skip on retry leaves `changed == false` and the stale remote + // manifest is never refreshed by the upload path (only by reconcile). + let fully_uploaded = summary.total_chunks > 0 + && summary.failed == 0 + && summary.conflicts == 0 + && summary.uploaded + summary.already_uploaded == summary.total_chunks; + if changed || fully_uploaded { let path = manifest::manifest_path(recording_dir); let bytes = std::fs::read(&path).map_err(|e| SyncError::Io { path: path.display().to_string(),