diff --git a/.gitignore b/.gitignore index b40d9258..afb9ce17 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ target/ target-docker/ +cache/ +out/ # Dependencies that are explicitly vendored for offline builds. # .vendor/ stores a snapshot of crates.io deps; re-generate via: diff --git a/Cargo.lock b/Cargo.lock index a471c296..19170b5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2263,6 +2263,7 @@ dependencies = [ "clap", "clap_complete", "hex", + "humantime", "predicates", "qrcode", "rand 0.8.6", diff --git a/Cargo.toml b/Cargo.toml index ece44249..6f0b8b08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } mpp = { version = "0.10.4", default-features = false, features = ["client", "tempo", "reqwest-rustls-tls"] } url = "2" +humantime = "2" humantime-serde = "1" tempfile = "3" futures = "0.3" diff --git a/crates/bloom-daemon/src/ipc.rs b/crates/bloom-daemon/src/ipc.rs index 64ae39a1..b6e2c810 100644 --- a/crates/bloom-daemon/src/ipc.rs +++ b/crates/bloom-daemon/src/ipc.rs @@ -24,6 +24,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::UNIX_EPOCH; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as B64; @@ -1334,12 +1335,19 @@ fn entry_to_json(e: &Entry) -> Value { EntryKind::File => "file", EntryKind::Symlink => "symlink", }; + let modified_ms = e.modified.map(|modified| { + modified + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + }); json!({ "name": e.name, "kind": kind, "size": e.size, "mode": e.mode, "link_target": e.link_target, + "modified_ms": modified_ms, }) } diff --git a/crates/bloom-evm/src/lib.rs b/crates/bloom-evm/src/lib.rs index f727ef9e..997a3a05 100644 --- a/crates/bloom-evm/src/lib.rs +++ b/crates/bloom-evm/src/lib.rs @@ -331,10 +331,29 @@ impl ChainClient { Ok(self.primary.get_transaction_by_hash(hash).await?) } + pub async fn raw_tx_by_hash( + &self, + hash: B256, + ) -> Result, ChainError> { + Ok(self + .primary + .client() + .request("eth_getTransactionByHash", (format!("{hash:#x}"),)) + .await?) + } + pub async fn receipt(&self, hash: B256) -> Result, ChainError> { Ok(self.primary.get_transaction_receipt(hash).await?) } + pub async fn raw_receipt(&self, hash: B256) -> Result, ChainError> { + Ok(self + .primary + .client() + .request("eth_getTransactionReceipt", (format!("{hash:#x}"),)) + .await?) + } + /// Re-execute a *reverted* transaction via `eth_call` at the block it /// was mined in, to capture revert returndata. Returns: /// diff --git a/crates/bloom-mount/src/adapter.rs b/crates/bloom-mount/src/adapter.rs index 8cd69a33..4f8dfa4c 100644 --- a/crates/bloom-mount/src/adapter.rs +++ b/crates/bloom-mount/src/adapter.rs @@ -18,7 +18,7 @@ use std::collections::{BTreeMap, HashMap}; use std::num::NonZeroUsize; use std::sync::Arc; -use std::time::{Duration, Instant, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use bytes::Bytes; @@ -239,6 +239,15 @@ fn epoch_ts() -> Timestamp { } } +/// Build a `Timestamp` from a system time, falling back to the Unix epoch. +fn system_time_to_ts(time: SystemTime) -> Timestamp { + let dur = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + Timestamp { + seconds: dur.as_secs() as i64, + nanos: dur.subsec_nanos(), + } +} + fn stable_attrs(object_type: ObjectType, fileid: u64) -> Attrs { let mut attrs = Attrs::new(object_type, fileid); let ts = epoch_ts(); @@ -278,6 +287,10 @@ fn entry_to_attrs(path: &VfsPath, e: &Entry, size: u64) -> Attrs { a.size = size; a.space_used = size; a.mode = e.mode; + let ts = system_time_to_ts(e.modified.unwrap_or_else(SystemTime::now)); + a.mtime = ts; + a.atime = ts; + a.ctime = ts; if matches!(e.kind, EntryKind::File | EntryKind::Symlink) { a.change = file_change_now(); } @@ -750,6 +763,10 @@ impl FileSystem for BloomFs { BloomHandle::Root => { let mut a = stable_attrs(ObjectType::Directory, fileid_for(&VfsPath::root())); a.mode = 0o755; + let ts = system_time_to_ts(SystemTime::now()); + a.mtime = ts; + a.atime = ts; + a.ctime = ts; a.change = self.dir_change(&VfsPath::root()).await; Ok(a) } @@ -2160,6 +2177,19 @@ mod tests { assert_eq!(attrs.mode & 0o777, 0o555); } + #[test] + fn entry_to_attrs_uses_entry_modified_time() { + let modified = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let entry = Entry::file("artifact.json").with_modified(modified); + let path = VfsPath::parse("/requests/pending/req_1/artifact.json").unwrap(); + + let attrs = entry_to_attrs(&path, &entry, 0); + + assert_eq!(attrs.mtime.seconds, 1_700_000_000); + assert_eq!(attrs.ctime.seconds, 1_700_000_000); + assert_eq!(attrs.atime.seconds, 1_700_000_000); + } + /// Bug #5: ACCESS strips MODIFY/EXTEND/DELETE for a read-only path /// so the kernel doesn't cache a false-positive write capability. #[tokio::test] diff --git a/crates/bloom-vfs/src/handler.rs b/crates/bloom-vfs/src/handler.rs index 3e645c1c..55ad4662 100644 --- a/crates/bloom-vfs/src/handler.rs +++ b/crates/bloom-vfs/src/handler.rs @@ -1,6 +1,8 @@ //! The Handler trait — every top-level subtree implements it. -use std::time::Duration; +use std::fs; +use std::path::Path; +use std::time::{Duration, SystemTime}; use async_trait::async_trait; use thiserror::Error; @@ -25,6 +27,9 @@ pub struct Entry { pub mode: u32, /// For symlinks, the target. pub link_target: Option, + /// Optional artifact modification time. Mount adapters surface this as + /// mtime/ctime/atime when a handler knows the backing artifact time. + pub modified: Option, } impl Entry { @@ -35,6 +40,7 @@ impl Entry { size: 0, mode: 0o755, link_target: None, + modified: None, } } /// Build a read-only file entry (mode 0o444). This is the right @@ -57,6 +63,7 @@ impl Entry { size: 0, mode: 0o444, link_target: None, + modified: None, } } /// Build a writable file entry (mode 0o644). Use for the small set @@ -68,6 +75,7 @@ impl Entry { size: 0, mode: 0o644, link_target: None, + modified: None, } } /// Build an executable read-only file entry (mode 0o555). This is used @@ -80,6 +88,7 @@ impl Entry { size: 0, mode: 0o555, link_target: None, + modified: None, } } pub fn symlink(name: &str, target: &str) -> Self { @@ -89,8 +98,66 @@ impl Entry { size: 0, mode: 0o777, link_target: Some(target.into()), + modified: None, } } + + pub fn with_modified(mut self, modified: SystemTime) -> Self { + self.modified = Some(modified); + self + } + + pub fn with_modified_ms(self, modified_ms: u128) -> Self { + if modified_ms > u64::MAX as u128 { + return self; + } + self.with_modified(SystemTime::UNIX_EPOCH + Duration::from_millis(modified_ms as u64)) + } + + pub fn with_fs_metadata(mut self, metadata: &fs::Metadata) -> Self { + if self.kind == EntryKind::File { + self.size = metadata.len(); + } + if let Ok(modified) = metadata.modified() { + self.modified = Some(modified); + } + self + } +} + +pub fn entry_from_fs_metadata(name: &str, kind: EntryKind, metadata: &fs::Metadata) -> Entry { + match kind { + EntryKind::Dir => Entry::dir(name), + EntryKind::File => Entry::file(name), + EntryKind::Symlink => Entry::symlink(name, ""), + } + .with_fs_metadata(metadata) +} + +pub fn entry_for_fs_path( + path: impl AsRef, + name: &str, + kind: EntryKind, +) -> Result { + let metadata = fs::metadata(path)?; + Ok(entry_from_fs_metadata(name, kind, &metadata)) +} + +pub fn entry_from_fs_dir_entry( + dir_entry: &fs::DirEntry, + name: &str, + kind: EntryKind, +) -> Result { + let metadata = dir_entry.metadata()?; + Ok(entry_from_fs_metadata(name, kind, &metadata)) +} + +pub fn fs_path_modified(path: impl AsRef) -> Result, HandlerError> { + match fs::metadata(path) { + Ok(metadata) => Ok(metadata.modified().ok()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } } #[derive(Debug, Error)] @@ -207,6 +274,7 @@ mod tests { assert_eq!(e.size, 0); assert_eq!(e.mode, 0o755); assert!(e.link_target.is_none()); + assert!(e.modified.is_none()); } #[test] @@ -218,6 +286,7 @@ mod tests { assert_eq!(a.mode, b.mode); assert_eq!(a.size, b.size); assert_eq!(a.link_target, b.link_target); + assert_eq!(a.modified, b.modified); } #[test] @@ -228,6 +297,7 @@ mod tests { assert_eq!(e.size, 0); assert_eq!(e.mode, 0o444); assert!(e.link_target.is_none()); + assert!(e.modified.is_none()); } #[test] @@ -238,6 +308,7 @@ mod tests { assert_eq!(e.size, 0); assert_eq!(e.mode, 0o644); assert!(e.link_target.is_none()); + assert!(e.modified.is_none()); } #[test] @@ -248,6 +319,7 @@ mod tests { assert_eq!(e.size, 0); assert_eq!(e.mode, 0o555); assert!(e.link_target.is_none()); + assert!(e.modified.is_none()); } #[test] @@ -258,6 +330,36 @@ mod tests { assert_eq!(e.size, 0); assert_eq!(e.mode, 0o777); assert_eq!(e.link_target.as_deref(), Some("../ethereum")); + assert!(e.modified.is_none()); + } + + #[test] + fn with_modified_records_artifact_time() { + let modified = SystemTime::UNIX_EPOCH + Duration::from_secs(123); + let e = Entry::file("artifact.json").with_modified(modified); + assert_eq!(e.modified, Some(modified)); + } + + #[test] + fn with_modified_ms_records_epoch_milliseconds() { + let e = Entry::dir("req").with_modified_ms(1_700_000_000_123); + assert_eq!( + e.modified, + Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_123)) + ); + } + + #[test] + fn with_fs_metadata_records_file_size_and_modified_time() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("artifact.json"); + std::fs::write(&path, b"hello").unwrap(); + let metadata = std::fs::metadata(&path).unwrap(); + + let entry = Entry::file("artifact.json").with_fs_metadata(&metadata); + + assert_eq!(entry.size, 5); + assert_eq!(entry.modified, metadata.modified().ok()); } #[test] diff --git a/crates/bloom-vfs/src/handlers/chains.rs b/crates/bloom-vfs/src/handlers/chains.rs index e4236851..3e7c7e71 100644 --- a/crates/bloom-vfs/src/handlers/chains.rs +++ b/crates/bloom-vfs/src/handlers/chains.rs @@ -36,7 +36,7 @@ //! address or `not a proxy\n` when the slot is empty. use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use async_trait::async_trait; @@ -661,29 +661,142 @@ impl Handler for ChainsHandler { } impl ChainsHandler { + fn entry_with_unix_timestamp(entry: Entry, timestamp_secs: u64) -> Entry { + entry.with_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp_secs)) + } + + fn json_hex_u64(value: &serde_json::Value, key: &str) -> Option { + let raw = value.get(key)?.as_str()?; + u64::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() + } + + fn json_status(value: &serde_json::Value) -> Option { + let raw = value.get("status")?.as_str()?; + match raw { + "0x1" | "1" => Some(true), + "0x0" | "0" => Some(false), + _ => None, + } + } + + async fn tx_json( + client: &ChainClient, + hash: alloy::primitives::B256, + ) -> Result { + match client.tx_by_hash(hash).await { + Ok(Some(tx)) => serde_json::to_value(tx).map_err(err_be), + Ok(None) => Err(HandlerError::not_found(format!("tx {hash:#x}"))), + Err(typed_err) => client + .raw_tx_by_hash(hash) + .await + .map_err(err_be)? + .ok_or_else(|| HandlerError::not_found(format!("tx {hash:#x}"))) + .inspect_err(|_| { + tracing::debug!(hash = %format!("{hash:#x}"), error = %typed_err, "chains.tx.raw_fallback_miss"); + }), + } + } + + async fn receipt_json( + client: &ChainClient, + hash: alloy::primitives::B256, + ) -> Result { + match client.receipt(hash).await { + Ok(Some(receipt)) => serde_json::to_value(receipt).map_err(err_be), + Ok(None) => Err(HandlerError::not_found(format!("receipt {hash:#x}"))), + Err(typed_err) => client + .raw_receipt(hash) + .await + .map_err(err_be)? + .ok_or_else(|| HandlerError::not_found(format!("receipt {hash:#x}"))) + .inspect_err(|_| { + tracing::debug!(hash = %format!("{hash:#x}"), error = %typed_err, "chains.receipt.raw_fallback_miss"); + }), + } + } + + async fn tx_block_number(client: &ChainClient, hash: alloy::primitives::B256) -> Option { + match client.receipt(hash).await { + Ok(Some(receipt)) => receipt.block_number, + _ => { + let raw = client.raw_receipt(hash).await.ok()??; + Self::json_hex_u64(&raw, "blockNumber") + } + } + } + + async fn entry_with_block_timestamp( + &self, + client: &ChainClient, + entry: Entry, + block_number: u64, + ) -> Entry { + match client.block_by_number(block_number).await { + Ok(Some(block)) => Self::entry_with_unix_timestamp(entry, block.header.timestamp), + _ => entry, + } + } + + async fn entry_with_head_timestamp(&self, client: &ChainClient, entry: Entry) -> Entry { + match client.block_latest().await { + Ok(Some(block)) => Self::entry_with_unix_timestamp(entry, block.header.timestamp), + _ => entry, + } + } + + async fn entry_with_tx_block_timestamp( + &self, + client: &ChainClient, + entry: Entry, + tx_hash: &str, + ) -> Entry { + let Ok(hash) = tx_hash.parse::() else { + return entry; + }; + let Some(block_number) = Self::tx_block_number(client, hash).await else { + return entry; + }; + self.entry_with_block_timestamp(client, entry, block_number) + .await + } + async fn lookup_inner(&self, path: &VfsPath) -> Result { let segs = path.segments(); if segs.is_empty() { return Ok(Entry::dir("")); } let chain = &segs[0]; - let _client = self.client(chain)?; + let client = self.client(chain)?; if segs.len() == 1 { return Ok(Entry::dir(chain)); } match segs[1].as_str() { "chain_id" if segs.len() == 2 => Ok(Entry::file("chain_id")), "head" => match segs.get(2).map(|s| s.as_str()) { - None => Ok(Entry::dir("head")), - Some("number") | Some("hash") | Some("timestamp") | Some("full.json") => { - Ok(Entry::file(segs.last().unwrap())) - } + None => Ok(self + .entry_with_head_timestamp(&client, Entry::dir("head")) + .await), + Some("number") | Some("hash") | Some("timestamp") | Some("full.json") => Ok(self + .entry_with_head_timestamp(&client, Entry::file(segs.last().unwrap())) + .await), _ => Err(HandlerError::not_found(path.to_string_path())), }, "blocks" => match segs.len() { 2 => Ok(Entry::dir("blocks")), - 3 => Ok(Entry::dir(&segs[2])), - 4 if segs[3] == "full.json" => Ok(Entry::file("full.json")), + 3 => { + let entry = Entry::dir(&segs[2]); + Ok(match segs[2].parse::() { + Ok(n) => self.entry_with_block_timestamp(&client, entry, n).await, + Err(_) => entry, + }) + } + 4 if segs[3] == "full.json" => { + let entry = Entry::file("full.json"); + Ok(match segs[2].parse::() { + Ok(n) => self.entry_with_block_timestamp(&client, entry, n).await, + Err(_) => entry, + }) + } _ => Err(HandlerError::not_found(path.to_string_path())), }, "addresses" => match segs.len() { @@ -754,11 +867,15 @@ impl ChainsHandler { }, "tx" => match segs.len() { 2 => Ok(Entry::dir("tx")), - 3 => Ok(Entry::dir(&segs[2])), + 3 => Ok(self + .entry_with_tx_block_timestamp(&client, Entry::dir(&segs[2]), &segs[2]) + .await), 4 => { let f = segs[3].as_str(); if TX_FILES.contains(&f) { - Ok(Entry::file(f)) + Ok(self + .entry_with_tx_block_timestamp(&client, Entry::file(f), &segs[2]) + .await) } else { Err(HandlerError::not_found(path.to_string_path())) } @@ -1034,55 +1151,53 @@ impl ChainsHandler { .map_err(|e| HandlerError::invalid(format!("tx hash: {e}")))?; match segs[3].as_str() { "full.json" => { - let tx = client - .tx_by_hash(hash) - .await - .map_err(err_be)? - .ok_or_else(|| HandlerError::not_found(format!("tx {hash:#x}")))?; + let tx = Self::tx_json(&client, hash).await?; Ok(serde_json::to_vec_pretty(&tx).map_err(err_be)?) } "receipt.json" => { - let r = - client.receipt(hash).await.map_err(err_be)?.ok_or_else(|| { - HandlerError::not_found(format!("receipt {hash:#x}")) - })?; + let r = Self::receipt_json(&client, hash).await?; Ok(serde_json::to_vec_pretty(&r).map_err(err_be)?) } "status" => { - let r = - client.receipt(hash).await.map_err(err_be)?.ok_or_else(|| { - HandlerError::not_found(format!("receipt {hash:#x}")) - })?; - let s = if r.status() { "success" } else { "reverted" }; + let r = Self::receipt_json(&client, hash).await?; + let status = Self::json_status(&r).ok_or_else(|| { + HandlerError::backend(format!( + "receipt {hash:#x} missing decodable status" + )) + })?; + let s = if status { "success" } else { "reverted" }; Ok(format!("{}\n", s).into_bytes()) } "block_number" => { - let r = - client.receipt(hash).await.map_err(err_be)?.ok_or_else(|| { - HandlerError::not_found(format!("receipt {hash:#x}")) - })?; - Ok(format!("{}\n", r.block_number.unwrap_or(0)).into_bytes()) + let r = Self::receipt_json(&client, hash).await?; + let block_number = Self::json_hex_u64(&r, "blockNumber").unwrap_or(0); + Ok(format!("{}\n", block_number).into_bytes()) } "gas_used" => { - let r = - client.receipt(hash).await.map_err(err_be)?.ok_or_else(|| { - HandlerError::not_found(format!("receipt {hash:#x}")) - })?; - Ok(format!("{}\n", r.gas_used).into_bytes()) + let r = Self::receipt_json(&client, hash).await?; + let gas_used = Self::json_hex_u64(&r, "gasUsed").ok_or_else(|| { + HandlerError::backend(format!( + "receipt {hash:#x} missing decodable gasUsed" + )) + })?; + Ok(format!("{}\n", gas_used).into_bytes()) } "logs.json" => { - let r = - client.receipt(hash).await.map_err(err_be)?.ok_or_else(|| { - HandlerError::not_found(format!("receipt {hash:#x}")) - })?; - Ok(serde_json::to_vec_pretty(&r.inner.logs()).map_err(err_be)?) + let r = Self::receipt_json(&client, hash).await?; + let logs = r + .get("logs") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + Ok(serde_json::to_vec_pretty(&logs).map_err(err_be)?) } "error.json" => { - let r = - client.receipt(hash).await.map_err(err_be)?.ok_or_else(|| { - HandlerError::not_found(format!("receipt {hash:#x}")) - })?; - if r.status() { + let receipt_json = Self::receipt_json(&client, hash).await?; + let status = Self::json_status(&receipt_json).ok_or_else(|| { + HandlerError::backend(format!( + "receipt {hash:#x} missing decodable status" + )) + })?; + if status { // Successful tx → emit an explicit "no error" // marker rather than a NotFound. Lets callers // `cat` the file unconditionally without @@ -1091,6 +1206,10 @@ impl ChainsHandler { // for every getattr on a successful tx. return Ok(b"null\n".to_vec()); } + let r = + client.receipt(hash).await.map_err(err_be)?.ok_or_else(|| { + HandlerError::not_found(format!("receipt {hash:#x}")) + })?; if let Some(cached) = self .revert_cache .lock() @@ -1240,7 +1359,7 @@ impl ChainsHandler { .collect()); } let chain = &segs[0]; - let _client = self.client(chain)?; + let client = self.client(chain)?; match segs.len() { 1 => { let mut entries = vec![ @@ -1260,15 +1379,36 @@ impl ChainsHandler { } Ok(entries) } - 2 if segs[1] == "head" => Ok(vec![ - Entry::file("number"), - Entry::file("hash"), - Entry::file("timestamp"), - Entry::file("full.json"), - ]), + 2 if segs[1] == "head" => { + let entries = vec![ + Entry::file("number"), + Entry::file("hash"), + Entry::file("timestamp"), + Entry::file("full.json"), + ]; + match client.block_latest().await { + Ok(Some(block)) => Ok(entries + .into_iter() + .map(|entry| Self::entry_with_unix_timestamp(entry, block.header.timestamp)) + .collect()), + _ => Ok(entries), + } + } 3 if segs[1] == "blocks" => { // /chains//blocks/ - Ok(BLOCK_FILES.iter().map(|n| Entry::file(n)).collect()) + let entries: Vec = BLOCK_FILES.iter().map(|n| Entry::file(n)).collect(); + Ok(match segs[2].parse::() { + Ok(n) => match client.block_by_number(n).await { + Ok(Some(block)) => entries + .into_iter() + .map(|entry| { + Self::entry_with_unix_timestamp(entry, block.header.timestamp) + }) + .collect(), + _ => entries, + }, + Err(_) => entries, + }) } 2 if segs[1] == "gas" => Ok(vec![Entry::file("current.json")]), 3 if segs[1] == "addresses" => { @@ -1315,7 +1455,20 @@ impl ChainsHandler { } 3 if segs[1] == "tx" => { // /chains//tx/ - Ok(TX_FILES.iter().map(|n| Entry::file(n)).collect()) + let entries: Vec = TX_FILES.iter().map(|n| Entry::file(n)).collect(); + let Ok(hash) = segs[2].parse::() else { + return Ok(entries); + }; + let Some(block_number) = Self::tx_block_number(&client, hash).await else { + return Ok(entries); + }; + match client.block_by_number(block_number).await { + Ok(Some(block)) => Ok(entries + .into_iter() + .map(|entry| Self::entry_with_unix_timestamp(entry, block.header.timestamp)) + .collect()), + _ => Ok(entries), + } } n if n >= 3 && segs[1] == "contracts" => { let client = self.client(chain)?; @@ -1786,6 +1939,47 @@ mod tests { assert_eq!(entries[0].kind, crate::handler::EntryKind::File); } + #[tokio::test] + async fn head_entries_surface_head_block_timestamp() { + let timestamp = 1_700_000_100; + let mut routes = std::collections::HashMap::new(); + routes.insert("eth_getBlockByNumber".to_string(), rpc_block(42, timestamp)); + let rpc = spawn_rpc(31_338, routes); + let h = ChainsHandler::new(registry_for_rpc(rpc, 31_338)); + + let entries = h + .list(&VfsPath::parse("/test/head").unwrap()) + .await + .unwrap(); + + let expected = + std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(timestamp); + assert!(entries.iter().all(|entry| entry.modified == Some(expected))); + } + + #[tokio::test] + async fn block_entries_surface_block_timestamp() { + let timestamp = 1_700_000_200; + let mut routes = std::collections::HashMap::new(); + routes.insert("eth_getBlockByNumber".to_string(), rpc_block(7, timestamp)); + let rpc = spawn_rpc(31_339, routes); + let h = ChainsHandler::new(registry_for_rpc(rpc, 31_339)); + + let dir = h + .lookup(&VfsPath::parse("/test/blocks/7").unwrap()) + .await + .unwrap(); + let leaf = h + .lookup(&VfsPath::parse("/test/blocks/7/full.json").unwrap()) + .await + .unwrap(); + + let expected = + std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(timestamp); + assert_eq!(dir.modified, Some(expected)); + assert_eq!(leaf.modified, Some(expected)); + } + #[tokio::test] async fn tx_hash_dir_lists_documented_leaves() { let h = ChainsHandler::new(anvil_registry()); @@ -1807,6 +2001,105 @@ mod tests { assert_eq!(names, TX_FILES); } + #[tokio::test] + async fn confirmed_tx_entries_surface_containing_block_timestamp() { + let timestamp = 1_700_000_300; + let hash = format!("0x{}", "22".repeat(32)); + let mut routes = std::collections::HashMap::new(); + routes.insert( + "eth_getTransactionReceipt".to_string(), + rpc_receipt(&hash, 9), + ); + routes.insert("eth_getBlockByNumber".to_string(), rpc_block(9, timestamp)); + let rpc = spawn_rpc(31_340, routes); + let h = ChainsHandler::new(registry_for_rpc(rpc, 31_340)); + + let entries = h + .list(&VfsPath::parse(&format!("/test/tx/{hash}")).unwrap()) + .await + .unwrap(); + let leaf = h + .lookup(&VfsPath::parse(&format!("/test/tx/{hash}/receipt.json")).unwrap()) + .await + .unwrap(); + + let expected = + std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(timestamp); + assert!(entries.iter().all(|entry| entry.modified == Some(expected))); + assert_eq!(leaf.modified, Some(expected)); + } + + #[tokio::test] + async fn unknown_typed_tx_receipt_falls_back_to_raw_json() { + let timestamp = 1_700_000_400; + let hash = format!("0x{}", "33".repeat(32)); + let mut routes = std::collections::HashMap::new(); + routes.insert( + "eth_getTransactionByHash".to_string(), + rpc_unknown_typed_tx(&hash, 11), + ); + routes.insert( + "eth_getTransactionReceipt".to_string(), + rpc_unknown_typed_receipt(&hash, 11), + ); + routes.insert("eth_getBlockByNumber".to_string(), rpc_block(11, timestamp)); + let rpc = spawn_rpc(31_341, routes); + let h = ChainsHandler::new(registry_for_rpc(rpc, 31_341)); + + let full = h + .read(&VfsPath::parse(&format!("/test/tx/{hash}/full.json")).unwrap()) + .await + .unwrap(); + let full: serde_json::Value = serde_json::from_slice(&full).unwrap(); + assert_eq!(full["type"], "0x7e"); + + let receipt = h + .read(&VfsPath::parse(&format!("/test/tx/{hash}/receipt.json")).unwrap()) + .await + .unwrap(); + let receipt: serde_json::Value = serde_json::from_slice(&receipt).unwrap(); + assert_eq!(receipt["type"], "0x7e"); + + let status = h + .read(&VfsPath::parse(&format!("/test/tx/{hash}/status")).unwrap()) + .await + .unwrap(); + assert_eq!(std::str::from_utf8(&status).unwrap(), "success\n"); + + let block_number = h + .read(&VfsPath::parse(&format!("/test/tx/{hash}/block_number")).unwrap()) + .await + .unwrap(); + assert_eq!(std::str::from_utf8(&block_number).unwrap(), "11\n"); + + let gas_used = h + .read(&VfsPath::parse(&format!("/test/tx/{hash}/gas_used")).unwrap()) + .await + .unwrap(); + assert_eq!(std::str::from_utf8(&gas_used).unwrap(), "21000\n"); + + let logs = h + .read(&VfsPath::parse(&format!("/test/tx/{hash}/logs.json")).unwrap()) + .await + .unwrap(); + let logs: serde_json::Value = serde_json::from_slice(&logs).unwrap(); + assert_eq!(logs, serde_json::json!([])); + + let error = h + .read(&VfsPath::parse(&format!("/test/tx/{hash}/error.json")).unwrap()) + .await + .unwrap(); + assert_eq!(std::str::from_utf8(&error).unwrap(), "null\n"); + + let entry = h + .lookup(&VfsPath::parse(&format!("/test/tx/{hash}/receipt.json")).unwrap()) + .await + .unwrap(); + let expected = + std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(timestamp); + assert_eq!(entry.modified, Some(expected)); + } + #[tokio::test] async fn tokens_dir_lists_meta_files() { let h = ChainsHandler::new(anvil_registry()); @@ -2200,6 +2493,78 @@ mod tests { reg } + fn rpc_block(number: u64, timestamp: u64) -> serde_json::Value { + let zero32 = format!("0x{}", "00".repeat(32)); + let zero8 = "0x0000000000000000".to_string(); + let zero_addr = format!("0x{}", "00".repeat(20)); + let zero_bloom = format!("0x{}", "00".repeat(256)); + serde_json::json!({ + "number": format!("0x{number:x}"), + "hash": format!("0x{}", "11".repeat(32)), + "parentHash": zero32, + "sha3Uncles": zero32, + "logsBloom": zero_bloom, + "transactionsRoot": zero32, + "stateRoot": zero32, + "receiptsRoot": zero32, + "miner": zero_addr, + "difficulty": "0x0", + "totalDifficulty": "0x0", + "extraData": "0x", + "size": "0x0", + "gasLimit": "0x0", + "gasUsed": "0x0", + "timestamp": format!("0x{timestamp:x}"), + "uncles": [], + "transactions": [], + "mixHash": format!("0x{}", "00".repeat(32)), + "nonce": zero8, + "baseFeePerGas": "0x0" + }) + } + + fn rpc_receipt(hash: &str, block_number: u64) -> serde_json::Value { + serde_json::json!({ + "transactionHash": hash, + "transactionIndex": "0x0", + "blockHash": format!("0x{}", "11".repeat(32)), + "blockNumber": format!("0x{block_number:x}"), + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "cumulativeGasUsed": "0x5208", + "gasUsed": "0x5208", + "contractAddress": null, + "logs": [], + "logsBloom": format!("0x{}", "00".repeat(256)), + "status": "0x1", + "effectiveGasPrice": "0x1", + "type": "0x2" + }) + } + + fn rpc_unknown_typed_tx(hash: &str, block_number: u64) -> serde_json::Value { + serde_json::json!({ + "hash": hash, + "blockHash": format!("0x{}", "11".repeat(32)), + "blockNumber": format!("0x{block_number:x}"), + "transactionIndex": "0x0", + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "gas": "0x5208", + "gasPrice": "0x1", + "input": "0x", + "nonce": "0x0", + "value": "0x0", + "type": "0x7e" + }) + } + + fn rpc_unknown_typed_receipt(hash: &str, block_number: u64) -> serde_json::Value { + let mut receipt = rpc_receipt(hash, block_number); + receipt["type"] = serde_json::json!("0x7e"); + receipt + } + /// Minimal ERC-20 ABI: `balanceOf`, `transfer`, `Transfer` event. /// Picked to exercise both function/event paths and overload branches. const ERC20_ABI: &str = r#"[ diff --git a/crates/bloom-vfs/src/handlers/defi.rs b/crates/bloom-vfs/src/handlers/defi.rs index 007ac548..2ae5088a 100644 --- a/crates/bloom-vfs/src/handlers/defi.rs +++ b/crates/bloom-vfs/src/handlers/defi.rs @@ -1305,16 +1305,16 @@ impl DefiHandler { 3 if segs[2] == "new" => Ok(Entry::writable_file("new")), 3 => { // // - let _ = self.get_session(&segs[1], &segs[2])?; - Ok(Entry::dir(&segs[2])) + let sess = self.get_session(&segs[1], &segs[2])?; + Ok(Entry::dir(&segs[2]).with_modified_ms(sess.created_ms)) } 4 => { - let _ = self.get_session(&segs[1], &segs[2])?; + let sess = self.get_session(&segs[1], &segs[2])?; if is_session_file(&segs[3]) { if segs[3] == "confirm" { - Ok(Entry::writable_file(&segs[3])) + Ok(Entry::writable_file(&segs[3]).with_modified_ms(sess.updated_ms)) } else { - Ok(Entry::file(&segs[3])) + Ok(Entry::file(&segs[3]).with_modified_ms(sess.updated_ms)) } } else { Err(HandlerError::not_found(path.to_string_path())) @@ -1437,23 +1437,24 @@ impl DefiHandler { // we show only `new`). let mut out = vec![Entry::writable_file("new")]; for id in self.list_sessions_for_wallet(&segs[1]) { - out.push(Entry::dir(&id)); + let sess = self.get_session(&segs[1], &id)?; + out.push(Entry::dir(&id).with_modified_ms(sess.created_ms)); } Ok(out) } 3 if segs[0] == "intents" => { - let _ = self.get_session(&segs[1], &segs[2])?; + let sess = self.get_session(&segs[1], &segs[2])?; Ok(vec![ - Entry::file("intent.txt"), - Entry::file("route.json"), - Entry::file("plan.md"), - Entry::file("policy_check.json"), - Entry::file("tx.json"), - Entry::file("simulation.json"), - Entry::file("settlement.json"), - Entry::file("wait_settlement"), - Entry::file("destination_chain.txt"), - Entry::writable_file("confirm"), + Entry::file("intent.txt").with_modified_ms(sess.updated_ms), + Entry::file("route.json").with_modified_ms(sess.updated_ms), + Entry::file("plan.md").with_modified_ms(sess.updated_ms), + Entry::file("policy_check.json").with_modified_ms(sess.updated_ms), + Entry::file("tx.json").with_modified_ms(sess.updated_ms), + Entry::file("simulation.json").with_modified_ms(sess.updated_ms), + Entry::file("settlement.json").with_modified_ms(sess.updated_ms), + Entry::file("wait_settlement").with_modified_ms(sess.updated_ms), + Entry::file("destination_chain.txt").with_modified_ms(sess.updated_ms), + Entry::writable_file("confirm").with_modified_ms(sess.updated_ms), ]) } _ => Err(HandlerError::NotADir(path.to_string_path())), @@ -2258,6 +2259,34 @@ mod tests { assert_eq!(loaded.intent_states.len(), 1); } + #[tokio::test] + async fn session_entries_surface_session_timestamps() { + let td = tempfile::tempdir().unwrap(); + let h = test_handler(td.path()); + let mut sess = fake_session("alice", "s1"); + sess.created_ms = 1_700_000_000_000; + sess.updated_ms = 1_700_000_123_000; + h.put_session(sess).unwrap(); + + let dir = h + .lookup(&VfsPath::parse("/intents/alice/s1").unwrap()) + .await + .unwrap(); + assert_eq!( + dir.modified, + Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)) + ); + + let plan = h + .lookup(&VfsPath::parse("/intents/alice/s1/plan.md").unwrap()) + .await + .unwrap(); + assert_eq!( + plan.modified, + Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_123_000)) + ); + } + #[test] fn normalize_session_state_lifts_legacy_staged_ids() { let mut sess = fake_session("alice", "s1"); diff --git a/crates/bloom-vfs/src/handlers/hyperliquid.rs b/crates/bloom-vfs/src/handlers/hyperliquid.rs index 34f070bf..2a9f7e74 100644 --- a/crates/bloom-vfs/src/handlers/hyperliquid.rs +++ b/crates/bloom-vfs/src/handlers/hyperliquid.rs @@ -2969,6 +2969,39 @@ impl HyperliquidHandler { Ok(Some(value)) } + fn session_created_ms( + &self, + network: &str, + wallet: &str, + session: &str, + ) -> Result, HandlerError> { + if let Some(active) = self.active_session_if_present(network, wallet, session) { + return Ok(Some(active.lock().session.created_ms)); + } + let value = match self.persisted_session_status_value(network, wallet, session) { + Ok(Some(value)) => value, + Ok(None) | Err(HandlerError::NotFound(_)) => return Ok(None), + Err(err) => return Err(err), + }; + Ok(value + .get("created_ms") + .and_then(Value::as_u64) + .map(u128::from)) + } + + fn agent_session_entry( + &self, + network: &str, + wallet: &str, + session: &str, + entry: Entry, + ) -> Result { + Ok(match self.session_created_ms(network, wallet, session)? { + Some(created_ms) => entry.with_modified_ms(created_ms), + None => entry, + }) + } + async fn try_recover_persisted_session( &self, client: &HyperliquidClient, @@ -3106,14 +3139,17 @@ impl HyperliquidHandler { names.insert(session.to_string()); } } - Ok(SESSION_ROOT_FILES + let mut entries: Vec = SESSION_ROOT_FILES .iter() .map(|f| match *f { "new.json" => Entry::writable_file(f), _ => Entry::file(f), }) - .chain(names.into_iter().map(|name| Entry::dir(&name))) - .collect()) + .collect(); + for name in names { + entries.push(self.agent_session_entry(network, wallet, &name, Entry::dir(&name))?); + } + Ok(entries) } fn read_session_audit( @@ -3341,7 +3377,7 @@ impl Handler for HyperliquidHandler { match file.as_str() { "new.json" => Ok(Entry::writable_file(file)), LAST_ERROR_FILE => Ok(Entry::file(file)), - _ => Ok(Entry::dir(file)), + _ => self.agent_session_entry(&segs[0], &segs[2], file, Entry::dir(file)), } } 5 if NETWORKS.contains(&segs[0].as_str()) && segs[1] == "agent_sessions" => { @@ -3349,8 +3385,18 @@ impl Handler for HyperliquidHandler { if SESSION_FILES.contains(&file.as_str()) { match file.as_str() { "status.json" | "session.json" | "audit.jsonl" | "last_response.json" - | LAST_ERROR_FILE => Ok(Entry::file(file)), - _ => Ok(Entry::writable_file(file)), + | LAST_ERROR_FILE => self.agent_session_entry( + &segs[0], + &segs[2], + &segs[3], + Entry::file(file), + ), + _ => self.agent_session_entry( + &segs[0], + &segs[2], + &segs[3], + Entry::writable_file(file), + ), } } else if file == APPROVAL_CHALLENGE_FILE && self.session_approval_challenge_exists(&segs[0], &segs[2], &segs[3])? @@ -3701,14 +3747,15 @@ impl Handler for HyperliquidHandler { self.list_agent_session_ids(&segs[0], &segs[2]) } 4 if NETWORKS.contains(&segs[0].as_str()) && segs[1] == "agent_sessions" => { - let mut entries: Vec<_> = SESSION_FILES - .iter() - .map(|f| match *f { + let mut entries = Vec::new(); + for f in SESSION_FILES { + let entry = match f { "status.json" | "session.json" | "audit.jsonl" | "last_response.json" | LAST_ERROR_FILE => Entry::file(f), _ => Entry::writable_file(f), - }) - .collect(); + }; + entries.push(self.agent_session_entry(&segs[0], &segs[2], &segs[3], entry)?); + } if self.session_approval_challenge_exists(&segs[0], &segs[2], &segs[3])? { entries.push(Entry::file(APPROVAL_CHALLENGE_FILE)); } @@ -5175,6 +5222,7 @@ mod tests { use bloom_auth_api::{ApprovalVerifier, AuthStoreView, AuthStoreWriter}; use bloom_hyperliquid::{MAINNET_API_URL, TESTNET_API_URL}; use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::SystemTime; static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(0); @@ -6210,12 +6258,12 @@ mod tests { let session_dir = store .join("agent_sessions") .join("mainnet") - .join("minnow") + .join("test-wallet") .join("session-1"); std::fs::create_dir_all(&session_dir).unwrap(); std::fs::write( session_dir.join("session.json"), - br#"{"id":"session-1","wallet":"minnow","orphaned":false,"tradable":true}"#, + br#"{"id":"session-1","wallet":"test-wallet","created_ms":1700000000000,"orphaned":false,"tradable":true}"#, ) .unwrap(); @@ -6223,18 +6271,25 @@ mod tests { .list(&VfsPath::parse("/mainnet/agent_sessions").unwrap()) .await .unwrap(); - assert!(wallets.iter().any(|entry| entry.name == "minnow")); + assert!(wallets.iter().any(|entry| entry.name == "test-wallet")); let sessions = h - .list(&VfsPath::parse("/mainnet/agent_sessions/minnow").unwrap()) + .list(&VfsPath::parse("/mainnet/agent_sessions/test-wallet").unwrap()) .await .unwrap(); assert!(sessions.iter().any(|entry| entry.name == "new.json")); - assert!(sessions.iter().any(|entry| entry.name == "session-1")); + let session_entry = sessions + .iter() + .find(|entry| entry.name == "session-1") + .expect("persisted session is listed"); + assert_eq!( + session_entry.modified, + Some(SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)) + ); let status = h .read( - &VfsPath::parse("/mainnet/agent_sessions/minnow/session-1/status.json") + &VfsPath::parse("/mainnet/agent_sessions/test-wallet/session-1/status.json") .expect("valid status path"), ) .await diff --git a/crates/bloom-vfs/src/handlers/outbox.rs b/crates/bloom-vfs/src/handlers/outbox.rs index 8c01230f..2ca85388 100644 --- a/crates/bloom-vfs/src/handlers/outbox.rs +++ b/crates/bloom-vfs/src/handlers/outbox.rs @@ -16,7 +16,9 @@ use std::path::PathBuf; use async_trait::async_trait; use bloom_auth_api::petal_identity::label_petal_digest; -use crate::handler::{Entry, Handler, HandlerError}; +use crate::handler::{ + Entry, EntryKind, Handler, HandlerError, entry_for_fs_path, entry_from_fs_dir_entry, +}; use crate::path::VfsPath; const STATES: &[&str] = &["pending", "sent", "failed"]; @@ -312,6 +314,21 @@ impl OutboxHandler { .latest_pending_action_id()? .map(|id| format!("pending/{id}"))) } + + fn file_entry_for_path( + &self, + path: impl AsRef, + name: &str, + writable: bool, + ) -> Result { + let metadata = std::fs::metadata(path)?; + let entry = if writable { + Entry::writable_file(name) + } else { + Entry::file(name) + }; + Ok(entry.with_fs_metadata(&metadata)) + } } fn validate_action_id(id: &str) -> Result<(), HandlerError> { @@ -355,11 +372,7 @@ impl Handler for OutboxHandler { if !fpath.exists() { return Err(HandlerError::NotFound(format!("/outbox/latest/{file}"))); } - if *file == "approval.json" { - Ok(Entry::writable_file(file)) - } else { - Ok(Entry::file(file)) - } + self.file_entry_for_path(fpath, file, *file == "approval.json") } [state, action_id] if STATES.contains(state) => { validate_action_id(action_id)?; @@ -369,7 +382,7 @@ impl Handler for OutboxHandler { "/outbox/{state}/{action_id}" ))); } - Ok(Entry::dir(action_id)) + entry_for_fs_path(dir, action_id, EntryKind::Dir) } [state, action_id, file] if STATES.contains(state) => { validate_action_id(action_id)?; @@ -384,11 +397,11 @@ impl Handler for OutboxHandler { "/outbox/{state}/{action_id}/{file}" ))); } - if *file == "approval.json" && *state == "pending" { - Ok(Entry::writable_file(file)) - } else { - Ok(Entry::file(file)) - } + self.file_entry_for_path( + fpath, + file, + *file == "approval.json" && *state == "pending", + ) } _ => Err(HandlerError::NotFound(path.to_string_path())), } @@ -490,7 +503,10 @@ impl Handler for OutboxHandler { let mut entries: Vec = std::fs::read_dir(&dir) .map_err(|e| HandlerError::backend(e.to_string()))? .filter_map(|e| e.ok()) - .map(|e| Entry::dir(e.file_name().to_string_lossy().as_ref())) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + entry_from_fs_dir_entry(&e, &name, EntryKind::Dir).ok() + }) .collect(); entries.sort_by(|a, b| a.name.cmp(&b.name)); Ok(entries) @@ -508,14 +524,21 @@ impl Handler for OutboxHandler { .filter_map(|e| e.ok()) .map(|e| { let name = e.file_name().to_string_lossy().to_string(); + let metadata = e.metadata().ok(); if e.file_type().map(|t| t.is_file()).unwrap_or(false) { - if state == &"pending" && name == "approval.json" { + let entry = if state == &"pending" && name == "approval.json" { Entry::writable_file(&name) } else { Entry::file(&name) - } + }; + metadata + .as_ref() + .map_or(entry.clone(), |m| entry.with_fs_metadata(m)) } else { - Entry::dir(&name) + let entry = Entry::dir(&name); + metadata + .as_ref() + .map_or(entry.clone(), |m| entry.with_fs_metadata(m)) } }) .collect(); @@ -568,6 +591,39 @@ mod tests { assert!(String::from_utf8_lossy(&data).contains("abc123")); } + #[tokio::test] + async fn entries_surface_action_artifact_modified_times() { + let h = handler(); + h.outbox + .stage("act-time", b"{}", "hash", "plan", b"[]") + .unwrap(); + let action_dir = h.outbox.action_dir("pending", "act-time"); + let dir_modified = std::fs::metadata(&action_dir).unwrap().modified().unwrap(); + let intent_path = action_dir.join("intent.json"); + let intent_modified = std::fs::metadata(&intent_path).unwrap().modified().unwrap(); + + let action_entry = h + .lookup(&VfsPath::parse("pending/act-time").unwrap()) + .await + .unwrap(); + assert_eq!(action_entry.modified, Some(dir_modified)); + + let file_entry = h + .lookup(&VfsPath::parse("pending/act-time/intent.json").unwrap()) + .await + .unwrap(); + assert_eq!(file_entry.modified, Some(intent_modified)); + + let listed_action = h + .list(&VfsPath::parse("pending").unwrap()) + .await + .unwrap() + .into_iter() + .find(|entry| entry.name == "act-time") + .expect("action listed"); + assert_eq!(listed_action.modified, Some(dir_modified)); + } + #[tokio::test] async fn read_rejects_unadvertised_action_files() { let h = handler(); diff --git a/crates/bloom-vfs/src/handlers/polymarket.rs b/crates/bloom-vfs/src/handlers/polymarket.rs index c9a85188..8be00d74 100644 --- a/crates/bloom-vfs/src/handlers/polymarket.rs +++ b/crates/bloom-vfs/src/handlers/polymarket.rs @@ -22,7 +22,7 @@ use bloom_keystore::{Keystore, KeystoreError}; use bloom_polymarket::eip712::{PUSD, PUSD_DECIMALS}; use bloom_polymarket::onboard::OnEvent; use bloom_polymarket::order::{self, OrderType}; -use bloom_polymarket::order_store::{OrderDraft, render_plan_md}; +use bloom_polymarket::order_store::{OrderDraft, OrderReceipt, render_plan_md}; use bloom_polymarket::signing::{action_id_for, poly1271_signature_from_raw}; use bloom_polymarket::trade; use bloom_polymarket::{ @@ -1813,6 +1813,28 @@ fn pretty(v: &impl serde::Serialize) -> Result, HandlerError> { serde_json::to_vec_pretty(v).map_err(|e| HandlerError::backend(e.to_string())) } +fn load_required_draft( + store: &OrderStore, + wallet: &str, + id: &str, +) -> Result { + store + .load_draft(wallet, id) + .map_err(err_be)? + .ok_or_else(|| HandlerError::not_found(format!("polymarket draft {wallet}/{id}"))) +} + +fn load_required_receipt( + store: &OrderStore, + wallet: &str, + id: &str, +) -> Result { + store + .load_receipt(wallet, id) + .map_err(err_be)? + .ok_or_else(|| HandlerError::not_found(format!("polymarket receipt {wallet}/{id}"))) +} + /// Removes `wallet` from the running set when the spawned run ends, however /// it ends (success, error, or panic unwind). struct RunningGuard { @@ -2120,9 +2142,13 @@ impl PolymarketHandler { 1 => Ok(Entry::dir("fund")), 2 => Ok(Entry::dir(&segs[1])), 3 if segs[2] == "new" => Ok(Entry::writable_file("new")), - 3 => Ok(Entry::dir(&segs[2])), - 4 if FUND_FILES.contains(&segs[3].as_str()) => Ok(Entry::file(&segs[3])), - 4 if segs[3] == "confirm" => Ok(Entry::writable_file("confirm")), + 3 => self.fund_session_dir_entry(path, &segs[1], &segs[2]), + 4 if FUND_FILES.contains(&segs[3].as_str()) => { + self.fund_session_file_entry(path, &segs[1], &segs[2], &segs[3]) + } + 4 if segs[3] == "confirm" => { + self.fund_session_file_entry(path, &segs[1], &segs[2], "confirm") + } _ => Err(HandlerError::not_found(path.to_string_path())), }, "trade" if self.orders.is_some() => match segs.len() { @@ -2130,15 +2156,25 @@ impl PolymarketHandler { 2 => Ok(Entry::dir(&segs[1])), 3 if segs[2] == "new" => Ok(Entry::writable_file("new")), 3 if segs[2] == "drafts" || segs[2] == "receipts" => Ok(Entry::dir(&segs[2])), - 4 if segs[2] == "drafts" || segs[2] == "receipts" => Ok(Entry::dir(&segs[3])), + 4 if segs[2] == "drafts" => { + let store = self.orders_or_not_found(path)?; + self.draft_dir_entry(store, &segs[1], &segs[3]) + } + 4 if segs[2] == "receipts" => { + let store = self.orders_or_not_found(path)?; + self.receipt_dir_entry(store, &segs[1], &segs[3]) + } 5 if segs[2] == "drafts" && segs[4] == "confirm" => { - Ok(Entry::writable_file("confirm")) + let store = self.orders_or_not_found(path)?; + self.draft_file_entry(store, &segs[1], &segs[3], "confirm") } 5 if segs[2] == "drafts" && DRAFT_FILES.contains(&segs[4].as_str()) => { - Ok(Entry::file(&segs[4])) + let store = self.orders_or_not_found(path)?; + self.draft_file_entry(store, &segs[1], &segs[3], &segs[4]) } 5 if segs[2] == "receipts" && segs[4] == "receipt.json" => { - Ok(Entry::file(&segs[4])) + let store = self.orders_or_not_found(path)?; + self.receipt_file_entry(store, &segs[1], &segs[3], &segs[4]) } 3 if segs[2] == "orders" => Ok(Entry::dir("orders")), 4 if segs[2] == "orders" => Ok(Entry::dir(&segs[3])), @@ -2408,6 +2444,79 @@ impl PolymarketHandler { Ok(ids) } + fn fund_session_dir_entry( + &self, + path: &VfsPath, + wallet: &str, + id: &str, + ) -> Result { + let sess = self.load_fund_session(path, wallet, id)?; + Ok(Entry::dir(id).with_modified_ms(sess.created_ms)) + } + + fn fund_session_file_entry( + &self, + path: &VfsPath, + wallet: &str, + id: &str, + file: &str, + ) -> Result { + let sess = self.load_fund_session(path, wallet, id)?; + let entry = if file == "confirm" { + Entry::writable_file(file) + } else { + Entry::file(file) + }; + Ok(entry.with_modified_ms(sess.updated_ms)) + } + + fn draft_dir_entry( + &self, + store: &OrderStore, + wallet: &str, + id: &str, + ) -> Result { + let draft = load_required_draft(store, wallet, id)?; + Ok(Entry::dir(id).with_modified_ms(draft.created_ms)) + } + + fn draft_file_entry( + &self, + store: &OrderStore, + wallet: &str, + id: &str, + file: &str, + ) -> Result { + let draft = load_required_draft(store, wallet, id)?; + let entry = if file == "confirm" { + Entry::writable_file(file) + } else { + Entry::file(file) + }; + Ok(entry.with_modified_ms(draft.updated_ms)) + } + + fn receipt_dir_entry( + &self, + store: &OrderStore, + wallet: &str, + id: &str, + ) -> Result { + let receipt = load_required_receipt(store, wallet, id)?; + Ok(Entry::dir(id).with_modified_ms(receipt.posted_ms)) + } + + fn receipt_file_entry( + &self, + store: &OrderStore, + wallet: &str, + id: &str, + file: &str, + ) -> Result { + let receipt = load_required_receipt(store, wallet, id)?; + Ok(Entry::file(file).with_modified_ms(receipt.posted_ms)) + } + fn read_fund( &self, path: &VfsPath, @@ -3466,14 +3575,20 @@ impl PolymarketHandler { entries.extend( self.list_fund_sessions(path, &segs[1])? .iter() - .map(|id| Entry::dir(id)), + .filter_map(|id| self.fund_session_dir_entry(path, &segs[1], id).ok()), ); Ok(entries) } (Some("fund"), 3) if segs[2] != "new" => { self.fund_root_or_not_found(path)?; - let mut entries: Vec = FUND_FILES.iter().map(|f| Entry::file(f)).collect(); - entries.push(Entry::writable_file("confirm")); + let mut entries: Vec = FUND_FILES + .iter() + .filter_map(|f| { + self.fund_session_file_entry(path, &segs[1], &segs[2], f) + .ok() + }) + .collect(); + entries.push(self.fund_session_file_entry(path, &segs[1], &segs[2], "confirm")?); Ok(entries) } (Some("trade"), 1) => { @@ -3492,22 +3607,40 @@ impl PolymarketHandler { (Some("trade"), 3) if segs[2] == "drafts" => { let store = self.orders_or_not_found(path)?; let ids = store.list_drafts(&segs[1]).map_err(err_be)?; - Ok(ids.iter().map(|id| Entry::dir(id)).collect()) + Ok(ids + .iter() + .filter_map(|id| self.draft_dir_entry(store, &segs[1], id).ok()) + .collect()) } (Some("trade"), 3) if segs[2] == "receipts" => { let store = self.orders_or_not_found(path)?; let ids = store.list_receipts(&segs[1]).map_err(err_be)?; - Ok(ids.iter().map(|id| Entry::dir(id)).collect()) + Ok(ids + .iter() + .filter_map(|id| self.receipt_dir_entry(store, &segs[1], id).ok()) + .collect()) } // Resting CLOB order-ids come from the live book/account views, not // the local store, so the orders dir is not enumerable by id here. (Some("trade"), 3) if segs[2] == "orders" => Ok(Vec::new()), (Some("trade"), 4) if segs[2] == "drafts" => { - let mut entries: Vec = DRAFT_FILES.iter().map(|f| Entry::file(f)).collect(); - entries.push(Entry::writable_file("confirm")); + let store = self.orders_or_not_found(path)?; + let mut entries: Vec = DRAFT_FILES + .iter() + .filter_map(|f| self.draft_file_entry(store, &segs[1], &segs[3], f).ok()) + .collect(); + entries.push(self.draft_file_entry(store, &segs[1], &segs[3], "confirm")?); Ok(entries) } - (Some("trade"), 4) if segs[2] == "receipts" => Ok(vec![Entry::file("receipt.json")]), + (Some("trade"), 4) if segs[2] == "receipts" => { + let store = self.orders_or_not_found(path)?; + Ok(vec![self.receipt_file_entry( + store, + &segs[1], + &segs[3], + "receipt.json", + )?]) + } (Some("trade"), 4) if segs[2] == "orders" => Ok(vec![Entry::writable_file("cancel")]), // redeem// — slugs are arbitrary (discovered via markets/), // so the wallet dir is not enumerable; the confirm leaf is reachable diff --git a/crates/bloom-vfs/src/handlers/requests.rs b/crates/bloom-vfs/src/handlers/requests.rs index f4fdd3a0..214a33a2 100644 --- a/crates/bloom-vfs/src/handlers/requests.rs +++ b/crates/bloom-vfs/src/handlers/requests.rs @@ -38,7 +38,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; use crate::auth::AuthServices; -use crate::handler::{Entry, Handler, HandlerError}; +use crate::handler::{ + Entry, EntryKind, Handler, HandlerError, entry_for_fs_path, entry_from_fs_dir_entry, + fs_path_modified, +}; use crate::path::VfsPath; const APPROVAL_FILE: &str = "approval.json"; @@ -1138,12 +1141,22 @@ impl Handler for RequestsHandler { match segs { [] => Ok(Entry::dir("requests")), [one] if one == "new" || one == "new.dry-run" => Ok(Entry::writable_file(one)), - [one] if one == "latest" => Ok(Entry::symlink("latest", &self.latest_target())), + [one] if one == "latest" => { + let mut entry = Entry::symlink("latest", &self.latest_target()); + if let Some(modified) = fs_path_modified(self.latest_path())? { + entry = entry.with_modified(modified); + } + Ok(entry) + } [one] if matches!(one.as_str(), "pending" | "sent" | "failed" | "sessions") => { - Ok(Entry::dir(one)) + entry_for_fs_path(self.requests_root().join(one), one, EntryKind::Dir) } [state, id] if matches!(state.as_str(), "pending" | "sent" | "failed" | "sessions") => { - Ok(Entry::dir(id)) + entry_for_fs_path( + self.requests_root().join(state).join(id), + id, + EntryKind::Dir, + ) } [state, _id, name] if matches!(state.as_str(), "pending" | "sent" | "failed") @@ -1154,34 +1167,49 @@ impl Handler for RequestsHandler { // generic file arm reports it as a 0-byte file, so a mounted // client caches it as a file and descending into it fails with // ENOTDIR. - Ok(Entry::dir("response")) + entry_for_fs_path( + self.req_dir(state, _id).join("response"), + "response", + EntryKind::Dir, + ) } [state, _id, name] if matches!(state.as_str(), "pending" | "sent" | "failed") => { if matches!(name.as_str(), "confirm" | "cancel") { - Ok(Entry::writable_file(name)) + let mut entry = Entry::writable_file(name); + if let Some(modified) = fs_path_modified(self.req_dir(state, _id))? { + entry = entry.with_modified(modified); + } + Ok(entry) } else { - Ok(Entry::file(name)) + entry_for_fs_path(self.req_dir(state, _id).join(name), name, EntryKind::File) } } [state, id, name] if state == "sessions" => { let file = self.requests_root().join("sessions").join(id).join(name); if matches!(name.as_str(), "topup" | "close") { if file.exists() { - Ok(Entry::writable_file(name)) + entry_for_fs_path(file, name, EntryKind::File).map(|mut entry| { + entry.mode = 0o644; + entry + }) } else { Err(HandlerError::NotFound(format!( "/requests/sessions/{id}/{name}: control unavailable until a fresh Tempo MPP session challenge is staged" ))) } } else { - Ok(Entry::file(name)) + entry_for_fs_path(file, name, EntryKind::File) } } - [state, _id, response, name] + [state, id, response, name] if matches!(state.as_str(), "pending" | "sent" | "failed") && response == "response" => { - Ok(Entry::file(name)) + entry_for_fs_path( + self.req_dir(state, id).join("response").join(name), + name, + EntryKind::File, + ) } _ => Err(HandlerError::NotFound(path.to_string_path())), } @@ -1195,11 +1223,26 @@ impl Handler for RequestsHandler { [] => Ok(vec![ Entry::writable_file("new"), Entry::writable_file("new.dry-run"), - Entry::symlink("latest", &self.latest_target()), - Entry::dir("pending"), - Entry::dir("sent"), - Entry::dir("failed"), - Entry::dir("sessions"), + entry_with_optional_fs_modified( + self.latest_path(), + Entry::symlink("latest", &self.latest_target()), + )?, + entry_with_optional_fs_modified( + self.requests_root().join("pending"), + Entry::dir("pending"), + )?, + entry_with_optional_fs_modified( + self.requests_root().join("sent"), + Entry::dir("sent"), + )?, + entry_with_optional_fs_modified( + self.requests_root().join("failed"), + Entry::dir("failed"), + )?, + entry_with_optional_fs_modified( + self.requests_root().join("sessions"), + Entry::dir("sessions"), + )?, ]), [state] if matches!(state.as_str(), "pending" | "sent" | "failed" | "sessions") => { list_dirs(self.requests_root().join(state)) @@ -2089,7 +2132,8 @@ fn list_dirs(path: PathBuf) -> Result, HandlerError> { for e in fs::read_dir(path)? { let e = e?; if e.file_type()?.is_dir() { - out.push(Entry::dir(&e.file_name().to_string_lossy())); + let name = e.file_name().to_string_lossy().to_string(); + out.push(entry_from_fs_dir_entry(&e, &name, EntryKind::Dir)?); } } } @@ -2106,16 +2150,28 @@ fn list_entries(path: PathBuf) -> Result, HandlerError> { } let ty = e.file_type()?; out.push(if ty.is_dir() { - Entry::dir(&name) + entry_from_fs_dir_entry(&e, &name, EntryKind::Dir)? } else if matches!(name.as_str(), "confirm" | "cancel" | "topup" | "close") { - Entry::writable_file(&name) + let mut entry = entry_from_fs_dir_entry(&e, &name, EntryKind::File)?; + entry.mode = 0o644; + entry } else { - Entry::file(&name) + entry_from_fs_dir_entry(&e, &name, EntryKind::File)? }); } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) } + +fn entry_with_optional_fs_modified( + path: impl AsRef, + entry: Entry, +) -> Result { + let Some(modified) = fs_path_modified(path.as_ref())? else { + return Ok(entry); + }; + Ok(entry.with_modified(modified)) +} fn new_request_id() -> String { static REQUEST_ID_COUNTER: AtomicU64 = AtomicU64::new(0); let ms = SystemTime::now() @@ -2730,6 +2786,40 @@ mod tests { } } + #[tokio::test] + async fn pending_request_entries_surface_artifact_modified_times() { + let f = fixture(Some("alice")); + let req = parse_request("GET https://merchant.test/pay wallet=alice").unwrap(); + let dir = f.handler.req_dir("pending", "req_time"); + write_request_artifacts(&dir, &req, "alice", "pending", false).unwrap(); + + let dir_modified = fs::metadata(&dir).unwrap().modified().unwrap(); + let request_toml = dir.join("request.toml"); + let file_metadata = fs::metadata(&request_toml).unwrap(); + let file_modified = file_metadata.modified().unwrap(); + + let pending_entries = f + .handler + .list(&VfsPath::parse("/pending").unwrap()) + .await + .unwrap(); + let req_entry = pending_entries + .iter() + .find(|entry| entry.name == "req_time") + .expect("pending request entry is listed"); + assert_eq!(req_entry.kind, EntryKind::Dir); + assert_eq!(req_entry.modified, Some(dir_modified)); + + let request_entry = f + .handler + .lookup(&VfsPath::parse("/pending/req_time/request.toml").unwrap()) + .await + .unwrap(); + assert_eq!(request_entry.kind, EntryKind::File); + assert_eq!(request_entry.size, file_metadata.len()); + assert_eq!(request_entry.modified, Some(file_modified)); + } + #[test] fn paid_http_canonical_envelope_commits_to_selected_plan() { let request = parse_request("GET https://merchant.test/pay wallet=alice").unwrap(); diff --git a/crates/bloom-vfs/src/handlers/simulate.rs b/crates/bloom-vfs/src/handlers/simulate.rs index bf845203..57ec0db1 100644 --- a/crates/bloom-vfs/src/handlers/simulate.rs +++ b/crates/bloom-vfs/src/handlers/simulate.rs @@ -97,6 +97,14 @@ impl SimulateHandler { let n = self.next_id.fetch_add(1, Ordering::SeqCst); format!("sim-{:04}", n) } + + fn session_entry(&self, id: &str, entry: Entry) -> Result { + let g = self.sessions.read(); + let s = g + .get(id) + .ok_or_else(|| HandlerError::not_found(id.to_string()))?; + Ok(entry.with_modified_ms(s.created_ms)) + } } /// JSON envelope accepted by `simulate/new`. Either an inline `intent` or a @@ -765,8 +773,8 @@ impl SimulateHandler { "last" => Ok(Entry::file("last")), id => { let g = self.sessions.read(); - if g.contains_key(id) { - Ok(Entry::dir(id)) + if let Some(s) = g.get(id) { + Ok(Entry::dir(id).with_modified_ms(s.created_ms)) } else { Err(HandlerError::not_found(path.to_string_path())) } @@ -780,9 +788,11 @@ impl SimulateHandler { } drop(g); match segs[1].as_str() { - "state-override.json" => Ok(Entry::writable_file("state-override.json")), + "state-override.json" => { + self.session_entry(id, Entry::writable_file("state-override.json")) + } "intent.json" | "simulation.json" | "plan.md" | "trace.json" => { - Ok(Entry::file(&segs[1])) + self.session_entry(id, Entry::file(&segs[1])) } _ => Err(HandlerError::not_found(path.to_string_path())), } @@ -837,7 +847,9 @@ impl SimulateHandler { let mut ids: Vec<&String> = g.keys().collect(); ids.sort(); for id in ids { - out.push(Entry::dir(id)); + if let Some(s) = g.get(id) { + out.push(Entry::dir(id).with_modified_ms(s.created_ms)); + } } Ok(out) } @@ -846,7 +858,10 @@ impl SimulateHandler { let s = g .get(&segs[0]) .ok_or_else(|| HandlerError::not_found(path.to_string_path()))?; - Ok(Self::session_dir_entries(s)) + Ok(Self::session_dir_entries(s) + .into_iter() + .map(|entry| entry.with_modified_ms(s.created_ms)) + .collect()) } _ => Err(HandlerError::NotADir(path.to_string_path())), } @@ -991,6 +1006,41 @@ mod tests { assert!(names.contains(&"last")); } + #[tokio::test] + async fn session_entries_surface_created_time() { + let r = ChainRegistry::new(); + let h = SimulateHandler::new(r, Arc::new(AddressBook::default())); + let created_ms = 1_700_000_000_000; + h.sessions.write().insert( + "sim-fixed".into(), + SimSession { + id: "sim-fixed".into(), + intent: None, + from: None, + state_override: None, + result: None, + plan_md: None, + trace_json: None, + created_ms, + }, + ); + + let dir = h + .lookup(&VfsPath::parse("sim-fixed").unwrap()) + .await + .unwrap(); + assert_eq!( + dir.modified, + Some(std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(created_ms as u64)) + ); + + let plan = h + .lookup(&VfsPath::parse("sim-fixed/plan.md").unwrap()) + .await + .unwrap(); + assert_eq!(plan.modified, dir.modified); + } + #[tokio::test] async fn write_new_invalid_json_errors() { let r = ChainRegistry::new(); diff --git a/crates/bloom-vfs/src/handlers/wallets.rs b/crates/bloom-vfs/src/handlers/wallets.rs index 78f5d925..7ba576df 100644 --- a/crates/bloom-vfs/src/handlers/wallets.rs +++ b/crates/bloom-vfs/src/handlers/wallets.rs @@ -2053,15 +2053,17 @@ impl WalletsHandler { // Confirm the entry actually lives in the requested state // (fix #8): a stale path like `outbox/sent/` // should NotFound, not silently succeed. - self.tx_engine + let entry = self + .tx_engine .outbox .read_in_state(wallet, chain, id, st) .map_err(err_be)?; - Ok(Entry::dir(id)) + Ok(Entry::dir(id).with_modified_ms(entry.staged.created_ms)) } [state, id, fname] => { let st = parse_state_seg(state)?; - self.tx_engine + let entry = self + .tx_engine .outbox .read_in_state(wallet, chain, id, st) .map_err(err_be)?; @@ -2074,9 +2076,9 @@ impl WalletsHandler { "confirm" | "confirm.override" | "replace" | "cancel" ) { - Ok(Entry::writable_file(fname)) + Ok(Entry::writable_file(fname).with_modified_ms(entry.staged.created_ms)) } else { - Ok(Entry::file(fname)) + Ok(Entry::file(fname).with_modified_ms(entry.staged.created_ms)) } } _ => Err(HandlerError::not_found(rest.join("/"))), @@ -2332,7 +2334,18 @@ impl WalletsHandler { .outbox .list(wallet, chain, st) .map_err(err_be)?; - Ok(ids.into_iter().map(|n| Entry::dir(&n)).collect()) + let entries = ids + .into_iter() + .filter_map(|id| { + let entry = self + .tx_engine + .outbox + .read_in_state(wallet, chain, &id, st) + .ok()?; + Some(Entry::dir(&id).with_modified_ms(entry.staged.created_ms)) + }) + .collect(); + Ok(entries) } [s, state, id] if s == "outbox" => { let st = parse_state_seg(state)?; @@ -2357,12 +2370,17 @@ impl WalletsHandler { "confirm" | "confirm.override" | "replace" | "cancel" ) { - out.push(Entry::writable_file(n)); + out.push( + Entry::writable_file(n) + .with_modified_ms(entry.staged.created_ms), + ); } else { - out.push(Entry::file(n)); + out.push( + Entry::file(n).with_modified_ms(entry.staged.created_ms), + ); } } else { - out.push(Entry::dir(n)); + out.push(Entry::dir(n).with_modified_ms(entry.staged.created_ms)); } } } @@ -2373,7 +2391,10 @@ impl WalletsHandler { if entry.state == OutboxState::Pending { for ctrl in ["confirm", "confirm.override", "replace", "cancel"] { if !out.iter().any(|e| e.name == ctrl) { - out.push(Entry::writable_file(ctrl)); + out.push( + Entry::writable_file(ctrl) + .with_modified_ms(entry.staged.created_ms), + ); } } } diff --git a/crates/bloom-vfs/src/handlers/watch.rs b/crates/bloom-vfs/src/handlers/watch.rs index f43ab871..d5135ad1 100644 --- a/crates/bloom-vfs/src/handlers/watch.rs +++ b/crates/bloom-vfs/src/handlers/watch.rs @@ -23,7 +23,7 @@ use async_trait::async_trait; use bloom_proto::HomeDir; use bloom_watch::{WatchExecutor, WatchKind, WatchRegistry, WatchSpec}; -use crate::handler::{Entry, Handler, HandlerError}; +use crate::handler::{Entry, Handler, HandlerError, fs_path_modified}; use crate::path::VfsPath; #[derive(Clone)] @@ -82,6 +82,27 @@ impl WatchHandler { out.sort(); out } + + fn spec_entry(spec: &WatchSpec, entry: Entry) -> Entry { + entry.with_modified_ms(spec.created_ms) + } + + fn watch_file_entry( + &self, + spec: &WatchSpec, + name: &str, + entry: Entry, + ) -> Result { + let path = if name == "live" { + WatchExecutor::live_path_for_spec(&self.home, spec) + } else { + self.watch_dir(spec).join(name) + }; + Ok(match fs_path_modified(path)? { + Some(modified) => entry.with_modified(modified), + None => Self::spec_entry(spec, entry), + }) + } } #[async_trait] @@ -132,17 +153,19 @@ impl WatchHandler { 1 => match segs[0].as_str() { "new" => Ok(Entry::writable_file("new")), id => { - let _ = self.resolve_id(id)?; - Ok(Entry::dir(id)) + let spec = self.resolve_id(id)?; + Ok(Self::spec_entry(&spec, Entry::dir(id))) } }, 2 => { - let _ = self.resolve_id(&segs[0])?; + let spec = self.resolve_id(&segs[0])?; match segs[1].as_str() { - "spec.toml" => Ok(Entry::file("spec.toml")), - "live" => Ok(Entry::file("live")), - "delete" => Ok(Entry::writable_file("delete")), - n if Self::is_history_segment(n) => Ok(Entry::file(n)), + "spec.toml" => Ok(Self::spec_entry(&spec, Entry::file("spec.toml"))), + "live" => self.watch_file_entry(&spec, "live", Entry::file("live")), + "delete" => Ok(Self::spec_entry(&spec, Entry::writable_file("delete"))), + n if Self::is_history_segment(n) => { + self.watch_file_entry(&spec, n, Entry::file(n)) + } _ => Err(HandlerError::not_found(path.to_string_path())), } } @@ -235,19 +258,19 @@ impl WatchHandler { 0 => { let mut out = vec![Entry::writable_file("new")]; for s in self.registry.list_all() { - out.push(Entry::dir(&s.id)); + out.push(Self::spec_entry(&s, Entry::dir(&s.id))); } Ok(out) } 1 => { let spec = self.resolve_id(&segs[0])?; let mut out = vec![ - Entry::file("spec.toml"), - Entry::file("live"), - Entry::writable_file("delete"), + Self::spec_entry(&spec, Entry::file("spec.toml")), + self.watch_file_entry(&spec, "live", Entry::file("live"))?, + Self::spec_entry(&spec, Entry::writable_file("delete")), ]; for n in self.list_history_segments(&spec) { - out.push(Entry::file(&n)); + out.push(self.watch_file_entry(&spec, &n, Entry::file(&n))?); } Ok(out) } @@ -407,6 +430,43 @@ threshold_gwei = 25.0 assert!(names.iter().any(|n| n.starts_with("w-"))); } + #[tokio::test] + async fn entries_surface_spec_and_artifact_modified_times() { + let (h, _t) = build_handler(); + let toml = r#" +kind = "block" +wallet = "alice" +chain = "anvil" +"#; + h.write(&VfsPath::parse("new").unwrap(), toml.as_bytes()) + .await + .unwrap(); + let spec = h.registry.list_all().into_iter().next().unwrap(); + let expected = std::time::SystemTime::UNIX_EPOCH + + std::time::Duration::from_millis(spec.created_ms as u64); + + let dir_entry = h.lookup(&VfsPath::parse(&spec.id).unwrap()).await.unwrap(); + assert_eq!(dir_entry.modified, Some(expected)); + + let spec_entry = h + .lookup(&VfsPath::parse(&format!("{}/spec.toml", spec.id)).unwrap()) + .await + .unwrap(); + assert_eq!(spec_entry.modified, Some(expected)); + + let live_path = WatchExecutor::live_path_for_spec(&h.home, &spec); + if let Some(parent) = live_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&live_path, b"{}\n").unwrap(); + let live_modified = std::fs::metadata(&live_path).unwrap().modified().unwrap(); + let live_entry = h + .lookup(&VfsPath::parse(&format!("{}/live", spec.id)).unwrap()) + .await + .unwrap(); + assert_eq!(live_entry.modified, Some(live_modified)); + } + #[tokio::test] async fn read_spec_toml_round_trips() { let (h, _t) = build_handler(); diff --git a/crates/bloom-vfs/src/lib.rs b/crates/bloom-vfs/src/lib.rs index f6279397..c9897b84 100644 --- a/crates/bloom-vfs/src/lib.rs +++ b/crates/bloom-vfs/src/lib.rs @@ -28,7 +28,10 @@ pub mod router; pub use auth::AuthServices; pub use cache::PathCache; -pub use handler::{Entry, EntryKind, Handler, HandlerError}; +pub use handler::{ + Entry, EntryKind, Handler, HandlerError, entry_for_fs_path, entry_from_fs_dir_entry, + entry_from_fs_metadata, fs_path_modified, +}; pub use paginate::{PAGE_SIZE, Projection, page_indices, page_name, page_slice, parse_page_name}; pub use path::{PercentDecodeError, VfsPath, percent_decode_segment}; pub use router::Vfs; diff --git a/crates/bloom/Cargo.toml b/crates/bloom/Cargo.toml index 3e373f14..cca639cc 100644 --- a/crates/bloom/Cargo.toml +++ b/crates/bloom/Cargo.toml @@ -47,6 +47,7 @@ base64.workspace = true qrcode.workspace = true rand.workspace = true rpassword.workspace = true +humantime.workspace = true [dev-dependencies] assert_cmd = "2" diff --git a/crates/bloom/src/main.rs b/crates/bloom/src/main.rs index 6026c257..c7c4f285 100644 --- a/crates/bloom/src/main.rs +++ b/crates/bloom/src/main.rs @@ -30,7 +30,7 @@ use bloom_proto::{AuditRecord, CeremonyIntent, CeremonyIntentKind, HomeDir, Home use bloom_tx::TxEngineError; use bloom_vfs::{ VfsPath, - handler::{Handler, HandlerError}, + handler::{Entry, EntryKind, Handler, HandlerError}, }; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{Shell, generate}; @@ -256,6 +256,8 @@ enum VfsCmd { #[arg(default_value = "/")] path: String, }, + /// `stat /bloom/` — inspect VFS metadata without a kernel mount. + Stat { path: String }, /// Write data to a writable VFS path. Reads from stdin if `--data` is omitted. Write { path: String, @@ -1150,6 +1152,90 @@ fn is_ipc_handler_permission_denied(e: &std::io::Error) -> bool { s.contains("-32007") || s.contains("permission denied") } +fn system_time_to_unix_ms(time: SystemTime) -> u128 { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn unix_ms_to_system_time(ms: u128) -> SystemTime { + let ms = ms.min(u64::MAX as u128) as u64; + UNIX_EPOCH + std::time::Duration::from_millis(ms) +} + +fn entry_kind_label(kind: EntryKind) -> &'static str { + match kind { + EntryKind::Dir => "dir", + EntryKind::File => "file", + EntryKind::Symlink => "symlink", + } +} + +fn print_vfs_stat( + path: &str, + name: &str, + kind: &str, + mode: u32, + size: u64, + link_target: Option<&str>, + modified: Option, +) { + let (modified, modified_source) = match modified { + Some(t) => (t, "artifact"), + None => (SystemTime::now(), "synthetic_now"), + }; + let modified_ms = system_time_to_unix_ms(modified); + println!("path: {path}"); + println!("name: {name}"); + println!("kind: {kind}"); + println!("mode: {:04o}", mode & 0o7777); + println!("size: {size}"); + println!("modified_ms: {modified_ms}"); + println!("modified: {}", humantime::format_rfc3339(modified)); + println!("modified_source: {modified_source}"); + if let Some(target) = link_target { + println!("link_target: {target}"); + } +} + +fn print_vfs_stat_entry(path: &str, entry: &Entry) { + print_vfs_stat( + path, + &entry.name, + entry_kind_label(entry.kind), + entry.mode, + entry.size, + entry.link_target.as_deref(), + entry.modified, + ) +} + +fn print_vfs_stat_json(path: &str, entry: &serde_json::Value) -> Result<()> { + let name = entry + .get("name") + .and_then(|v| v.as_str()) + .context("ipc lookup: missing name")?; + let kind = entry + .get("kind") + .and_then(|v| v.as_str()) + .context("ipc lookup: missing kind")?; + let mode = entry + .get("mode") + .and_then(|v| v.as_u64()) + .context("ipc lookup: missing mode")? as u32; + let size = entry + .get("size") + .and_then(|v| v.as_u64()) + .context("ipc lookup: missing size")?; + let link_target = entry.get("link_target").and_then(|v| v.as_str()); + let modified = entry + .get("modified_ms") + .and_then(|v| v.as_u64()) + .map(|ms| unix_ms_to_system_time(ms as u128)); + print_vfs_stat(path, name, kind, mode, size, link_target, modified); + Ok(()) +} + async fn run(cli: Cli) -> Result<()> { let (connect, ipc_socket) = if cli.connect.is_some() { (cli.connect, None) @@ -1292,6 +1378,28 @@ async fn run(cli: Cli) -> Result<()> { } Ok(()) } + Cmd::Vfs(VfsCmd::Stat { path }) => { + let p = VfsPath::parse(&path).context("parse path")?; + let client = IpcClient::new(&client_endpoint.socket); + let ipc_res = try_ipc( + &client, + &client_endpoint, + "lookup", + serde_json::json!({ "path": path }), + ) + .await + .with_context(|| format!("ipc lookup via {}", client_endpoint.display))?; + if let Some(res) = ipc_res { + debug!(endpoint = %client_endpoint.display, "cli.vfs.stat.via_ipc"); + print_vfs_stat_json(&path, &res)?; + } else { + debug!("cli.vfs.stat.via_inproc: no daemon socket present"); + let d = Daemon::from_home(home).context("build daemon")?; + let entry = d.vfs.lookup(&p).await.context("vfs lookup")?; + print_vfs_stat_entry(&path, &entry); + } + Ok(()) + } Cmd::Vfs(VfsCmd::Write { path, data, diff --git a/crates/bloom/tests/cli.rs b/crates/bloom/tests/cli.rs index 969afb53..f55313dc 100644 --- a/crates/bloom/tests/cli.rs +++ b/crates/bloom/tests/cli.rs @@ -15,7 +15,7 @@ use std::sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, }; -use std::time::Duration; +use std::time::{Duration, UNIX_EPOCH}; use assert_cmd::Command; use async_trait::async_trait; @@ -90,6 +90,30 @@ impl Handler for RecordingWriteHandler { } } +struct FixedStatHandler; + +#[async_trait] +impl Handler for FixedStatHandler { + async fn lookup(&self, p: &VfsPath) -> Result { + if p.is_root() { + return Ok(Entry::dir("")); + } + let mut entry = Entry::read_only_file("meta"); + entry.size = 42; + Ok(entry.with_modified(UNIX_EPOCH + Duration::from_millis(1_700_000_000_123))) + } + + async fn list(&self, p: &VfsPath) -> Result, HandlerError> { + if p.is_root() { + Ok(vec![Entry::read_only_file("meta").with_modified( + UNIX_EPOCH + Duration::from_millis(1_700_000_000_123), + )]) + } else { + Err(HandlerError::NotADir(p.to_string_path())) + } + } +} + fn spawn_ipc_server( home: &Path, vfs: bloom_vfs::Vfs, @@ -453,6 +477,52 @@ fn vfs_cat_status_version_returns_pkg_version() { .stdout(predicate::eq(expected)); } +#[test] +fn vfs_stat_reports_metadata_without_mount() { + let home = fresh_home(); + let out = bloom_cmd(home.path()) + .args(["vfs", "stat", "/status/version"]) + .assert() + .success(); + let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("path: /status/version"), "{stdout}"); + assert!(stdout.contains("name: version"), "{stdout}"); + assert!(stdout.contains("kind: file"), "{stdout}"); + assert!(stdout.contains("mode: 0444"), "{stdout}"); + assert!(stdout.contains("size: 0"), "{stdout}"); + assert!(stdout.contains("modified_ms: "), "{stdout}"); + assert!(stdout.contains("modified: "), "{stdout}"); + assert!( + stdout.contains("modified_source: synthetic_now"), + "{stdout}" + ); +} + +#[test] +fn vfs_stat_via_ipc_preserves_entry_modified_ms() { + let home = fresh_home(); + let vfs = bloom_vfs::Vfs::builder() + .mount("fixed", Arc::new(FixedStatHandler)) + .build(); + let (server, server_thread) = spawn_ipc_server(home.path(), vfs); + + let out = bloom_cmd(home.path()) + .args(["vfs", "stat", "/fixed/meta"]) + .assert() + .success(); + + stop_ipc_server(server, server_thread); + + let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("path: /fixed/meta"), "{stdout}"); + assert!(stdout.contains("name: meta"), "{stdout}"); + assert!(stdout.contains("kind: file"), "{stdout}"); + assert!(stdout.contains("mode: 0444"), "{stdout}"); + assert!(stdout.contains("size: 42"), "{stdout}"); + assert!(stdout.contains("modified_ms: 1700000000123"), "{stdout}"); + assert!(stdout.contains("modified_source: artifact"), "{stdout}"); +} + #[test] fn vfs_default_missing_socket_falls_back_in_process() { let home = fresh_home();